1. 从"这条数据是谁改的"到自动化审计,痛点在哪里
先讲一个我接手过的模拟项目X的真实场景。当时系统里有一张订单表,运营部门隔三差五跑过来问:某个订单的状态为什么从"待审核"变成了"已取消"?是谁改的?什么时候改的?改之前的值是什么?当时代码里的现状是:每个Service的方法里都手动写一段日志,把"操作人、操作时间、变更内容"塞到一个审计表里。一开始还好,后来方法多了,日志格式五花八门,有的记录了操作人但没记录旧值,有的记录了一句话描述但根本没法还原字段级别的差异。更要命的是,新来的同事经常忘记写这段日志,审计数据断断续续,运营一问就卡壳。
这种需求在业务系统里太常见了,术语叫"数据变更追踪"或"审计日志",本质上要回答四个问题:谁、什么时候、对哪个业务实体、做了什么改动。改动要到字段级别,最好能精确到"订单金额从100元变为90元"这种程度。
我当时的第一反应是去改所有Service方法,每个方法里手动对比旧值和新值,然后组装变更记录。但很快放弃了,理由有几点:一是工作量太大,几十个核心方法都要动一遍;二是对比逻辑分散在各处,以后想统一调整格式(比如增加版本号、增加租户ID)就得满世界改;三是侵入性太强,业务代码里塞满了与业务无关的日志逻辑,代码可读性直线下降。
后来我确定了用SprinBoot + AOP + 自定义注解这套组合来做。思路很朴素:在需要追踪的方法上打一个注解,用一个统一切面拦截这些方法,在方法执行前拿一次"旧值快照",在方法执行后拿一次"新值快照",然后做字段级对比,差异自动写入变更记录表。业务代码里只需要加一个注解,不用改任何业务逻辑,这天然就把横切逻辑从业务代码里抽离出来了。
这篇文章我会把完整的落地过程拆开讲,包括自定义注解怎么设计、切面怎么拦截、字段对比怎么做才不漏不错、事务和异步场景会踩哪些坑,以及我把这套方案放到生产环境后复盘出的几个重要细节。内容适合已经会用SpringBoot做基本CRUD、但对AOP实战还不熟悉的后端开发,也适合正在为"审计日志怎么写才优雅"发愁的团队参考。
2. 方案选型:为什么是注解 + AOP,而不是拦截器、监听器或数据库触发器
在确定方案之前,我其实比较过几条常见的技术路线,每一条都有它的适用场景,但放在"通用、可复用、侵入性小"这三个诉求下,注解加AOP的胜出优势很明显。
2.1 几种常见方案对比
先看数据库触发器。触发器在数据库层面记录变更有一个天然优势:无论应用层通过什么方式改数据(后台任务、手动SQL、另一个系统直连),都能被捕获。但缺点是业务逻辑会散落到数据库里,以后想要在变更记录里补充操作人、来源IP这类应用层上下文就非常吃力,因为触发器拿不到这些信息。而且触发器维护起来比其他方案更隐蔽,很多人接手项目时根本不知道数据库里还埋着这么一层逻辑。对小团队来说,这属于"可控性差"的方案。
再看MyBatis拦截器或Hibernate事件监听器。这类方案能拦截到ORM层的持久化操作,从技术上讲很"底层",能覆盖到所有实体变更。但我最终没有选它,原因很实际:拦截器拿到的是参数对象和SQL信息,但"哪个字段是业务主键""哪个字段需要参与对比""操作人是多少号员工"这些信息它都不知道,还是得靠额外的配置或约定去推断。而且拦截器作用范围太广,很难精细控制"这个方法我要追踪、那个方法我不追踪"。
然后是Spring的事件监听机制。发布一个领域事件,然后在监听器里记录变更,这个思路很干净,符合领域驱动设计的味道。但它要求业务代码里主动发布事件,本质上还是"侵入式"的,只不过侵入点从Service方法移到了事件发布。对大量存量方法来说,改造量并没有减少。
最后落在自定义注解 + AOP切面上。它的核心优势是"声明式":我要追踪哪些方法,只需要在方法上打一个注解,追踪逻辑全部收敛到切面里。业务方法本身完全不需要知道"有人在记录我的变更",这就把横切关注点彻底隔离了。相比拦截器方案,它可以精确控制方法粒度;相比事件方案,它不需要业务代码主动配合;相比触发器,它能拿到完整的应用上下文。
2.2 三层结构:注解做标记,AOP做拦截,快照对比做差异
这套方案整体可以拆成三个层次,我建议在设计时脑子里始终有这张三层图:
第一层是自定义注解。它只做一件事:标记"这个方法需要被追踪",同时携带一些元信息,比如属于哪个业务模块、业务对象的类型、怎么从参数里提取业务ID、操作人从哪里拿。注解本身不包含任何逻辑,它更像是一个"说明书"。
第二层是AOP切面。切面拦截所有标注了该注解的方法,在方法执行前读取参数、生成"旧值快照",在方法执行后拿到返回值或修改后的参数对象、生成"新值快照"。快照本质上就是对象序列化后的某个Map结构或JSON字符串,保存的是"那一刻对象的状态"。
第三层是字段对比器。它负责比较新旧两个快照,输出一个结构化的变更明细列表,比如字段名、字段描述、旧值、新值。这一步是整个方案的技术核心,对比逻辑的健壮性直接决定审计数据的质量。我见过很多方案最后"看起来能用但不敢信",问题多半出在对比器上——日期格式不一致就误报差异、枚举序列化方式不同就全字段飘红、嵌套对象一改动就整个子对象被标记为变更。
三层各司其职,互不耦合。注解可以随时打在任何方法上,切面负责统一调度,对比器可以单独做过单元测试。后面我把每一层的实现细节都过一遍,代码可以直接抄。
3. 核心实现:自定义注解、切面与字段对比器
下面进入正题。我会用一个模拟的订单模块来演示,业务实体是一个Order对象,需求是:修改订单信息的接口需要自动记录"改了哪些字段、从什么值改成什么值、谁改的"。
3.1 第一步:定义一个携带元信息的自定义注解
先上注解的代码:
package com.example.audit.annotation; import java.lang.annotation.*; /** * 数据变更追踪注解 * 标注在需要记录字段变更的Service方法上 */ @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface ChangeTrack { /** * 业务模块,比如 order、user、product */ String module() default ""; /** * 业务对象类型,用于指定要对哪个参数做快照 * 默认取第一个参数 */ Class<?> entityClass() default Void.class; /** * 从方法参数中提取业务ID的SpEL表达式 * 比如 "#order.id" 表示取第一个参数的id字段 */ String businessId() default ""; /** * 操作人的SpEL表达式,优先从方法参数取,比如 "#operatorId" * 为空时自动从当前登录上下文获取 */ String operator() default ""; }有几个设计细节我要强调一下:
@Retention(RetentionPolicy.RUNTIME)