【Spring Boot + MyBatis|第9篇】使用 AOP 实现接口操作日志记录
前言
前面我们已经学习了登录认证、全局异常处理、文件上传等内容。到这里,一个后台管理系统已经有了比较完整的基础功能。
但是在真实项目中,还有一个很常见的需求:记录用户做了哪些操作。
比如:
- 谁新增了员工?
- 谁删除了部门?
- 谁修改了班级信息?
- 哪个接口执行失败了?
- 某个接口执行了多长时间?
这些信息如果每个 Controller 方法里都手动写日志,代码会非常乱。所以这一篇我们来学习一个非常适合处理这类问题的技术:Spring AOP。
通过 AOP,我们可以在不修改原有业务代码的情况下,统一记录接口操作日志。
一、什么是 AOP?
AOP 的全称是 Aspect Oriented Programming,翻译过来叫面向切面编程。
刚开始听这个概念可能有点抽象,可以先这样理解:
AOP 就是在不改变原来业务代码的基础上,给方法额外增强一些功能。
比如一个新增员工接口,原本只需要完成新增员工的业务:
@PostMappingpublicResultsave(@RequestBodyEmpemp){empService.save(emp);returnResult.success();}现在我们还想在这个方法执行前后记录日志。
如果不用 AOP,可能就会这样写:
@PostMappingpublicResultsave(@RequestBodyEmpemp){longbegin=System.currentTimeMillis();empService.save(emp);longend=System.currentTimeMillis();System.out.println("新增员工接口耗时:"+(end-begin)+"ms");returnResult.success();}这样虽然能实现,但是如果每个接口都这样写,Controller 代码会越来越混乱。
AOP 的作用就是把这些“和核心业务无关,但是很多地方都需要用到的功能”抽出来统一处理。
常见场景包括:
- 操作日志
- 权限校验
- 接口耗时统计
- 事务控制
- 统一异常处理
- 数据权限过滤
这一篇我们重点讲操作日志。
二、操作日志功能需求
我们希望实现这样一个效果:
当用户访问某些接口时,系统自动记录操作日志。
日志内容包括:
| 字段 | 含义 |
|---|---|
id | 日志 id |
operateUser | 操作人 id |
operateTime | 操作时间 |
className | 操作的类名 |
methodName | 操作的方法名 |
methodParams | 方法参数 |
returnValue | 返回值 |
costTime | 方法执行耗时 |
比如用户调用新增员工接口后,日志表中就会多一条记录:
操作人:1 操作时间:2026-06-05 10:20:30 类名:EmpController 方法名:save 参数:{"name":"张三","gender":1} 返回值:{"code":1,"msg":"success","data":null} 耗时:32ms这样后期排查问题时,就能知道接口被谁调用过、传了什么参数、有没有成功返回。
三、准备工作
1. 引入 AOP 依赖
如果项目中没有引入 AOP,需要在pom.xml中加入依赖。
<!-- pom.xml --><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-aop</artifactId></dependency>引入这个依赖之后,Spring Boot 才能支持切面编程。
2. 创建操作日志表
这里给出一个示例日志表结构。
createtableoperate_log(idintunsignedprimarykeyauto_incrementcomment'ID',operate_userintunsignedcomment'操作人ID',operate_timedatetimecomment'操作时间',class_namevarchar(100)comment'操作类名',method_namevarchar(100)comment'操作方法名',method_paramsvarchar(2000)comment'方法参数',return_valuevarchar(2000)comment'返回值',cost_timebigintcomment'方法执行耗时,单位ms')comment'操作日志表';这个表不是业务表,而是系统辅助表,专门用来保存用户操作记录。
四、创建操作日志实体类
publicclassOperateLog{privateIntegerid;privateIntegeroperateUser;privateLocalDateTimeoperateTime;privateStringclassName;privateStringmethodName;privateStringmethodParams;privateStringreturnValue;privateLongcostTime;// getter、setter 省略}文字说明
这个实体类和operate_log表对应。
其中:
operateUser表示操作人 id。
operateTime表示操作时间。
className methodName表示当前执行的是哪个类的哪个方法。
methodParams returnValue分别保存请求参数和响应结果。
costTime表示接口执行耗时。
五、创建 Mapper 保存日志
1. Mapper 接口
@MapperpublicinterfaceOperateLogMapper{@Insert("insert into operate_log(operate_user, operate_time, class_name, method_name, method_params, return_value, cost_time) "+"values(#{operateUser}, #{operateTime}, #{className}, #{methodName}, #{methodParams}, #{returnValue}, #{costTime})")voidinsert(OperateLogoperateLog);}2. 文字说明
这里的 Mapper 只做一件事:把操作日志插入到数据库中。
这和之前的业务 Mapper 是一样的,都是通过 MyBatis 操作数据库。
只不过这个 Mapper 操作的是日志表,而不是员工表、部门表这类业务表。
六、自定义 @Log 注解
如果所有接口都记录日志,有时候也不太合适。
比如登录接口、查询列表接口,不一定都需要记录。
所以我们可以自定义一个注解:只有加了@Log的方法才记录操作日志。
@Target(ElementType.METHOD)@Retention(RetentionPolicy.RUNTIME)public@interfaceLog{}文字说明
这里有两个元注解:
@Target(ElementType.METHOD)表示这个注解只能加在方法上。
@Retention(RetentionPolicy.RUNTIME)表示这个注解在程序运行时仍然有效。
为什么要运行时有效?
因为 AOP 需要在程序运行时判断某个方法上有没有@Log注解。
七、在 Controller 方法上使用 @Log
@RestController@RequestMapping("/emps")publicclassEmpController{@AutowiredprivateEmpServiceempService;@Log@PostMappingpublicResultsave(@RequestBodyEmpemp){empService.save(emp);returnResult.success();}@Log@DeleteMapping("/{id}")publicResultdelete(@PathVariableIntegerid){empService.delete(id);returnResult.success();}@GetMapping("/{id}")publicResultgetById(@PathVariableIntegerid){Empemp=empService.getById(id);returnResult.success(emp);}}文字说明
这里我们给新增和删除接口加了@Log注解。
@Log@PostMappingpublicResultsave(@RequestBodyEmpemp){empService.save(emp);returnResult.success();}表示这个新增接口需要记录操作日志。
而查询接口没有加@Log:
@GetMapping("/{id}")publicResultgetById(@PathVariableIntegerid){Empemp=empService.getById(id);returnResult.success(emp);}表示查询员工详情时不记录操作日志。
这样就可以灵活控制哪些接口记录日志,哪些接口不记录。
八、编写 AOP 切面类
下面是这一篇最核心的代码。
@Component@AspectpublicclassLogAspect{@AutowiredprivateOperateLogMapperoperateLogMapper;@Around("@annotation(com.example.anno.Log)")publicObjectrecordLog(ProceedingJoinPointjoinPoint)throwsThrowable{longbegin=System.currentTimeMillis();Objectresult=joinPoint.proceed();longend=System.currentTimeMillis();longcostTime=end-begin;MethodSignaturesignature=(MethodSignature)joinPoint.getSignature();StringclassName=joinPoint.getTarget().getClass().getName();StringmethodName=signature.getName();Object[]args=joinPoint.getArgs();OperateLogoperateLog=newOperateLog();operateLog.setOperateUser(1);operateLog.setOperateTime(LocalDateTime.now());operateLog.setClassName(className);operateLog.setMethodName(methodName);operateLog.setMethodParams(Arrays.toString(args));operateLog.setReturnValue(String.valueOf(result));operateLog.setCostTime(costTime);operateLogMapper.insert(operateLog);returnresult;}}文字说明
这个切面类完成了日志记录的核心逻辑。
@Aspect表示当前类是一个切面类。
@Component表示把这个类交给 Spring 容器管理。
@Around("@annotation(com.example.anno.Log)")表示只要方法上加了@Log注解,就会被这个切面拦截。
这里使用的是环绕通知,意思是:
目标方法执行前后,AOP 都可以插入自己的逻辑。
核心代码是:
Objectresult=joinPoint.proceed();这行代码表示执行原来的目标方法。
如果没有这行代码,原来的 Controller 方法就不会真正执行。
在目标方法执行前记录开始时间:
longbegin=System.currentTimeMillis();在目标方法执行后记录结束时间:
longend=System.currentTimeMillis();然后计算耗时:
longcostTime=end-begin;最后把操作信息封装成OperateLog对象,保存到数据库中。
九、获取当前登录用户 id
上面的代码中有一行:
operateLog.setOperateUser(1);这里为了演示,暂时写死了操作人 id。
但是在真实项目中,操作人应该从登录信息中获取。
如果前面使用了 JWT 登录认证,JWT 中通常会存用户 id:
claims.put("id",loginEmp.getId());那么拦截器解析 token 后,可以把用户 id 存入ThreadLocal,后面在 AOP 中再取出来。
示例工具类:
publicclassCurrentHolder{privatestaticfinalThreadLocal<Integer>THREAD_LOCAL=newThreadLocal<>();publicstaticvoidsetCurrentId(Integerid){THREAD_LOCAL.set(id);}publicstaticIntegergetCurrentId(){returnTHREAD_LOCAL.get();}publicstaticvoidremove(){THREAD_LOCAL.remove();}}拦截器中解析 token 后保存用户 id:
Claimsclaims=JwtUtils.parseJwt(token);IntegerempId=(Integer)claims.get("id");CurrentHolder.setCurrentId(empId);AOP 中获取当前用户:
IntegeroperateUser=CurrentHolder.getCurrentId();operateLog.setOperateUser(operateUser);请求结束后,还需要移除 ThreadLocal 中的数据:
CurrentHolder.remove();涉及知识点
1. ThreadLocal 是什么?
ThreadLocal可以给当前线程保存一份独立的数据。
一次请求通常由一个线程处理,所以我们可以在拦截器中把用户 id 放入当前线程,后面的 Controller、Service、AOP 就都可以从当前线程中取到这个用户 id。
2. 为什么要 remove?
线程池中的线程会被复用。
如果请求结束后不清理 ThreadLocal,可能会出现数据残留,甚至导致内存泄漏。
所以使用完之后要调用:
CurrentHolder.remove();十、异常情况下如何记录日志?
上面的写法有一个问题:如果目标方法执行时抛出异常,下面这些代码就不会继续执行:
longend=System.currentTimeMillis();operateLogMapper.insert(operateLog);如果希望异常接口也记录日志,可以使用try...catch...finally改造。
@Around("@annotation(com.example.anno.Log)")publicObjectrecordLog(ProceedingJoinPointjoinPoint)throwsThrowable{longbegin=System.currentTimeMillis();Objectresult=null;Throwablethrowable=null;try{result=joinPoint.proceed();returnresult;}catch(Throwablee){throwable=e;throwe;}finally{longend=System.currentTimeMillis();MethodSignaturesignature=(MethodSignature)joinPoint.getSignature();OperateLogoperateLog=newOperateLog();operateLog.setOperateUser(CurrentHolder.getCurrentId());operateLog.setOperateTime(LocalDateTime.now());operateLog.setClassName(joinPoint.getTarget().getClass().getName());operateLog.setMethodName(signature.getName());operateLog.setMethodParams(Arrays.toString(joinPoint.getArgs()));operateLog.setReturnValue(throwable==null?String.valueOf(result):throwable.getMessage());operateLog.setCostTime(end-begin);operateLogMapper.insert(operateLog);}}文字说明
这里使用finally的原因是:
无论目标方法执行成功还是失败,finally 都会执行。
但是要注意:
throwe;异常一定要继续抛出去,不能被 AOP 吃掉。
如果 AOP 捕获异常后不继续抛出,Controller 或全局异常处理就感知不到异常,接口行为会变得不正常。
十一、常见问题总结
1. 为什么要使用自定义 @Log 注解?
因为不是所有接口都需要记录操作日志。
加了@Log的方法才记录,这样更加灵活。
2. @Around 中为什么必须调用 proceed?
因为joinPoint.proceed()才表示执行原来的目标方法。
如果不调用,原来的业务代码不会执行。
3. AOP 会不会影响原来的业务逻辑?
正常情况下不会。
AOP 只是额外增强功能,核心业务还是写在 Controller、Service、Mapper 中。
4. 日志参数可以直接保存吗?
学习阶段可以简单保存。
真实项目中要注意脱敏,比如密码、token、手机号等敏感信息不要直接写入日志表。
5. 日志保存失败会不会影响业务接口?
这要看项目要求。
如果日志非常重要,可以让日志保存失败时影响接口结果。
如果只是普通操作记录,可以考虑异步记录日志,避免日志保存失败影响主业务。
十二、实际开发建议
在项目中使用 AOP 记录日志时,建议注意下面几点:
- 用自定义注解控制哪些接口需要记录日志
- 日志记录逻辑不要写在 Controller 中
- 不要记录密码、token 等敏感数据
- 异常情况下也可以记录日志
- 使用 ThreadLocal 获取当前登录用户时,请求结束后要清理
- 日志表字段不要设计得太随意,后期排查问题会经常用到
- 如果日志量很大,可以考虑异步写入或单独日志系统
十三、总结
这一篇主要学习了如何使用 Spring AOP 实现接口操作日志记录。
AOP 最核心的价值是:在不修改原有业务代码的情况下,对方法进行统一增强。
在操作日志场景中,我们通过自定义@Log注解标记需要记录日志的方法,再通过@Around环绕通知获取方法参数、返回值、执行耗时等信息,最后使用 MyBatis 保存到日志表中。
这个功能相比前面的 CRUD 更接近真实项目,因为它解决的是系统可追踪、可排查的问题。后期如果项目出现数据被误删、接口异常、操作来源不清楚等情况,操作日志就会非常有用。
