来自苍穹外卖项目
引言
在大多数企业级应用中,数据库表通常包含一些公共字段,例如:
create_time(创建时间)update_time(更新时间)create_user(创建人)update_user(更新人)
这些字段在每次插入或更新记录时都需要手动赋值,导致业务代码中充斥着重复的样板代码。不仅降低了开发效率,也容易因遗漏赋值而产生数据不一致。
本文将介绍如何利用 AOP(面向切面编程) 思想,通过自定义注解和切面技术,实现公共字段的自动填充,从而消除重复代码,提升代码的可维护性。
AOP 简介
AOP(Aspect-Oriented Programming)是一种编程范式,它允许开发者将横切关注点(如日志、事务、权限控制、公共字段填充)从核心业务逻辑中分离出来,实现关注点的分离。
在 Spring 框架中,AOP 通过 切面、切入点、通知 等概念实现(前3个概念是关键):
- 切面(Aspect):封装横切关注点的模块。
- 切入点(Pointcut):定义在哪些方法上应用切面。
- 通知(Advice):在目标方法执行的特定时机(如前置、后置、环绕)执行的代码。
- 连接点(JoinPoint):程序执行过程中的某个可被拦截的点。在 Spring AOP 中通常是“方法执行点”。
- 目标对象(Target):真正执行业务逻辑的对象,例如
EmployeeMapper的实现对象。 - 代理对象(Proxy):Spring 创建的增强对象。外部实际上调用的是代理,代理再决定何时执行通知与目标方法。
- 织入(Weaving):把切面逻辑应用到目标对象,形成代理对象的过程。Spring 运行期通过动态代理完成织入。
- 引入(Introduction):在不修改原类代码的前提下,为类动态增加方法或接口(本项目未用到,可作为扩展概念了解)。
在本项目的“公共字段自动填充”场景中,这些概念可以一一对应到具体代码:
- 切面(Aspect)→
AutoFillAspect - 切入点(Pointcut)→
execution(* com.sky.mapper.*.*(..)) && @annotation(com.sky.annotation.AutoFill) - 前置通知(Before Advice)→
autoFill(JoinPoint joinPoint) - 连接点(JoinPoint)→ 某次具体 Mapper 方法调用(如
EmployeeMapper.update(employee)) - 目标对象(Target)→
EmployeeMapper/DishMapper等 Mapper - 代理对象(Proxy)→ Spring 为 Mapper 生成的代理对象(调用链实际先进入代理)
初探AOP
先简单了解下AOP的作用时机和使用场景,之后再进一步学习。
下面这个时序图用最通俗的方式展示了一次数据更新操作中,AOP切面在哪里工作:
sequenceDiagram
title 数据更新 业务流程
actor 用户
participant Controller
participant Service
participant Mapper
participant AutoFillAspect
participant 数据库
用户->>Controller: 发起更新请求
Controller->>Service: 调用update方法
Service->>Mapper: 调用update方法(带@AutoFill注解)
Note over Mapper: 方法被AOP拦截
Mapper->>AutoFillAspect: 触发前置通知
AutoFillAspect->>AutoFillAspect: 自动填充公共字段<br/>(updateTime, updateUser)
AutoFillAspect->>Mapper: 返回填充后的实体
Mapper->>数据库: 执行SQL更新
数据库-->>Mapper: 更新成功
Mapper-->>Service: 返回
Service-->>Controller: 返回
Controller-->>用户: 返回成功响应
- 用户通过前端界面发起更新请求(比如修改员工信息)
- 请求依次经过Controller → Service → Mapper
- 当Mapper方法上标注了
@AutoFill(OperationType.UPDATE)注解时,AOP切面(AutoFillAspect)就会自动拦截这个方法 - 切面在Mapper方法执行之前,通过反射为实体对象自动填充
updateTime(更新时间)和updateUser(更新人ID) - 填充完成后,Mapper才真正执行SQL更新数据库
- 整个过程中,业务代码完全不用关心公共字段的赋值,全部由AOP自动完成
核心思想:AOP像是一个“智能拦截器”,专门在Mapper方法执行前插入公共字段填充逻辑
苍穹外卖中的公共字段填充实现方案
本方案采用 自定义注解 + AOP 切面 的方式,实现以下目标:
- 在 Mapper 方法上通过注解声明需要自动填充的操作类型(INSERT / UPDATE)。
- 通过 AOP 拦截被注解的方法,在方法执行前通过反射为实体对象的公共字段赋值。
- 自动填充当前时间、当前登录用户 ID。
1. 定义数据库操作类型枚举
首先,定义一个枚举类 OperationType,用于标识数据库操作类型:
package com.sky.enumeration;
/**
* 数据库操作类型
*/
public enum OperationType {
/**
* 更新操作
*/
UPDATE,
/**
* 插入操作
*/
INSERT
}
2. 自定义注解 @AutoFill
创建一个自定义注解 @AutoFill,用于标记需要进行公共字段自动填充的方法:
package com.sky.annotation;
import com.sky.enumeration.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 {
//数据库操作类型:UPDATE INSERT
OperationType value();
}
注解说明:
@Target(ElementType.METHOD):表示该注解只能标注在方法上。@Retention(RetentionPolicy.RUNTIME):表示注解在运行时保留,可以通过反射读取。value()属性:用于指定当前方法对应的数据库操作类型(INSERT 或 UPDATE)。
3. 实体类示例
以 Employee 实体为例,它包含了四个公共字段:
package com.sky.entity;
import lombok.Data;
import lombok.Builder;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
import java.io.Serializable;
import java.time.LocalDateTime;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Employee implements Serializable {
private static final long serialVersionUID = 1L;
private Long id;
private String username;
private String name;
private String password;
private String phone;
private String sex;
private String idNumber;
private Integer status;
// 公共字段
private LocalDateTime createTime;
private LocalDateTime updateTime;
private Long createUser;
private Long updateUser;
}
4. 核心:自动填充切面 AutoFillAspect
切面类 AutoFillAspect 是整个自动填充功能的核心,它负责拦截被 @AutoFill 注解标记的方法,并根据操作类型为实体对象的公共字段赋值。
package com.sky.aspect;
import java.lang.reflect.Method;
import java.time.LocalDateTime;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;
import com.sky.annotation.AutoFill;
import com.sky.context.BaseContext;
import com.sky.enumeration.OperationType;
import lombok.extern.slf4j.Slf4j;
@Aspect
@Component
@Slf4j
public class AutoFillAspect {
/**
* 切入点
*/
@Pointcut("execution(* com.sky.mapper.*.*(..)) && @annotation(com.sky.annotation.AutoFill)")
public void autoFillPointcut() {}
/**
* 前置通知,在通知中进行公共字段的赋值
*/
@Before("autoFillPointcut()")
public void autoFill(JoinPoint joinPoint) {
log.info("前置通知:开始进行公共字段自动填充");
// 1. 获取当前被拦截方法上的数据库操作类型
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
AutoFill autoFill = signature.getMethod().getAnnotation(AutoFill.class);
OperationType operationType = autoFill.value();
// 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. 根据当前不同的操作类型,为对应属性通过反射来赋值
try {
if (operationType == OperationType.INSERT) {
// 插入操作:为4个公共字段赋值
Method setCreateTime = entity.getClass().getDeclaredMethod("setCreateTime", LocalDateTime.class);
Method setUpdateTime = entity.getClass().getDeclaredMethod("setUpdateTime", LocalDateTime.class);
Method setCreateUser = entity.getClass().getDeclaredMethod("setCreateUser", Long.class);
Method setUpdateUser = entity.getClass().getDeclaredMethod("setUpdateUser", Long.class);
setCreateTime.invoke(entity, now);
setUpdateTime.invoke(entity, now);
setCreateUser.invoke(entity, currentId);
setUpdateUser.invoke(entity, currentId);
} else if (operationType == OperationType.UPDATE) {
// 更新操作:为2个公共字段赋值
Method setUpdateTime = entity.getClass().getDeclaredMethod("setUpdateTime", LocalDateTime.class);
Method setUpdateUser = entity.getClass().getDeclaredMethod("setUpdateUser", Long.class);
setUpdateTime.invoke(entity, now);
setUpdateUser.invoke(entity, currentId);
}
} catch (Exception e) {
log.error("公共字段自动填充失败:{}", e.getMessage());
}
}
}
切面执行流程详解:
- 定义切入点:
@Pointcut("execution(* com.sky.mapper.*.*(..)) && @annotation(com.sky.annotation.AutoFill)")- 匹配
com.sky.mapper包下所有方法 - 要求方法上必须标注
@AutoFill注解 - 使用
&&确保两个条件同时满足,避免误拦截查询方法
- 匹配
- 前置通知:
@Before("autoFillPointcut()")- 在目标方法执行前触发
- 获取操作类型:通过
MethodSignature获取方法上的@AutoFill注解,并读取其value()属性 - 获取实体对象:约定 Mapper 方法的第一个参数为需要填充的实体对象
- 准备数据:
LocalDateTime.now():当前时间BaseContext.getCurrentId():从 ThreadLocal 中获取当前登录用户 ID(需要提前在登录拦截器中设置)
- 反射赋值:
- INSERT 操作:填充
createTime、updateTime、createUser、updateUser四个字段 - UPDATE 操作:仅填充
updateTime、updateUser两个字段 - 通过
getDeclaredMethod获取 setter 方法,再通过invoke调用
- INSERT 操作:填充
- 异常处理:捕获反射可能抛出的异常,记录错误日志,避免影响主业务流程
5. 在 Mapper 中使用 @AutoFill 注解
在需要自动填充的 Mapper 方法上标注 @AutoFill,并指定操作类型:
// EmployeeMapper.java
@Insert("insert into employee ...")
@AutoFill(value = OperationType.INSERT)
void insert(Employee employee);
@Update("update employee set ... where id = #{id}")
@AutoFill(value = OperationType.UPDATE)
void update(Employee employee);
其他实体(如 Dish、Category、Setmeal)的 Mapper 同样方式使用:
// DishMapper.java
@AutoFill(value = OperationType.INSERT)
void insert(Dish dish);
@AutoFill(value = OperationType.UPDATE)
void update(Dish dish);
完整的时序图
以下为修改员工信息的完整的业务流程时序图
sequenceDiagram
actor 用户
participant 浏览器
participant EmployeeController
participant EmployeeServiceImpl
participant EmployeeMapper
participant AutoFillAspect
participant 数据库
用户->>浏览器: 填写表单,点击提交
浏览器->>EmployeeController: PUT /admin/employee<br/>JSON数据
EmployeeController->>EmployeeController: 日志记录
EmployeeController->>EmployeeServiceImpl: employeeService.update(employeeDTO)
EmployeeServiceImpl->>EmployeeServiceImpl: BeanUtils.copyProperties<br/>创建Employee实体
EmployeeServiceImpl->>EmployeeMapper: employeeMapper.update(employee)
Note over EmployeeMapper: 方法带有@AutoFill(OperationType.UPDATE)
EmployeeMapper->>AutoFillAspect: 被切入点拦截,执行前置通知
AutoFillAspect->>AutoFillAspect: 1.获取操作类型(UPDATE)<br/>2.获取实体对象<br/>3.准备数据(now, currentId)
AutoFillAspect->>AutoFillAspect: 4.反射调用setUpdateTime(now)<br/>setUpdateUser(currentId)
AutoFillAspect->>EmployeeMapper: 返回填充后的实体
EmployeeMapper->>数据库: 执行UPDATE SQL
数据库-->>EmployeeMapper: 更新成功(1 row affected)
EmployeeMapper-->>EmployeeServiceImpl: 返回
EmployeeServiceImpl-->>EmployeeController: 返回
EmployeeController->>浏览器: Result.success()
浏览器-->>用户: 显示成功提示
关键节点解析:
| 步骤 | 组件 | 关键动作 | AOP介入点 |
|---|---|---|---|
| 1-3 | 前端 → Controller | 接收HTTP请求,解析参数 | - |
| 4-5 | Controller → Service | 业务逻辑处理,DTO转Entity | - |
| 6 | Service → Mapper | 调用数据持久层方法 | AOP切入点 |
| 7-10 | AutoFillAspect | 拦截Mapper方法,反射填充字段 | 核心填充逻辑 |
| 11-12 | Mapper → 数据库 | 执行SQL更新 | - |
| 13-15 | 逐层返回结果 | 返回成功响应 | - |
注意事项
- 实体类规范:实体类必须包含对应的 setter 方法(如
setCreateTime、setUpdateUser),否则反射会失败。 - 参数顺序约定:切面默认取方法的第一个参数作为实体对象。如果方法有多个参数,需要调整切面逻辑或确保实体对象是第一个参数。
- ThreadLocal 上下文:
BaseContext.getCurrentId()需要在用户登录后,通过拦截器或过滤器将用户 ID 存入 ThreadLocal。示例:public class BaseContext { private static ThreadLocal<Long> 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(); } } - 切入点表达式优化:使用
&& @annotation(...)确保只拦截带有注解的方法,避免误伤查询方法。 - 性能考虑:反射调用有一定性能开销,但相对于数据库操作而言可忽略。若对性能有极致要求,可考虑使用字节码增强(如 ByteBuddy)或编译时注解处理(APT)。
- 扩展性:如需支持更多操作类型(如 DELETE)或更多字段(如逻辑删除标记
deleted),只需扩展OperationType枚举和切面中的赋值逻辑。
总结
通过 AOP 实现公共字段自动填充,带来了以下好处:
- 代码精简:消除了每个 Service 或 Mapper 中重复的赋值代码,使业务逻辑更清晰。
- 维护方便:公共字段的赋值逻辑集中在一处,修改时只需调整切面类。
- 一致性保证:所有插入/更新操作都遵循相同的填充规则,避免遗漏。
- 解耦:将横切关注点与业务逻辑分离,符合单一职责原则。
AOP让开发者只需关注业务本身,而将公共处理交给框架,让代码更加优雅、简洁、可维护。
参考
苍穹外卖www.bilibili.com/video/BV1TP…
deekseek-v4