AOP 操作日志笔记

AOP 操作日志笔记

一、AOP 基础概念

AOP(Aspect Oriented Programming,面向切面编程)把”日志记录、权限校验、事务管理”这类横跨多个业务方法、但又和业务本身无关的通用逻辑,从业务代码中抽取出来,集中写在一个”切面”里,由 Spring 动态代理在方法执行的前后自动织入,业务代码零侵入。

核心术语:

术语 英文 在本项目中的对应
切面 Aspect(@Aspect) OperLogAspect 类,日志逻辑全部集中在这里
切点 Pointcut(@Pointcut) @annotation(com.situ.mall.annotation.MyLog)——拦截所有标注了 @MyLog 的方法
通知 Advice(@Before/@AfterReturning/@AfterThrowing 等) doBefore、doAfterReturning、doAfterThrowable 三个方法
连接点 JoinPoint 每一个被拦截到的 Controller 方法

常用通知类型:

  • @Before:方法执行前
  • @AfterReturning:方法正常返回后
  • @AfterThrowing:方法抛出异常后
  • @After:方法执行后(无论成功失败,相当于 finally)
  • @Around:环绕通知,功能最强,可完全控制是否执行目标方法

二、整体流程

用户请求(带JWT)
    │
    ▼
网关 AuthGlobalFilter:校验token,把用户id/name塞进请求头 X-Login-Id / X-Login-Name
    │
    ▼
微服务 LoginInterceptor:读取请求头,写入 ThreadLocal(LoginContext)
    │
    ▼
Controller 方法(@MyLog标注)  ◄────── AOP 代理拦截 ──────┐
    │                                                  │
    │   ┌───────────────────────────────────────────┐  │
    │   │ OperLogAspect                             │  │
    ├──►│ @Before          记录开始时间/IP/参数/UA    │  │
    │   │        │                                   │ │
    │   │        ▼  执行目标方法                    │  │
    │   │ @AfterReturning  记录响应+耗时,存库       │  │
    │   │ @AfterThrowing   记录异常+耗时,存库       │  │
    │   └───────────────────────────────────────────┘  │
    ▼
日志最终写入 MySQL 的 oper_log 表

依赖链:自定义注解 @MyLog(标记谁要记日志)→ 切面 OperLogAspect(怎么记)→ 实体 OperLog + MyBatis-Plus(记到哪)。

三、引入依赖(根 pom.xml)

<!--AOP-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
</dependency>

<!--工具库:取IP、解析User-Agent、格式化参数等-->
<dependency>
    <groupId>cn.hutool</groupId>
    <artifactId>hutool-all</artifactId>
    <version>5.8.16</version>
</dependency>

spring-boot-starter-aop 引入了 AspectJ,是 @Aspect 生效的前提;hutool 用来获取客户端真实 IP(JakartaServletUtil.getClientIP,能穿透 Nginx 等代理头)和解析 User-Agent。

四、自定义注解 @MyLog(mall-common/.../annotation/MyLog.java)

@Target({ElementType.METHOD})       // 只能标注在方法上
@Retention(RetentionPolicy.RUNTIME) // 运行时保留,反射才能读到
@Documented
public @interface MyLog {

    /**
     * 模块
     */
    public String module() default "";

}

说明:

  • @Target(METHOD)——注解只能贴在方法上;
  • @Retention(RUNTIME)——关键。注解默认在编译后就被丢弃,只有声明为 RUNTIME,切面里才能通过反射 method.getAnnotation(MyLog.class) 读到它;
  • module 属性用来描述这个方法是干什么的(”查询”/”添加”/”删除”……),最终写进日志表的 module 字段,查询日志时一眼能看出是什么操作。

为什么用注解而不是切所有 Controller?切点写成 execution(public com.situ.mall.controller..*(..)) 也可以(代码里保留了这行被注释的写法),但那样粒度太粗——查询、登录等无需审计的请求也会记日志。注解方式的好处是”想记谁就贴谁”,由业务开发者自己决定。

