找回密码
 立即注册
首页 业界区 业界 SpringBoot使用AOP优雅的实现系统操作日志的持久化! ...

SpringBoot使用AOP优雅的实现系统操作日志的持久化!

马璞玉 前天 12:24
基于AOP+Spring实现操作日志记录:从设计到落地全指南

在日常开发中,操作日志是系统不可或缺的一部分——它用于追溯用户行为、排查问题、审计安全操作。但如果在每个业务方法中硬编码日志逻辑,会导致代码耦合度高、重复工作量大、维护困难。本文将基于 AOP(面向切面编程) 思想,结合Spring生态,实现一套“业务与日志解耦、可复用、易扩展”的操作日志方案,附完整代码和关键问题解决方案。
一、方案背景与核心设计

1.1 为什么选择AOP?

传统日志实现的痛点:

  • 日志逻辑与业务逻辑混杂(如每个Service方法都写“记录日志”代码);
  • 新增日志需求时,需修改所有相关业务代码;
  • 日志格式/内容调整时,全局修改成本高。
AOP的优势恰好解决这些问题:

  • 解耦:日志逻辑作为“切面”独立存在,不侵入业务代码;
  • 复用:通过“切点”批量拦截目标方法,统一日志逻辑;
  • 灵活:新增/修改日志规则时,只需调整切面,无需改动业务。
1.2 核心架构设计

本方案采用“注解标记+AOP拦截+接口解耦+数据库存储”的架构,避免模块间循环依赖,同时保证扩展性。架构图如下:
flowchart TD    A[Aspect
(切面)] --> P[Pointcut
(切点)]    A --> Ad[Advice
(处理)]    A --> W[Weaving
(织入)]    W --> T[Target
(目标对象)]    T --> JP[joinPoint
(连接点)]        P --> E[execution
(路径表达式)]    P --> An[annotation
(注解)]    An --> SysA[系统注解]    An --> CusA[自定义注解]        Ad --> Time[处理时机]    Ad --> Content[处理内容]    Time --> Before[Before
(前置处理)]    Time --> After[After
(后置处理)]    Time --> Around[Around
(环绕处理)]    Time --> AfterReturning[AfterReturning
(后置返回通知)]    Time --> AfterThrowing[AfterThrowing
(异常抛出通知)]各组件职责:

  • @OperationLog注解:标记需要记录日志的业务方法,指定“操作模块”“操作描述”;
  • AOP切面(OperationLogAspect):拦截注解标记的方法,收集请求IP、方法信息、参数、耗时等;
  • LogHandler接口:定义日志保存规范,解耦Common模块与业务模块(避免循环依赖);
  • SysOperationLog实体:封装日志数据,映射数据库表;
  • 数据库表:持久化存储日志数据。
二、环境准备

需引入的核心依赖(Spring Boot项目为例):
  1. <dependency>
  2.     <groupId>org.springframework.boot</groupId>
  3.     spring-boot-starter-aop</artifactId>
  4. </dependency>
  5. <dependency>
  6.     <groupId>org.mybatis.spring.boot</groupId>
  7.     mybatis-spring-boot-starter</artifactId>
  8.     <version>2.3.0</version>
  9. </dependency>
  10. <dependency>
  11.     <groupId>org.projectlombok</groupId>
  12.     lombok</artifactId>
  13.     <optional>true</optional>
  14. </dependency>
  15. <dependency>
  16.     <groupId>com.mysql</groupId>
  17.     mysql-connector-j</artifactId>
  18.     <scope>runtime</scope>
  19. </dependency>
  20. <dependency>
  21.     <groupId>org.mybatis</groupId>
  22.     mybatis-typehandlers-jsr310</artifactId>
  23.     <version>1.0.1</version>
  24. </dependency>
复制代码
三、分步实现详解

3.1 步骤1:自定义操作日志注解(@OperationLog)

