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

第 43 章:AOP 面向切面 ​

学习目标 ​

  • 知道 AOP 解决什么问题
  • 看懂 @Aspect 切面类:它是什么、怎么生效的
  • 掌握 5 种通知 + 切点表达式
  • 实战:日志切面 + 限流切面

一、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 标注。

打比方:快递员送货(业务方法)。但每个快递员出发前要"检查车辆、登记出库",回来后要"登记入库"。这些检查登记不是送货,但每个包裹都做 → 横切逻辑。把它们集中到一个"快递站"统一管 → 就是切面。

看一个最小切面,拆开理解:

java
@Aspect          // 告诉 Spring:这是个"快递站",装的都是横切逻辑
@Component       // 让它成为 Spring 管理的 Bean
@Slf4j           // 生成 log 对象(和 AOP 无关,只是方便打印)
public class DemoAspect {

    @Pointcut("execution(* com.taskflow.service..*.*(..))")  // 订规矩:拦截谁
    public void serviceMethods() { }                          // 空方法,只是切点名字

    @Before("serviceMethods()")          // 检查动作:目标方法执行前做啥
    public void before(JoinPoint jp) { }

    @Around("serviceMethods()")          // 检查动作:目标方法前后都能管
    public Object around(ProceedingJoinPoint pjp) throws Throwable {
        System.out.println("方法执行前");
        Object result = pjp.proceed();   // 手动放行,进真正的业务方法
        System.out.println("方法执行后");
        return result;
    }
}

对应关系:

注解比喻含义
@Aspect快递站标记这是横切逻辑的载体类
@Pointcut订规矩定"拦截谁"(哪些方法)
@Before/@Around 等检查动作定"拦截后做啥"(通知)

💡 别被 @Pointcut 的空方法吓到:它只是给切点表达式起个名字,方法体不用写,@Before("serviceMethods()") 里引用的是这个名字。

三、它怎么"凭空"就生效了?——动态代理 ​

Spring 启动扫描到 @Aspect,会对命中切点的方法生成一个代理对象。你调用方法时,实际走的是代理:

你的代码:userService.getUser(1)
        ↓
    代理对象(快递站)
   ├── @Around 前半(方法前逻辑)
   ├── 真正的 getUser()   ← pjp.proceed() 放行进到这里
   ├── @Around 后半(方法后逻辑)
        ↓
    返回结果给你
java
// 你调用这个"普通"方法
userService.getUser(1);
// 实际执行顺序(切面自动织入,方法一个字没改)
// 1. DemoAspect.around() 里 proceed() 之前的代码
// 2. 真正的 getUser() 业务逻辑
// 3. DemoAspect.around() 里 proceed() 之后的代码

⚠️ 坑 1:@Around 里必须调 pjp.proceed()——它负责放行进业务方法。不调,业务方法就不会执行;不 return result,调用方会拿到 null。

四、五种通知(@Around 一个够用) ​

切面里可以写多种"检查动作",按在方法执行时序中的位置区分:

java
@Aspect
@Component
@Slf4j
public class DemoAspect {

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

    @Before("serviceMethods()")
    public void before(JoinPoint jp) { }                // ① 前置:方法执行前

    @AfterReturning(value = "serviceMethods()", returning = "result")
    public void afterReturning(JoinPoint jp, Object result) { }   // ② 正常返回后

    @AfterThrowing(value = "serviceMethods()", throwing = "e")
    public void afterThrowing(JoinPoint jp, Exception e) { }      // ③ 抛异常后

    @After("serviceMethods()")
    public void after(JoinPoint jp) { }                 // ④ finally:无论正常/异常都执行

