架构实战第4篇:谁动了我的数据——MyBatis拦截器实现审计字段自动注入
摘要:任何企业级系统都需要记录"谁创建了这条数据、谁最后修改了它"。如果让每个开发者在每次save/update时手动set这些字段,既繁琐又容易遗漏。本文结合《鹿鲸项目管理工具》的MyBatisInterceptor拦截器,展示如何用一个MyBatis插件实现审计字段的全自动注入——开发者无需写一行审计代码,系统自动填充创建人、修改人、创建时间、修改时间。
在前面的几篇文章中,我们分别讲解了
架构实战第1篇:构建统一全局响应封装,打造标准化接口契约
架构实战第2篇:项目中各种O(PO\BO\DTO\VO)的实践与思考
架构实战第3篇:三个文件消灭90%重复代码-泛型CRUD架构解析
今天我们主要讲解:MyBatis拦截器实现审计字段自动注入
一、问题:审计字段手动填充有多痛?
1.1 审计字段是刚需
几乎所有业务表都有这几个字段:
字段 | 说明 | 示例 |
|---|---|---|
create_user_id | 创建人ID | "a1b2c3..." |
create_user_name | 创建人姓名 | "张三" |
created_time | 创建时间 | 2026-06-17 10:30:00 |
modify_user_id | 修改人ID | "d4e5f6..." |
modify_user_name | 修改人姓名 | "李四" |
modified_time | 修改时间 | 2026-06-17 14:20:00 |
它们用于数据追溯、操作审计、权限控制,是系统的"黑匣子记录仪"。
1.2 传统做法的痛苦
// ❌ 传统做法:每个Service都要手动填充 @Service public class UserServiceImpl { public boolean addUser(AddUserDTO dto) { UserPO userPO = new UserPO(); userPO.setUserName(dto.getUserName()); // 手动填充审计字段... userPO.setCreateUserId(SecurityUtil.getLoginId()); userPO.setCreateUserName(SecurityUtil.getLoginUser().getUserName()); userPO.setCreatedTime(new Date()); return userMapper.insert(userPO) > 0; } public boolean updateUser(ModifyUserDTO dto) { UserPO userPO = userMapper.selectById(dto.getId()); userPO.setUserName(dto.getUserName()); // 又要手动填充... userPO.setModifyUserId(SecurityUtil.getLoginId()); userPO.setModifyUserName(SecurityUtil.getLoginUser().getUserName()); userPO.setModifiedTime(new Date()); return userMapper.updateById(userPO) > 0; } }1.3 四大问题
问题 | 后果 |
|---|---|
| 代码重复 | 50个实体 × 2个方法 = 100处手动填充代码 |
| 容易遗漏 | 新人忘了写 |
| 侵入业务 | 审计逻辑混入业务代码,可读性差 |
| 维护困难 | 如果审计字段名变更,需要全局搜索修改 |
二、鹿鲸方案:MyBatis拦截器自动注入
2.1 方案全貌
开发者代码 MyBatis拦截器 数据库
│ │ ││ userMapper.insert(userPO) │ ││ (不设置任何审计字段) │ ││ ────────────────────────────> │ ││ │ ││ │ 1. 拦截INSERT/UPDATE ││ │ 2. 反射获取所有字段 ││ │ 3. 遍历字段,找空值 ││ │ 4. 按字段名自动填充 ││ │ createUserId → 当前登录ID ││ │ createUserName → 当前用户名 ││ │ createdTime → new Date() ││ │ modifyUserId → 当前登录ID ││ │ modifyUserName → 当前用户名 ││ │ modifiedTime → new Date() ││ │ ────────────────────────────> ││ │ │ 写入完整数据
2.2 核心组件一览
整个方案由4个组件配合:
组件 | 职责 |
|---|---|
MyBatisInterceptor | MyBatis拦截器,拦截INSERT/UPDATE,自动填充审计字段 |
GeneralFieldConstant | 审计字段名常量,统一管理6个字段名 |
SecurityUtil | 获取当前登录用户ID和姓名 |
2.3 PO继承体系:审计字段在哪里定义?
在了解拦截器之前,先看审计字段的定义位置。鹿鲸项目通过PO基类继承体系,让所有实体自动拥有审计字段:
BasePO(创建审计)
├── id (主键,自动生成NanoID)├── createUserId (创建人ID)├── createUserName (创建人姓名)└── createdTime (创建时间)│└── SubPO(修改审计)├── modifyUserId (修改人ID)├── modifyUserName (修改人姓名)└── modifiedTime (修改时间)│└── MasterPO(业务扩展)├── sortNo (排序号)├── pyCode (拼音码)└── wbCode (五笔码)
BasePO 定义了创建审计字段:
public class BasePO { @Id @Column(value = ”id”, jdbcType = JdbcType.VARCHAR) private String id; public String getId() { if (StringUtils.isEmpty(id)) { this.id = IdUtil.nanoId(); // 主键自动生成 } return this.id; } @Column(value = ”create_user_id”, jdbcType = JdbcType.VARCHAR) private String createUserId; @Column(value = ”create_user_name”, jdbcType = JdbcType.VARCHAR) private String createUserName; @Column(value = ”created_time”, jdbcType = JdbcType.TIMESTAMP) private Date createdTime; }SubPO 继承BasePO,增加修改审计字段:
public class SubPO extends BasePO { @Column(value = ”modify_user_id”, jdbcType = JdbcType.VARCHAR) private String modifyUserId; @Column(value = ”modify_user_name”, jdbcType = JdbcType.VARCHAR) private String modifyUserName; @Column(value = ”modified_time”, jdbcType = JdbcType.TIMESTAMP) private Date modifiedTime; }所有业务PO继承MasterPO(继承自SubPO),自动拥有全部6个审计字段,无需重复定义。
2.4 字段名常量:GeneralFieldConstant
GeneralFieldConstant 统一管理6个审计字段的Java属性名:
public class GeneralFieldConstant { // 创建审计 public static final String CREATE_USER_ID = ”createUserId”; public static final String CREATE_USER_NAME = ”createUserName”; public static final String CREATED_TIME = ”createdTime”; // 修改审计 public static final String MODIFY_USER_ID = ”modifyUserId”; public static final String MODIFY_USER_NAME = ”modifyUserName”; public static final String MODIFIED_TIME = ”modifiedTime”; }拦截器通过反射匹配字段名,找到对应的属性进行填充。
三、拦截器核心源码解析
3.1 拦截器声明
@Component @Intercepts({ @Signature(type = Executor.class, method = ”update”, args = {MappedStatement.class, Object.class}) }) public class MyBatisInterceptor implements Interceptor { }@Intercepts声明这是一个MyBatis拦截器
@Signature拦截
Executor.update方法(INSERT、UPDATE、DELETE都走这个方法)args方法参数为
MappedStatement(SQL信息)和Object(参数对象)
3.2 入口方法:intercept
@Override public Object intercept(Invocation invocation) throws Throwable { MappedStatement mappedStatement = (MappedStatement) invocation.getArgs()[0]; SqlCommandType sqlCommandType = mappedStatement.getSqlCommandType(); Object parameter = invocation.getArgs()[1]; if (parameter == null) { return invocation.proceed(); // 无参数,直接放行 } if (SqlCommandType.INSERT == sqlCommandType || SqlCommandType.UPDATE == sqlCommandType) { replaceEntityProperty(parameter, sqlCommandType); } return invocation.proceed(); // 执行原始方法 }执行流程:
拦截到 update 调用│├── 参数为null?──→ 是 → 直接放行│ 否│ ↓├── SQL类型判断│ ├── INSERT → dealInsert(填充创建审计)│ ├── UPDATE → dealUpdate(填充修改审计)│ └── DELETE → 直接放行(不处理)│└── 执行原始SQL
3.3 支持批量操作:Map参数处理
private void replaceEntityProperty(Object parameter, SqlCommandType sqlCommandType) { if (parameter instanceof Map) { // 批量插入/更新时,MyBatis会将参数包装为Map replaceMap((Map) parameter, sqlCommandType); } else { // 单条操作,直接处理实体对象 replace(parameter, sqlCommandType); } } private void replaceMap(Map parameter, SqlCommandType sqlCommandType) { Collection values = parameter.values(); for (Object value : values) { replace(value, sqlCommandType); // 逐个处理Map中的每个实体 } }MyBatis在处理批量操作时,会将多个实体对象包装为Map。拦截器自动识别Map类型,遍历每个实体逐一填充。
3.4 INSERT场景:自动填充创建审计
private void dealInsert(Object parameter) { Field[] allFields = getAllFields(parameter); // 获取所有字段(含父类) for (Field field : allFields) { try { // 跳过JDK内部字段 String declaringClassName = field.getDeclaringClass().getName(); if (declaringClassName.startsWith(”java.”) || declaringClassName.startsWith(”javax.”) || declaringClassName.startsWith(”sun.”) || declaringClassName.startsWith(”jdk.”)) { continue; } field.setAccessible(true); Object currentValue = field.get(parameter); // ★ 核心逻辑:已有值的字段不覆盖 if (Objects.nonNull(currentValue)) { field.setAccessible(false); continue; } // 按字段名匹配,自动填充 ProjectId projectIdAnnotation = field.getDeclaredAnnotation(ProjectId.class); if (Objects.nonNull(projectIdAnnotation)) { // @ProjectId注解字段 → 填充当前项目ID field.set(parameter, ”8613d617-f816-44ed-8743-dc53292c1ef9”); } else if (GeneralFieldConstant.CREATE_USER_ID.equals(field.getName())) { // createUserId → 当前登录用户ID field.set(parameter, SecurityUtil.getLoginId()); } else if (GeneralFieldConstant.CREATE_USER_NAME.equals(field.getName())) { // createUserName → 当前登录用户姓名 field.set(parameter, SecurityUtil.getLoginUser().getUserName()); } else if (GeneralFieldConstant.CREATED_TIME.equals(field.getName())) { // createdTime → 当前时间 field.set(parameter, new Date()); } field.setAccessible(false); } catch (Exception e) { log.error(”dealInsert.error:{}”, e.getMessage(), e); } }3.5 UPDATE场景:自动填充修改审计
private void dealUpdate(Object parameter) { Field[] allFields = getAllFields(parameter); for (Field field : allFields) { try { // 跳过JDK内部字段 String declaringClassName = field.getDeclaringClass().getName(); if (declaringClassName.startsWith(”java.”) || ...) { continue; } field.setAccessible(true); Object currentValue = field.get(parameter); // ★ 核心逻辑:已有值的字段不覆盖 if (Objects.nonNull(currentValue)) { field.setAccessible(false); continue; } if (GeneralFieldConstant.MODIFY_USER_ID.equals(field.getName())) { field.set(parameter, SecurityUtil.getLoginId()); } else if (GeneralFieldConstant.MODIFY_USER_NAME.equals(field.getName())) { field.set(parameter, SecurityUtil.getLoginUser().getUserName()); } else if (GeneralFieldConstant.MODIFIED_TIME.equals(field.getName())) { field.set(parameter, new Date()); } field.setAccessible(false); } catch (Exception e) { log.error(”dealInsert.error:{}”, e.getMessage(), e); } } }3.6 反射获取所有字段(含父类)
private Field[] getAllFields(Object object) { Class<?> clazz = object.getClass(); List<Field> fieldList = new ArrayList<>(); while (clazz != null) { // 跳过JDK核心包 String className = clazz.getName(); if (className.startsWith(”java.”) || className.startsWith(”javax.”) || className.startsWith(”sun.”) || className.startsWith(”jdk.”)) { break; } fieldList.addAll(Arrays.asList(clazz.getDeclaredFields())); clazz = clazz.getSuperclass(); // 向上遍历父类 } return fieldList.toArray(new Field[0]); }这个方法从当前类开始,逐层向上遍历父类,收集所有字段。对于继承自MasterPO → SubPO → BasePO的实体,能获取到完整的6个审计字段。
3.7 SecurityUtil:获取当前登录用户
SecurityUtil 基于Sa-Token获取当前登录用户信息:
@UtilityClass public class SecurityUtil { private final String USER_KEY = ”DEER_WHALE_LOWCODE”; public String getLoginId() { LoginUser loginUser = getLoginUser(); if (null == loginUser) { return ”Anonymous”; // 未登录时返回匿名 } return loginUser.getUserId(); } public LoginUser getLoginUser() { return (LoginUser) StpUtil.getTokenSession().get(USER_KEY); } }四、设计亮点分析
4.1 "不覆盖"策略
拦截器最关键的设计是只填充null值的字段:
Object currentValue = field.get(parameter); if (Objects.nonNull(currentValue)) { field.setAccessible(false); continue; // 已有值,跳过 }这意味着:
- INSERT时
如果开发者手动设置了
createUserId,拦截器不会覆盖 - UPDATE时
如果开发者想保留原始创建人信息,拦截器不会覆盖
这个设计既保证了自动填充的便利性,又保留了手动控制的灵活性。
4.2 异常隔离
每个字段的填充都包裹在try-catch中:
try { // 反射操作 } catch (Exception e) { log.error(”dealInsert.error:{}”, e.getMessage(), e); // 不抛出异常,继续处理下一个字段 }即使某个字段填充失败(如类型不匹配、安全上下文异常),也不会中断整个SQL执行,保证业务可用性。
4.3 JDK字段过滤
反射时主动跳过JDK内部字段:
String declaringClassName = field.getDeclaringClass().getName(); if (declaringClassName.startsWith(”java.”) || declaringClassName.startsWith(”javax.”) || declaringClassName.startsWith(”sun.”) || declaringClassName.startsWith(”jdk.”)) { continue; }
这在Java 9+模块系统下尤为重要,直接反射访问JDK内部字段会触发InaccessibleObjectException。
五、传统方案 vs 拦截器方案:对比
5.1 代码量对比
以50个实体为例:
维度 | 传统方案 | 拦截器方案 |
|---|---|---|
每个Service的insert方法 | 3行审计代码 | 0行 |
每个Service的update方法 | 3行审计代码 | 0行 |
50个实体的总审计代码 | 50 × 6 = 300行 | 0行 |
拦截器本身 | 0行 | 1个文件(~230行) |
| 合计 | 300行散落代码 | 230行集中代码 |
拦截器代码集中在一处,可维护性远超300行散落在50个文件中的代码。
5.2 能力对比
能力 | 传统方案 | 拦截器方案 |
|---|---|---|
自动填充创建人 | ❌ 手动set | ✅ 自动 |
自动填充修改人 | ❌ 手动set | ✅ 自动 |
自动填充创建时间 | ❌ 手动set | ✅ 自动 |
自动填充修改时间 | ❌ 手动set | ✅ 自动 |
批量操作支持 | ❌ 每条都要set | ✅ 自动处理Map参数 |
不覆盖已有值 | ❌ 需手动判断 | ✅ 自动判断null |
遗漏风险 | 🔴 高 | 🟢 无 |
5.3 遗漏场景对比
传统方案的灾难场景:
拦截器方案:上述代码完全不需要修改,拦截器自动填充所有审计字段,不可能遗漏。
六、实际运行效果
6.1 开发者代码
// 某开发者新增了一个Service方法,忘了写审计字段 public boolean importUsers(List<AddUserDTO> dtos) { for (AddUserDTO dto : dtos) { UserPO userPO = new UserPO(); userPO.setUserName(dto.getUserName()); // ❌ 忘了 setCreateUserId、setCreatedTime... userMapper.insert(userPO); } } // 结果:新增条数据全部没有创建人信息,无法追溯 @Service public class UserServiceImpl extends OneEntityServiceImpl<...> { @Override public boolean insertInfo(AddUserDTO dto) { UserPO userPO = userConverter.toPO(dto); // 不设置任何审计字段,直接保存 return this.save(userPO); } }
6.2 数据库中的数据
SELECT id, user_name, create_user_id, create_user_name, created_time, modify_user_id, modify_user_name, modified_time FROM dw_system.”user” WHERE id = 'abc123';
结果:
id | user_name | create_user_id | create_user_name | created_time | modify_user_id | modify_user_name | modified_time |
|---|---|---|---|---|---|---|---|
abc123 | 张三 | u001 | 管理员 | 2026-06-17 10:30:00 | NULL | NULL | NULL |
所有创建审计字段已被自动填充,修改审计字段为NULL(因为是新增)。
6.3 更新后的数据
-- 执行更新操作后 SELECT id, modify_user_id, modify_user_name, modified_time FROM dw_system.”user” WHERE id = 'abc123';id | modify_user_id | modify_user_name | modified_time |
|---|---|---|---|
abc123 | u002 | 李四 | 2026-06-17 14:20:00 |
修改审计字段已被自动填充,创建审计字段保持不变(因为已有值,不覆盖)。
七、与MetaObjectHandler对比
MyBatis-Plus提供了MetaObjectHandler接口实现类似功能,对比一下两种方案:
7.1 MyBatis-Plus的MetaObjectHandler
public class MyMetaObjectHandler implements MetaObjectHandler { @Override public void insertFill(MetaObject metaObject) { this.strictInsertFill(metaObject, ”createUserId”, String.class, SecurityUtil.getLoginId()); this.strictInsertFill(metaObject, ”createdTime”, Date.class, new Date()); } @Override public void updateFill(MetaObject metaObject) { his.strictUpdateFill(metaObject, ”modifyUserId”, String.class, SecurityUtil.getLoginId()); this.strictUpdateFill(metaObject, ”modifiedTime”, Date.class, new Date()); } }7.2 对比
维度 | MetaObjectHandler | MyBatisInterceptor |
|---|---|---|
框架依赖 | 依赖MyBatis-Plus | 纯MyBatis拦截器,框架无关 |
字段标记 | 需要在实体字段上加 | 按字段名匹配,无需额外注解 |
批量支持 | 需要额外配置 | 天然支持(自动处理Map参数) |
控制粒度 | 精确到字段 | 精确到字段 |
不覆盖策略 | strictFill方法已内置 | 手动判断null |
鹿鲸项目使用的是MyBatis-Flex而非MyBatis-Plus,因此选择了基于原生MyBatis拦截器接口实现,设计思路与MetaObjectHandler殊途同归,但更具框架无关性。
八、使用指南
8.1 自动生效,零配置
只要PO继承了BasePO或SubPO,审计字段就会自动填充,无需任何额外配置:
// PO定义 @Table(schema = ”dw_system”, value = ”user”) public class UserPO extends MasterPO { // MasterPO → SubPO → BasePO private String userName; private String loginCode; // 不需要定义审计字段,基类已经有了 } // Service代码 public boolean insertInfo(AddUserDTO dto) { UserPO userPO = userConverter.toPO(dto); // 不需要设置审计字段,拦截器自动填充 return this.save(userPO); } // 完事。审计字段已经自动填充好了。8.2 手动覆盖
如果某些场景需要手动指定创建人(如数据迁移),直接设置即可,拦截器不会覆盖:
public boolean importData(ImportDTO dto) { UserPO userPO = new UserPO(); userPO.setUserName(dto.getUserName()); userPO.setCreateUserId(”system”); // 手动指定 userPO.setCreateUserName(”数据迁移脚本”); // 手动指定 userPO.setCreatedTime(dto.getOriginTime()); // 使用原始时间 // 拦截器检测到这些字段已有值,不会覆盖 return this.save(userPO); }九、局限与改进方向
9.1 当前局限
局限 | 说明 |
|---|---|
反射性能 | 每次INSERT/UPDATE都通过反射遍历字段,有轻微性能开销 |
未登录场景 | SecurityUtil.getLoginId()在未登录时返回 "Anonymous",可能不符合某些业务需求 |
字段名耦合 | 拦截器按固定字段名匹配,字段名变更需要同步修改常量 |
9.2 改进方向
- 项目ID动态化
将硬编码改为从
SecurityUtil或RequestContext动态获取当前项目ID - 缓存反射结果
对Class的Field数组做缓存,避免每次操作都反射获取
- 注解驱动扩展
将字段名匹配改为注解匹配,定义
@AuditField(type = AuditType.CREATE_USER_ID)等注解 - 异步线程支持
当前
SecurityUtil依赖ThreadLocal,异步线程中可能获取不到用户信息
十、总结
核心价值
维度 | 价值 |
|---|---|
| 开发效率 | 开发者无需写一行审计代码,专注业务逻辑 |
| 数据完整性 | 不可能遗漏审计字段,数据追溯链完整 |
| 代码整洁 | 审计逻辑从业务代码中彻底剥离 |
| 统一管理 | 所有审计逻辑集中在一个拦截器中,修改一处生效全局 |
| 灵活可控 | "不覆盖"策略保留了手动控制的能力 |
核心设计思想
这套方案的本质是AOP思想在持久层的落地:
- 横切关注点
审计字段填充是所有实体共有的需求,属于横切关注点
- 统一拦截
通过MyBatis拦截器在SQL执行前统一处理
- 声明式
开发者只需让PO继承基类,审计能力自动获得
这与Spring的@Transactional(事务管理)、@Cacheable(缓存)的设计思想一致——将通用逻辑提取到框架层,让业务代码只关注业务。
最后的一个比喻:
没有拦截器的日子,就像每个员工入职都要自己填考勤卡——忘了填就没有记录。
有了拦截器,就像装了门禁系统——刷卡进门的那一刻,时间、人员信息已自动记录,谁也忘不了。
关注引导
希望这篇文章对你有所帮助!如果觉得有用,欢迎点赞、收藏、分享~
「AI低码加速派」
专注于项目架构、低代码平台建设的实战分享。
在这里你会看到:
大型项目架构设计的真实案例拆解
框架级抽象设计的思路与方法论
AOP、注解驱动、泛型模板等进阶技巧的落地实践
从 0 到 1 构建企业级项目的完整复盘
扫码关注,一起成长!
关注+点赞 + 转发,是我持续输出的最大动力~
