在微服务项目中,操作日志是审计追踪的核心能力。本文基于 Spring Boot 2.x,手把手实现一个自定义操作日志注解,采用AOP拦截 + Spring事件 + 异步监听 + 数据库落库的三段解耦架构。
技术栈:Spring Boot 2.7 + Spring AOP + MyBatis-Plus + Hutool + Lombok
一、整体架构设计
核心思想是采集、传输、存储三段解耦,让日志逻辑零侵入业务代码。
| 特性 | 说明 |
|---|---|
| 解耦 | 切面只管采集发布,落库逻辑可独立演进 |
| 异步 | @Async让落库在独立线程执行,业务零等待 |
| 防御式 | 序列化失败有兜底、超长自动截断、NPE 全防护 |
二、实现步骤
第1步:定义注解
注解本身只是元数据标记,不包含任何逻辑。
package com.example.common.log.annotation; import java.lang.annotation.*; /** * 自定义操作日志注解 */ @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface MyLog { /** 日志标题(必填) */ String value(); /** 模块名称 */ String moduleName() default ""; /** 操作类型(新增/编辑/删除/导入等) */ String logType() default ""; /** 是否记录请求参数 */ boolean recordParams() default true; /** 是否记录返回结果 */ boolean recordResult() default true; /** 序列化时排除的字段名(如富文本字段) */ String[] excludeFields() default {}; }关键点:
@Retention(RUNTIME):必须设置,否则 AOP 运行时无法反射读取。@Target(METHOD):限定只能标注在方法上。- 可选属性给
default值,降低使用成本。
第2步:定义日志实体
对应数据库表,承载数据。
package com.example.common.log.entity; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; import java.io.Serializable; import java.time.LocalDateTime; /** * 操作日志实体 */ @Data @TableName("my_log") public class MyLogEntity implements Serializable { private static final long serialVersionUID = 1L; @TableId(type = IdType.ASSIGN_ID) private String id; /** 日志标题 */ private String title; /** 模块名称 */ private String moduleName; /** 操作类型 */ private String logType; /** 操作人 */ private String createBy; /** 操作人ID */ private String createId; /** 客户端IP */ private String remoteAddr; /** 请求URI */ private String requestUri; /** HTTP方法 */ private String method; /** 请求参数 */ private String params; /** 返回结果 */ private String jsonResult; /** 执行耗时(ms) */ private Long time; /** 异常信息 */ private String exception; /** 创建时间 */ private LocalDateTime createTime; }第3步:定义事件载体(纯 POJO)
重点:Spring 4.2+ 之后,事件对象不需要继承ApplicationEvent,纯 POJO 即可。ApplicationEventPublisher.publishEvent(Object)接受任意对象,@EventListener也能处理非ApplicationEvent的事件。
package com.example.common.log.event; import com.example.common.log.entity.MyLogEntity; import lombok.AllArgsConstructor; import lombok.Getter; /** * 日志事件(纯POJO,无需继承ApplicationEvent) */ @Getter @AllArgsConstructor public class MyLogEvent { private final MyLogEntity logEntity; }为什么用纯 POJO?
| 对比项 | 继承 ApplicationEvent | 纯 POJO |
|---|---|---|
| Spring 版本 | 所有版本 | 4.2+ |
| 框架依赖 | 依赖 Spring | 零依赖 |
| 构造器约束 | 必须调用 super(source) | 无约束 |
| source 字段 | 强制携带 | 不需要 |
Spring Boot 2.x 对应 Spring 5.x,完全支持纯 POJO 事件,推荐使用纯 POJO 写法。
第4步:定义切面(核心)
切面是整个机制的大脑,负责拦截、采集、发布。
package com.example.common.log.aspect; import cn.hutool.core.util.StrUtil; import cn.hutool.extra.servlet.ServletUtil; import cn.hutool.json.JSONArray; import cn.hutool.json.JSONObject; import cn.hutool.json.JSONUtil; import com.example.common.log.annotation.MyLog; import com.example.common.log.entity.MyLogEntity; import com.example.common.log.event.MyLogEvent; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; import org.springframework.context.ApplicationEventPublisher; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; import org.springframework.web.multipart.MultipartFile; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.time.LocalDateTime; import java.util.Arrays; import java.util.List; import java.util.Objects; import java.util.stream.Collectors; /** * 操作日志AOP切面 */ @Slf4j @Aspect @AllArgsConstructor public class MyLogAspect { private final ApplicationEventPublisher publisher; /** 请求参数/返回结果最大记录长度 */ private static final int MAX_PARAM_LENGTH = 2000; @Around("@annotation(myLog)") public Object around(ProceedingJoinPoint point, MyLog myLog) { // 1.构建日志实体(从请求上下文提取信息) MyLogEntity logEntity = buildLogEntity(myLog); // 2.记录请求参数 if (myLog.recordParams()) { String params = buildRequestParams(point, myLog.excludeFields()); logEntity.setParams(subStr(params)); } // 3.执行目标方法并计时 long startTime = System.currentTimeMillis(); Object result; try { result = point.proceed(); } catch (Throwable e) { // 4a. 异常场景:记录异常信息后发布事件,然后重新抛出 logEntity.setException(e.getMessage()); logEntity.setTime(System.currentTimeMillis() - startTime); publisher.publishEvent(new MyLogEvent(logEntity)); throw e; } // 4b. 正常场景:记录耗时与返回结果 logEntity.setTime(System.currentTimeMillis() - startTime); if (myLog.recordResult() && result != null) { try { logEntity.setJsonResult(subStr(JSONUtil.toJsonStr(result))); } catch (Exception e) { log.warn("返回结果序列化失败:{}", e.getMessage()); } } // 5. 发布事件(不直接入库,交给异步监听器) publisher.publishEvent(new MyLogEvent(logEntity)); return result; } /** * 从请求上下文提取用户、IP、URI等信息 */ private MyLogEntity buildLogEntity(MyLog myLog) { HttpServletRequest request = ((ServletRequestAttributes) Objects .requireNonNull(RequestContextHolder.getRequestAttributes())) .getRequest(); MyLogEntity entity = new MyLogEntity(); entity.setTitle(myLog.value()); entity.setModuleName(myLog.moduleName()); entity.setLogType(myLog.logType()); entity.setRemoteAddr(ServletUtil.getClientIP(request)); entity.setRequestUri(request.getRequestURI()); entity.setMethod(request.getMethod()); entity.setCreateTime(LocalDateTime.now()); return entity; } /** * 构建请求参数:GET取QueryString,POST取方法参数序列化 */ private String buildRequestParams(ProceedingJoinPoint point, String[] excludeFields) { HttpServletRequest request = ((ServletRequestAttributes) Objects .requireNonNull(RequestContextHolder.getRequestAttributes())) .getRequest(); // GET/DELETE:优先取URL Query参数 if ("GET".equalsIgnoreCase(request.getMethod()) || "DELETE".equalsIgnoreCase(request.getMethod())) { String queryString = request.getQueryString(); if (StrUtil.isNotBlank(queryString)) { return queryString; } } // POST/PUT/PATCH:序列化方法参数 Object[] args = point.getArgs(); if (args == null || args.length == 0) { return ""; } // 过滤不可序列化的参数 List<Object> logArgs = Arrays.stream(args) .filter(arg -> !(arg instanceof HttpServletRequest) && !(arg instanceof HttpServletResponse) && !(arg instanceof MultipartFile)) .collect(Collectors.toList()); if (logArgs.isEmpty()) { return ""; } try { String json = JSONUtil.toJsonStr(logArgs); return removeExcludedFields(json, excludeFields); } catch (Exception e) { log.warn("请求参数序列化失败:{}", e.getMessage()); return logArgs.toString(); } } /** * 移除JSON中指定的大字段,防止富文本撑爆存储 */ private String removeExcludedFields(String json, String[] excludeFields) { if (excludeFields == null || excludeFields.length == 0) { return json; } try { JSONArray array = JSONUtil.parseArray(json); for (int i = 0; i < array.size(); i++) { Object item = array.get(i); if (item instanceof JSONObject) { for (String field : excludeFields) { ((JSONObject) item).remove(field); } } } return array.toString(); } catch (Exception e) { return json; } } /** * 字符串截断保护 */ private String subStr(String str) { if (str == null) { return ""; } return str.length() <= MAX_PARAM_LENGTH ? str : str.substring(0, MAX_PARAM_LENGTH) + "..."; } }第5步:定义异步监听器
监听器负责异步落库,不阻塞业务线程。
package com.example.common.log.event; import com.example.common.log.entity.MyLogEntity; import com.example.common.log.mapper.MyLogMapper; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.event.EventListener; import org.springframework.core.annotation.Order; import org.springframework.scheduling.annotation.Async; /** * 日志异步监听器 */ @Slf4j @AllArgsConstructor public class MyLogListener { private final MyLogMapper myLogMapper; @Async @Order @EventListener(MyLogEvent.class) public void saveLog(MyLogEvent event) { try { MyLogEntity logEntity = event.getLogEntity(); myLogMapper.insert(logEntity); } catch (Exception e) { // 日志落库失败不影响业务,仅记录错误 log.error("日志保存失败", e); } } }注意:如果是微服务架构,需要跨服务归集日志,把
myLogMapper.insert()替换为 Feign 调用即可:feignLogService.saveLog(logEntity, SecurityConstants.FROM_IN);
第6步:自动配置类
让其他服务引入依赖后开箱即用。
package com.example.common.log; import com.example.common.log.aspect.MyLogAspect; import com.example.common.log.event.MyLogListener; import com.example.common.log.mapper.MyLogMapper; import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.scheduling.annotation.EnableAsync; /** * 日志自动配置 */ @Configuration @ConditionalOnWebApplication @EnableAsync public class MyLogAutoConfiguration { @Bean public MyLogAspect myLogAspect(ApplicationEventPublisher publisher) { return new MyLogAspect(publisher); } @Bean public MyLogListener myLogListener(MyLogMapper myLogMapper) { return new MyLogListener(myLogMapper); } }注意:如果切面在公共 common 模块中,还需在resources/META-INF/spring.factories或resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports中注册自动配置类,否则不会被加载。
三、使用方式
在 Controller 方法上标注即可:
@Operation(summary = "新增展会") @MyLog(value = "新增展会", moduleName = "展会管理", logType = "新增") @PostMapping public R save(@RequestBody ExhibitionDTO dto) { return R.ok(service.save(dto)); }带大字段排除的进阶用法:
@MyLog(value = "编辑展会项目", moduleName = "展会管理", logType = "编辑", excludeFields = {"rentalDescriptionText", "subtitleContent"}) @PostMapping("/update") public R<Boolean> update(@RequestBody ExhibitionProjectDTO dto) { return R.ok(service.update(dto)); }四、完整执行流程
- 请求到达 Controller(带
@MyLog) MyLogAspect.around()环绕拦截buildLogEntity:从 Request 提取 IP/URI/Method/用户buildRequestParams:序列化参数(过滤大字段+截断)point.proceed():执行业务方法(计时)- 序列化返回结果 →
jsonResult publisher.publishEvent(MyLogEvent)← 只发布不落库
MyLogListener@Async异步接收MyLogMapper.insert()入库
五、注意事项
5.1 注解设计
@Retention必须RUNTIME,否则 AOP 运行时无法读取。@Target限定METHOD,避免误用在类/字段上。
5.2 切面实现
- 异常不能吞:
point.proceed()的异常必须重新throw,否则全局异常处理无法捕获。 - 异常也要记录:
catch块中发布事件记录异常信息,然后throw。 - 过滤不可序列化参数:
HttpServletRequest/HttpServletResponse/MultipartFile必须过滤。 - 大字段排除:富文本字段可能几十 KB,需要
excludeFields机制。 - 长度截断:
MAX_PARAM_LENGTH兜底截断。
5.3 异步与线程安全
@EnableAsync必须开启,否则@Async不生效。RequestContextHolder和SecurityContextHolder的信息必须在切面(同步线程)中提前提取,异步监听器中无法获取。
5.4 事件设计
- Spring 4.2+ 事件可为纯 POJO,无需继承
ApplicationEvent。 - 纯 POJO 的优势:零框架依赖、构造器无约束、更符合现代 Spring 设计理念。
5.5 自动配置
- 公共模块的切面 Bean 必须通过
@Configuration + @Bean注册。 - Spring Boot 2.7 推荐使用
AutoConfiguration.imports注册自动配置类。
六、总结
| 组件 | 职责 | 设计要点 |
|---|---|---|
| @MyLog 注解 | 元数据标记 | RUNTIME保留 + METHOD限定 |
| MyLogAspect切面 | 拦截+采集+发布 | 环绕通知,异常不吞,参数防护 |
| MyLogEvent事件 | 解耦载体 | 纯POJO,零框架依赖 |
| MyLogListener监听器 | 异步落库 | @Async + try-catch不影响业务 |
| MyLogAutoConfiguration | 自动装配 | @ConditionalOnWebApplication + @EnableAsync |
一句话总结:注解定义元数据 → AOP拦截采集 → 纯 POJO事件解耦 → 异步监听落库,四段各司其职,业务零侵入。
本文完整代码可直接用于 Spring Boot 2.x 项目,基于纯 POJO 事件设计,无需继承ApplicationEvent。
Spring Boot 3.x 实现自定义注解的差异说明
Spring Boot 3.x 实现自定义注解的核心机制(AOP + 事件驱动 + 异步落库)完全不变,但有几个关键的 API 层面差异需要适配。
核心差异速览
| 维度 | Spring Boot 2.x | Spring Boot 3.x | 影响程度 |
|---|---|---|---|
| JDK最低版本 | 8 | 17 | 环境要求 |
| Servlet 包名 | javax.servlet.* | jakarta.servlet.* | 代码必须改 |
| Validation 包名 | javax.validation.* | jakarta.validation.* | 代码必须改 |
| 底层框架 | Spring Framework 5.x | Spring Framework 6.x | 内部变化 |
| 自动配置注册 | spring.factories | AutoConfiguration.imports | 推荐改 |
| 纯 POJO事件 | 支持 | 支持(不变) | 无影响 |
一、最大变化:javax → jakarta 包名迁移
这是代码层面唯一必须改的地方。Spring Boot 3.x 将所有javax.*命名空间迁移到jakarta.*,因为 Jakarta EE 9 之后规范变更了包名。
切面中的影响
// ❌ Spring Boot 2.x 写法(3.x 编译报错) import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; // ✅ Spring Boot 3.x 写法 import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse;实体类中的影响
// ❌ Spring Boot 2.x import javax.validation.constraints.NotNull; // ✅ Spring Boot 3.x import jakarta.validation.constraints.NotNull;注解类中的影响
总结:只需要把所有javax.servlet.*和javax.validation.*的 import 改为jakarta.*,其余java.lang.*、java.time.*等不受影响。
二、代码差异对比(仅列出有变化的部分)
日志实体(仅 import 变化)
// Spring Boot 2.x import javax.validation.constraints.NotNull; // ❌ // Spring Boot 3.x import jakarta.validation.constraints.NotNull; // ✅切面(两处 import 变化)
// Spring Boot 2.x import javax.servlet.http.HttpServletRequest; // ❌ import javax.servlet.http.HttpServletResponse; // ❌ // Spring Boot 3.x import jakarta.servlet.http.HttpServletRequest; // ✅ import jakarta.servlet.http.HttpServletResponse; // ✅其余切面逻辑(@Around、@Aspect、ApplicationEventPublisher、RequestContextHolder)完全不变。
监听器(无变化)
// @Async、@EventListener、@Order 全部不变 // 纯 POJO事件仍然支持自动配置注册方式(推荐变化)
// 自动配置类本身代码不变 @Configuration @ConditionalOnWebApplication @EnableAsync public class MyLogAutoConfiguration { // ... }变化的是注册方式:
# ❌ Spring Boot 2.x(spring.factories,3.x 中已废弃但仍兼容) # resources/META-INF/spring.factories内容: org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ com.example.common.log.MyLogAutoConfiguration # ✅ Spring Boot 3.x(推荐新方式) # resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports内容: com.example.common.log.MyLogAutoConfiguration三、不变的部分(核心机制完全一致)
| 机制 | 是否变化 | 说明 |
|---|---|---|
@Around("@annotation(myLog)") | 不变 | AOP 表达式语法不变 |
ApplicationEventPublisher.publishEvent(Object) | 不变 | 纯 POJO事件仍然支持 |
@EventListener + @Async | 不变 | 监听机制不变 |
RequestContextHolder | 不变 | 请求上下文获取方式不变 |
@ConditionalOnWebApplication | 不变 | 条件装配不变 |
@EnableAsync | 不变 | 异步开关不变 |
四、迁移检查清单
如果你要把 2.x 的代码迁移到 3.x,按这个清单逐项检查即可:
- JDK 升级到 17+
- 全局替换
javax.servlet→jakarta.servlet - 全局替换
javax.validation→ `jakarta.validation