通过注解标记需要记录日志的方法,并携带“操作模块”“操作描述”等元信息(放在Common公共模块)。
  1. import java.lang.annotation.*;
  2. /**
  3. * 自定义操作日志注解:标记需要记录日志的方法
  4. */
  5. @Target({ElementType.METHOD}) // 仅作用于方法
  6. @Retention(RetentionPolicy.RUNTIME) // 运行时生效(AOP需动态获取注解信息)
  7. @Documented // 生成JavaDoc时包含该注解
  8. public @interface OperationLog {
  9.     /** 操作模块(如:用户管理、订单处理) */
  10.     String module() default "";
  11.     /** 操作描述(如:新增用户、删除订单) */
  12.     String description() default "";
  13. }
复制代码
3.2 步骤2:定义日志实体类(SysOperationLog)

封装日志的所有字段,与数据库表sys_operation_log一一对应(放在Common模块)。
  1. import lombok.Data;
  2. import java.time.LocalDateTime;
  3. /**
  4. * 操作日志实体类:映射数据库表sys_operation_log
  5. */
  6. @Data // Lombok自动生成getter/setter/toString
  7. public class SysOperationLog {
  8.     /** 日志ID(自增主键) */
  9.     private Long id;
  10.     /** 操作用户(用户名/账号) */
  11.     private String username;
  12.     /** 操作时间 */
  13.     private LocalDateTime operationTime;
  14.     /** 操作模块(如:用户管理) */
  15.     private String module;
  16.     /** 操作描述(如:新增用户) */
  17.     private String description;
  18.     /** 操作方法全路径(如:com.example.service.UserService.addUser) */
  19.     private String method;
  20.     /** 方法参数(JSON格式) */
  21.     private Object params;
  22.     /** 操作结果(成功/失败,JSON格式) */
  23.     private Object result;
  24.     /** 异常信息(失败时记录) */
  25.     private String exception;
  26.     /** 操作耗时(毫秒) */
  27.     private Long costTime;
  28.     /** 客户端IP */
  29.     private String clientIp;
  30. }
复制代码
3.3 步骤3:定义日志处理接口(LogHandler)

为避免Common模块直接依赖业务模块(导致循环依赖),通过接口定义日志保存规范,业务模块实现该接口(放在Common模块)。
  1. import com.wuxi.common.log.entity.SysOperationLog;
  2. /**
  3. * 日志处理接口:Common模块定义规范,业务模块实现具体逻辑
  4. * 作用:解耦Common与业务模块,避免循环依赖
  5. */
  6. public interface LogHandler {
  7.     /**
  8.      * 保存操作日志
  9.      * @param sysOperationLog 日志实体
  10.      */
  11.     void saveOperationLog(SysOperationLog sysOperationLog);
  12. }
复制代码
3.4 步骤4:实现AOP切面核心逻辑(OperationLogAspect)

