缘博客
发布日期

后端Web进阶:AOP、切点与操作日志

作者
  • 姓名
    社交账号

从一段重复代码认识 AOP

假设需要统计所有业务层方法的执行耗时。最直接的写法,是在每个方法中分别记录开始时间、调用业务代码、计算结束时间并输出日志:

public void delete(Integer id) {
    long begin = System.currentTimeMillis();

    deptMapper.delete(id);

    long end = System.currentTimeMillis();
    log.info("删除部门耗时:{} ms", end - begin);
}

这样能实现功能,却把同一段“计时逻辑”散落在很多业务方法中。以后要调整日志格式、统计规则,或者增加异常记录,就得逐个修改。

这类横跨多个模块、与核心业务无关但又反复出现的功能,通常称为横切关注点。日志、性能统计、事务、权限校验都属于这一类。

AOP(Aspect Oriented Programming,面向切面编程)就是把横切关注点抽出来,统一应用到符合条件的方法上。业务方法只保留业务本身,公共逻辑放在切面中维护。

Controller
代理对象
   ↓  前置 / 环绕等通知
目标 Service 方法
通知继续处理结果、异常或耗时

AOP 是一种思想;Spring AOP 是 Spring 对这套思想的实现。它会为符合条件的 Bean 创建代理对象,在调用目标方法的合适时机执行通知代码。


快速实现:统一统计业务方法耗时

Spring Boot 项目使用 AOP 时,通常引入对应起步依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
</dependency>

然后声明一个切面。@Aspect 表示这是切面类,@Component 让它被 Spring 扫描并交给 IOC 容器管理。

@Slf4j
@Aspect
@Component
public class RecordTimeAspect {

    @Around("execution(* com.example.service..*(..))")
    public Object recordTime(ProceedingJoinPoint joinPoint) throws Throwable {
        long begin = System.currentTimeMillis();

        Object result = joinPoint.proceed();

        long end = System.currentTimeMillis();
        log.info("{}.{} 执行耗时:{} ms",
                joinPoint.getTarget().getClass().getSimpleName(),
                joinPoint.getSignature().getName(),
                end - begin);

        return result;
    }
}

joinPoint.proceed() 很关键:它负责调用原始方法,并取得原始方法的返回值。漏掉它,目标方法就不会真正执行;漏掉 return result,有返回值的接口可能得到错误结果。

System.currentTimeMillis() 足以演示业务计时。若需要精细的耗时分析,通常改用 System.nanoTime(),并结合监控系统聚合数据,而不是只依赖单条日志。


五个核心概念

概念含义在计时案例中的对应内容
目标对象(Target)被增强的原始对象DeptServiceImpl
连接点(JoinPoint)可以被 AOP 控制的方法执行位置一次 delete() 调用
切入点(Pointcut)用来筛选连接点的规则execution(* com.example.service..*(..))
通知(Advice)在特定时机执行的公共逻辑计时、记录日志的方法
切面(Aspect)切入点与通知的组合RecordTimeAspect

可以把切面理解为一句完整的话:当某类方法被调用时,在指定时机执行某段公共逻辑。

Spring AOP 运行时,Controller 注入和调用的往往不是原始 Service,而是代理对象。代理对象先判断当前调用是否匹配切入点;匹配时执行通知,并在合适位置调用原始目标对象的方法。


通知类型:什么时候执行公共逻辑

Spring AOP 中最常见的通知有五种:

注解执行时机典型用途
@Before目标方法前参数校验、记录开始日志
@After目标方法结束后,无论是否抛异常资源清理
@AfterReturning正常返回后记录返回结果
@AfterThrowing抛出异常后异常告警、异常日志
@Around目标方法前后均可控制计时、统一日志、权限控制

环绕通知的控制能力最完整,因此最常用于“既要拿到参数,又要拿到返回值和耗时”的场景。它的参数必须是 ProceedingJoinPoint,因为只有这个类型能够执行 proceed();其余四类通知一般使用 JoinPoint 获取方法信息即可。

多个切面同时匹配一个方法时,可以用 @Order 明确顺序:

@Order(1)
@Aspect
@Component
public class LogAspect {
}

数字越小,目标方法执行前越早进入;目标方法执行后越晚退出。不要依赖类名字母顺序,顺序有业务意义时应显式标注。


切入点表达式:准确找到需要增强的方法

execution:按方法签名匹配

execution 可以按访问修饰符、返回值、包名、类名、方法名和参数匹配:

execution(访问修饰符? 返回值 包名.类名.?方法名(参数) throws 异常?)

常用通配符:

通配符含义
*一个任意片段,例如任意返回值、类名的一部分或一个参数
..多个连续片段,例如任意层级包名或任意个参数

例如:

// 匹配 service 包及其子包中的所有方法
@Around("execution(* com.example.service..*(..))")

// 匹配名称以 update 开头、且有一个参数的方法
@Around("execution(* com.example.service.*.update*(*)")