五、日志实体 OperLog(mall-common/.../pojo/OperLog.java)

对应数据库 oper_log 表,MyBatis-Plus 注解映射:

@Data
@EqualsAndHashCode(callSuper = false)
public class OperLog implements Serializable {

    @TableId(value = "id", type = IdType.AUTO)
    private Long id;

    /** 模块标题 */
    private String module;

    /** 日志类型 INFO/ERROR */
    @TableField("log_type")
    private String logType;

    /** 操作人员id */
    @TableField("admin_id")
    private Long adminId;

    /** 操作人员 */
    @TableField("admin_name")
    private String adminName;

    /** 请求方式GET/POST */
    @TableField("request_method")
    private String requestMethod;

    /** 请求URI */
    @TableField("request_uri")
    private String requestUri;

    /** 请求参数 */
    @TableField("request_params")
    private String requestParams;

    /** 返回参数 */
    @TableField("response_params")
    private String responseParams;

    /** 客户端IP */
    @TableField("request_ip")
    private String requestIp;

    /** 请求服务器地址 */
    @TableField("server_address")
    private String serverAddress;

    /** 是否异常 1是异常,0不是 */
    private Integer exception;

    /** 异常信息 */
    @TableField("exception_msg")
    private String exceptionMsg;

    /** 开始/结束/执行耗时 */
    @TableField("start_time")
    private Date startTime;
    @TableField("end_time")
    private Date endTime;
    @TableField("execute_time")
    private Integer executeTime;

    /** 用户代理原始字符串 */
    @TableField("user_agent")
    private String userAgent;

    /** 操作系统 */
    @TableField("device_name")
    private String deviceName;

    /** 浏览器名称 */
    @TableField("browser_name")
    private String browserName;

    /** 逻辑删除 1表示删除,0表示未删除 */
    @TableLogic
    private Integer deleted;

    /** 创建/更新时间,MyBatis-Plus自动填充 */
    @TableField(value = "create_time", fill = FieldFill.INSERT)
    private Date createTime;
    @TableField(value = "update_time", fill = FieldFill.INSERT_UPDATE)
    private Date updateTime;
}

建表 SQL(对应字段):

CREATE TABLE oper_log (
    id              BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键',
    module          VARCHAR(64)  COMMENT '模块标题(来自@MyLog的module)',
    log_type        VARCHAR(16)  COMMENT '日志类型 INFO/ERROR',
    admin_id        BIGINT       COMMENT '操作人id(网关透传)',
    admin_name      VARCHAR(64)  COMMENT '操作人姓名',
    request_method  VARCHAR(16)  COMMENT 'GET/POST/PUT/DELETE',
    request_uri     VARCHAR(255) COMMENT '请求URI',
    request_params  TEXT         COMMENT '请求参数',
    response_params TEXT         COMMENT '响应结果(JSON)',
    request_ip      VARCHAR(64)  COMMENT '客户端IP',
    server_address  VARCHAR(128) COMMENT '服务器地址',
    exception       TINYINT      COMMENT '是否异常 1是 0否',
    exception_msg   TEXT         COMMENT '异常信息',
    start_time      DATETIME     COMMENT '开始时间',
    end_time        DATETIME     COMMENT '结束时间',
    execute_time    INT          COMMENT '执行耗时(毫秒)',
    user_agent      VARCHAR(512) COMMENT 'User-Agent原文',
    device_name     VARCHAR(64)  COMMENT '操作系统',
    browser_name    VARCHAR(64)  COMMENT '浏览器',
    deleted         TINYINT DEFAULT 0 COMMENT '逻辑删除',
    create_time     DATETIME     COMMENT '创建时间',
    update_time     DATETIME     COMMENT '更新时间'
) COMMENT '操作日志记录';

六、核心:切面类 OperLogAspect(mall-common/.../aspect/OperLogAspect.java)

@Log4j2
@Aspect      // 声明这是一个切面
@Component   // 交给Spring管理,这样才能注入Service
public class OperLogAspect {

