Skip to content
第 43 / 250 章后端⏱ 10 分钟阅读

第 43 章:AOP 面向切面

学习目标

  • 理解 AOP 解决什么问题
  • 掌握切点表达式与五种通知
  • 实战:日志切面、限流切面

一、AOP 解决什么问题?

横切关注点:日志、事务、权限、限流、缓存——这些逻辑散落在每个方法里。

java
// ❌ 业务代码被非业务逻辑淹没
public void createOrder(Order order) {
    log.info("开始创建订单: {}", order);              // 日志
    long start = System.currentTimeMillis();          // 耗时统计
    if (!hasPermission("order:create")) {             // 权限
        throw new ForbiddenException();
    }
    TransactionStatus tx = txManager.getTransaction();// 事务
    try {
        orderMapper.insert(order);                    // ← 只有这一行是业务!
        txManager.commit(tx);
    } catch (Exception e) {
        txManager.rollback(tx);
        throw e;
    } finally {
        log.info("耗时 {}ms", System.currentTimeMillis() - start);
    }
}

AOP 的解法:把这些逻辑抽出去,用「切面」在运行时织入。

java
// ✅ 业务代码只剩业务
@Transactional
@RequiresPermission("order:create")
@LogExecution
public void createOrder(Order order) {
    orderMapper.insert(order);
}

二、核心概念

