资讯动态

Spring AOP实战:公共字段自动填充,告别重复写set方法

发布时间:2026/10/21 3:38:57 来源:尧图企业网站定制
做苍穹外卖项目的时候进度到Day3.01排了一个看起来很小、但实际非常重要的功能公共字段自动填充。我第一反应是“不就是几个set方法吗手写不就行了”结果真写起来才发现十个Mapper十个insert每张业务表都要set创建时间、修改时间、创建人、修改人复制粘贴到手软而且一旦哪天要加一个公共字段所有Mapper层全得跟着改一遍。更难受的是这些赋值代码跟核心业务挤压在一起看一个insert方法一半是业务字段赋值一半是公共字段“苦力活”。这个Day3.01讲的就是把这类横切逻辑从业务代码中剥离出来用Spring AOP切面在运行时统一填充。它解决的痛点非常明确不再重复写公共字段赋值代码新增表/新增公共字段时改动最小。对正处在入门到进阶阶段的人来说这个点也是理解AOP落地的好样本——自定义注解、切点表达式、前置通知、反射操作对象、ThreadLocal传递用户信息全都能在这一小节里串起来。这篇博文我会从方案选型、代码实现、踩坑排查到后续扩展完整过一遍建议把后面的代码直接拿过去改一改。1. 为什么公共字段自动填充值得单独拿出来讲1.1 业务表里那四个“雷打不动”的字段打开任何一个稍正规的数据库设计业务表里几乎都躺着四个公共字段create_time、update_time、create_user、update_user。比如外卖项目里的分类表、菜品表、套餐表、员工表全都长这样。这四个字段负责回答最基本的审计问题这条数据是谁在什么时候创建的最近又是谁在什么时候改的。在没有自动填充之前最朴素的写法就是在Mapper层或者Service层每次insert都要手动set一遍Category category new Category(); category.setName(川菜); category.setSort(1); // 下面这四个就是公共字段的重复劳动 category.setCreateTime(LocalDateTime.now()); category.setUpdateTime(LocalDateTime.now()); category.setCreateUser(baseContext.getCurrentId()); category.setUpdateUser(baseContext.getCurrentId()); categoryMapper.insert(category);这段代码看起来不难但问题在于哪个Mapper都得来一遍。员工insert、菜品insert、套餐insert、地址薄insert……每个地方都散落着同样的四行赋值。项目小的时候看不出问题等表多了之后这就成了维护负担改了字段名要全局搜索替换忘了赋值就是空字段代码评审的时候还得一个人一个人盯。1.2 自动填充的几种主流实现方案公共字段自动填充并不是苍穹外卖项目原创的玩法业界常见有三条路方案优点缺点适用场景数据库默认值/触发器数据库层兜底应用层完全不感知字段值固定拿不到当前操作用户逻辑分散在数据库里不好统一管理比较简单的固定时间字段MyBatis拦截器或MyBatis-Plus的MetaObjectHandler和ORM框架深度集成开发效率最高绑定了MyBatis/MP技术栈换了ORM框架就不适用改动隐藏在框架底层新手不好理解项目已经用了MyBatis-Plus想快速落地Spring AOP 自定义注解不绑定ORM框架横切逻辑集中在一个切面类里业务层只加注解需要自己写反射填充逻辑对AOP机制要有一定理解想彻底掌握原理或者要适配多种持久层框架我最终选了Spring AOP 自定义注解这条路。原因很直接这个项目用的是原生MyBatis虽然MyBatis-Plus有MetaObjectHandler但我不想为了一个公共字段填充去改变整个项目的ORM基础。用Spring AOP做成独立切面业务代码只需要在Mapper方法上加一个AutoFill注解其他什么都不用管哪个表要填充就标哪个方法。而且这套套路往后还能直接迁移到操作日志、参数校验、权限控制上属于一鱼多吃。1.3 为什么不用数据库默认值一劳永逸可能会有人说数据库字段默认值设成DEFAULT CURRENT_TIMESTAMP插入的时候不就有了吗。但这里有两个绕不过去的坑第一update_time需要随每次更新而变化数据库默认值只解决insert时的问题update还需要额外写update语句去改第二create_user和update_user这种当前登录人数据库根本不认识必须由应用层传递。所以哪怕是数据库默认值方案create_user、update_user依然要程序去处理兜不住全局。另外还有一个问题用数据库默认值填充后应用内存里的实体对象依然没有值实体在被插入后如果要马上返回给前端字段还是会空着或者需要重新查一次数据库。用AOP切面在方法执行前给实体填充好对象里的值是完整的后续逻辑使用或返回都方便。2. 动手前必须想清楚的几个设计点2.1 实体类统一继承BaseEntity要让公共字段自动填充生效首先得让所有需要填充的实体类有一个“公共字段的家”也就是统一继承一个基类。这样切面才能用同一套反射逻辑去处理不同类型实体。实际项目中我会建一个BaseEntity把四个公共字段都放进去import lombok.Data; import java.io.Serializable; import java.time.LocalDateTime; Data public class BaseEntity implements Serializable { private LocalDateTime createTime; private LocalDateTime updateTime; private Long createUser; private Long updateUser; }之后分类实体、菜品实体、套餐实体都继承它Data public class Category extends BaseEntity { private Long id; private String name; private Integer sort; private Integer status; }这里有一个细节值得注意create_user和update_user为什么用Long而不是Integer。因为用户ID在大多数系统里都设计成Long尤其是分布式ID雪花算法之类的生成结果就是Long。如果数据库字段是bigintJava侧用Long是匹配的。有些同学习惯所有ID都用Integer等到数据量大了再改类型那会是非常痛苦的迁移。2.2 自定义注解AutoFill和操作类型枚举Spring AOP切面怎么知道哪个方法需要填充哪个方法填哪些字段靠的就是自定义注解。我们定义AutoFill注解并带一个OperationType枚举值import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface AutoFill { OperationType value(); }对应的枚举public enum OperationType { INSERT, UPDATE }Target(ElementType.METHOD)表示这个注解只能打在方法上因为我们希望它在Mapper接口的insert/update方法上生效。Retention(RetentionPolicy.RUNTIME)是关键点之一切面要在运行时通过反射读取注解值所以必须保留到运行时。如果写成了SOURCE或者CLASS反射是拿不到注解的这也是很多新手切面没效果的原因之一。2.3 ThreadLocal保存当前登录用户填充create_user和update_user时需要知道“当前操作人是谁”。这个信息在Controller层是由拦截器从JWT或Session中解析出来的但要传到Mapper层切面里最干净的方式就是用一个ThreadLocal容器。线上环境是并发多线程的如果用一个普通的静态变量存用户IDA请求还没处理完B请求就把值改掉了那整个系统就乱套了。ThreadLocal保证每个线程保存一份互不干扰的数据。所以项目里会有这样一个上下文工具类public class BaseContext { private static final ThreadLocalLong THREAD_LOCAL new ThreadLocal(); public static void setCurrentId(Long id) { THREAD_LOCAL.set(id); } public static Long getCurrentId() { return THREAD_LOCAL.get(); } public static void removeCurrentId() { THREAD_LOCAL.remove(); } }这里我必须强调两个容易踩的坑一是getCurrentId()返回的是Long切面里要做判空处理不能想当然认为一定存在二是请求处理完之后一定要调用removeCurrentId()。ThreadLocal在线程池场景下如果不清理线程复用时会拿到上一个请求的用户ID造成数据串号。项目里我是在拦截器的afterCompletion方法里统一remove掉的这样基本不会漏。3. AOP切面实现核心代码逐段拆解3.1 先引入AOP依赖确认能跑起来Spring Boot项目里用AOP首先得确保引入了相关依赖。这个依赖不是默认就带的全家桶需要在pom.xml里显式加dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-aop/artifactId /dependencyspring-boot-starter-aop会帮我们把AspectJ相关的类引入进来Spring Boot还能自动完成AOP代理的配置正常情况下不需要额外写EnableAspectJAutoProxy。如果你遇到切面不生效倒是可以检查一下启动类上是不是在某些情况下被屏蔽了自动配置临时加一行EnableAspectJAutoProxy也能解决。3.2 定义切点拦截所有带AutoFill注解的Mapper方法切面类的核心结构先搭出来import lombok.extern.slf4j.Slf4j; import org.aspectj.lang.JoinPoint; import org.aspectj.lang.annotation.*; import org.aspectj.lang.reflect.MethodSignature; import org.springframework.stereotype.Component; import java.lang.reflect.Method; Aspect Component Slf4j public class AutoFillAspect { Pointcut(execution(* com.sky.mapper.*.*(..)) annotation(com.sky.annotation.AutoFill)) public void autoFillPointCut() { } }这段切点表达式要分两段理解execution(* com.sky.mapper.*.*(..))限定在com.sky.mapper包下所有类的所有方法上返回值不限参数任意。annotation(com.sky.annotation.AutoFill)要求当前执行的方法上必须标了AutoFill注解。两个条件用连接意思是“只有这个包下带注解的方法才触发”。这个包名要和你项目实际包路径一致否则切面死活不生效这是最常见的坑之一。3.3 前置通知里读取注解值区分INSERT还是UPDATE选Before还是Around这里选择Before理由很简单我们只需要在Mapper方法执行业务逻辑之前把实体字段填好不需要控制方法何时执行、是否需要返回值前置通知是最贴合需求、代码也最少的。如果用了Around还要手动调用proceed()反而容易出错。前置通知的代码Before(autoFillPointCut()) public void autoFill(JoinPoint joinPoint) { // 1. 获取当前拦截方法上的AutoFill注解 MethodSignature signature (MethodSignature) joinPoint.getSignature(); AutoFill autoFill signature.getMethod().getAnnotation(AutoFill.class); if (autoFill null) { return; } // 2. 获取方法参数约定第一个参数是实体对象 Object[] args joinPoint.getArgs(); if (args null || args.length 0) { return; } Object entity args[0]; // 3. 准备公共字段的值 LocalDateTime now LocalDateTime.now(); Long currentId BaseContext.getCurrentId(); // 4. 根据操作类型决定填充哪些字段 OperationType operationType autoFill.value(); if (operationType OperationType.INSERT) { setFieldValue(entity, setCreateTime, now); setFieldValue(entity, setUpdateTime, now); setFieldValue(entity, setCreateUser, currentId); setFieldValue(entity, setUpdateUser, currentId); } else if (operationType OperationType.UPDATE) { setFieldValue(entity, setUpdateTime, now); setFieldValue(entity, setUpdateUser, currentId); } }为什么insert时要把update_time和update_user也一起填上这是很多项目的统一约定新增一条数据从创建那一刻起就是有效的修改时间和修改人也不能为空。这样后面按修改时间排序、排查“这条数据最后是谁动过”都很方便。3.4 反射填充的健壮写法切面里真正干活的是setFieldValue。如果直接看一眼对象类型再调用具体setter切面就得针对每种实体写一遍那就失去AOP的意义了。反射在这里的作用就是运行时动态找到实体的setter方法并调用。最直观的写法private void setFieldValue(Object entity, String methodName, Object value) { if (value null) { return; } try { Method setter entity.getClass().getMethod(methodName, value.getClass()); setter.invoke(entity, value); } catch (Exception e) { log.error(公共字段自动填充失败方法{}, methodName, e); } }这段代码大多数情况下能跑通但还不够健壮。问题出在value.getClass()如果实体setter的参数类型是父类或者接口而value实际类型是子类getMethod(methodName, value.getClass())是找不到方法的。比如字段类型是Numbervalue是Long那么getMethod会抛NoSuchMethodException。更稳妥的写法是遍历方法列表通过参数类型的isInstance判断类型兼容性private void setFieldValue(Object entity, String methodName, Object value) { if (value null) { return; } Class? clazz entity.getClass(); Method setter Arrays.stream(clazz.getMethods()) .filter(m - m.getName().equals(methodName)) .filter(m - m.getParameterCount() 1) .filter(m - m.getParameterTypes()[0].isInstance(value)) .findFirst() .orElse(null); if (setter null) { log.warn(未找到合适的setter方法{}值类型{}, methodName, value.getClass().getName()); return; } try { setter.invoke(entity, value); } catch (Exception e) { log.error(公共字段自动填充失败方法{}, methodName, e); } }这样写虽然多十几行代码但兼容性和稳定性高了不少。如果你是在自己的小项目里用也可以直接用前面那版简单的只要能定位到当前项目没有复杂继承关系即可。3.5 在Mapper接口上标注解切面写好之后就要在Mapper层“打点”了。以分类Mapper为例Mapper public interface CategoryMapper { AutoFill(OperationType.INSERT) void insert(Category category); AutoFill(OperationType.UPDATE) void update(Category category); }然后Service层原本手动set公共字段的四行代码就可以直接删除。整个调用中切面会在categoryMapper.insert(category)真正执行之前自动替你把四个公共字段都填进去。这里有一个非常重要的技术细节为什么注解写在Mapper接口上切面也能拦到。Spring AOP对MyBatis的Mapper接口生成的是JDK动态代理对象当我们调用categoryMapper.insert(category)时实际调用的是代理对象的invoke方法。切面拦截“接口方法调用”这个动作所以注解标在接口方法上完全可行。如果你把注解标在实现类或DAO类上反而可能因为代理方式不同而拦截不到。4. 实测效果与代码结构分析4.1 一条insert请求的完整调用链自己动手实现完再看一次调用链会发现AOP切面的位置非常巧妙前端请求进来先经过拦截器。JWT解析成功拿到当前登录用户ID放进BaseContext。Controller层调用Service层Service层组装业务数据。Service调用CategoryMapper.insert(category)。Spring AOP拦截到这个Mapper方法调用执行AutoFillAspect.autoFill()。切面通过反射给实体对象的createTime、updateTime、createUser、updateUser赋值。切面放行MyBatis真正执行insert SQL此时实体对象里的公共字段已经齐全。这个链条中业务代码只看到了categoryMapper.insert(category)这一行它不需要关心公共字段填充是怎么发生的。这就是AOP“横切关注点分离”的价值。4.2 如何验证是否生效很多人写完切面不敢确定到底有没有填充成功。我建议在切面的autoFill方法里加一行日志先观察再删掉。日志打印log.info(公共字段自动填充操作类型{}实体class{}, operationType, entity.getClass().getSimpleName());在控制台里你会看到类似这样的输出公共字段自动填充操作类型INSERT实体classCategory如果你开启了MyBatis的SQL日志还能看到insert语句里确实带上了create_time和create_user字段值。这是最直观的验证方式。如果没有这条日志说明切面压根没进去优先排查切点表达式和注解位置。4.3 影响范围到底哪些实体和Mapper受益这个自动填充机制一旦跑通受益的不是某一个Mapper而是整个项目中所有符合规范的部分。在苍穹外卖项目里经常用到的分类、菜品、套餐、员工等业务都适用。只需要满足两个条件实体类继承BaseEntity至少包含createTime/updateTime等公共字段。Mapper接口的insert/update方法上标注了AutoFill(OperationType.INSERT/UPDATE)。之后每新增一张业务表建实体、建Mapper时顺手加上注解即可Service层完全不用动。以后如果要在公共字段里增加一个“操作人IP”之类的字段只需要在字段、注解定义和切面类中统一加逻辑所有使用方自动生效不用一个表一个表去改。4.4 代码维护体验肉眼可见地变清爽我印象最深的是一个几十行的手写insert方法去掉公共字段填充后只剩下一半的代码量。业务代码应该只关心业务字段那些基础设施级别的重复赋值本就不该出现在业务方法里。以前新增一个表我习惯打开旧代码复制粘贴then改表名字段名。现在只要在Mapper方法上打一个注解维护成本骤降。而且因为填充逻辑集中在切面里代码审查的时候只需看切面一处不用翻遍所有Service类去确认“这人有没有set创建人”。5. 常见问题与排查技巧实录这部分是真正值钱的经验因为自动填充方案代码本身并不难难点全在“不如预期”的时候怎么定位。5.1 切面没有执行实体字段还是null先排查这四个地方依赖没加spring-boot-starter-aop没引入或者项目里因为某种原因没走Spring Boot自动配置。先看启动日志有没有AOP相关的代理配置或者直接开一个测试方法验证。切点表达式包名写错com.sky.mapper与实际包名不一致多一层少一层都会导致匹配不上。这个错误说实话很难查因为日志不报错就是没反应。注解没打到Mapper接口方法上如果你把注解打到了Service方法或实体类上切面自然拦截不到。明确要求注解只能标在切点表达式中拦截的方法上也就是Mapper方法。前端根本没经过登录拦截器BaseContext.getCurrentId()返回null切面虽然执行了但因为判空跳过了createUser和updateUser填充。这种情况在测试时最容易被忽略你以为切面有问题其实是上下文没数据。5.2 反射调用setter报错集中在这两个原因NoSuchMethodException之前提到过value.getClass()和setter参数类型不一致会触发。比如实体类里的createUser是Long而服务层实体里某个字段期望Integer或者公共字段的类型本身就是Number。解决方案就是使用我前面写的兼容性版本遍历方法并使用isInstance判断。NullPointerException这个多半是value.getClass()在value为null时报的错。切面代码里一定先判空再处理currentId这种值从ThreadLocal拿回来时要当作“可能为null”来写。5.3 ThreadLocal串号或拿不到用户ID串号问题一般出现在用了线程池、异步任务或者拦截器没清理ThreadLocal的场景。比如页面使用了异步任务去触发一个insert新线程里第一次调用BaseContext.getCurrentId()永远是null因为ThreadLocal的子线程不会自动继承主线程的值。如果想支持异步得在任务提交前手动把用户ID传递过去或者使用InheritableThreadLocal但这又会带来线程池下值复用的新问题反而更危险。还有一个容易忽略的点拦截器里设置了BaseContext.setCurrentId()但请求结束时忘记removeCentralId()。在Tomcat线程复用机制下下一次请求很可能读到上次残留的ID。所以我在项目里的拦截器afterCompletion中必定写public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { BaseContext.removeCurrentId(); }5.4 事务边界和切面顺序的提醒AOP切面默认是在方法调用阶段生效和事务执行顺序有关。如果你的Mapper方法是在一个事务方法里被调用的切面填充实体值发生在SQL执行前这个时机是安全的。但如果把用户ID的获取放到了事务提交之后才去拿那就太晚了公共字段已经生成SQL了。另外切面本身不应该参与业务逻辑判断比如“如果用户ID为空就不填充”这种规则尽量简单直接。我在实现时把value null直接return保留数据库侧如果有默认值则可以兜底这样最安全。5.5 常见问题速查表现象可能原因排查方向切面方法完全没打印日志切点表达式不匹配检查包名、方法级别确认注解在Mapper接口方法上insert语句里create_user为空BaseContext没有值检查登录拦截器是否生效、是否使用异步线程更新时update_user是nullUPDATE分支没匹配或currentId为空检查枚举类型是否写对打印currentId报NoSuchMethodExceptionsetter参数类型和value类型不一致改用兼容性反射写法反射调用报IllegalArgumentExceptionsetter参数类型与传入值不匹配打印实体类字段类型和值类型对比一下多线程环境下用户串号ThreadLocal没有remove在拦截器afterCompletion中清理6. 这个功能做完之后我对AOP又深了一层6.1 从自动填充到通用操作日志的扩展公共字段自动填充用的这套“自定义注解 AOP切面 反射”组合几乎是通用横切逻辑的标准模板。后来我在项目里做一个简单的操作日志功能思路一模一样定义一个LogRecord注解声明操作模块、操作类型写一个切面拦截Controller方法记录请求参数、返回结果、执行耗时写入日志表。核心代码甚至可以复用里面的反射遍历方法。如果你能把Day3.01这个自动填充吃透以后看Spring中的Transactional、Cacheable等注解也会更容易理解它们背后大概是怎么工作的。6.2 和MyBatis-Plus自带的MetaObjectHandler对比如果你的项目用的是MyBatis-Plus其实可以不写切面直接实现MetaObjectHandler接口Component public class MyMetaObjectHandler implements MetaObjectHandler { Override public void insertFill(MetaObject metaObject) { this.strictInsertFill(metaObject, createTime, LocalDateTime::now, LocalDateTime.class); } }这确实更方便但问题在于它是MP生态特有的能力会把你和MP绑定得更紧。苍穹外卖这个项目用的是原生MyBatis切面方案是没有MP时的标准解法。而且从学习角度看自己实现一遍AOP切面能让你对代理、反射、切面生命周期的理解比直接配置一个Handler要深刻得多。对比项Spring AOP自定义注解MyBatis-Plus MetaObjectHandler框架依赖只依赖Spring AOP必须使用MyBatis-Plus触发方式方法级注解显式标记全局拦截insertFill/updateFill灵活性可按不同方法指定INSERT/UPDATE靠insertFill/updateFill区分学习价值高完整理解AOP原理中偏框架封装的配置6.3 个人的一点实操心得自动填充这点功能核心代码加起来可能不到100行但它有一个特别好的示范价值让你第一次感受到“把脏活累活集中处理”的美感。以前我一直觉得反射很神秘性能也比直接调用差所以有些排斥。实际写完这个切面后我发现像公共字段填充这种低频操作一次请求就反射调用几次setter性能损耗完全可以忽略而换来的统一维护性收益高得多。所以后面遇到“很多地方都在重复干同一件事”的场景我会先停下来想一想是不是可以用注解AOP抽出来把横切关注点从业务逻辑中拆出去一开始会有点绕但只要把这个切面写顺了后面再写操作日志、权限校验、参数加密都会事半功倍。这个小功能确实值得你在Day3.01多花点时间敲一遍。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