    String LOG_INFO = "INFO";
    String LOG_ERROR = "ERROR";

    //ThreadLocal是线程隔离的
    private ThreadLocal<OperLog> operLogThreadLocal = new ThreadLocal<>();

    @Autowired
    private IOperLogService operLogService;

    /**
     * 日志切点:拦截所有标注了 @MyLog 注解的方法
     */
    //@Pointcut("execution(public * com.situ.mall.controller.*.*(..))")  // 按包拦截的备选写法
    @Pointcut("@annotation(com.situ.mall.annotation.MyLog))")
    public void operLogAspect() {
    }

    /**
     * 前置通知:目标方法执行前,收集本次请求的全部信息
     */
    @Before(value = "operLogAspect()")
    public void doBefore(JoinPoint joinPoint) throws Throwable {
        // 拿到当前HTTP请求对象(因为切面不是Controller,拿不到request,必须从RequestContextHolder取)
        HttpServletRequest request = ((ServletRequestAttributes) Objects
                .requireNonNull(RequestContextHolder.getRequestAttributes())).getRequest();
        OperLog operLog = new OperLog();

        // 1. 操作人:从LoginContext(ThreadLocal)取,网关鉴权后透传过来的
        Map<String, Object> map = LoginContext.getLoginInfo();
        if (map != null) {
            Long id = (Long) map.get("id");
            String name = (String) map.get("name");
            operLog.setAdminId(id);
            operLog.setAdminName(name);
        }

        // 2. 通过反射读取方法上的 @MyLog 注解,拿到 module 描述
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
        MyLog myLog = method.getAnnotation(MyLog.class);
        if (myLog != null && myLog.module() != null) {
            operLog.setModule(myLog.module());
        }

        // 3. 记录请求各项信息
        operLog.setStartTime(new Date());
        operLog.setRequestUri(URLUtil.getPath(request.getRequestURI()));
        operLog.setRequestParams(formatParams(request.getParameterMap()));
        operLog.setRequestMethod(request.getMethod());
        operLog.setRequestIp(JakartaServletUtil.getClientIP(request));   // 能穿透代理,取真实IP
        operLog.setServerAddress(request.getScheme() + "://" + request.getServerName() + ":" + request.getServerPort());

        // 4. 解析User-Agent,得到操作系统和浏览器
        String userAgentStr = request.getHeader("User-Agent");
        operLog.setUserAgent(userAgentStr);
        UserAgent userAgent = UserAgentUtil.parse(userAgentStr);
        operLog.setDeviceName(userAgent.getOs().getName());
        operLog.setBrowserName(userAgent.getBrowser().getName());

        // 5. 存入ThreadLocal,留给"返回通知/异常通知"续用
        operLogThreadLocal.set(operLog);

        log.info("开始计时: {}  URI: {}  IP: {}", operLog.getStartTime(), operLog.getRequestUri(), operLog.getRequestIp());
    }

    /**
     * 正常返回通知:方法成功返回后,补齐响应和耗时,写库
     */
    @AfterReturning(pointcut = "operLogAspect()", returning = "ret")
    public void doAfterReturning(Object ret) {
        OperLog operLog = operLogThreadLocal.get();
        operLog.setLogType(LOG_INFO);
        operLog.setEndTime(new Date());
        operLog.setExecuteTime(Math.toIntExact(Long.valueOf(ChronoUnit.MILLIS.between(
                LocalDateTime.ofInstant(operLog.getStartTime().toInstant(), ZoneId.systemDefault()),
                LocalDateTime.ofInstant(operLog.getEndTime().toInstant(), ZoneId.systemDefault())))));
        operLog.setException(0);

        // 响应结果转JSON存字符串
        if (ret instanceof Result) {
            Result r = Convert.convert(Result.class, ret);
            try {
                operLog.setResponseParams(new ObjectMapper().writeValueAsString(r));
            } catch (JsonProcessingException e) {
                throw new RuntimeException(e);
            }
        } else if (ret instanceof String) {//转发界面
            operLog.setResponseParams((String) ret);
        }
        operLogService.save(operLog);
        operLogThreadLocal.remove();   // 用完必须清,防止内存泄漏和脏数据

        log.info("计时结束: {}  用时: {}ms  URI: {} ", operLog.getEndTime(), operLog.getExecuteTime(),
                operLog.getRequestUri());
    }