术语英文含义
切面Aspect横切逻辑的载体类(@Aspect
连接点JoinPoint可以被拦截的点(Spring 中只支持方法)
切点Pointcut「哪些连接点要被拦截」的规则表达式
通知Advice拦截后做什么(前置/后置/环绕...)
织入Weaving把切面应用到目标对象的过程(Spring 在运行期用动态代理)

三、五种通知

java
@Aspect
@Component
@Slf4j
public class DemoAspect {

    @Pointcut("execution(* com.taskflow.service..*.*(..))")   // ① 定义切点,复用
    public void serviceMethods() { }

    @Before("serviceMethods()")                    // ② 方法执行前
    public void before(JoinPoint jp) {
        log.info("前置: {}", jp.getSignature().getName());
    }

    @AfterReturning(value = "serviceMethods()", returning = "result")
    public void afterReturning(JoinPoint jp, Object result) {   // ③ 正常返回后
        log.info("返回值: {}", result);
    }

    @AfterThrowing(value = "serviceMethods()", throwing = "e")
    public void afterThrowing(JoinPoint jp, Exception e) {      // ④ 抛异常后
        log.error("异常: {}", e.getMessage());
    }

    @After("serviceMethods()")                     // ⑤ 无论如何都执行(相当于 finally)
    public void after(JoinPoint jp) {
        log.info("后置");
    }

    @Around("serviceMethods()")                    // ⑥ 环绕:功能最强
    public Object around(ProceedingJoinPoint pjp) throws Throwable {
        long start = System.currentTimeMillis();
        Object result = pjp.proceed();             // ⑦ 必须调用!否则目标方法不执行
        log.info("耗时: {}ms", System.currentTimeMillis() - start);
        return result;                             // ⑧ 必须返回!否则调用方拿到 null
    }
}

执行顺序

实践建议@Around 一个就够用了。它能覆盖其他四种的所有能力,代码也更集中。

四、切点表达式

execution(最常用)

execution(修饰符? 返回类型 包名.类名.方法名(参数) 异常?)
java
// 所有 public 方法
execution(public * *(..))

// service 包下所有类的所有方法(一个点 = 只有这层包)
execution(* com.taskflow.service.*.*(..))

// service 包及子包(两个点 = 含子包)
execution(* com.taskflow.service..*.*(..))

// 所有以 save 开头的方法
execution(* com.taskflow..*.save*(..))

// 指定返回类型和参数
execution(User com.taskflow.service.UserService.getById(Long))
通配符含义
*匹配任意一个(返回类型、类名、方法名)
..包位置:当前包及子包;参数位置:任意个参数
+类名后:该类及其子类

@annotation(推荐,最精准)

java
// 拦截所有标注了 @LogExecution 的方法
@Around("@annotation(com.taskflow.annotation.LogExecution)")

为什么推荐注解方式? execution 靠包路径匹配,改包名就失效,而且看代码时不知道这个方法被切了。注解方式在方法上直接可见,意图明确。

其他表达式

java
@within(org.springframework.stereotype.Service)   // 类上有此注解
@args(com.taskflow.MyAnno)                        // 参数类型上有此注解
within(com.taskflow.service..*)                   // 限定包范围
args(Long, String)                                // 参数类型匹配
bean(*Service)                                    // Bean 名称匹配

组合使用

java
@Around("within(com.taskflow.service..*) && @annotation(logExecution)")
public Object around(ProceedingJoinPoint pjp, LogExecution logExecution) {
    // ① 注解对象可以直接作为参数拿到,读取注解属性
    String module = logExecution.module();
}

五、实战一:操作日志切面

定义注解

java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface OperationLog {
    String module();                    // 模块名
    String action();                    // 操作类型
    boolean saveParams() default true;  // 是否记录入参
}

切面实现

java
@Aspect
@Component
@Slf4j
@RequiredArgsConstructor
public class OperationLogAspect {

    private final OperationLogService logService;

    @Around("@annotation(operationLog)")
    public Object around(ProceedingJoinPoint pjp, OperationLog operationLog)
            throws Throwable {

        long start = System.currentTimeMillis();
        OperationLogEntity entity = new OperationLogEntity();

        entity.setModule(operationLog.module());
        entity.setAction(operationLog.action());
        entity.setMethod(pjp.getSignature().toShortString());
        entity.setOperator(SecurityUtils.getCurrentUsername());   // ① 从上下文取当前用户
        entity.setIp(IpUtils.getClientIp());

        if (operationLog.saveParams()) {
            // ② 敏感字段脱敏后再记录
            entity.setParams(JsonUtils.toJsonWithMask(pjp.getArgs()));
        }

        try {
            Object result = pjp.proceed();
            entity.setSuccess(true);
            return result;

        } catch (Throwable e) {
            entity.setSuccess(false);
            entity.setErrorMsg(StringUtils.substring(e.getMessage(), 0, 2000));  // ③ 截断防超长
            throw e;                                              // ④ 必须往上抛,不能吞

        } finally {
            entity.setCostMs(System.currentTimeMillis() - start);
            logService.saveAsync(entity);                         // ⑤ 异步落库,不阻塞主流程
        }
    }
}

⑤ 为什么必须异步? 日志落库要几十毫秒。同步的话,每个接口都白白慢几十毫秒,而且日志库挂了会导致业务失败。

使用

java
@OperationLog(module = "订单", action = "创建订单")
public void createOrder(Order order) { }

六、实战二:接口限流切面

java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimit {
    int permits() default 10;                       // 允许次数
    int seconds() default 1;                        // 时间窗口
    LimitType type() default LimitType.IP;          // 限流维度
}

@Aspect
@Component
@RequiredArgsConstructor
public class RateLimitAspect {

    private final StringRedisTemplate redis;

    @Before("@annotation(rateLimit)")
    public void check(JoinPoint jp, RateLimit rateLimit) {

        // ① 构造限流 key:方法签名 + 维度标识
        String key = "rate:" + jp.getSignature().toShortString() + ":"
                + resolveIdentifier(rateLimit.type());

        // ② 计数 +1
        Long count = redis.opsForValue().increment(key);

        // ③ 第一次访问时设置过期时间(滑动窗口的简化版:固定窗口)
        if (count != null && count == 1) {
            redis.expire(key, rateLimit.seconds(), TimeUnit.SECONDS);
        }

        if (count != null && count > rateLimit.permits()) {
            throw new BusinessException(ErrorCode.RATE_LIMITED);
        }
    }
}

⚠️ 这是固定窗口限流,存在临界问题(窗口交界处可能通过 2 倍请求)。生产环境用 Redis + Lua 实现滑动窗口,或直接用 Sentinel(见第 69 章)。

七、切面执行顺序

多个切面作用在同一方法时,用 @Order 控制:

java
@Aspect
@Order(1)      // ① 数字越小优先级越高,越先执行(外层)
@Component
public class LogAspect { }

@Aspect
@Order(2)
@Component
public class RateLimitAspect { }

像洋葱一样:先进的后出。

八、常见坑

java
// 坑 1:@Around 忘记 return
@Around("...")
public Object around(ProceedingJoinPoint pjp) throws Throwable {
    pjp.proceed();       // ❌ 没有 return,调用方永远拿到 null
}

// 坑 2:自调用不生效(见第 34 章)
public void a() {
    this.b();            // ❌ 切面不生效
}

// 坑 3:private / final 方法切不到
@LogExecution
private void doWork() { }   // ❌ CGLIB 无法代理

// 坑 4:切面吞掉异常
@Around("...")
public Object around(ProceedingJoinPoint pjp) {
    try {
        return pjp.proceed();
    } catch (Throwable e) {
        log.error("出错", e);
        return null;      // ❌ 异常被吞,业务以为成功了
    }
}

// 坑 5:切面里抛异常影响主流程
finally {
    logService.save(entity);   // ❌ 日志库挂了会导致业务接口 500
}
// ✅ 切面的辅助逻辑要自己 try 住
finally {
    try { logService.saveAsync(entity); }
    catch (Exception e) { log.warn("日志记录失败", e); }
}

九、本章小结

要点关键
AOP抽离横切关注点
切点推荐 @annotation 方式
通知@Around 最强,一个够用
proceed()必须调用且必须 return
@Order控制多切面顺序,小的在外层
自调用不走代理,切面失效
辅助逻辑自己 try 住,不能影响主流程

动手练习

练习 1:基础题

写一个 @Timed 注解 + 切面,统计方法耗时。超过 1 秒时打 WARN 日志。

练习 2:进阶题

实现 @Retry(times = 3, delay = 1000) 切面:方法抛出指定异常时自动重试,支持指数退避(1s、2s、4s)。


下一章第 44 章:第一个 REST 接口

本站基于 VitePress 构建 · 由 Codebook 团队维护