    @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 前半 → @Before → 目标方法 → @AfterReturning / @AfterThrowing → @After → @Around 后半

实践建议:@Around 一个就够用,能覆盖其他四种所有能力。想简单就用 @Before / @After,想精细控制就用 @Around。

五、切点表达式 ​

5.1 execution(按包路径匹配) ​

java
execution(* com.taskflow.service..*.*(..))   // 关键:两个点 = 含子包
通配符含义
*任意一个
..包位置:当前包及子包;参数位置:任意参数
+类名后:该类及子类

5.2 @annotation(按注解找人,推荐 ✅) ​

execution 是按包名找人;@annotation 是按注解找人——谁的方法上写了这个注解,就切谁。

java
@Around("@annotation(com.taskflow.annotation.LogExecution)")
//        方法上标了 @LogExecution → 命中
@LogExecution
public void createOrder(Order order) { }

为什么推荐注解? execution 按包名匹配,一改包名就失效;注解直接写在方法上,意图明确、跟着方法走,删方法注解切面就自动不生效。

5.3 注解对象能直接当参数读 ​

切点里给注解起个小写名字,切面方法就能把这个注解对象当参数接进来,直接读它的属性:

java
@Around("@annotation(logExecution)")              // 起名 logExecution
public Object around(ProceedingJoinPoint pjp, LogExecution logExecution) {
    String module = logExecution.module();        // 直接读注解的属性值
    String action = logExecution.action();
}

💡 小贴士:@annotation(...) 里写完整类名(带包路径,如 com.taskflow.annotation.LogExecution),不需要加 .class——切点表达式本质是字符串,Spring 内部用反射加载类。不想读注解属性时,可以省略路径直接写类名,Spring 会按切面方法的参数类型自动匹配(@annotation(logExecution) + LogExecution logExecution 参数)。

六、实战一:操作日志切面 ​

做一件事:谁、在哪、做了什么、成没成、耗时多久——业务方法里不写一行日志代码,全由切面记录。分三步。

6.1 第一步:定义注解(标签) ​

java
// 声明一个自定义注解,名字叫 OperationLog。固定套路,只改名字和属性
public @interface OperationLog {
    String module();                       // 属性一:哪个模块(如 "订单")
    String action();                       // 属性二:做了什么(如 "创建订单")
    boolean saveParams() default true;     // 属性三:要不要存参数,不填默认 true
}
java
// 贴注解前还要配两行,告诉 JVM 这注解怎么用:
@Target(ElementType.METHOD)              // 规定:只能贴在【方法】上
@Retention(RetentionPolicy.RUNTIME)      // 规定:留到运行时(切面靠反射才能读到)
public @interface OperationLog { ... }

💡 @interface = 声明一个"标签";@Target = 这标签能贴哪;@Retention(RUNTIME) = 运行时还看得到它(做切面的注解必须用 RUNTIME);里面的方法 = 贴标签时可填的说明。

6.2 第二步:写切面类(快递站) ​

java
@Aspect                  // 告诉 Spring:这是切面(快递站),装的是横切逻辑
@Component               // 让 Spring 创建它(不写不生效)
@RequiredArgsConstructor // 自动生成构造方法,把下面这个依赖注入进来
public class OperationLogAspect {

    private final OperationLogService logService;  // 注入日志服务,最后用它存日志

    // 匹配:任何标了 @OperationLog 的方法 → 都拦
    // 小写 operationLog 要和参数同名,类型决定匹配哪个注解,还能读它的值
    @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.setOperator(SecurityUtils.getCurrentUsername()); // 填:是谁

        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); // ⑤ 成不成都到这:记耗时
            try { logService.saveAsync(entity); }                  // ⑥ 单子交给日志系统存(异步)
            catch (Exception e) { log.warn("日志记录失败", e); }   //     存失败只警告,别影响业务
        }
    }
}

6.3 第三步:在业务方法上贴标签 ​

java
@OperationLog(module = "订单", action = "创建订单")   // 贴上标签,切面就会来拦
public void createOrder(Order order) {                // 方法里只写业务,一行日志都不写
    orderMapper.insert(order);
}

跑起来就是这样:

你调 createOrder()
  ↓
代理拦截(OperationLogAspect.around)
  ├ 填单子:module=订单, action=创建订单, 谁调的
  ├ proceed() 放行 → 真跑 createOrder 业务
  ├ 成功→勾成功 / 失败→记错误并抛回
  └ finally→记耗时,异步存日志

⚠️ 坑 2:日志必须异步落库。同步的话每个接口都白白慢几十毫秒,日志库挂了还会导致业务失败。

七、实战二:接口限流切面 ​

目的:一个用户一段时间内最多调用 N 次,超了直接拒绝。原理是借 Redis 的计数器,逻辑只有 4 行:

