介绍

背景

  • 典型字段:创建时间(create_time)、创建人ID(create_user)、修改时间(update_time)、修改人ID(update_user)
  • 问题本质:多个业务表(员工表、分类表、菜品表等)存在相同字段导致代码冗余
  • 维护痛点:字段变更时需要修改多处代码,维护成本高且容易遗漏
  • 操作类型区分:
    • 创建字段:仅在insert操作时赋值
    • 修改字段:在insert和update操作时均需赋值
  • 技术方案:
    • 注解标记:自定义注解标识需要自动填充的方法
    • AOP拦截:通过切面统一处理带注解的Mapper方法
    • 反射赋值:利用反射机制动态设置字段值
  • 技术要点:使用枚举、注解、AOP和反射技术实现

公共字段自动填充是在软件开发中,特别是涉及数据库操作的应用里,自动为记录添加通用信息(如创建时间、修改时间、创建者、修改者等)的机制。其原理综合运用了面向切面编程、线程本地存储、注解反射等技术:

1.面向切面编程(AOP)

  • 原理:AOP 能将横切关注点(如日志记录、事务管理、公共字段填充)从业务逻辑中分离出来。通过定义切面(Aspect),确定在哪些连接点(Join Point,如方法调用)执行特定的通知(Advice,如前置通知、后置通知)。
  • 应用示例:在Java开发中,使用Spring AOP框架,开发者可定义一个切面,指定在所有数据持久化方法(如保存、更新操作)执行前,触发前置通知,完成公共字段的填充。例如,在保存菜品信息到数据库前,通过切面自动填充创建时间、创建人等字段。

2.线程本地存储(ThreadLocal)

  • 原理:ThreadLocal为每个线程提供独立的变量副本,使得在多线程环境下,每个线程都能独立操作自己的变量,避免线程安全问题。在公共字段填充场景中,常用于存储和传递与当前线程相关的上下文信息,如当前登录用户。
  • 应用示例:在一个Web应用中,当用户登录后,将用户信息存入ThreadLocal。在后续处理业务逻辑(如订单创建、商品更新等)时,不同的业务方法可能在不同的类和方法调用层次,但通过ThreadLocal都能获取到当前登录用户信息,用于填充公共字段(如创建人、修改人)。

大致的项目结构如下所示:

实现思路

  1. 自定义注解 AutoFill,用于标识需要进行公共字段自动填充的方法
  2. 自定义切面类 AutoFillAspect,统一拦截加入了 AutoFill 注解的方法,通过反射为公共字段赋值
  3. 在Mapper 的方法上加入 AutoFill 注解
sky-server
├── src/main/java/com/sky
│   ├── annotation
│   │   └── AutoFill.java  # 自定义注解
│   ├── aspect
│   │   └── AutoFillAspect.java  # 切面类
│   ├── mapper
│   │   ├── CategoryMapper.java  # 添加 @AutoFill 注解
│   │   ├── DishMapper.java      # 格式调整
│   │   └── EmployeeMapper.java  # 添加 @AutoFill 注解
│   ├── service/impl
│   │   ├── CategoryServiceImpl.java  # 修改:移除手动填充代码
│   │   └── EmployeeServiceImpl.java  # 修改:移除手动填充代码
│   ├── constant
│   │   └── AutoFillConstant.java  # 公共字段自动填充相关常量
│   ├── context
│   │   └── BaseContext.java       # 全局上下文
│   └── enumeration
│       ├── OperationType.java     # 引用:数据库操作类型枚举
│       └── StatusConstant.java    # 引用:状态常量枚举
  • @Target(ElementType.METHOD):指定注解只能加在方法上
  • @Retention(RetentionPolicy.RUNTIME):运行时保留
  • 包含OperationType value()属性,指定数据库操作类型,OperationType枚举定义在com.sky.enumeration包,包含UPDATE和INSERT两种操作类型,不包含DELETE和SELECT,因为这些操作不需要填充公共字段
package com.sky.annotation;  
  
/**  
 * 自定义注解,标识某个方法需要进行功能字段自动填充  
 */  
  
@Target(ElementType.METHOD) // 作用在方法上  
@Retention(RetentionPolicy.RUNTIME) // 运行时  
public @interface AutoFill {  
    // 数据库操作类型  
    OperationType value();  
}
package com.sky.enumeration;  
  
/**  
 * 数据库操作类型(枚举)
 */  
public enum OperationType {  
    UPDATE,  
    INSERT   
}
package com.sky.constant;  
  
/**  
 * 公共字段自动填充相关常量  
 */  
public class AutoFillConstant {  
    /**  
     * 实体类中的方法名称  
     */  
    public static final String SET_CREATE_TIME = "setCreateTime";  
    public static final String SET_UPDATE_TIME = "setUpdateTime";  
    public static final String SET_CREATE_USER = "setCreateUser";  
    public static final String SET_UPDATE_USER = "setUpdateUser";  
}
/**  
 * 插入数据  
 * @param category  
 */  
@Insert("insert into category(type, name, sort, status, create_time, update_time, create_user, update_user)" +  
        " VALUES" +  
        " (#{type}, #{name}, #{sort}, #{status}, #{createTime}, #{updateTime}, #{createUser}, #{updateUser})")  