AOP切面是日志收集的核心,负责拦截注解标记的方法、收集日志信息、调用LogHandler保存日志(放在Common模块)。
  1. import lombok.RequiredArgsConstructor;
  2. import lombok.extern.slf4j.Slf4j;
  3. import org.aspectj.lang.ProceedingJoinPoint;
  4. import org.aspectj.lang.annotation.Around;
  5. import org.aspectj.lang.annotation.Aspect;
  6. import org.aspectj.lang.annotation.Pointcut;
  7. import org.aspectj.lang.reflect.MethodSignature;
  8. import org.springframework.web.context.request.RequestContextHolder;
  9. import org.springframework.web.context.request.ServletRequestAttributes;
  10. import javax.servlet.http.HttpServletRequest;
  11. import java.lang.reflect.Method;
  12. import java.time.LocalDateTime;
  13. import java.util.Arrays;
  14. /**
  15. * 操作日志AOP切面:核心日志收集逻辑
  16. */
  17. @Slf4j // Lombok自动生成日志对象
  18. @Aspect // 标记为AOP切面
  19. @Component // 纳入Spring容器管理
  20. @RequiredArgsConstructor // Lombok自动生成构造函数,注入LogHandler
  21. public class OperationLogAspect {
  22.     // 注入日志处理接口(业务模块实现),避免依赖具体业务
  23.     private final LogHandler logHandler;
  24.     /**
  25.      * 定义切点:拦截所有添加@OperationLog注解的方法
  26.      */
  27.     @Pointcut("@annotation(com.wuxi.common.log.annotation.OperationLog)")
  28.     public void logPointCut() {}
  29.     /**
  30.      * 环绕通知:在方法执行前后拦截,收集日志信息
  31.      * 优势:可获取方法执行前(如开始时间)、执行后(如结果、耗时)、异常信息
  32.      */
  33.     @Around("logPointCut()")
  34.     public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
  35.         // 1. 记录方法开始时间(用于计算耗时)
  36.         long startTime = System.currentTimeMillis();
  37.         // 2. 初始化日志实体
  38.         SysOperationLog logEntity = new SysOperationLog();
  39.         logEntity.setOperationTime(LocalDateTime.now()); // 操作时间
  40.         // 3. 获取客户端IP(从请求上下文获取)
  41.         ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
  42.         if (requestAttributes != null) {
  43.             HttpServletRequest request = requestAttributes.getRequest();
  44.             logEntity.setClientIp(request.getRemoteAddr()); // 客户端IP
  45.         }
  46.         // 4. 获取方法信息(全路径、参数)
  47.         MethodSignature signature = (MethodSignature) joinPoint.getSignature();
  48.         Method method = signature.getMethod();
  49.         // 方法全路径:包名+类名+方法名
  50.         logEntity.setMethod(method.getDeclaringClass().getName() + "." + method.getName());
  51.         // 方法参数:数组转字符串(后续需序列化为JSON)
  52.         logEntity.setParams(Arrays.toString(joinPoint.getArgs()));
  53.         // 5. 获取@OperationLog注解信息(模块、描述)
  54.         OperationLog operationLog = method.getAnnotation(OperationLog.class);
  55.         logEntity.setModule(operationLog.module());
  56.         logEntity.setDescription(operationLog.description());
  57.         // 6. 获取操作用户(从登录上下文获取,如Spring Security)
  58.         logEntity.setUsername(getCurrentUsername());
  59.         Object businessResult = null; // 业务方法返回结果
  60.         try {
  61.             // 执行目标业务方法(核心业务逻辑)
  62.             businessResult = joinPoint.proceed();
  63.             // 方法执行成功:标记结果
  64.             logEntity.setResult("成功");
  65.             // 若需记录业务返回结果,可序列化后赋值:logEntity.setResult(JSON.toJSONString(businessResult));
  66.         } catch (Exception e) {
  67.             // 方法执行失败:记录异常信息
  68.             logEntity.setResult("失败");
  69.             logEntity.setException(e.getMessage()); // 异常信息(简化,可记录堆栈)
  70.             throw e; // 重新抛出异常,不影响原有业务异常处理逻辑
  71.         } finally {
  72.             // 7. 计算操作耗时(结束时间-开始时间)
  73.             logEntity.setCostTime(System.currentTimeMillis() - startTime);
  74.             // 8. 保存日志(调用业务模块实现的LogHandler)
  75.             saveOperationLog(logEntity);
  76.         }
  77.         // 返回业务方法结果,不影响业务流程
  78.         return businessResult;
  79.     }
  80.     /**
  81.      * 从登录上下文获取当前用户(实际项目需替换为真实逻辑)
  82.      * 示例:Spring Security可通过SecurityContextHolder获取
  83.      */
  84.     private String getCurrentUsername() {
  85.         // 模拟:从自定义UserContext获取(实际项目需实现上下文管理)
  86.         String currentUser = UserContext.getUser();
  87.         // 若未获取到用户(如系统操作),默认赋值为"system"
  88.         return currentUser == null ? "system" : currentUser;
  89.     }
  90.     /**
  91.      * 调用LogHandler保存日志,捕获异常避免影响主业务
  92.      */
  93.     private void saveOperationLog(SysOperationLog logEntity) {
  94.         try {
  95.             logHandler.saveOperationLog(logEntity);
  96.         } catch (Exception e) {
  97.             // 日志保存失败不影响主业务,仅记录日志告警
  98.             log.error("记录系统操作日志失败,日志信息:{}", logEntity, e);
  99.             // 若日志为核心审计需求,可抛出自定义异常:throw new DbException("记录日志失败", e);
  100.         }
  101.     }
  102. }