实际书写时应遵循三个原则:业务方法命名规范、优先面向接口描述、在满足需求的前提下缩小匹配范围。特别是不要轻易用一个很宽的 .. 把无关方法也纳入切面。

@annotation:按标记匹配

有些操作并不适合靠方法名区分,例如“哪些接口需要记录操作日志”。这时可以定义一个标记注解:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Log {
}

在需要记录的接口上标记:

@Log
@DeleteMapping("/depts")
public Result delete(Integer id) {
    deptService.delete(id);
    return Result.success();
}

切面按注解匹配:

@Around("@annotation(com.example.anno.Log)")

这比把 saveupdatedelete 写成一长串方法名更直观,也更不容易漏记新增的业务接口。

重复使用的表达式可以用 @Pointcut 抽取:

@Pointcut("@annotation(com.example.anno.Log)")
private void logPointcut() {
}

@Around("logPointcut()")
public Object recordLog(ProceedingJoinPoint joinPoint) throws Throwable {
    // ...
}

private 切入点只在当前切面复用;若多个切面需要共享,可以声明为 public


从连接点中取得方法运行信息

连接点对象不仅用于调用原方法,还能取得本次调用的上下文:

String className = joinPoint.getTarget().getClass().getName();
String methodName = joinPoint.getSignature().getName();
Object[] args = joinPoint.getArgs();

这三个信息分别对应目标类、目标方法和运行参数。配合 proceed() 的返回值、开始结束时间,就可以构造一条完整的操作日志。

需要注意参数和返回值可能含有密码、令牌、文件内容等敏感信息。生产项目不能不加区分地序列化全部对象;应当脱敏、截断超长内容,并明确哪些字段禁止进入日志。


实战:记录增删改操作日志

一个操作日志通常包含:操作人、操作时间、类名、方法名、参数、返回值和执行时长。下面的切面展示核心流程:

@Slf4j
@Aspect
@Component
public class OperateLogAspect {

    @Around("@annotation(com.example.anno.Log)")
    public Object recordLog(ProceedingJoinPoint joinPoint) throws Throwable {
        long begin = System.currentTimeMillis();
        Object result = joinPoint.proceed();
        long costTime = System.currentTimeMillis() - begin;

        OperateLog operateLog = new OperateLog();
        operateLog.setOperateEmpId(CurrentHolder.get());
        operateLog.setOperateTime(LocalDateTime.now());
        operateLog.setClassName(joinPoint.getTarget().getClass().getName());
        operateLog.setMethodName(joinPoint.getSignature().getName());
        operateLog.setMethodParams(JSON.toJSONString(joinPoint.getArgs()));
        operateLog.setReturnValue(JSON.toJSONString(result));
        operateLog.setCostTime(costTime);

        operateLogMapper.insert(operateLog);
        return result;
    }
}

这里的示例省略了异常日志策略和敏感字段脱敏。若希望失败操作也入库,可以在 try / catch / finally 中分别记录成功、异常和最终清理状态,不能让“记录日志失败”掩盖原业务异常。


ThreadLocal:在同一次请求中传递当前用户

登录过滤器已经解析了 JWT,却不能把用户 ID 当作参数逐层传给 Controller、Service 和 AOP。ThreadLocal 很适合这种“同一请求线程内共享上下文”的需求。

public final class CurrentHolder {

    private static final ThreadLocal<Long> CURRENT_EMP_ID = new ThreadLocal<>();

    private CurrentHolder() {
    }

    public static void set(Long empId) {
        CURRENT_EMP_ID.set(empId);
    }

    public static Long get() {
        return CURRENT_EMP_ID.get();
    }

    public static void remove() {
        CURRENT_EMP_ID.remove();
    }
}

过滤器在令牌校验成功后写入,在请求结束时删除:

try {
    Long empId = parseEmpId(token);
    CurrentHolder.set(empId);
    filterChain.doFilter(request, response);
} finally {
    CurrentHolder.remove();
}

remove() 不能省略。Web 容器通常复用工作线程;如果旧请求的数据残留在线程中,后续请求可能读取到错误用户,既是功能错误也可能造成安全问题。

ThreadLocal 提供的是线程隔离存储,不是跨线程传参工具。异步任务、线程池、消息消费等场景会切换线程,不能期待它自动把当前用户带过去,应使用明确的上下文传递方案。


小结

AOP 适合处理日志、性能统计、事务和权限这类公共逻辑,但不该替代清晰的业务分层。一次完整的 AOP 实践,可以按下面的顺序检查:

  1. 确认公共逻辑与业务代码是否应该分离。
  2. 选定合适的通知类型;需要控制原方法、返回值和耗时时优先考虑 @Around
  3. 用尽量精确的 execution 或语义清晰的 @annotation 定义切入点。
  4. 通过 JoinPoint 获取必要上下文,并注意日志脱敏。
  5. 使用 ThreadLocal 传递请求级数据时,在 finally 中清理。

掌握这些内容后,AOP 就不再只是几个注解,而是一种让业务代码保持专注、让横切能力集中治理的组织方式。