@AutoFill(value = OperationType.INSERT)  
void insert(Category category);

AutoFillAspect 切面类工作原理

1. 切面定义和切入点

@Aspect // 标识这是一个切面类
@Component // 作为Spring组件管理
@Slf4j // 日志记录
public class AutoFillAspect {
    
    // 定义切入点:拦截mapper包下所有方法且有@AutoFill注解的方法
    @Pointcut("execution(* com.sky.mapper.*.*(..)) && @annotation(com.sky.annotation.AutoFill)")
    public void autoFillPointCut() {
    }
}

2. 前置通知处理逻辑

在方法执行前,通过 @Before("autoFillPointCut()") 拦截目标方法:

@Before("autoFillPointCut()")
public void autoFill(JoinPoint joinPoint) {
    log.info("开始进行公共字段自动填充...");
    
    // 获取方法签名和@AutoFill注解中的操作类型(INSERT或UPDATE)
    MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
    AutoFill autoFill = methodSignature.getMethod().getAnnotation(AutoFill.class);
    OperationType operationType = autoFill.value();
    
    // 获取方法参数(实体对象)
    Object[] args = joinPoint.getArgs();
    if (args == null || args.length == 0) {
        return;
    }
    Object entity = args[0];
    
    // 准备填充数据:当前时间和当前用户ID
    LocalDateTime now = LocalDateTime.now();
    Long currentId = BaseContext.getCurrentId();
    
    // 根据操作类型进行字段填充
    if (operationType == OperationType.INSERT) {
        // INSERT操作填充所有4个字段
        // createTime, createUser, updateTime, updateUser
        // ...
    } else {
        // UPDATE操作只填充updateTime和updateUser
        // ...
    }
}

3. 字段填充的具体实现

使用反射机制调用实体对象的setter方法:

// INSERT操作
if (operationType == OperationType.INSERT) {
    try {
        // 通过反射获取实体类的setter方法
        Method setCreateTime = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_CREATE_TIME, LocalDateTime.class);
        Method setCreateUser = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_CREATE_USER, Long.class);
        Method setUpdateTime = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_TIME, LocalDateTime.class);
        Method setUpdateUser = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_USER, Long.class);
 
        // 调用setter方法进行赋值
        setCreateTime.invoke(entity, now);
        setCreateUser.invoke(entity, currentId);
        setUpdateTime.invoke(entity, now);
        setUpdateUser.invoke(entity, currentId);
    } catch (Exception e) {
        throw new RuntimeException(e);
    }
} else {
    // UPDATE操作只更新更新时间和更新人
    try {
        Method setUpdateTime = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_TIME, LocalDateTime.class);
        Method setUpdateUser = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_USER, Long.class);
        setUpdateTime.invoke(entity, now);
        setUpdateUser.invoke(entity, currentId);
    } catch (Exception e) {
        throw new RuntimeException(e);
    }
}

4. 核心组件说明

  • @AutoFill 注解:标记在 Mapper 接口方法上,指定操作类型(INSERT 或 UPDATE)
  • OperationType 枚举:定义操作类型(INSERT 和 UPDATE)
  • AutoFillConstant 常量类:定义实体类中公共字段的 setter 方法名
  • BaseContext:通过 ThreadLocal 存储当前用户 ID,用于填充 createUser 和 updateUser 字段

5. 工作流程总结

  1. 当执行带有 @AutoFill 注解的 Mapper 方法时,触发切面逻辑
  2. 切面获取方法上的注解,判断操作类型(INSERT 或 UPDATE)
  3. 获取方法参数中的实体对象
  4. 获取当前时间和当前用户 ID(从 ThreadLocal 中获取)
  5. 根据操作类型,通过反射调用实体对象的 setter 方法填充相应字段:
    • INSERT 操作:填充创建时间、创建人、更新时间、更新人四个字段
    • UPDATE 操作:只填充更新时间、更新人两个字段

这种设计实现了在数据持久化时自动填充公共字段,避免了在业务代码中重复设置这些字段,提高了代码的整洁性和维护性。

package com.sky.aspect;  
 
// 完整代码点击标题
 
/**  
 * 切面类,实现公共字段自动填充处理逻辑  
 */  
@Aspect // 表示当前类是一个切面类  
@Component // 表示当前类是一个组件,会被Spring容器扫描到并管理  
@Slf4j // 表示自动注入log对象,log对象表示日志记录器,可以记录日志信息  
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("开始进行公共字段自动填充...");  
        // 获取到当前被拦截的方法上的数据库操作类型(UPDATE INSERT)
        // 获取到当前被拦截的方法签名对象    
        MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature(); 
        // 获取到当前被拦截的方法上的AutoFill注解对象  
        AutoFill autoFill = methodSignature.getMethod().getAnnotation(AutoFill.class); 
        // 获取到当前被拦截的方法上的数据库操作类型(UPDATE INSERT)  
        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();  
  
        // 判断当前数据库操作类型,然后通过反射进行赋值  
 
		Method setUpdateUser = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_USER, Long.class);  
  
		// 通过反射为实体对象中的属性赋值  
		setUpdateUser.invoke(entity, currentId);
       
    }  
}