第 43 章:AOP 面向切面
学习目标
- 知道 AOP 解决什么问题
- 看懂
@Aspect切面类:它是什么、怎么生效的 - 掌握 5 种通知 + 切点表达式
- 实战:日志切面 + 限流切面
一、AOP 解决啥
横切关注点:日志、事务、权限、限流、缓存——这些逻辑散落在每个方法里,把业务代码淹没了。
// ❌ 业务代码被非业务逻辑淹没
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 的解法:把这些逻辑抽到一个「切面」里,运行时自动织入,业务代码只剩业务。
// ✅ 业务代码只剩业务
@Transactional
@RequiresPermission("order:create")
@LogExecution
public void createOrder(Order order) {
orderMapper.insert(order);
}二、切面(Aspect)是个啥?
一句话:切面 = 一个专门放"横切逻辑"的类。 用 @Aspect 标注。
打比方:快递员送货(业务方法)。但每个快递员出发前要"检查车辆、登记出库",回来后要"登记入库"。这些检查登记不是送货,但每个包裹都做 → 横切逻辑。把它们集中到一个"快递站"统一管 → 就是切面。
看一个最小切面,拆开理解:
@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 后半(方法后逻辑)
↓
返回结果给你// 你调用这个"普通"方法
userService.getUser(1);
// 实际执行顺序(切面自动织入,方法一个字没改)
// 1. DemoAspect.around() 里 proceed() 之前的代码
// 2. 真正的 getUser() 业务逻辑
// 3. DemoAspect.around() 里 proceed() 之后的代码⚠️ 坑 1:
@Around里必须调pjp.proceed()——它负责放行进业务方法。不调,业务方法就不会执行;不return result,调用方会拿到null。
四、五种通知(@Around 一个够用)
切面里可以写多种"检查动作",按在方法执行时序中的位置区分:
@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(按包路径匹配)
execution(* com.taskflow.service..*.*(..)) // 关键:两个点 = 含子包| 通配符 | 含义 |
|---|---|
* | 任意一个 |
.. | 包位置:当前包及子包;参数位置:任意参数 |
+ | 类名后:该类及子类 |
5.2 @annotation(按注解找人,推荐 ✅)
execution 是按包名找人;@annotation 是按注解找人——谁的方法上写了这个注解,就切谁。
@Around("@annotation(com.taskflow.annotation.LogExecution)")
// 方法上标了 @LogExecution → 命中
@LogExecution
public void createOrder(Order order) { }为什么推荐注解?
execution按包名匹配,一改包名就失效;注解直接写在方法上,意图明确、跟着方法走,删方法注解切面就自动不生效。
5.3 注解对象能直接当参数读
切点里给注解起个小写名字,切面方法就能把这个注解对象当参数接进来,直接读它的属性:
@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 第一步:定义注解(标签)
// 声明一个自定义注解,名字叫 OperationLog。固定套路,只改名字和属性
public @interface OperationLog {
String module(); // 属性一:哪个模块(如 "订单")
String action(); // 属性二:做了什么(如 "创建订单")
boolean saveParams() default true; // 属性三:要不要存参数,不填默认 true
}// 贴注解前还要配两行,告诉 JVM 这注解怎么用:
@Target(ElementType.METHOD) // 规定:只能贴在【方法】上
@Retention(RetentionPolicy.RUNTIME) // 规定:留到运行时(切面靠反射才能读到)
public @interface OperationLog { ... }💡
@interface= 声明一个"标签";@Target= 这标签能贴哪;@Retention(RUNTIME)= 运行时还看得到它(做切面的注解必须用 RUNTIME);里面的方法 = 贴标签时可填的说明。
6.2 第二步:写切面类(快递站)
@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 第三步:在业务方法上贴标签
@OperationLog(module = "订单", action = "创建订单") // 贴上标签,切面就会来拦
public void createOrder(Order order) { // 方法里只写业务,一行日志都不写
orderMapper.insert(order);
}跑起来就是这样:
你调 createOrder()
↓
代理拦截(OperationLogAspect.around)
├ 填单子:module=订单, action=创建订单, 谁调的
├ proceed() 放行 → 真跑 createOrder 业务
├ 成功→勾成功 / 失败→记错误并抛回
└ finally→记耗时,异步存日志⚠️ 坑 2:日志必须异步落库。同步的话每个接口都白白慢几十毫秒,日志库挂了还会导致业务失败。
七、实战二:接口限流切面
目的:一个用户一段时间内最多调用 N 次,超了直接拒绝。原理是借 Redis 的计数器,逻辑只有 4 行:
@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 定:数字小的在最外层。
@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)
@Around("...")
public Object around(ProceedingJoinPoint pjp) throws Throwable {
pjp.proceed(); // ❌ 只放行了,没把结果还回去 → 调用方永远拿到 null
}② 代理别绕 —— 坑 5、坑 6(没走代理就切不到)
切面靠代理生效,凡是绕开代理的调用都白切:
// 坑 5:自调用不生效
public void a() {
this.b(); // ❌ this. 是本人直接调,没经过代理 → 切面不生效
}
// 坑 6:private / final 方法切不到
@LogExecution
private void doWork() { } // ❌ 代理靠生成子类包住你,private 子类看不到、final 不能重写Spring 切面 = 代理拦截,绕代理 = 切不到——保持 public 非 final、避免 this 自调、需要自调就注入自己——记住这 3 条,AOP 坑基本就避开了。
③ 异常别吞 —— 坑 7(切面把失败藏了)
try {
return pjp.proceed();
} catch (Throwable e) {
log.error("出错", e);
return null; // ❌ 业务明明炸了,却返回 null 假装成功 → 调用方以为没事,坑死人
}✅ 正确做法:catch 里记完日志,必须
throw e抛回去,让调用方知道失败了。
十、本章小结
| 要点 | 关键 |
|---|---|
| 切面 | 放横切逻辑的类,@Aspect 标注 |
| 生效原理 | 动态代理,pjp.proceed() 放行 |
| 通知 | @Around 一个够用,必须 return |
| 切点 | 推荐 @annotation |
| 日志 | 异步落库,自己 try 住 |
| 自调用 | 不走代理,失效 |
动手练习
- 基础:写
@Timed注解 + 切面,超过 1 秒打 WARN - 进阶:实现
@Retry(times=3, delay=1000)切面,支持指数退避 - 排查:写一个
this.b()的自调用方法,验证切面确实不生效,想明白为什么