AOP 核心概念:@Aspect、@Pointcut 与 @Around 一次讲清楚
从痛点出发理解 Spring Boot AOP:@Aspect、@Pointcut 和 @Around 分别解决什么问题,什么时候直接写切点,什么时候用自定义注解。
AOP 核心概念:@Aspect、@Pointcut 与 @Around 一次讲清楚
在 Spring Boot 项目中,日志、权限、缓存、耗时统计、限流和异常处理,经常会被很多接口重复使用。每个方法都手写一遍,业务代码会越来越臃肿;抽成公共方法,又需要在每个业务方法里主动调用。
AOP(Aspect-Oriented Programming,面向切面编程)解决的就是这类“横切关注点”:把与核心业务无关、但会横跨很多方法的功能集中处理。
这篇文章只围绕三个最核心的概念展开:@Aspect、@Pointcut 和 @Around。
一、一句话速记
| 概念 | 一句话解释 | 比喻 |
|---|---|---|
| @Aspect | 把重复代码集中写在一个类里 | 工具箱 |
| @Pointcut | 定义“拦截哪些方法”的规则 | 地址或筛选条件 |
| @Around | 真正执行增强逻辑,并能控制原方法是否执行 | 施工队 |
它们的关系可以这样记:
@Pointcut 定义地址,@Around 根据地址去干活。
@Around 也可以直接写拦截规则,所以不是每次都必须单独定义 @Pointcut。
二、它们分别解决什么问题?
1. 统一统计耗时
如果有 100 个方法都需要统计耗时,没有 AOP 时就要复制 100 遍计时代码。使用一个切面后,可以统一拦截目标方法,只写一次计时逻辑。
主要使用:@Around。
2. 统一判断权限
如果每个接口都自己写 if 判断,代码重复,而且漏写一个判断就可能形成安全漏洞。给需要保护的方法加一个自定义注解,再由切面统一校验即可。
主要使用:@Around + 自定义注解。
3. 统一处理缓存
查询方法通常要经历“查缓存 → 缓存没有就查数据库 → 写回缓存”。这段逻辑不应该散落在每个业务方法里。切面发现缓存命中时,可以直接返回缓存结果,跳过原方法。
主要使用:@Around。
4. 统一处理异常、限流和日志
业务方法只负责业务本身,异常转换、限流判断、访问日志等交给切面统一完成。
三、最简单的耗时统计:直接使用 @Around
只有一个通知需要使用某条拦截规则时,可以不定义 @Pointcut,直接把 execution 表达式写在 @Around 上:
@Aspect
@Component
public class TimeCostAspect {
@Around("execution(* com.yourpackage.controller.*.*(..))")
public Object cost(ProceedingJoinPoint pjp) throws Throwable {
long start = System.currentTimeMillis();
Object result = pjp.proceed();
System.out.println(
pjp.getSignature() + " 耗时:"
+ (System.currentTimeMillis() - start) + "ms"
);
return result;
}
}
这里最关键的一行是 pjp.proceed()。它代表调用原来的目标方法。如果不调用它,原方法就不会执行。
四、多个通知共用规则:定义 @Pointcut
当多个通知都要拦截同一批方法时,可以把规则提取成一个 @Pointcut:
@Aspect
@Component
public class ServiceAspect {
// 只拦截 service 包下的所有方法
@Pointcut("execution(* com.yourpackage.service.*.*(..))")
public void serviceMethod() {}
@Around("serviceMethod()")
public Object cost(ProceedingJoinPoint pjp) throws Throwable {
long start = System.currentTimeMillis();
Object result = pjp.proceed();
System.out.println(
"耗时:" + (System.currentTimeMillis() - start) + "ms"
);
return result;
}
@Before("serviceMethod()")
public void logBefore() {
System.out.println("方法开始执行");
}
@After("serviceMethod()")
public void cleanUp() {
System.out.println("方法执行完毕,清理资源");
}
}
@Pointcut 本身不负责执行业务逻辑,它只是把一条拦截规则命名为 serviceMethod,方便其他通知引用。
五、自定义注解 + AOP:最常用的方式
有些方法需要权限校验,有些方法不需要。此时按包拦截就太粗了,更适合用自定义注解作为开关。
1. 定义注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface NeedPermission {
String value() default "ADMIN";
}
2. 编写切面
@Aspect
@Component
public class PermissionAspect {
@Around("@annotation(needPermission)")
public Object check(
ProceedingJoinPoint pjp,
NeedPermission needPermission) throws Throwable {
String role = needPermission.value();
User user = UserContext.getCurrentUser();
if (!user.hasRole(role)) {
throw new RuntimeException("无权限访问");
}
return pjp.proceed();
}
}
@annotation(needPermission) 表示:只有方法上标注了 NeedPermission 的目标方法,才会进入这个切面。同时,切面方法可以直接拿到注解对象,读取它的 value。
3. 在接口上使用
@GetMapping("/delete")
@NeedPermission("ADMIN")
public String delete(Long id) {
return "删除成功";
}
这样,权限规则就从业务方法中抽离出来了。需要权限的接口加注解,不需要权限的接口不加注解。
六、@Around 独有的能力
@Before 和 @After 只能在目标方法前后执行逻辑,而 @Around 可以包住整个调用过程,因此拥有更多控制权。
1. 阻止原方法执行
缓存命中时可以直接返回:
if (cached != null) {
return cached;
}
return pjp.proceed();
不调用 proceed(),原方法就不会执行。
2. 修改返回值
可以不返回原方法结果,而是返回默认值、降级数据或包装后的结果。
3. 获取或修改参数
可以通过 pjp.getArgs() 获取参数,并在调用原方法前完成参数处理。
4. 失败重试
可以在循环中多次调用 pjp.proceed(),捕获特定异常后进行重试。但重试次数、异常类型和幂等性必须谨慎设计。
5. 统一异常处理
可以捕获原方法抛出的异常,记录日志后返回降级数据,或者转换成项目统一的业务异常。
七、@Pointcut 常用表达式
| 目标 | 表达式 |
|---|---|
| 指定包下所有类的所有方法 | execution(* com.example.service..(..)) |
| 指定包及其子包下所有方法 | execution(* com.example.service...(..)) |
| 指定某个类的某个方法 | execution(* com.example.UserService.getUser(..)) |
| 以 save 开头的方法 | execution(* save*(..)) |
| 返回值为 String 的方法 | execution(String com.example..(..)) |
| 参数必须是 Integer 类型 | execution(* com.example..(Integer)) |
| 带有指定注解的方法 | @annotation(com.example.MyLog) |
| 组合条件(且) | execution(* com.example.service..(..)) && args(id) |
| 组合条件(或) | execution(* save*(..)) |
在这些表达式中:
-
- 可以表示任意类型、类名或方法名。
- .. 在包路径中表示当前包及任意子包。
- .. 在参数位置中表示任意数量、任意类型的参数。
八、常见踩坑点
1. 切面不生效:切面类没有交给 Spring 管理
切面类通常需要同时添加 @Aspect 和 @Component。@Aspect 表示它是切面,@Component 才表示把它交给 Spring 容器管理。
2. 切点表达式写错
包名、类名、方法名必须与实际代码完全匹配。建议先从一个范围较小的表达式开始验证,再逐步扩大范围。
3. private 方法无法被常规 Spring AOP 代理
Spring AOP 基于代理机制,通常只能拦截代理对象能够调用到的方法。private 方法无法被外部代理调用,因此不会按预期触发切面。
4. 同一个类内部调用不会经过代理
一个类中的方法直接调用同类另一个方法时,使用的是 this 引用,不会重新经过 Spring 代理。需要调整调用关系,或者通过 Spring 管理的代理对象调用。
5. @Around 忘记调用 proceed()
如果目标方法一直没有执行,第一件事就是检查环绕通知中是否调用了 pjp.proceed()。
6. 切点范围过大导致重复执行
表达式过宽可能让一个方法被多个切面重复拦截。此时应该缩小切点范围,或者使用排除条件明确排除不需要增强的方法。
九、最终决策树
只需要做一件事
例如只统计耗时:直接写 @Around("execution(...)"),不必额外定义 @Pointcut。
多个通知共享同一批目标方法
例如同时统计耗时、打印日志和清理资源:定义一个 @Pointcut,让多个通知共同引用。
需要按方法开关
例如只有部分接口需要权限校验:定义自定义注解,再使用 @Around("@annotation(xxx)")。
需要阻止原方法、修改返回值、重试或统一异常
必须使用 @Around,因为这些操作都需要控制 pjp.proceed() 的调用过程。
十、终极总结
@Aspect 是容器,@Pointcut 是地址,@Around 是干活的人。
当很多方法都在重复编写权限、日志、缓存、耗时、限流或异常代码时,就应该考虑使用 AOP 把横切关注点抽出来,集中写一次,再让符合规则的方法自动获得增强能力。
最后只记住一个选择原则:
- 只有一个通知:直接在通知上写切点表达式。
- 多个通知复用同一规则:定义 @Pointcut。
- 按方法灵活开关:自定义注解 + @Around。
- 需要控制原方法是否执行:使用 @Around。