AOP思想实践:公共字段自动填充

3 阅读8分钟

来自苍穹外卖项目

引言

在大多数企业级应用中,数据库表通常包含一些公共字段,例如:

  • 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-->>用户: 返回成功响应
  1. 用户通过前端界面发起更新请求(比如修改员工信息)
  2. 请求依次经过Controller → Service → Mapper
  3. 当Mapper方法上标注了@AutoFill(OperationType.UPDATE)注解时,AOP切面(AutoFillAspect)就会自动拦截这个方法
  4. 切面在Mapper方法执行之前,通过反射为实体对象自动填充updateTime(更新时间)和updateUser(更新人ID)
  5. 填充完成后,Mapper才真正执行SQL更新数据库
  6. 整个过程中,业务代码完全不用关心公共字段的赋值,全部由AOP自动完成

核心思想:AOP像是一个“智能拦截器”,专门在Mapper方法执行前插入公共字段填充逻辑

苍穹外卖中的公共字段填充实现方案

本方案采用 自定义注解 + AOP 切面 的方式,实现以下目标:

  1. 在 Mapper 方法上通过注解声明需要自动填充的操作类型(INSERT / UPDATE)。
  2. 通过 AOP 拦截被注解的方法,在方法执行前通过反射为实体对象的公共字段赋值。
  3. 自动填充当前时间、当前登录用户 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());
        }
    }
}
切面执行流程详解:
  1. 定义切入点@Pointcut("execution(* com.sky.mapper.*.*(..)) && @annotation(com.sky.annotation.AutoFill)")
    • 匹配 com.sky.mapper 包下所有方法
    • 要求方法上必须标注 @AutoFill 注解
    • 使用 && 确保两个条件同时满足,避免误拦截查询方法
  2. 前置通知@Before("autoFillPointcut()")
    • 在目标方法执行前触发
  3. 获取操作类型:通过 MethodSignature 获取方法上的 @AutoFill 注解,并读取其 value() 属性
  4. 获取实体对象:约定 Mapper 方法的第一个参数为需要填充的实体对象
  5. 准备数据
    • LocalDateTime.now():当前时间
    • BaseContext.getCurrentId():从 ThreadLocal 中获取当前登录用户 ID(需要提前在登录拦截器中设置)
  6. 反射赋值
    • INSERT 操作:填充 createTimeupdateTimecreateUserupdateUser 四个字段
    • UPDATE 操作:仅填充 updateTimeupdateUser 两个字段
    • 通过 getDeclaredMethod 获取 setter 方法,再通过 invoke 调用
  7. 异常处理:捕获反射可能抛出的异常,记录错误日志,避免影响主业务流程

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-5Controller → Service业务逻辑处理,DTO转Entity-
6Service → Mapper调用数据持久层方法AOP切入点
7-10AutoFillAspect拦截Mapper方法,反射填充字段核心填充逻辑
11-12Mapper → 数据库执行SQL更新-
13-15逐层返回结果返回成功响应-

注意事项

  1. 实体类规范:实体类必须包含对应的 setter 方法(如 setCreateTimesetUpdateUser),否则反射会失败。
  2. 参数顺序约定:切面默认取方法的第一个参数作为实体对象。如果方法有多个参数,需要调整切面逻辑或确保实体对象是第一个参数。
  3. 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();
        }
    }
    
  4. 切入点表达式优化:使用 && @annotation(...) 确保只拦截带有注解的方法,避免误伤查询方法。
  5. 性能考虑:反射调用有一定性能开销,但相对于数据库操作而言可忽略。若对性能有极致要求,可考虑使用字节码增强(如 ByteBuddy)或编译时注解处理(APT)。
  6. 扩展性:如需支持更多操作类型(如 DELETE)或更多字段(如逻辑删除标记 deleted),只需扩展 OperationType 枚举和切面中的赋值逻辑。

总结

通过 AOP 实现公共字段自动填充,带来了以下好处:

  1. 代码精简:消除了每个 Service 或 Mapper 中重复的赋值代码,使业务逻辑更清晰。
  2. 维护方便:公共字段的赋值逻辑集中在一处,修改时只需调整切面类。
  3. 一致性保证:所有插入/更新操作都遵循相同的填充规则,避免遗漏。
  4. 解耦:将横切关注点与业务逻辑分离,符合单一职责原则。

AOP让开发者只需关注业务本身,而将公共处理交给框架,让代码更加优雅、简洁、可维护。

参考

苍穹外卖www.bilibili.com/video/BV1TP…

deekseek-v4