Spring Boot 自定义注解实战:手把手实现一个操作日志注解(AOP +事件驱动 + 异步落库)

📅 2026/7/22 5:44:40
Spring Boot 自定义注解实战:手把手实现一个操作日志注解(AOP +事件驱动 + 异步落库)
在微服务项目中操作日志是审计追踪的核心能力。本文基于 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纯 POJOSpring 版本所有版本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取QueryStringPOST取方法参数序列化 */ 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 ; } // 过滤不可序列化的参数 ListObject 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 RBoolean update(RequestBody ExhibitionProjectDTO dto) { return R.ok(service.update(dto)); }四、完整执行流程请求到达 Controller带MyLogMyLogAspect.around()环绕拦截buildLogEntity从 Request 提取 IP/URI/Method/用户buildRequestParams序列化参数过滤大字段截断point.proceed()执行业务方法计时序列化返回结果 →jsonResultpublisher.publishEvent(MyLogEvent)← 只发布不落库MyLogListenerAsync异步接收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.xSpring Boot 3.x影响程度JDK最低版本817环境要求Servlet 包名javax.servlet.*jakarta.servlet.*代码必须改Validation 包名javax.validation.*jakarta.validation.*代码必须改底层框架Spring Framework 5.xSpring Framework 6.x内部变化自动配置注册spring.factoriesAutoConfiguration.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.xspring.factories3.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