news 2026/7/22 5:44:39

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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纯 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.factoriesresources/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)); }

四、完整执行流程

  1. 请求到达 Controller(带@MyLog
  2. MyLogAspect.around()环绕拦截
    • buildLogEntity:从 Request 提取 IP/URI/Method/用户
    • buildRequestParams:序列化参数(过滤大字段+截断)
    • point.proceed():执行业务方法(计时)
    • 序列化返回结果 →jsonResult
    • publisher.publishEvent(MyLogEvent)← 只发布不落库
  3. MyLogListener@Async异步接收
  4. 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不生效。
  • RequestContextHolderSecurityContextHolder的信息必须在切面(同步线程)中提前提取,异步监听器中无法获取。
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@AspectApplicationEventPublisherRequestContextHolder)完全不变。

监听器(无变化)

// @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.servletjakarta.servlet
  • 全局替换javax.validation→ `jakarta.validation
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/7/22 5:44:38

从自学到Deno核心贡献者的技术成长之路

1. 从辍学生到Deno核心贡献者的逆袭之路2019年&#xff0c;一个名为justjavac的中国开发者突然出现在Deno项目的贡献者名单前列。这个网名背后&#xff0c;是一位只有高中学历的普通程序员。从网吧自学编程到成为国际知名开源项目的核心贡献者&#xff0c;他用了整整十年时间完…

作者头像 李华
网站建设 2026/7/22 5:43:55

K3S节点添加失败问题分析与解决方案

1. K3S节点添加失败问题概述最近在部署K3S集群时遇到了节点添加失败的问题&#xff0c;错误提示为"Node password rejected, duplicate hostname or contents of /etc/rancher/node/password may not match server node-passwd entry"。这个问题在K3S集群部署中相当常…

作者头像 李华
网站建设 2026/7/22 5:40:49

Spring Boot多数据源配置实战:Druid+MyBatisPlus最佳实践

1. 多数据源架构的必要性与挑战在企业级应用开发中&#xff0c;多数据源的需求非常普遍。我最近在开发一个电商平台时&#xff0c;就遇到了需要同时访问商品数据库和用户数据库的场景。这种架构设计通常出现在以下典型场景中&#xff1a;业务数据隔离需求&#xff08;如多租户系…

作者头像 李华
网站建设 2026/7/22 5:39:57

FT891与SDR切换器一线通方案:硬件集成与抗干扰实战

那天晚上&#xff0c;我正调试着新到的 Yaesu FT-891 短波电台和手头的 SDR&#xff08;软件定义无线电&#xff09;设备&#xff0c;桌上摊着好几根线——电台要供电&#xff0c;SDR 要供电&#xff0c;PTT&#xff08;Push-to-Talk&#xff09;信号还要单独接。电源线、数据线…

作者头像 李华
网站建设 2026/7/22 5:39:33

6款高效免费软件推荐:办公设计全搞定

1. 前言&#xff1a;免费软件的实用价值在这个数字化时代&#xff0c;优质软件能极大提升我们的工作效率和生活品质。但很多专业软件价格昂贵&#xff0c;对普通用户来说负担不小。今天我要分享6款完全免费的实用软件&#xff0c;它们不仅功能强大&#xff0c;而且能解决日常工…

作者头像 李华
网站建设 2026/7/22 5:37:43

Python3.8与RobotFramework环境搭建及兼容性解决方案

1. Python3.8与RobotFramework环境搭建实录去年接手一个遗留项目时&#xff0c;发现团队还在用Python3.8RobotFramework3.2的老旧组合。本想直接升级到最新版&#xff0c;结果发现项目中有大量基于旧版本开发的测试用例。经过多次尝试&#xff0c;终于摸索出一套稳定的安装方案…

作者头像 李华