SpringBoot使用AOP优雅的实现系统操作日志的持久化!
基于AOP+Spring实现操作日志记录:从设计到落地全指南
在日常开发中,操作日志是系统不可或缺的一部分——它用于追溯用户行为、排查问题、审计安全操作。但如果在每个业务方法中硬编码日志逻辑,会导致代码耦合度高、重复工作量大、维护困难。本文将基于
AOP(面向切面编程)
思想,结合Spring生态,实现一套“业务与日志解耦、可复用、易扩展”的操作日志方案,附完整代码和关键问题解决方案。一、方案背景与核心设计
1.1 为什么选择AOP?
传统日志实现的痛点:
- 日志逻辑与业务逻辑混杂(如每个Service方法都写“记录日志”代码);
- 新增日志需求时,需修改所有相关业务代码;
- 日志格式/内容调整时,全局修改成本高。
AOP的优势恰好解决这些问题:
解耦
:日志逻辑作为“切面”独立存在,不侵入业务代码;复用
:通过“切点”批量拦截目标方法,统一日志逻辑;灵活
:新增/修改日志规则时,只需调整切面,无需改动业务。
1.2 核心架构设计
本方案采用“
注解标记+AOP拦截+接口解耦+数据库存储
”的架构,避免模块间循环依赖,同时保证扩展性。架构图如下: flowchart TD A[Aspect<br/>(切面)] --> P[Pointcut<br/>(切点)] A --> Ad[Advice<br/>(处理)] A --> W[Weaving<br/>(织入)] W --> T[Target<br/>(目标对象)] T --> JP[joinPoint<br/>(连接点)] P --> E[execution<br/>(路径表达式)] P --> An[annotation<br/>(注解)] An --> SysA[系统注解] An --> CusA[自定义注解] Ad --> Time[处理时机] Ad --> Content[处理内容] Time --> Before[Before<br/>(前置处理)] Time --> After[After<br/>(后置处理)] Time --> Around[Around<br/>(环绕处理)] Time --> AfterReturning[AfterReturning<br/>(后置返回通知)] Time --> AfterThrowing[AfterThrowing<br/>(异常抛出通知)]各组件职责:
@OperationLog注解
:标记需要记录日志的业务方法,指定“操作模块”“操作描述”;AOP切面(OperationLogAspect)
:拦截注解标记的方法,收集请求IP、方法信息、参数、耗时等;LogHandler接口
:定义日志保存规范,解耦Common模块与业务模块(避免循环依赖);SysOperationLog实体
:封装日志数据,映射数据库表;数据库表
:持久化存储日志数据。
二、环境准备
需引入的核心依赖(Spring Boot项目为例):
<!-- Spring AOP --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-aop</artifactId> </dependency> <!-- MyBatis(操作数据库) --> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.0</version> </dependency> <!-- Lombok(简化实体类代码) --> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <!-- MySQL驱动 --> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <!-- Java 8时间类型支持(LocalDateTime映射MySQL datetime) --> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis-typehandlers-jsr310</artifactId> <version>1.0.1</version> </dependency>
三、分步实现详解
3.1 步骤1:自定义操作日志注解(@OperationLog)
通过注解标记需要记录日志的方法,并携带“操作模块”“操作描述”等元信息(放在Common公共模块)。
import java.lang.annotation.*; /** * 自定义操作日志注解:标记需要记录日志的方法 */ @Target({ElementType.METHOD}) // 仅作用于方法 @Retention(RetentionPolicy.RUNTIME) // 运行时生效(AOP需动态获取注解信息) @Documented // 生成JavaDoc时包含该注解 public @interface OperationLog { /** 操作模块(如:用户管理、订单处理) */ String module() default ""; /** 操作描述(如:新增用户、删除订单) */ String description() default ""; }
3.2 步骤2:定义日志实体类(SysOperationLog)
封装日志的所有字段,与数据库表sys_operation_log
一一对应(放在Common模块)。
import lombok.Data; import java.time.LocalDateTime; /** * 操作日志实体类:映射数据库表sys_operation_log */ @Data // Lombok自动生成getter/setter/toString public class SysOperationLog { /** 日志ID(自增主键) */ private Long id; /** 操作用户(用户名/账号) */ private String username; /** 操作时间 */ private LocalDateTime operationTime; /** 操作模块(如:用户管理) */ private String module; /** 操作描述(如:新增用户) */ private String description; /** 操作方法全路径(如:com.example.service.UserService.addUser) */ private String method; /** 方法参数(JSON格式) */ private Object params; /** 操作结果(成功/失败,JSON格式) */ private Object result; /** 异常信息(失败时记录) */ private String exception; /** 操作耗时(毫秒) */ private Long costTime; /** 客户端IP */ private String clientIp; }
3.3 步骤3:定义日志处理接口(LogHandler)
为避免Common模块直接依赖业务模块(导致循环依赖),通过接口定义日志保存规范,业务模块实现该接口(放在Common模块)。
import com.wuxi.common.log.entity.SysOperationLog; /** * 日志处理接口:Common模块定义规范,业务模块实现具体逻辑 * 作用:解耦Common与业务模块,避免循环依赖 */ public interface LogHandler { /** * 保存操作日志 * @param sysOperationLog 日志实体 */ void saveOperationLog(SysOperationLog sysOperationLog); }
3.4 步骤4:实现AOP切面核心逻辑(OperationLogAspect)
AOP切面是日志收集的核心,负责拦截注解标记的方法、收集日志信息、调用LogHandler保存日志(放在Common模块)。
import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.annotation.Pointcut; import org.aspectj.lang.reflect.MethodSignature; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; import javax.servlet.http.HttpServletRequest; import java.lang.reflect.Method; import java.time.LocalDateTime; import java.util.Arrays; /** * 操作日志AOP切面:核心日志收集逻辑 */ @Slf4j // Lombok自动生成日志对象 @Aspect // 标记为AOP切面 @Component // 纳入Spring容器管理 @RequiredArgsConstructor // Lombok自动生成构造函数,注入LogHandler public class OperationLogAspect { // 注入日志处理接口(业务模块实现),避免依赖具体业务 private final LogHandler logHandler; /** * 定义切点:拦截所有添加@OperationLog注解的方法 */ @Pointcut("@annotation(com.wuxi.common.log.annotation.OperationLog)") public void logPointCut() {} /** * 环绕通知:在方法执行前后拦截,收集日志信息 * 优势:可获取方法执行前(如开始时间)、执行后(如结果、耗时)、异常信息 */ @Around("logPointCut()") public Object around(ProceedingJoinPoint joinPoint) throws Throwable { // 1. 记录方法开始时间(用于计算耗时) long startTime = System.currentTimeMillis(); // 2. 初始化日志实体 SysOperationLog logEntity = new SysOperationLog(); logEntity.setOperationTime(LocalDateTime.now()); // 操作时间 // 3. 获取客户端IP(从请求上下文获取) ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); if (requestAttributes != null) { HttpServletRequest request = requestAttributes.getRequest(); logEntity.setClientIp(request.getRemoteAddr()); // 客户端IP } // 4. 获取方法信息(全路径、参数) MethodSignature signature = (MethodSignature) joinPoint.getSignature(); Method method = signature.getMethod(); // 方法全路径:包名+类名+方法名 logEntity.setMethod(method.getDeclaringClass().getName() + "." + method.getName()); // 方法参数:数组转字符串(后续需序列化为JSON) logEntity.setParams(Arrays.toString(joinPoint.getArgs())); // 5. 获取@OperationLog注解信息(模块、描述) OperationLog operationLog = method.getAnnotation(OperationLog.class); logEntity.setModule(operationLog.module()); logEntity.setDescription(operationLog.description()); // 6. 获取操作用户(从登录上下文获取,如Spring Security) logEntity.setUsername(getCurrentUsername()); Object businessResult = null; // 业务方法返回结果 try { // 执行目标业务方法(核心业务逻辑) businessResult = joinPoint.proceed(); // 方法执行成功:标记结果 logEntity.setResult("成功"); // 若需记录业务返回结果,可序列化后赋值:logEntity.setResult(JSON.toJSONString(businessResult)); } catch (Exception e) { // 方法执行失败:记录异常信息 logEntity.setResult("失败"); logEntity.setException(e.getMessage()); // 异常信息(简化,可记录堆栈) throw e; // 重新抛出异常,不影响原有业务异常处理逻辑 } finally { // 7. 计算操作耗时(结束时间-开始时间) logEntity.setCostTime(System.currentTimeMillis() - startTime); // 8. 保存日志(调用业务模块实现的LogHandler) saveOperationLog(logEntity); } // 返回业务方法结果,不影响业务流程 return businessResult; } /** * 从登录上下文获取当前用户(实际项目需替换为真实逻辑) * 示例:Spring Security可通过SecurityContextHolder获取 */ private String getCurrentUsername() { // 模拟:从自定义UserContext获取(实际项目需实现上下文管理) String currentUser = UserContext.getUser(); // 若未获取到用户(如系统操作),默认赋值为"system" return currentUser == null ? "system" : currentUser; } /** * 调用LogHandler保存日志,捕获异常避免影响主业务 */ private void saveOperationLog(SysOperationLog logEntity) { try { logHandler.saveOperationLog(logEntity); } catch (Exception e) { // 日志保存失败不影响主业务,仅记录日志告警 log.error("记录系统操作日志失败,日志信息:{}", logEntity, e); // 若日志为核心审计需求,可抛出自定义异常:throw new DbException("记录日志失败", e); } } }
3.5 步骤5:数据库表设计(sys_operation_log)
创建日志存储表,字段与SysOperationLog实体对应,添加索引优化查询(如按时间、用户查询)。
CREATE TABLE `sys_operation_log` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '日志ID(自增主键)', `username` varchar(50) NOT NULL COMMENT '操作用户', `operation_time` datetime NOT NULL COMMENT '操作时间', `module` varchar(100) NOT NULL COMMENT '操作模块(如:用户管理)', `description` varchar(255) DEFAULT NULL COMMENT '操作描述(如:新增用户)', `method` varchar(255) NOT NULL COMMENT '操作方法全路径', `params` text COMMENT '方法参数(JSON格式)', `result` text COMMENT '操作结果(成功/失败,JSON格式)', `exception` text COMMENT '异常信息(失败时记录)', `cost_time` bigint DEFAULT NULL COMMENT '操作耗时(毫秒)', `client_ip` varchar(50) DEFAULT NULL COMMENT '客户端IP', PRIMARY KEY (`id`), KEY `idx_operation_time` (`operation_time`) COMMENT '按操作时间查询索引', KEY `idx_username` (`username`) COMMENT '按用户查询索引' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='系统操作日志表';
3.6 步骤6:实现LogHandler接口(业务模块)
在业务模块(如用户服务、订单服务)中实现LogHandler接口,调用MyBatis将日志插入数据库(避免Common模块依赖业务)。
import com.wuxi.common.log.entity.SysOperationLog; import com.wuxi.common.log.handler.LogHandler; import com.wuxi.user.mapper.SysOperationLogMapper; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import com.alibaba.fastjson.JSON; // 需引入FastJSON依赖 /** * 日志处理实现类:业务模块实现,负责将日志插入数据库 */ @Component @RequiredArgsConstructor public class LogHandlerImpl implements LogHandler { // 注入MyBatis Mapper(操作数据库) private final SysOperationLogMapper sysOperationLogMapper; @Override public void saveOperationLog(SysOperationLog sysOperationLog) { // 关键:将Object类型的params/result序列化为JSON字符串(适配数据库text类型) if (sysOperationLog.getParams() != null) { sysOperationLog.setParams(JSON.toJSONString(sysOperationLog.getParams())); } if (sysOperationLog.getResult() != null) { sysOperationLog.setResult(JSON.toJSONString(sysOperationLog.getResult())); } // 调用MyBatis Mapper插入数据库 sysOperationLogMapper.insert(sysOperationLog); } }
3.7 步骤7:MyBatis映射(插入日志)
编写MyBatis Mapper接口和XML映射文件,实现日志插入逻辑。
7.1 Mapper接口
import com.wuxi.common.log.entity.SysOperationLog; import org.apache.ibatis.annotations.Insert; import org.apache.ibatis.annotations.Options; /** * 操作日志MyBatis Mapper */ public interface SysOperationLogMapper { /** * 插入操作日志 * @Options:自增主键回写(将数据库生成的id赋值给实体类的id字段) */ @Insert("INSERT INTO sys_operation_log (" + "username, operation_time, module, description, method, " + "params, result, exception, cost_time, client_ip" + ") VALUES (" + "#{username}, #{operationTime}, #{module}, #{description}, #{method}, " + "#{params}, #{result}, #{exception}, #{costTime}, #{clientIp}" + ")") @Options(useGeneratedKeys = true, keyProperty = "id") int insert(SysOperationLog sysOperationLog); }
7.2 (可选)XML映射文件
若偏好XML配置,可替换为以下方式(SysOperationLogMapper.xml
):
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="com.wuxi.user.mapper.SysOperationLogMapper"> <!-- 插入操作日志 --> <insert parameterType="com.wuxi.common.log.entity.SysOperationLog" useGeneratedKeys="true" keyProperty="id"> INSERT INTO sys_operation_log ( username, operation_time, module, description, method, params, result, exception, cost_time, client_ip ) VALUES ( #{username}, #{operationTime}, #{module}, #{description}, #{method}, #{params}, #{result}, #{exception}, #{costTime}, #{clientIp} ) </insert> </mapper>
四、实际使用示例
在业务方法上添加@OperationLog
注解,即可自动记录日志,无需额外编写日志代码。
import com.wuxi.common.log.annotation.OperationLog; import com.wuxi.user.entity.User; import com.wuxi.user.service.UserService; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; @RestController @RequiredArgsConstructor public class UserController { private final UserService userService; /** * 新增用户接口:添加@OperationLog注解,自动记录日志 */ @PostMapping("/user/add") @OperationLog(module = "用户管理", description = "新增用户") public String addUser(@RequestBody User user) { userService.saveUser(user); return "新增用户成功"; } /** * 删除用户接口:记录日志 */ @PostMapping("/user/delete") @OperationLog(module = "用户管理", description = "删除用户") public String deleteUser(Long userId) { userService.deleteUser(userId); return "删除用户成功"; } }
五、关键问题与解决方案
5.1 如何避免循环依赖?
问题:Common模块需调用业务模块的日志保存逻辑,若Common直接依赖业务模块,会形成“Common→业务→Common”的循环依赖。
解决方案:
接口解耦
- Common模块定义
LogHandler
接口,不依赖业务; - 业务模块实现
LogHandler
接口,依赖Common模块; - 最终依赖链:
业务模块→Common模块
(单向依赖,无循环)。
5.2 Object类型参数/结果如何存储?
问题:SysOperationLog的params
和result
是Object类型,数据库是text类型,直接存储会报错。
解决方案:
JSON序列化
使用FastJSON/Jackson将Object序列化为JSON字符串,存储到数据库(如LogHandlerImpl中
JSON.toJSONString()
)。 5.3 LocalDateTime与MySQL datetime映射问题?
问题:Java 8的LocalDateTime
与MySQL的datetime
类型默认不兼容,会报类型转换错误。
解决方案:
- 引入
mybatis-typehandlers-jsr310
依赖(已在环境准备中添加); - MyBatis自动识别该类型处理器,无需额外配置。
5.4 日志保存失败影响主业务?
问题:若数据库异常导致日志保存失败,不能阻断核心业务流程。
解决方案:
异常隔离
在AOP的
saveOperationLog
方法中捕获异常,仅记录告警日志,不抛出异常(除非是核心审计日志,需强制记录)。 六、方案优化方向
异步保存日志
:通过@Async
注解异步执行日志保存,避免日志操作阻塞主业务(需开启Spring异步支持@EnableAsync
);日志脱敏
:对敏感参数(如密码、手机号)进行脱敏处理后再存储(如用***
替换中间字符);日志分表
:日志数据量较大时,按时间分表(如每月一张表),提升查询性能;分布式日志
:微服务场景下,可将日志发送到ELK(Elasticsearch+Logstash+Kibana),实现日志集中查询与分析。
七、总结
本文基于AOP+Spring实现的操作日志方案,核心优势在于:
解耦
:日志逻辑与业务逻辑完全分离,无侵入;可扩展
:新增日志字段或修改保存逻辑,只需调整切面或LogHandler实现;易用性
:业务方法只需添加注解,即可自动记录日志。
该方案适用于单体应用和微服务架构,可根据实际需求扩展异步、脱敏、分表等功能,是企业级系统操作日志的最佳实践之一。