ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Spring Boot实战记录:AOP自动填充与员工管理模块核心设计

Spring Boot实战记录:AOP自动填充与员工管理模块核心设计 今天的进度比昨天扎实多了。day1基本是在搭架子、通登录流程整个人处于“能跑但没完全懂”的状态到了day2苍穹外卖这个项目才真正开始有业务开发的质感员工管理模块、公共字段自动填充、AOP切面、ThreadLocal一套组合拳下来代码量不大但每一行都有值得琢磨的地方。这篇学习记录不打算写流水账我把day2里最折腾人的几个点单独拉出来讲清楚包括自动填充为什么非要走注解加切面、员工管理里DTO/VO/实体三者怎么分工、分页查询为什么容易查出一堆莫名其妙的bug。正在啃这个项目、或者刚接触Spring Boot加AOP这一套的同学这篇应该能帮你少踩几个我踩过的坑。1. day2到底在做什么从一个登录模块到员工管理1.1 day1留了哪些坑day2又接了什么活day1结束时项目大概的状态是工程骨架建好、数据库表建好、登录接口能跑通JWT令牌也能发能验。但严格意义上业务其实只覆盖了一个入口。day2的任务就是把“员工管理”这个最基础的RBAC模块补完整包括新增员工、分页查询、启用停用、编辑信息四个接口外加一个贯穿全局的公共字段自动填充机制。看到这个安排的时候我心里其实有点嘀咕员工管理不就是简单的CRUD吗有什么好学的真正动手之后才明白CRUD只是表象难点全在基础设施和规范设计上。比如新增员工时create_time、create_user这些公共字段谁来赋值如果每个Mapper方法里都手动set一遍代码会变得极其啰嗦如果不用手动set就得借助AOP加自定义注解去做统一处理。这个选择直接决定了项目后续所有业务的写法是day2真正的灵魂。1.2 员工管理模块的业务链路梳理在动手写代码之前我先把员工管理的完整链路画了一遍。前端页面对应的是系统管理里的员工列表页后端接口挂载在/admin/employee下全部要走JWT认证。请求进来先过拦截器校验令牌、解析出当前登录员工的ID然后路由到Controller层Controller层接收DTO对象Service层处理业务逻辑Mapper层操作数据库。这条链路里有个非常重要的细节JWT解析出来的当前用户ID是要被后续业务代码使用的。新增员工时要记录create_user修改时要记录update_user这个值不可能让前端传否则就是严重的安全漏洞。那它怎么从拦截器传到Service层day2给出的答案是ThreadLocal用一个BaseContext工具类包了一层。这个设计第一眼看觉得多余后来越想越合理后面我会单独展开讲。2. 公共字段自动填充注解加AOP比手动set高明在哪2.1 没有自动填充时你要写多少重复代码我先把问题具体化。假设现在有员工表和后续要做的分类表、菜品表每张表都有create_time、create_user、update_time、update_user四个公共字段。如果用最笨的办法新增员工的代码会长这样Employee employee new Employee(); employee.setName(dto.getName()); employee.setPhone(dto.getPhone()); employee.setSex(dto.getSex()); employee.setIdNumber(dto.getIdNumber()); employee.setPassword(DigestUtils.md5DigestAsHex(123456.getBytes())); employee.setStatus(StatusConstant.ENABLE); employee.setCreateTime(LocalDateTime.now()); employee.setCreateUser(BaseContext.getCurrentId()); employee.setUpdateTime(LocalDateTime.now()); employee.setUpdateUser(BaseContext.getCurrentId());新增分类的代码又要写一遍几乎一样的四行新增菜品再写一遍。业务表一多这些代码就是纯粹的体力活而且很容易漏。最要命的是如果哪天内网时间服务出了问题要统一调整时间处理逻辑你得把所有Mapper方法全部翻出来改一遍。这种重复早就超出了“代码冗余”的范畴属于典型的维护灾难。2.2 自定义注解AutoFill的完整定义day2给的解法是用自定义注解加AOP切面。先定义一个AutoFill注解只作用于方法上value属性用枚举区分当前是插入还是更新Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface AutoFill { OperationType value(); }OperationType就是两个枚举值INSERT和UPDATE。然后在Mapper接口的方法上加上这个注解比如insert(Employee employee)标AutoFill(value OperationType.INSERT)update(Employee employee)标AutoFill(value OperationType.UPDATE)。这个设计的核心思路是通过注解把“这个操作需要自动填充公共字段”这件事声明出来至于怎么填充、填充什么值全部交给切面统一处理。以后新增任何一张表只要在Mapper方法上加个注解就行业务代码里完全不用关心公共字段的赋值。这种“声明式”的风格一旦习惯了回过头再看手动set的实现会觉得效率低到难以接受。2.3 切面类AutoFillAspect的实现逻辑注解只是标记真正干活的是切面。切面类需要做四件事拦截加了注解的方法、拿到注解上的操作类型、反射set公共字段、拿着方法参数里的实体对象去赋值。核心代码如下Aspect Component public class AutoFillAspect { Pointcut(execution(* com.sky.mapper.*.*(..)) annotation(com.sky.annotation.AutoFill)) public void autoFillPointCut() {} Before(autoFillPointCut()) public void autoFill(JoinPoint joinPoint) throws Exception { MethodSignature signature (MethodSignature) joinPoint.getSignature(); AutoFill autoFill signature.getMethod().getAnnotation(AutoFill.class); OperationType operationType autoFill.value(); Object[] args joinPoint.getArgs(); if (args null || args.length 0) { return; } Object entity args[0]; LocalDateTime now LocalDateTime.now(); Long currentId BaseContext.getCurrentId(); if (operationType OperationType.INSERT) { entity.getClass().getDeclaredMethod(setCreateTime, LocalDateTime.class).invoke(entity, now); entity.getClass().getDeclaredMethod(setCreateUser, Long.class).invoke(entity, currentId); entity.getClass().getDeclaredMethod(setUpdateTime, LocalDateTime.class).invoke(entity, now); entity.getClass().getDeclaredMethod(setUpdateUser, Long.class).invoke(entity, currentId); } else if (operationType OperationType.UPDATE) { entity.getClass().getDeclaredMethod(setUpdateTime, LocalDateTime.class).invoke(entity, now); entity.getClass().getDeclaredMethod(setUpdateUser, Long.class).invoke(entity, currentId); } } }切点表达式值得单独说一句。execution(* com.sky.mapper.*.*(..))表示拦截Mapper包下所有类的所有方法 annotation(com.sky.annotation.AutoFill)表示只拦截那些加了AutoFill注解的方法。这两个条件加在一起效果是不是Mapper包下的方法不拦但光在Mapper包下还不够必须贴了AutoFill注解才拦。反射调用那里有个容易踩的坑getDeclaredMethod只能拿到当前类声明的方法如果实体类有父类、方法定义在父类里这里会抛NoSuchMethodException。day2的实体都是独立类没有继承问题但如果你在自己的项目里复刻这个方案实体类一旦用了继承结构反射部分就得改成遍历整个继承链去查找方法。2.4 ThreadLocal与BaseContext的配合使用切面里用BaseContext.getCurrentId()拿当前登录用户ID这个ID是登录接口签发JWT时放进去的然后在拦截器校验令牌后重新解析出来的。整个链路是这样的// 登录成功后JWT的payload里存了员工ID MapString, Object claims new HashMap(); claims.put(empId, employee.getId()); String token JwtUtil.createJWT(secretKey, ttl, claims); // JWT拦截器校验通过后解析出empId并存入ThreadLocal Long empId claims.get(empId, Long.class); BaseContext.setCurrentId(empId);BaseContext封装了一个ThreadLocalpublic class BaseContext { private static ThreadLocalLong threadLocal new ThreadLocal(); public static void setCurrentId(Long id) { threadLocal.set(id); } public static Long getCurrentId() { return threadLocal.get(); } public static void removeCurrentId() { threadLocal.remove(); } }为什么不直接把一个Long employeeId用参数透传到Service层因为很多Mapper接口签名是固定的比如insert(Employee employee)你不能为了传递当前用户ID强行改接口设计而且Controller、Service、Mapper三层每层都传一遍代码污染太严重。ThreadLocal的本质是线程私有的全局变量同一个请求线程内在拦截器里set的值后续Service、Mapper里都能直接get天然适合这种“一次请求内上下文传递”的场景。但这里必须强调一个坑ThreadLocal用完之后一定要remove。Web应用最常用的容器是Tomcat它用的是线程池复用机制线程处理完请求后不会销毁而是归还到池里等待下一次任务。如果你不remove线程被复用的时候ThreadLocal里还残留着上一个请求的员工ID下一个请求如果没有正常set就会读到脏数据导致create_user错乱。day2的拦截器里在preHandle里set、在afterCompletion里remove两个都做了才安全。3. 员工管理模块核心实现每一个接口都有讲究3.1 新增员工DTO设计、密码处理、唯一性校验新增员工的接口路径是POST /admin/employee前端传的参数是EmployeeDTO包含name、phone、sex、idNumber、username五个字段。这里有个设计要重点理解DTO是接收前端参数的载体Entity是数据库映射的载体VO是返回给前端的载体三者职责不同不能混用。如果你图省事直接拿Employee实体接收前端参数前端就能随意传createTime、id这些不该传的字段进去等于把数据库结构暴露给了调用方。Service层的新增逻辑我整理成四步第一步对DTO做基本校验用户名不能为空、手机号格式对不对这些一般在Controller层或Spring的validation注解里做第二步判断用户名是否已存在调用Mapper的selectByUsername查一遍存在就直接抛业务异常避免数据库的唯一索引下手太狠第三步把DTO的属性复制到Employee实体上密码默认123456用MD5加密后存库第四步设置状态为启用剩下的公共字段交给AOP自动填充。密码加密这里有个经典细节。Spring自带的DigestUtils.md5DigestAsHex(123456.getBytes())生成的字符串是32位十六进制而123456本身是6个字符。如果建表的时候把password字段设成varchar(20)插入的时候就会报Data too long for column password。day2课程里password字段是varchar(64)就是为了兼容扩展性。但我在自己本机复建的时候顺手用了段代码生成表字段长度写成32结果卡了快二十分钟才反应过来是这么回事。数据表字段设计这事永远要在写代码之前定好。3.2 分页查询POJO、VO、DTO三者的关系分页查询接口是GET /admin/employee/page参数是page、pageSize和可选的name关键字。day2用的是PageHelper插件核心就两行代码PageHelper.startPage(page, pageSize); PageEmployee pageResult employeeMapper.pageQuery(name);这里有个铁律PageHelper.startPage之后的第一个查询语句才是被分页的查询中间不能穿插其他SQL操作。我之前在业务表A的数据准备阶段就犯过这个错startPage之后紧跟的不是查询而是先做了一步计算结果分页数据莫名其妙少了一条或者total和records完全对不上。分页查询的返回结果需要处理一下再给前端。Employee实体里包含了password字段直接把PageEmployee返回给前端密码的MD5值就裸奔在接口响应里了。正确做法是转成EmployeeVO再返回。转换的时候很多人用BeanUtils.copyProperties这里有一个非常隐蔽的坑如果你先创建了一个空VO对象把实体的全部属性copy过去password字段也会被copy过去。我建议的做法是VO里不定义password属性copy之后就天然不存在这个字段或者copy之后手动vo.setPassword(null)双保险。分页结果里还有一个容易被忽略的细节前端不仅需要records列表还需要total总条数、page当前页码、pageSize每页条数。PageHelper的Page对象里这些值都有但如果你把Page转成普通List再返回这些分页元信息全丢了。我第一版就是这么干的页面上的分页器一直显示不出来调试了半天才发现是类型转换把分页信息抹掉了。3.3 启用禁用与编辑员工请求参数绑定的两个坑启用禁用员工的接口设计是POST /admin/employee/status/{status}路径上直接带目标状态请求参数里再带一个员工ID。我当时很自然地这样写PostMapping(/status/{status}) public Result startOrStop(PathVariable Integer status, Long id) { employeeService.startOrStop(status, id); return Result.success(); }麻烦来了ID没有加任何注解Spring MVC在解析的时候会把它当成一个普通请求参数需要用RequestParam明确标注才有效。改成RequestParam Long id之后接口就正常了。这算是一个典型的Spring MVC参数绑定基础问题但项目一忙起来真的容易忘。编辑员工接口的路径是PUT /admin/employee前端回显数据的时候会把整个表单对象都提交上来包括createTime、updateTime这些不该被修改的字段。day2的处理方式是Service层从DTO里提取需要的字段set到实体上再调用Mapper的update方法。你可能会问为什么不直接用updateTime覆盖实体里的updateTime因为updateTime是公共字段由AOP自动填充取当前时间前端传的旧值会被覆盖掉反而正确。但如果前端传的id是空值或者恶意传了别人的IDupdate语句就可能更新错误的数据。实际业务中这里通常还要做权限校验和数据归属校验项目里先不做那么复杂但这个意识要有。DTO和实体之间还有一个隐含问题DTO里字段和实体字段不一致。比如DTO有idNumber属性实体叫idNumber看似相同但前端可能传idNumber也可能传id_number这个在JSON序列化配置里要统一。苍穹外卖这个项目统一用的驼峰命名加Jackson默认配置直接copy属性就能对上。3.4 为什么强烈建议手动转VO而不是直接返回实体之前说了分页查询要转VO其实编辑员工时要查详情返回给前端回显也是一样的情况。有一次我图省事直接在Controller里返回了查询出来的Employee实体结果前端拿到响应之后死活找不到字段打开控制台一看password的MD5字符串明晃晃地躺在JSON里。从这次之后我给自己定了个规矩凡是出网的响应对象一律单独建VO字段能少就少绝不偷懒直接返回实体。这个原则放到真实企业项目里一样成立。接口返回什么、不返回什么本质上是API设计的一部分返回了password意味着任何拿到接口权限的人都能看到密码哈希后续如果改用了加盐哈希或者更复杂的加密方案这些字段直接暴露在接口里就是重大安全事件。而且手动写VO转换的代码还有一个好处你被迫一个一个字段对过去等于每写一次就梳理了一遍接口的出入参。这个习惯坚持下来接口设计会越来越干净。4. 实战中踩过的坑与排查实录4.1 坑一create_user和create_time全是null启动项目测试新增员工接口返回成功但打开数据库一看create_time是nullcreate_user是null。第一时间怀疑AOP没生效检查了三点切面类有没有加Component、Aspect两个注解项目的启动类有没有加EnableAspectJAutoProxyMapper方法上有没有加AutoFill注解。都没问题代码看起来也没问题。最后靠打断点才定位到原因Mapper接口的方法参数名字被编译成arg0我一开始在切面里用joinPoint.getArgs()[0]拿实体对象这没问题问题出在我自己的判断逻辑里——我写成了判断args[0] null就return而Debug时看到的对象不为空但反射invoke的是entity.getClass().getDeclaredMethod(setCreateTime, LocalDateTime.class)实体类名和方法的全限定名我写错了。反射这东西一动起来报错信息远不如普通方法调用直观排查起来真的很烧耐心。4.2 坑二密码字段报Data too long这个问题前面提到过但值得再强调一次。用DigestUtils.md5DigestAsHex生成的是32位小写十六进制字符串如果建表时password字段是varchar(20)或者varchar(32)还能勉强放下但有同事把字段设成char(20)存进去之后总是报错。更隐蔽的是有些教程用的工具类生成的是带盐的MD5长度会变成64位甚至更长建表时字段长度不够就等着哭吧。我的建议是数据库里密码这类字段直接给varchar(64)起步别省那点空间。如果后续要升级成BCryptBCrypt哈希是60个字符64位照样放得下。4.3 坑三分页查询total和records返回异常接口通的时候页面显示总条数是0列表数据却有几条。查了代码发现我在PageHelper.startPage(page, pageSize)之后又执行了一条别的查询用来做数据准备这正好踩了PageHelper“紧跟其后才算分页”的规则。PageHelper的实现是在说SQL语句时工艺化改写如果第一个查询不是目标查询改写就会作用在错误的SQL上。这个问题的排查方法很直白打开mybatis的SQL日志看打印出来的第一条带limit的SQL是不是你要分页的查询。不是的话就把startPage的位置往前挪紧跟目标查询。4.4 坑四PathVariable和RequestParam混用导致接口400启用禁用接口如果漏写RequestParam请求发过去直接400前端报Required request parameter id for method parameter type Long is not present。这类问题在初学者身上特别常见因为Controller方法参数一多忘加注解是常有的事。我的习惯是写Controller时先确认每个参数来源路径参数用PathVariable请求参数用RequestParamJSON用RequestBody实体接收三者不要混。我把day2遇到的几个问题整理成了速查表方便以后翻现象可能原因定位手段公共字段没写入AOP没生效、反射方法名/参数类型写错、ThreadLocal没set打断点检查切面是否进入、反射是否抛异常密码字段插入报Data too long密码字段长度小于加密后字符串长度检查建表语句扩展到varchar(64)分页数据对不上startPage后跟了非目标查询打印SQL看limit语句作用在哪400参数缺失忘了加PathVariable或RequestParam看Spring报错信息确认参数来源注解前端缺失字段/多出字段直接返回了实体而不是VO统一改用VO出参5. 公共字段自动填充方案的手写对比与设计思考5.1 为什么最终选择AOP而不是拦截器或手动赋值很多同学看到AOP切面那十几行代码第一反应是“这比手动set复杂多了何必呢”。我刚开始也是这么想的但实际维护过几次之后看法完全反过来了。手动set的问题在于它把“什么时候该填公共字段”的判断权交给了每一个写业务代码的人张三记性好每次都填了李四一忙就忘了代码review又看不出来。而用AOP加自定义注解逻辑收敛在一个类里填不填、怎么填、填什么值全部由切面决定业务开发者的容错率大幅提升。有人可能会问能不能用Spring MVC的拦截器来做拦截器的粒度是请求级别它可以拦截所有请求但它拿不到“某个Mapper方法是否需要填充”这种方法级别的信息。而且拦截器里修改变量值需要拿到目标对象对于MyBatis的Mapper来说你根本不知道它底层用了什么动态代理生成的对象。自定义注解加AOP正好卡在“方法调用”这个精确的粒度上注解又天然是声明式的语义非常清楚。5.2 这套方案的局限性与扩展思路AOP方案也有它别扭的地方。最明显的一条切面里默认情况是拿方法参数里的第一个对象作为实体来反射赋值这就要求所有被拦截的Mapper方法第一个参数必须是实体对象。如果哪天有个方法需要传两个实体参数或者第一个参数是其他类型切面代码就得改造。day2的代码能这么写是因为现阶段所有Mapper的入参恰好都是(实体)这个形态这个“恰好”其实是项目设计约束下的一种简化。如果后续要扩展到更复杂的场景比如要自动填充的字段不止四个、或者要按租户ID填充不同的值可以把切面里的赋值逻辑抽出来做成策略模式根据操作类型分发到不同的ReferenceHandler去处理。这些不算什么高深技术但属于真实项目里比较常见的演进路线。6. 后续课程预告与个人实践心得day2的代码量虽然不大但信息密度很高。我自己的体会是这个项目真正的价值不在于“会写员工管理”而在于它把日常开发里那些“约定俗成”的东西比如DTO/VO分层、ThreadLocal上下文、AOP公共字段填充用一个完整业务串起来了。这些概念单独拿出来看每个都有教程但放进一个真实流程里同时使用才是它们最自然的组合方式。按照课程进度day3应该是进入分类管理和菜品管理了到时候公共字段自动填充这套机制会继续复用有day2打的底子后面写起来应该能轻松不少。我个人在day2最大的收获倒不是会写那十几个接口而是养成了一个习惯动手之前先想一下这个字段该由谁赋值、这个对象该不该直接出网、这个参数该从哪个位置拿。这些思考比代码本身值钱多了。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进