    /**
     * 异常通知:方法抛异常后,记录异常信息,写库
     */
    @AfterThrowing(pointcut = "operLogAspect()", throwing = "e")
    public void doAfterThrowable(Throwable e) {
        OperLog operLog = operLogThreadLocal.get();
        operLog.setLogType(LOG_ERROR);
        operLog.setEndTime(new Date());
        operLog.setExecuteTime(Math.toIntExact(Long.valueOf(ChronoUnit.MILLIS.between(
                LocalDateTime.ofInstant(operLog.getStartTime().toInstant(), ZoneId.systemDefault()),
                LocalDateTime.ofInstant(operLog.getEndTime().toInstant(), ZoneId.systemDefault())))));
        operLog.setException(1);
        operLog.setExceptionMsg(e.getMessage());
        operLogService.save(operLog);
        operLogThreadLocal.remove();

        log.info("计时结束: {}  用时: {}ms  URI: {} ", operLog.getEndTime(), operLog.getExecuteTime(),
                operLog.getRequestUri());
    }

    /**
     * 格式化请求参数,密码脱敏
     */
    private String formatParams(Map<String, String[]> parameterMap) {
        if (parameterMap == null) {
            return null;
        }
        StringBuilder params = new StringBuilder();
        for (Map.Entry<String, String[]> param : (parameterMap).entrySet()) {
            if (params.length() != 0) {
                params.append("&");
            }
            params.append(param.getKey() + "=");
            if (StrUtil.endWithIgnoreCase(param.getKey(), "password")) {
                params.append("*");   // 参数名以password结尾的,值打码
            } else if (param.getValue() != null) {
                params.append(ArrayUtil.join(param.getValue(), ","));
            }
        }
        return params.toString();
    }
}

逐点解读

① 切点 @Pointcut @annotation(com.situ.mall.annotation.MyLog) 意思是:拦截所有标注了 @MyLog 注解的方法。下面的空方法 operLogAspect() 只是给切点起个名字,其他通知用 "operLogAspect()" 引用它,避免重复写表达式。

② ThreadLocal 存 OperLog(贯穿全程的”接力棒”) 一次请求要被三个通知接力处理:@Before 收集信息 → @AfterReturning(或 @AfterThrowing)补齐结果和耗时。这三个方法之间没有参数传递的途径,所以用 ThreadLocal<OperLog> 把半成品日志对象暂存起来。ThreadLocal 是线程隔离的——Tomcat 每个请求一个线程,各请求的日志对象互不串扰。用完必须 remove(),否则线程池复用线程时会内存泄漏、拿到上一个请求的脏数据。

③ 操作人信息从哪来(微服务下的链路) 切面运行在微服务里,request 里没有登录态。完整链路是:前端带 JWT → 网关 AuthGlobalFilter 解析 token,把用户 id/name 写进请求头 X-Login-Id/X-Login-Name → LoginInterceptor 读请求头存入 LoginContext(也是 ThreadLocal)→ 切面里 LoginContext.getLoginInfo() 取出。三个 ThreadLocal 环环相扣。

④ RequestContextHolder 获取 request 切面类不是 Controller,方法参数里没有 HttpServletRequest。Spring 把当前请求绑存在 RequestContextHolder 里,任何代码都能通过 ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest() 拿到——这是在非 Web 层代码里获取 request 的标准姿势。

⑤ 耗时计算 @Before 记 startTime,返回/异常通知记 endTime,用 ChronoUnit.MILLIS.between(...) 算毫秒差存入 executeTime,日志表就是一份现成的”接口性能报表”。