复制代码
3.5 步骤5:数据库表设计(sys_operation_log)

创建日志存储表,字段与SysOperationLog实体对应,添加索引优化查询(如按时间、用户查询)。
  1. CREATE TABLE `sys_operation_log` (
  2.   `id` bigint NOT NULL AUTO_INCREMENT COMMENT '日志ID(自增主键)',
  3.   `username` varchar(50) NOT NULL COMMENT '操作用户',
  4.   `operation_time` datetime NOT NULL COMMENT '操作时间',
  5.   `module` varchar(100) NOT NULL COMMENT '操作模块(如:用户管理)',
  6.   `description` varchar(255) DEFAULT NULL COMMENT '操作描述(如:新增用户)',
  7.   `method` varchar(255) NOT NULL COMMENT '操作方法全路径',
  8.   `params` text COMMENT '方法参数(JSON格式)',
  9.   `result` text COMMENT '操作结果(成功/失败,JSON格式)',
  10.   `exception` text COMMENT '异常信息(失败时记录)',
  11.   `cost_time` bigint DEFAULT NULL COMMENT '操作耗时(毫秒)',
  12.   `client_ip` varchar(50) DEFAULT NULL COMMENT '客户端IP',
  13.   PRIMARY KEY (`id`),
  14.   KEY `idx_operation_time` (`operation_time`) COMMENT '按操作时间查询索引',
  15.   KEY `idx_username` (`username`) COMMENT '按用户查询索引'
  16. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='系统操作日志表';
复制代码
3.6 步骤6:实现LogHandler接口(业务模块)

在业务模块(如用户服务、订单服务)中实现LogHandler接口,调用MyBatis将日志插入数据库(避免Common模块依赖业务)。
  1. import com.wuxi.common.log.entity.SysOperationLog;
  2. import com.wuxi.common.log.handler.LogHandler;
  3. import com.wuxi.user.mapper.SysOperationLogMapper;
  4. import lombok.RequiredArgsConstructor;
  5. import org.springframework.stereotype.Component;
  6. import com.alibaba.fastjson.JSON; // 需引入FastJSON依赖
  7. /**
  8. * 日志处理实现类:业务模块实现,负责将日志插入数据库
  9. */
  10. @Component
  11. @RequiredArgsConstructor
  12. public class LogHandlerImpl implements LogHandler {
  13.     // 注入MyBatis Mapper(操作数据库)
  14.     private final SysOperationLogMapper sysOperationLogMapper;
  15.     @Override
  16.     public void saveOperationLog(SysOperationLog sysOperationLog) {
  17.         // 关键:将Object类型的params/result序列化为JSON字符串(适配数据库text类型)
  18.         if (sysOperationLog.getParams() != null) {
  19.             sysOperationLog.setParams(JSON.toJSONString(sysOperationLog.getParams()));
  20.         }
  21.         if (sysOperationLog.getResult() != null) {
  22.             sysOperationLog.setResult(JSON.toJSONString(sysOperationLog.getResult()));
  23.         }
  24.         // 调用MyBatis Mapper插入数据库
  25.         sysOperationLogMapper.insert(sysOperationLog);
  26.     }
  27. }
复制代码
3.7 步骤7:MyBatis映射(插入日志)

编写MyBatis Mapper接口和XML映射文件,实现日志插入逻辑。
7.1 Mapper接口
  1. import com.wuxi.common.log.entity.SysOperationLog;
  2. import org.apache.ibatis.annotations.Insert;
  3. import org.apache.ibatis.annotations.Options;
  4. /**
  5. * 操作日志MyBatis Mapper
  6. */
  7. public interface SysOperationLogMapper {
  8.     /**
  9.      * 插入操作日志
  10.      * @Options:自增主键回写(将数据库生成的id赋值给实体类的id字段)
  11.      */
  12.     @Insert("INSERT INTO sys_operation_log (" +
  13.             "username, operation_time, module, description, method, " +
  14.             "params, result, exception, cost_time, client_ip" +
  15.             ") VALUES (" +
  16.             "#{username}, #{operationTime}, #{module}, #{description}, #{method}, " +
  17.             "#{params}, #{result}, #{exception}, #{costTime}, #{clientIp}" +
  18.             ")")
  19.     @Options(useGeneratedKeys = true, keyProperty = "id")
  20.     int insert(SysOperationLog sysOperationLog);
  21. }
复制代码
7.2 (可选)XML映射文件

若偏好XML配置,可替换为以下方式(SysOperationLogMapper.xml):
  1. <?xml version="1.0" encoding="UTF-8"?>
  2. <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
  3. <mapper namespace="com.wuxi.user.mapper.SysOperationLogMapper">
  4.    
  5.     <insert id="insert" parameterType="com.wuxi.common.log.entity.SysOperationLog"
  6.             useGeneratedKeys="true" keyProperty="id">
  7.         INSERT INTO sys_operation_log (
  8.             username, operation_time, module, description, method,
  9.             params, result, exception, cost_time, client_ip
  10.         ) VALUES (
  11.             #{username}, #{operationTime}, #{module}, #{description}, #{method},
  12.             #{params}, #{result}, #{exception}, #{costTime}, #{clientIp}
  13.         )
  14.     </insert>
  15. </mapper>
复制代码
四、实际使用示例

在业务方法上添加@OperationLog注解,即可自动记录日志,无需额外编写日志代码。
  1. import com.wuxi.common.log.annotation.OperationLog;
  2. import com.wuxi.user.entity.User;
  3. import com.wuxi.user.service.UserService;
  4. import lombok.RequiredArgsConstructor;
  5. import org.springframework.web.bind.annotation.PostMapping;
  6. import org.springframework.web.bind.annotation.RequestBody;
  7. import org.springframework.web.bind.annotation.RestController;
  8. @RestController
  9. @RequiredArgsConstructor
  10. public class UserController {
  11.     private final UserService userService;
  12.     /**
  13.      * 新增用户接口:添加@OperationLog注解,自动记录日志
  14.      */
  15.     @PostMapping("/user/add")
  16.     @OperationLog(module = "用户管理", description = "新增用户")
  17.     public String addUser(@RequestBody User user) {
  18.         userService.saveUser(user);
  19.         return "新增用户成功";
  20.     }
  21.     /**
  22.      * 删除用户接口:记录日志
  23.      */
  24.     @PostMapping("/user/delete")
  25.     @OperationLog(module = "用户管理", description = "删除用户")
  26.     public String deleteUser(Long userId) {
  27.         userService.deleteUser(userId);
  28.         return "删除用户成功";
  29.     }
  30. }
复制代码
五、关键问题与解决方案

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实现;
  • 易用性:业务方法只需添加注解,即可自动记录日志。
该方案适用于单体应用和微服务架构,可根据实际需求扩展异步、脱敏、分表等功能,是企业级系统操作日志的最佳实践之一。

来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!

相关推荐

您需要登录后才可以回帖 登录 | 立即注册