java
@Around("@annotation(rateLimit)")     // 匹配任何标了 @RateLimit 的方法
public Object around(ProceedingJoinPoint pjp, RateLimit rateLimit) throws Throwable {
    // ① 造一个唯一的"计数钥匙":哪个方法 + 谁(用户/IP),拼成 Redis 的 key
    String key = "rate:" + pjp.getSignature().toShortString() + ":"
            + resolveIdentifier(rateLimit.type());

    // ② 给这个 key 的计数器 +1(Redis 原子自增),并返回当前值
    Long count = redis.opsForValue().increment(key);
    // ③ 第一次自增(=1)时,给 key 设过期时间,比如 60 秒后自动清零重计
    if (count != null && count == 1) {
        redis.expire(key, rateLimit.seconds(), TimeUnit.SECONDS);
    }
    // ④ 次数超了 → 直接抛业务异常,拦下来,不跑业务
    if (count != null && count > rateLimit.permits()) {
        throw new BusinessException(ErrorCode.RATE_LIMITED);
    }
    return pjp.proceed();             // 没超 → 放行
}

思路一句话:同一把"钥匙"上的计数器涨到上限就拒绝,过期自动重置。注意没超限时也要 pjp.proceed() 放行,否则所有请求都被卡住。

⚠️ 坑 3:这是固定窗口限流,临界处可能通过 2 倍请求。生产用 Redis + Lua 实现滑动窗口,或直接用 Sentinel。

八、切面执行顺序(多个切面) ​

一个方法可能被好几个切面同时盯上,谁先谁后由 @Order 定:数字小的在最外层。

java
@Aspect @Order(1) @Component public class LogAspect { }          // 最外层:最先入,最后出
@Aspect @Order(2) @Component public class RateLimitAspect { }    // 中间
@Aspect @Order(3) @Component public class TxAspect { }           // 最里层:最贴近业务

执行像穿洋葱 / 剥洋葱,一层进一层出:

Order(1) 入 → Order(2) 入 → Order(3) 入 → 真业务方法 → Order(3) 出 → Order(2) 出 → Order(1) 出

为什么顺序重要? 例:限流应该最先做(人太多先拦下,省得后面日志白记),事务要最贴近业务。顺序乱了,被限流拒绝的请求也可能被记进日志。所以大流程上让@Order(1) 放限流、@Order(2) 放日志。

九、常见坑速查 ​

切面的问题就三大类:return 别丢、代理别绕、异常别吞。

① return 别丢 —— 坑 4(@Around 忘记 return)

java
@Around("...")
public Object around(ProceedingJoinPoint pjp) throws Throwable {
    pjp.proceed();   // ❌ 只放行了,没把结果还回去 → 调用方永远拿到 null
}

② 代理别绕 —— 坑 5、坑 6(没走代理就切不到)

切面靠代理生效,凡是绕开代理的调用都白切:

java
// 坑 5:自调用不生效
public void a() {
    this.b();   // ❌ this. 是本人直接调,没经过代理 → 切面不生效
}

// 坑 6:private / final 方法切不到
@LogExecution
private void doWork() { }   // ❌ 代理靠生成子类包住你,private 子类看不到、final 不能重写

Spring 切面 = 代理拦截,绕代理 = 切不到——保持 public 非 final、避免 this 自调、需要自调就注入自己——记住这 3 条,AOP 坑基本就避开了。

③ 异常别吞 —— 坑 7(切面把失败藏了)

java
try {
    return pjp.proceed();
} catch (Throwable e) {
    log.error("出错", e);
    return null;   // ❌ 业务明明炸了,却返回 null 假装成功 → 调用方以为没事,坑死人
}

✅ 正确做法:catch 里记完日志,必须 throw e 抛回去,让调用方知道失败了。

十、本章小结 ​

要点关键
切面放横切逻辑的类,@Aspect 标注
生效原理动态代理,pjp.proceed() 放行
通知@Around 一个够用,必须 return
切点推荐 @annotation
日志异步落库,自己 try 住
自调用不走代理,失效

动手练习 ​

  1. 基础:写 @Timed 注解 + 切面,超过 1 秒打 WARN
  2. 进阶:实现 @Retry(times=3, delay=1000) 切面,支持指数退避
  3. 排查:写一个 this.b() 的自调用方法,验证切面确实不生效,想明白为什么

下一章:第 44 章:第一个 REST 接口 + Maven →

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