⑥ 正常与异常两条分支 @AfterReturning 标 logType=INFO、exception=0,把返回的 Result 序列化成 JSON 存 response_params;@AfterThrowing 标 logType=ERROR、exception=1,存 exception_msg。两个通知不会同时触发,正好覆盖成功/失败两种结局。

⑦ 密码脱敏 formatParams 里对参数名以 password 结尾(不区分大小写)的值替换成 *,避免明文密码进日志库——日志往往权限控制比业务库宽松,这是安全底线。

七、使用方式:在 Controller 方法上贴注解

@RestController
@RequestMapping("/admin")
public class AdminController {

    @MyLog(module = "查询")
    @GetMapping
    public Result select(...)

    @MyLog(module = "添加")
    @PostMapping
    public Result add(...)

    @MyLog(module = "修改")
    @PutMapping("/{id}")
    public Result update(...)

    @MyLog(module = "删除")
    @DeleteMapping("/{id}")
    public Result delete(...)

    @MyLog(module = "批量删除")
    @DeleteMapping
    public Result deleteBatch(...)
}

业务方法里一行日志代码都不用写,只要贴上 @MyLog(module = "xxx"),AOP 自动在方法前后完成收集、计时、入库。

目前 mall-admin 的 AdminController(查询/添加/修改/删除/批量删除)和 mall-user 的 UserController 都在用这个注解。

八、存储层(MyBatis-Plus 三件套)

// Mapper
public interface OperLogMapper extends BaseMapper<OperLog> {
}

// Service接口
public interface IOperLogService extends IService<OperLog> {
}

// Service实现:save()方法直接继承自MyBatis-Plus,一行不写
@Service
public class OperLogServiceImpl extends ServiceImpl<OperLogMapper, OperLog> implements IOperLogService {
}

切面里 operLogService.save(operLog) 一个方法完成入库——这就是用 MyBatis-Plus 的好处,单表操作零 SQL。

九、日志查询接口(待完成)

@RestController
@RequestMapping("/oper-log")
public class OperLogController {
    // 目前是空壳,可补充分页查询
}

可以补一个分页查询(配合 MyBatis-Plus 的 Page):

@Autowired
private IOperLogService operLogService;

@GetMapping
public Result page(@RequestParam(defaultValue = "1") Integer pageNum,
                   @RequestParam(defaultValue = "10") Integer pageSize) {
    Page<OperLog> page = operLogService.page(new Page<>(pageNum, pageSize),
            new QueryWrapper<OperLog>().orderByDesc("id"));
    return Result.ok(page);
}

⚠️ 注意:网关 application.yml 里目前没有 /oper-log/ 的路由**,前端经网关访问日志接口会 404,需要补一条路由;同时别忘了 AuthGlobalFilter 的鉴权(日志查询应要求登录,无需加白名单)。

十、可改进的点

  1. @RequestBody 的 JSON 参数没被记录:formatParams(request.getParameterMap()) 只能拿到 URL 参数和表单参数,本项目登录等接口都是 JSON body,request_params 会是空的。可在 doBefore 里追加 joinPoint.getArgs() 序列化:
   if (StrUtil.isBlank(operLog.getRequestParams()) && joinPoint.getArgs().length > 0) {
       operLog.setRequestParams(new ObjectMapper().writeValueAsString(joinPoint.getArgs()));
   }
   
  1. 日志入库是同步的:save() 卡在请求主链路上,接口慢一点、日志库挂了还会影响业务。改进:@Async 异步保存,或塞进消息队列。
  2. Convert.convert(Result.class, ret) 多余:ret 本来就是 Result,强转即可 Result r = (Result) ret;,不必走 hutool 的反射转换。
  3. new ObjectMapper() 每次新建:ObjectMapper 创建开销大且线程安全,应注入单例复用。
  4. 异常通知里 e.getMessage() 可能为 null(如 NPE),可改存 e.toString() 或堆栈前几百字符。
  5. @AfterReturning 里假设 ThreadLocal 一定有值:极端情况下(如异步场景)operLogThreadLocal.get() 可能返回 null 导致 NPE,建议加判空。
上一篇
下一篇