当前位置: 首页 > news >正文

【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 记录日志时,建议注意下面几点:

  1. 用自定义注解控制哪些接口需要记录日志
  2. 日志记录逻辑不要写在 Controller 中
  3. 不要记录密码、token 等敏感数据
  4. 异常情况下也可以记录日志
  5. 使用 ThreadLocal 获取当前登录用户时,请求结束后要清理
  6. 日志表字段不要设计得太随意,后期排查问题会经常用到
  7. 如果日志量很大,可以考虑异步写入或单独日志系统

十三、总结

这一篇主要学习了如何使用 Spring AOP 实现接口操作日志记录。

AOP 最核心的价值是:在不修改原有业务代码的情况下,对方法进行统一增强。

在操作日志场景中,我们通过自定义@Log注解标记需要记录日志的方法,再通过@Around环绕通知获取方法参数、返回值、执行耗时等信息,最后使用 MyBatis 保存到日志表中。

这个功能相比前面的 CRUD 更接近真实项目,因为它解决的是系统可追踪、可排查的问题。后期如果项目出现数据被误删、接口异常、操作来源不清楚等情况,操作日志就会非常有用。

http://www.zskr.cn/news/1537130.html

相关文章:

  • manjaro安装电脑版微信
  • 2026武汉黄金回收实测:这家从检测到收款只用一首歌时间 - 奢侈品回收测评
  • 临沂北城新区专业管道疏通 2026 真实评测最新综合排行榜 - 居顺联家政疏通
  • Java 基础第四篇 | 循环结构:while、do-while、for
  • 卖表别被坑!2026 杭州名表回收套路盘点,浪琴名匠、帝舵碧湾怎么卖价最高 - 奢侈品回收评测
  • Python-100-Days实战:从零构建企业级RESTful API架构深度指南
  • 2026 年 6 月长沙艺体特色高中测评,升学避坑指南 - 讲清楚了
  • 客户口碑好的GEO优化公司怎么选?2026避坑指南|干货 - 品牌测评鉴赏家
  • 保研边缘人逆袭指南:从‘末流211’到东南软院,我的GPA、竞赛与面试全复盘
  • 家中闲置包包配件齐全怎么溢价?2026深圳收的顶官方顶估价标准公开 - 奢侈品回收测评
  • 2026济南名表回收排名出炉:添价收荣登榜首,七家品牌实力盘点 - 薛定谔的梨花猫
  • 西门子博图ModbusRTU轮询FB
  • HTML打包EXE离线一机一码新增试用功能(附2026最新版下载地址)
  • 持证鉴定 + 资金兜底,2026 厦门黄金回收标杆品牌权威排行榜 - 奢侈品回收评测
  • 20260616第三周
  • 在鸿蒙PC上使用pkgsrc进行包管理
  • 回收店不会说的秘密:合肥首饰保值、贬值的核心原因 - 奢侈品回收评测
  • 终极3DS游戏格式转换指南:3dsconv让你的游戏管理更高效
  • ARINC429数据收发老出错?可能是你的HI-3593 SPI配置没搞对(调试避坑实录)
  • 2026年深圳专利申请机构推荐全景榜:从产业分层视角看五家代表性服务方的选型逻辑 - 速递信息
  • 2026年北京黄金回收白名单:本地人亲测、无套路的六家正规回收门店测评 - 名奢变现站
  • 告别‘命令未找到’:在Ubuntu 20.04/22.04上快速搞定ARM交叉编译环境(含gcc-arm-linux-gnueabihf配置)
  • 3大实战突破:用GammaGammaFitter模型精准量化客户终身价值
  • 2026北京迷你仓公司排行榜 前5正规品牌盘点 - 速递信息
  • 避开这3个坑,你的ESP-01S和天问51单片机才能稳定连接巴法云
  • 2026年苏州驾校推荐榜:考驾照/学车/驾驶培训优质之选,专业教练与高效拿证服务深度解析 - 企业推荐官【官方】
  • LIN总线休眠唤醒测试避坑指南:从“主节点丢失”到“预休眠处理”的实战案例分析
  • 图形学期末求生指南:从八叉树到Gerstner波,手把手梳理电科软工核心考点与避坑心得
  • 2026 福州闲置包变现测评:回收 vs 寄卖哪个更赚 - 奢侈品回收评测
  • 湖州安吉上门疏通管道 2026 真实评测最新综合排行榜 - 居顺联家政疏通