@AspectJ 全解:切点表达式与五种通知

bee2026-10-0855 分钟0 次阅读
execution 表达式怎么写、五种通知谁先谁后、参数怎么绑定、多个切面如何排队——切面开发中所有会踩的坑,一篇文章补齐。
1 / 107
小节
〇、30 秒看懂
2 / 107

先说人话。你的业务代码里总会反复出现同一批「跟业务无关的活儿」:打日志、算耗时、查权限、发告警。如果不做处理,这些代码就得手抄进每一个方法,几十个方法抄几十遍,规则一改就要翻全项目。切面(AOP)干的事就一件:把这些重复的杂活从方法里抽出来,集中写一份,然后由容器在运行期自动把它们垫到方法调用的前后。你写的业务方法一个字都不用改,也不用知道自己被垫了什么。

3 / 107

要用这套机制,你必须先分清五个词。连接点(join point)是「一次真实的方法调用」这个时刻;切点(pointcut)是一段表达式,用来圈定「哪些连接点归我管」;通知(advice)是你真正想多做的这段代码;切面(aspect)是切点 + 通知打包成的那个类;织入(weaving)是容器把通知接到调用路径上的动作。这五个词的关系只有一句话:切面 = 在哪拦(切点)+ 拦下来做什么(通知),织入负责把它们接上。

4 / 107
类比

给手机套壳讲的是「通知与切面」。手机(业务对象)本身没被拆开、没被改造,套上一个壳(切面)之后,它多了防摔、支架、卡槽三样功能——这三样功能不属于手机厂商,却长在每一台同款手机上。切面就是那个壳:@Before 是壳顶的镜头保护圈,@Around 是整圈包边,壳可以随时拆下来换,手机毫发无伤。代价也要记住:壳会让按键隔着层按(性能),而且裸机(自己 new 的对象)没法套壳。

5 / 107
类比

快递分拣讲的是「切点求值」。包裹(方法调用)到达分拣中心时,机器会按三道筛子决定它进哪条滑道:先看城市与小区(哪个包、哪个类)、再看门牌与收件人(方法名、返回类型)、最后看包裹规格(参数表)。三道必须道道对上,光对上城市不发车。而切点表达式写错最阴的地方在于:分错了不报错,只会安静地不进滑道——这正是第七节那张表的头号症状。

6 / 107
架构图
图 · 本篇地图:切点表达式语法树
图 · 本篇地图:切点表达式语法树
7 / 107

学完这一篇,你要能回答三个问题:

8 / 107
  1. execution( com.example.service...*(..)) 里每个符号各自管什么?少写一个点会怎样?
  2. 五种通知谁先进、谁后出?为什么只有 @Around 能改返回值、能把异常吃掉?
  3. 切面「静默不生效」有哪四种成因,第一步该看什么才能确定不是自己的错觉?
9 / 107
小节
一、开启切面:一行注解背后发生了什么
10 / 107

写切面之前先得把开关打开。纯 Spring 项目里通常是这一行:

11 / 107
代码对照
代码java
@Configuration@EnableAspectJAutoProxypublic class AopConfig {}
解读
  • @EnableAspectJAutoProxy 会向容器注册一个叫 AnnotationAwareAspectJAutoProxyCreator 的组件
  • 这个组件是个 BeanPostProcessor(更准确说,它同时是 SmartInstantiationAwareBeanPostProcessor),职责是「在 Bean 初始化完成后,判断它有没有被切成切面的目标,若有就顺手造一个代理」
  • 它的名字里带 AutoProxy:代理不是你去创建的,而是它在后台悄悄完成——这也是为什么切面一旦生效,你线上拿到的基本都是代理对象

提示:Spring Boot 下通常零配置就能用切面。因为 AopAutoConfiguration 在检测到类路径上有 AspectJ 支持时,会自动帮我们开启 @AspectJ 自动代理(对应属性 spring.aop.auto=true、spring.aop.proxy-target-class 默认为 true)。你只管往类上加 @Aspect 和 @Component 即可。

12 / 107
小节
二、@Pointcut 表达式全解
13 / 107

切点是切面里最需要练手感的部分。核心是 execution,它的完整语法可以先记成这样一个骨架:

14 / 107
text
execution( 修饰符?  返回类型  声明类型?.方法名(参数)  抛出的异常? )
15 / 107

除了「返回类型、方法名、参数」这三段是必需的,其余都可以省略或用 *、.. 通配。下面五个例子覆盖了日常绝大多数写法:

16 / 107
代码对照
代码java
// 例 1:service 包下任意类的任意方法,任意返回类型、任意参数@Pointcut("execution(* com.example.service.*.*(..))")// 例 2:只匹配 UserService 的 public 方法@Pointcut("execution(public * com.example.service.UserService.*(..))")// 例 3:返回值为 void、方法名以 save 开头@Pointcut("execution(void com.example.service.*.save*(..))")// 例 4:com.example 及其所有子包下、类名以 Service 结尾@Pointcut("execution(* com.example..*Service.*(..))")// 例 5:精确匹配 findById 且参数类型为 Long@Pointcut("execution(* com.example.service.UserService.findById(Long))")
解读
  • 例 1: 匹配「任意返回类型」,第一个 . 匹配「该包下任意类」,(..) 表示「任意个数的任意参数」
  • 例 2:加上 public 限定修饰符;类名写死则只针对这一个类
  • 例 3:方法名里的 save* 是前缀通配,save / saveOrder / saveAll 都能命中
  • 例 4:com.example.. 的两个点表示「本包及其所有子包」,是排查「为什么我的切点包不到子包」的关键
  • 例 5:参数写成具体类型 Long,只有签名完全匹配的方法才会被选中,写 (..) 则会命中任何参数
17 / 107

除了 execution,还有一批按「更省事」的场景设计的切点指示符,区别在于它们匹配的「维度」不同:

18 / 107
对照表
指示符匹配维度示例使用场景
within类型 / 包within(com.example.service..*)整包、整个类的粗粒度拦截
this代理对象类型this(com.example.UserService)判断生成的代理属于哪种类型
target目标对象类型target(com.example.UserService)判断原始目标对象的类型
args方法参数args(Long, ..)按参数类型/个数匹配,并可绑定参数
@annotation方法注解@annotation(com.example.anno.Log)只拦「打了某注解」的方法
@within类型注解@within(com.example.anno.Loggable)拦「类上打了某注解」的全部方法
19 / 107
说明

this 和 target 最容易混。this 判断的是「代理对象」是否为某类型,target 判断的是「被代理的原始对象」是否为某类型。在引入(introduction)场景里两者会不一致,日常业务里大多等价,但面试爱考这个区别。

20 / 107
小节
三、切点组合:&& || !
21 / 107

多个切点可以用逻辑运算符拼起来,读起来很像自然语言:

22 / 107
代码对照
代码java
// service 包下的方法 & 且带 @Log 注解@Pointcut("execution(* com.example.service..*(..)) && @annotation(com.example.anno.Log)")// 排除内部类:不要 com.example.service.internal 下的方法@Pointcut("execution(* com.example.service..*(..)) && !within(com.example.service.internal..*)")// 两个条件满足其一@Pointcut("@annotation(com.example.anno.Log) || @within(com.example.anno.Loggable)")
解读
  • && 取交集,|| 取并集,! 取补集——注意它们不是 Java 的短路运算符语义,而是切点自己的组合语法
  • 组合优先级里 && 高于 ||,混用时务必加括号,否则很容易得到意料之外的结果
  • !within(...) 是最实用的排除法:先圈一个包,再减掉不需要的地方,比精确枚举类名省事得多

坑:切点里写 .. 和 的差别最容易踩。com.example.service. 只匹配直接子级(一层),而 com.example.service..* 才匹配所有层级。如果你的切点死活包不到某个子包下的类,先检查是不是少写了一个点。

23 / 107
小节
四、五种通知:能做什么,不能做什么
24 / 107

通知就是插在切点匹配到的方法上的那段代码。Spring AOP 提供了五种,关键区别在于它们的「权限」:

25 / 107
对照表
通知能否改参数能否改返回值能否吞异常典型用途
@Before否否否参数校验、调用日志
@AfterReturning否否(只能读取)否记录成功结果、做后置统计
@AfterThrowing否否否(能捕获但不能吞)异常记录、告警、补日志
@After否否否资源清理(无论成败都执行)
@Around是是是事务、缓存、耗时、重试
26 / 107

一句话提炼:只有 @Around 是「全能选手」——它拿到 ProceedingJoinPoint,可以先改参数、决定要不要调用目标、改写返回值、甚至把异常吞掉。其余四种都只能「观察」和「记录」,改不了入参也改不了出参。

27 / 107

这张对照图把上面那行的意思摆成了两侧:左边是「只能看不能动」,右边是「什么都能动,但责任也在你」。选型的判断依据从来不是「哪个更高级」,而是「这次到底要不要改结果」:

28 / 107
架构图
图 · @Around vs 其余四种通知
图 · @Around vs 其余四种通知
29 / 107
小节
五、通知的参数绑定:把上下文接进来
30 / 107

通知方法怎么拿到「当前调用的信息」?靠参数绑定。下面是一个五脏俱全的切面类:

31 / 107
代码对照
代码java
@Aspect@Componentpublic class LogAspect {    private static final Logger log = LoggerFactory.getLogger(LogAspect.class);    @Pointcut("execution(* com.example.service..*(..))")    public void serviceMethods() {}    @Before("serviceMethods()")    public void before(JoinPoint jp) {        log.info("调用 {}.{}",                jp.getSignature().getDeclaringTypeName(), jp.getSignature().getName());    }    @AfterReturning(pointcut = "serviceMethods()", returning = "result")    public void afterReturning(JoinPoint jp, Object result) {        log.info("{} 成功返回: {}", jp.getSignature().getName(), result);    }    @AfterThrowing(pointcut = "serviceMethods()", throwing = "ex")    public void afterThrowing(JoinPoint jp, Throwable ex) {        log.error("{} 抛出异常: {}", jp.getSignature().getName(), ex.getMessage());    }    @After("serviceMethods()")    public void after(JoinPoint jp) {        log.info("{} 处理结束", jp.getSignature().getName());    }    @Around("serviceMethods()")    public Object around(ProceedingJoinPoint pjp) throws Throwable {        long start = System.nanoTime();        try {            return pjp.proceed();     // 必须调用,否则目标方法根本不执行        } finally {            log.info("{} 耗时 {}ms", pjp.getSignature().getName(),                     (System.nanoTime() - start) / 1_000_000);        }    }}
解读
  • JoinPoint 是所有非环绕通知的通用入口,能拿到签名、参数、目标对象
  • @AfterReturning(returning = "result") 把返回值绑定到名为 result 的形参上,名字必须一一对应
  • @AfterThrowing(throwing = "ex") 把抛出的异常绑定到形参 ex
  • @Around 用的是 ProceedingJoinPoint,它的 proceed() 才是「放行到目标方法」的那把闸门

要点:returning / throwing 里的名字是「形参名」,写错不会报编译错,而是启动时或运行时报绑定失败。命名时务必和形参保持一致,别看花眼。

32 / 107

「按名字对齐」这一步到底怎么发生的,六帧动画拆开:注意第 ④ 帧——注解里的字符串和形参名逐个比对,这里没有表达式语言参与,纯靠名字:

33 / 107
原理动画
动图 · 通知怎么拿到入参、返回值和异常
动图 · 通知怎么拿到入参、返回值和异常
34 / 107

正因为这里没有表达式,很多新手会把 returning = "result" 和 @Cacheable(key = "#user.id") 里的 #{} 当成同一套语法,然后写出 returning = "#result" 这种静默失效的怪东西。那是 SpEL,另一套语言:它有自己的根对象(#root / #this)、集合筛选与投影、三元与安全导航,写错的报错也完全不同。切到最后一档能一次看完三种典型写错:

35 / 107
内核实验
TeaVM切点绑定不是 SpEL:两套语法各管各的未启动
先 literal 看清 #root 与 #this 的分工,再 collection 看筛选与投影,最后 fail 对照三种真实报错
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
36 / 107
小节
六、执行顺序:一次调用的完整通知链
37 / 107

把所有通知挂到同一个方法上,谁先谁后?直接跑一段看结果最清楚:

38 / 107
架构图
图 1 · 五种通知的执行时序
图 1 · 五种通知的执行时序
39 / 107
代码对照
代码text
@Around 前置@Before==== 目标方法执行 ====@AfterReturning@After@Around 后置 / 返回
解读
  • @Around 的前半段最先进入,后半段最后离开,像一对括号把其余通知包在中间
  • 目标是成功返回时走 @AfterReturning;一旦抛异常,@AfterReturning 被换成 @AfterThrowing
  • 无论成功还是异常,@After 都会执行,所以它最适合做「清理」类工作
40 / 107

多个切面同时匹配一个方法时,顺序由 @Order 决定:

41 / 107
代码对照
代码java
@Aspect@Order(1)          // 数字越小 = 越"外层"@Componentpublic class AuthAspect { /* 权限校验,应该最先拦 */ }@Aspect@Order(2)@Componentpublic class LogAspect { /* 日志记录,排在权限之后 */ }
解读
  • @Order 数字小 = 更外层:AuthAspect 的 @Before 先执行、@After 后执行
  • 如果不写 @Order,顺序未定义,不要依赖「代码里的书写顺序」
  • 想让「鉴权失败直接挡住请求、不进入后续日志」,就应让鉴权切面排在外层
42 / 107
小节
七、切面不生效排查清单
43 / 107

「明明写了切面,怎么就是不执行」是切面开发的头号问题。按下面这张表逐行排查,八成能定位:

44 / 107
对照表
症状原因修复
只在被别的 Bean 调用时生效,自己内部调用不生效方法内部自调用绕过代理抽出到另一个 Bean,或注入自身代理
private / final 方法上的通知完全不触发这些方法不可被代理改为 public、非 final;或改用 AspectJ
启动无报错,但一个方法都没被拦截切点表达式写错(包层级、签名不对)打开 debug 日志核对切点匹配结果
目标对象是容器外 new 出来的非容器 Bean 不会有代理交给容器管理(@Component / @Bean)
强转实现类时报 ClassCastException用了 JDK 代理却按实现类接收注入改用接口类型,或开启 proxyTargetClass
45 / 107

排查的第一手段永远是日志:把 logging.level.org.springframework.aop=DEBUG 打开,容器会打印出每个 Bean 被哪些切面、用什么代理类型处理,切点匹配不上时会给出「does not match」的原因。

46 / 107
小节
八、坑:@Around 忘记调用 proceed()
47 / 107
坑

@Around 通知里如果没有调用 pjp.proceed(),目标方法就永远不会执行——但程序也不会报错,只是「什么都没发生」。这种 bug 极其难查,因为日志里前前后后都正常,唯独业务逻辑消失了。写 @Around 时请把 proceed() 当成必填项,并习惯用 try/finally 包住,确保后置逻辑一定执行、异常也一定向上抛。

48 / 107
小节
九、动手体验:通知链执行顺序实测
49 / 107

下面这个演示把通知链的执行顺序可视化出来。切到「异常路径」再跑一遍,可以直观看到 @AfterThrowing 是怎么替换掉 @AfterReturning、而 @After 又是怎么在两条路径上都执行的:

50 / 107
内核实验
TeaVM通知链执行顺序实测未启动
切到「异常路径」,观察 @AfterThrowing 与 @After 的先后
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
51 / 107

上面那一支看的是「代理怎么转」,第六节那张时序表还得配一支专门跑通知顺序的实验。advices 把五种通知全挂上同一个方法,normal 与 error 各跑一遍,两条路径的差异一眼就出来:

52 / 107
内核实验
TeaVM五种通知:成功与异常两条路径未启动
先 normal 抄一遍日志顺序,再 error 看 @AfterReturning 是怎么被 @AfterThrowing 替换掉的
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
53 / 107
原理动画
动图 · 切点表达式如何匹配方法
动图 · 切点表达式如何匹配方法
54 / 107
小节
十、决策:日志用 @Around 还是 @Before
55 / 107
决策
决策你要给一批 Service 方法加「调用日志」,需要记录方法名、入参、返回值和耗时。用 `@Before` 还是 `@Around`?
56 / 107
小节
十一、上手体验:把切点、织入、顺序都亲手跑一遍
57 / 107

前面全是文字,这一节全部可以点。四个实验按「先看到现象、再理解机制、最后搞清顺序」排列,每个都能自己切参数。

58 / 107

第一个实验回答第〇节那个「快递分拣」类比:同一段包路径写进 execution 和 within,含义完全不同。切到「execution 各段」能看到逐段核对的过程;切到「为什么没匹配上」会列出静默失配的五种成因——这是新手最容易卡住的一格,因为它不报任何错:

59 / 107
内核实验
TeaVM切点表达式求值现场:三道筛子怎么过未启动
先看 execution 各段,再切「为什么没匹配上」对照第十三节的速查表
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
60 / 107

「段与段怎么核对」这件事,光看动画还是太快。把它摊成单步台:左边是一个候选方法与一条表达式,右边同步刷新「此刻判到哪一段、结论是什么」。连点下一步,盯住第 ⑤ 步——整条表达式在这里就已经短路了,后面两段根本没被判断:

61 / 107
单步调试台
单步台单步求值:为什么 impl 子包里的方法死活不被拦1 / 6
按顺序点下一步,重点在第 4、5 步那一段声明类型;第 8 步改成两点星再看一次结论
被调试的代码
1String c = "com.example.service.impl.UserServiceImpl.save(User)"; // ① 候选方法
2@Pointcut("execution(* com.example.service.*.*(..))") // ② 你写的表达式
3段1 返回类型 * -> 任意返回类型都算命中 // ③
4段2 声明类型 com.example.service.* // ④
5 单个星号只吃直接子级,impl 是孙级 -> 本段判 false // ⑤
6段3 方法名 * -> 根本没轮到它判 // ⑥
7段4 参数 (..) -> 同上,任意个任意参数 // ⑦
8@Pointcut("execution(* com.example.service..*.*(..))") // ⑧ 改:一个点变两个点
此刻的变量
候选方法UserServiceImpl#save(User)
类全名com.example.service.impl.UserServiceImpl
切点execution(* com.example.service.*.*(..))
调用栈
1matchOrNot
2execution 求值
1先看清被判断的对象是谁:注意包名里多出来的那一段 impl。新手 90% 的「切面不生效」都栽在这一层——你以为它和 UserService 在同一个包,其实它在子包里。
62 / 107

第二个实验专门处理「注解到底贴在方法上还是类上」。@annotation 与 @within 名字只差一个词,效果南辕北辙;这一支还会告诉你一个隐形杀手:Java 的方法注解不会被实现类自动继承,只写在接口方法上常常匹配不到:

63 / 107
内核实验
TeaVM@annotation 还是 @within:一字之差未启动
重点看第⑥步「接口上的注解算不算」,它解释了第十五节练习里的失败写法
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
64 / 107

第三个实验是织入前后的对照——同样是 userService.save("张三") 这一次调用,没有切面时调用栈只有一层、方法体里混着 log/try-catch/计时;织入切面后调用栈多了三层横切通知,而业务代码一行未改。切到「代理的代价」能看清套壳要付什么(启动更慢、final/private/自调用四种情况增强不了):

65 / 107
内核实验
TeaVM织入前 vs 织入后:同一次调用的两种形状未启动
依次点「没有切面时」→「织入切面后」→「代理的代价」,顺序别换
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
66 / 107

第四个实验解决第六节末尾那个追问:多个切面同时命中一个方法时谁在外层。切到「嵌套执行时序」会把完整链条一步步列出来(Around1 前 → Around2 前 → Before → 目标 → AfterReturning → After → Around2 后 → Around1 后);切到「顺序颠倒的坑」会展示忘写 @Order 为什么会变成「我机器上好的,CI 上过不去」:

67 / 107
内核实验
TeaVM多切面嵌套顺序:洋葱怎么剥未启动
先看「@Order 生效」,再看「嵌套执行时序」,最后看「顺序颠倒的坑」
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
68 / 107
原理动画
动图 · 一次调用里五种通知的走位
动图 · 一次调用里五种通知的走位
69 / 107

四支实验跑完,剩下的就是把「表达式」和「它圈到的人」练成条件反射。第二节那张表讲的是维度,这一局练的是读一条表达式立刻说出它命中谁、漏掉谁——左右两列各不重复,靠位置猜不出来:

70 / 107
配对闯关
闯关一条表达式 ↔ 它到底圈住了谁已配对 0/7 · 配错 0
左边是真实写过的切点片段,右边是它实际覆盖的范围;配错会告诉你漏掉的是哪一类方法
先点左边一个
71 / 107

实验做到这里,可以换成命令行自己敲。这台控制台连着浏览器里的同一个容器,回显全部由内核算出来——boot 起容器,beans 看谁被套了壳,lab pointcut nomatch 就是上面那个单步台的完整版:

72 / 107
内核控制台
73 / 107
小节
十二、沙盘:切点表达式的各段自己拼一遍
74 / 107

沙盘左边逐个改「返回类型 / 包深度 / 方法名 / 参数表」四段,右边立刻给出这次能命中哪些方法、以及为什么命不中。把「少写一个点」「() 当成 (..)」这两格亲手选一次,比背十条规则管用:

75 / 107
沙盘
沙盘execution 四段拼装:命中了谁、漏了谁
运行结果
execution(* com.example.service..*.*(..))
命中:save(User) / findById(Long) / deleteAll() —— 含 impl 子包
匹配数:该类全部可代理的 public 方法
最常用也最宽的写法:整个 service 包及子包一网打尽。
76 / 107
说明

沙盘里 one-star 那一格是高频事故点。判断口诀:. 分隔符之间的**一个 只吃一层*,两个点 .. 才吃「本包及其所有子孙包」;而参数括号里的 .. 又是另一回事——它表示「任意个任意类型的参数」。同一个符号在两个位置上两种含义,这是 execution 最难背的地方,也是第十三节速查表第三行的来源。

77 / 107
小节
十三、常见报错速查
78 / 107

下面每一行的「报错原文」都可以整段粘进搜索框,别意译、别缩写:

79 / 107
对照表
报错原文(片段)真实原因30 秒自救深挖看第几篇
无任何异常,通知就是不执行(症状:日志里一行都没有)切点表达式求值为 false 是合法结果,Spring 认为「你不该被拦」,于是安静地什么都不做临时放宽成 @Around("execution( (..))") 试一次:能命中就说明问题在你的表达式;再开 logging.level.org.springframework.aop=TRACE 数候选 Advisor本篇第十一节 pointcut 实验 · #14 AOP 内核
Invalid binding type: long. Method: public void ...afterThrowing(long) / ConflictingAdviceReqArgExceptionreturning = "result" / throwing = "ex" 里写的名字和形参名不一致,或同一通知里绑定了两个同类型参数把注解里的字符串改成形参变量名的原样拷贝;Throwable 绑定参数只能是最后一个本篇第五节 · #15 AOP 实战
IllegalArgumentException: error at anonymous pointcut expression around this / Can't parse advice pointcut designator切点字符串语法错:括号不成对、&& 两边缺操作数、注解写成简单名而不是全限定名检查三点:小括号是否成对、@annotation(...) 里是否为全限定类名、&& `` 混用时是否加了括号本篇第三节
Cannot find class [com.example.anno.Log] for annotation [@Log] / 切点里的类名找不到切点里引用的注解类还没编译、不在同一模块,或包名拼错从 IDE 里复制注解类的全限定名重新粘进切点字符串,不要手打本篇第十二节沙盘 · #2 Maven 依赖
ClassCastException: class com.sun.proxy.$Proxy42 cannot be cast to class com.example.service.UserServiceImpl目标有接口却用了 JDK 动态代理,而你把注入字段声明成了实现类注入改成接口类型;或强制 CGLIB:Boot 下 spring.aop.proxy-target-class=true(默认已 true),纯 Spring 写 @EnableAspectJAutoProxy(proxyTargetClass = true)#12 动态代理 · #14 AOP 内核
通知打在 private / final / static 方法上完全不生效,且不报错代理看不见 private 方法;CGLIB 无法重写 final 方法;static 不属于实例调用改成 public 非 final 的实例方法;确实要拦私有方法只能上 AspectJ 编译期织入本篇第七节 · #14 AOP 内核第七节
No qualifying bean of type 'com.example.aspect.LogAspect' available@Aspect 只是描述符,不会让类成为 Bean;少了 @Component(或 @Bean 注册),创建器根本发现不了它补 @Component 并确认它在组件扫描范围内;findCandidateAdvisors() 返回空列表时所有切面集体静默#14 AOP 内核第六节自查清单
同类内部调用 this.method() 时通知消失this 指向原始对象而不是代理,调用根本没经过通知链抽到另一个 Bean;或开 exposeProxy = true 后用 AopContext.currentProxy();或注入自身代理#12 动态代理 · #15 AOP 实战
子包里的类永远匹配不上(com.example.service..(..) 无效)单层 * 只匹配直接子级,impl 子包在它之外把包段改成 com.example.service...(..)(两个点)本篇第三节坑位 · 第十二节沙盘第一格
(..) 写成 () 后所有带参方法都不进了() 要求「恰好零个参数」,(..) 才是「任意个任意类型」记住:(..) 任意 / () 必须无参 / (*) 恰好一个 / (Long) 恰好一个 Long本篇第二节例 5 · 第十二节沙盘
80 / 107
坑

与 .. 混用是这张表里出现频率最高的两类错的共同根源。 只吃一层(一个包名段、一个类名、一个参数),.. 在类型位吃「任意层级子包」、在参数位吃「任意个任意参数」。所以 com.example..Service.(..) 读作:com.example 及任意深度子包下、类名以 Service 结尾的类的所有方法、任意参数。写长表达式时建议逐段念出来核对,别整条凭感觉。

81 / 107

这张表第一行是「静默」,而切点写歪还有另一种命运:当场炸给你看,而且栈顶在 Spring 里、真凶在 AspectJ 的解析器里,读起来格外绕。练一次:

82 / 107
报错急救
报错急救IllegalArgumentException: a valid WildcardType is required
切点表达式写歪,容器创建切面 Bean 时当场炸

只是把 execution 的包名段改了一下,应用起不来了;报错里既没有你的类名,也看不出哪个字符写错。

APPLICATION FAILED TO START
org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'timingAspect' defined in file [TimingAspect.class]: Instantiation of bean failed; nested exception is org.springframework.beans.BeanInstantiationException: Failed to instantiate [com.example.aspect.TimingAspect]: Constructor threw exception; nested exception is java.lang.IllegalArgumentException: error The near term's relationship is malformed; a valid WildcardType is required
at org.springframework.aop.aspectj.annotation.ReflectiveAspectJAdvisorFactory.getAdvice(ReflectiveAspectJAdvisorFactory.java:273)
at org.springframework.aop.aspectj.annotation.InstantiationModelAwarePointcutAdvisorImpl.<init>(InstantiationModelAwarePointcutAdvisorImpl.java:103)
at org.springframework.aop.aspectj.AspectJExpressionPointcut.buildPointcutExpression(AspectJExpressionPointcut.java:302)
at org.aspectj.weaver.tools.PointcutParser.parsePointcutExpression(PointcutParser.java:181)
at org.aspectj.weaver.patterns.PerCflowPointcut.resolve(PerCflowPointcut.java:83)
Caused by: java.lang.IllegalArgumentException: error The near term's relationship is malformed; a valid WildcardType is required
点你认为的「凶手行」(可反复试)
不会也没关系:先猜异常名,再猜哪一行在做决定。
83 / 107
小节
十四、随堂自测
84 / 107
随堂自测
随堂自测你写了 `@Before("execution(* com.example.service.*.*(..))")`,但 `com.example.service.impl.UserServiceImpl#save()` 的通知死活不触发,启动也没有任何报错。最可能的原因是?
先自己选一个,选中立刻告诉你对不对
85 / 107
随堂自测
随堂自测一个 `@Around` 通知的方法体里只写了 `log.info("enter")` 和耗时统计,忘了写 `pjp.proceed()`。运行后会怎样?
先自己选一个,选中立刻告诉你对不对
86 / 107
小节
十五、动手练习
87 / 107
小节
第一档 · 照做
88 / 107

目标:三分钟跑出一个「能看见日志」的完整切面,确认你的工程里 AOP 真的通了。以下五个文件放进一个新建的 Spring Boot 项目即可运行(pom.xml 需包含 spring-boot-starter-aop,或至少有 org.springframework.boot:spring-boot-starter + org.aspectj:aspectjweaver)。

89 / 107
java
package com.example;import org.springframework.boot.SpringApplication;import org.springframework.boot.autoconfigure.SpringBootApplication;import org.springframework.context.ConfigurableApplicationContext;import org.springframework.stereotype.Service;@SpringBootApplicationpublic class AspectDemoApplication {    public static void main(String[] args) {        try (ConfigurableApplicationContext ctx =                     SpringApplication.run(AspectDemoApplication.class, args)) {            UserService svc = ctx.getBean(UserService.class);            svc.save("张三");            System.out.println("实际拿到的是:" + svc.getClass().getName());        }    }}@Serviceclass UserService {    public void save(String name) {        System.out.println("==== 目标方法执行:保存 " + name + " ====");    }}
90 / 107
java
package com.example.aspect;import org.aspectj.lang.JoinPoint;import org.aspectj.lang.ProceedingJoinPoint;import org.aspectj.lang.annotation.*;import org.slf4j.Logger;import org.slf4j.LoggerFactory;import org.springframework.stereotype.Component;@Aspect@Component                       // 少了这一行,切面根本不会被发现public class TraceAspect {    private static final Logger log = LoggerFactory.getLogger(TraceAspect.class);    @Pointcut("execution(* com.example..*Service.*(..))")    public void serviceMethods() {}    @Before("serviceMethods()")    public void before(JoinPoint jp) {        log.info("[BEFORE] {}", jp.getSignature().toShortString());    }    @Around("serviceMethods()")    public Object around(ProceedingJoinPoint pjp) throws Throwable {        long start = System.nanoTime();        log.info("[AROUND-IN]  {} 参数={}", pjp.getSignature().getName(), java.util.Arrays.toString(pjp.getArgs()));        try {            return pjp.proceed();          // 必填项:不调它,目标方法一次都不执行        } finally {            log.info("[AROUND-OUT] {} 耗时={}ms",                    pjp.getSignature().getName(), (System.nanoTime() - start) / 1_000_000);        }    }    @AfterReturning(pointcut = "serviceMethods()", returning = "result")    public void afterReturning(JoinPoint jp, Object result) {        log.info("[RETURNED] {} -> {}", jp.getSignature().getName(), result);    }    @After("serviceMethods()")    public void after(JoinPoint jp) {        log.info("[AFTER] {}", jp.getSignature().getName());    }}
91 / 107

预期输出(==== 那行由 System.out 打印,其余是 slf4j 日志,前缀会带时间与类名):

92 / 107
代码对照
代码text
[AROUND-IN]  save 参数=[张三][BEFORE] UserServiceBean.save(..)==== 目标方法执行:保存 张三 ====[RETURNED] save -> null[AFTER] save[AROUND-OUT] save 耗时=1ms实际拿到的是:com.example.UserService$$SpringCGLIB$$0
解读
  • [AROUND-IN] 在 [BEFORE] 之前:@Around 的前半段是最外层括号
  • save -> null:目标是 void 方法,@AfterReturning 绑到的返回值自然是 null
  • 最后一行出现 $$SpringCGLIB$$:你从容器拿到的已经是代理,而不是 new 出来的那个对象
93 / 107
小节
第二档 · 变体
94 / 107

只做一件事,然后观察结论:

95 / 107
  1. 把切点从 com.example..Service.(..) 改成 com.example..Service.(..)(少一个点)。你会观察到:UserService 若正好位于 com.example 这一层仍会命中,但只要挪进 com.example.service.impl,通知就全灭且不报错——回到第十二节沙盘的 one-star 那一格对照,你会一眼看懂「单层 只吃一层」。
  2. 删掉 @Around 里的 return pjp.proceed();,只留 log.info。你会观察到:==== 目标方法执行 ==== 这行彻底消失,接口/返回值变成 null,而程序一句异常都不抛。这就是第十四节第二道自测的现场版。
  3. 给 UserService#save 再加一个 MetricAspect(@Order(2),内容与 TraceAspect 相同但前缀换成 [METRIC]),TraceAspect 标 @Order(1)。你会观察到:日志顺序是 AROUND-IN(Trace) → AROUND-IN(Metric) → BEFORE → 目标 → AFTER → AROUND-OUT(Metric) → AROUND-OUT(Trace);把两个数字互换,进入与退出顺序必然整体翻转。再把两个 @Order 都删掉,反复重启几次,你会发现顺序开始飘——这就是「幽灵 bug」的养成方式。
  4. 把 save 改成 private。你会观察到:通知再次全部消失,同样不报错。原因见 #14 AOP 内核的代理边界。
96 / 107
小节
第三档 · 造一个
97 / 107

做一个「耗时归因切面」:不只打印耗时,还要按阈值分级并把慢调用汇总起来。

98 / 107
  • 定义注解 @SlowWatch(value = "下单", warnMs = 200, errorMs = 1000),@Target(METHOD)、@Retention(RUNTIME)
  • 写一个切面 @Around("@annotation(slowWatch)"):正常路径打 INFO,超过 warnMs 打 WARN,超过 errorMs 打 ERROR,日志里必须带上方法名、@SlowWatch 的 value、耗时、参数个数
  • 用 ConcurrentHashMap<String, LongAdder> 按方法累计调用次数与总耗时,暴露一个 @Scheduled 或在关闭时打印「Top 3 最慢方法」
  • 异常路径不许吞:捕获后原样 throw,并额外记一条「失败次数」
99 / 107

验收清单:① 同一个方法分别构造出 50ms / 300ms / 1200ms 三种耗时,日志级别依次为 INFO / WARN / ERROR;② 抛异常时上层仍能收到原异常(用 @AfterThrowing 或 try/catch 验证堆栈未被替换);③ 把 @SlowWatch 贴到 private 方法上,你能说清为什么不生效且不靠猜;④ 关掉这个切面(去掉 @Component),业务输出与之前完全一致——证明它确实只是「套在外面的壳」。

100 / 107
小节
十六、要点自查
101 / 107
自检

不看上文,写出 execution( com.example.service...*(..)) 从左到右五段各自管什么,并说出哪两段可以省略。

102 / 107
自检

(..)、()、(*)、(Long) 四种参数写法各自匹配什么?哪一种最容易和第一种搞混?

103 / 107
自检

@annotation(com.example.anno.Log) 和 @within(com.example.anno.Log) 的判定依据分别落在哪个元素上?注解只写在接口方法上会发生什么?

104 / 107
自检

一次成功调用里,五种通知的执行顺序是什么?抛异常时哪一条会被替换掉?

105 / 107
自检

两个切面都没写 @Order 时会发生什么?为什么说这是「换台机器才复现」的 bug?

106 / 107
口诀

**切点管在哪拦、通知管拦了干啥、@Around 必须有 proceed、.. 吃子包 吃一层*。

107 / 107
总结

这一节把切面开发里最容易绊倒人的地方过了一遍——execution 的骨架是「修饰符 返回类型 类?.方法(参数) 异常?」,.. 与 * 决定匹配层级;within / this / target / args / @annotation / @within 各自匹配不同维度;五种通知里只有 @Around 能改参数、改返回值、吞异常;通知顺序上 @Around 像括号包住其余四种,多切面用 @Order(数字小更外层)排队。最后记住两条铁律:@Around 必须调用 proceed(),切面不生效先去查「自调用、private/final、切点写错、非容器对象」这四个高频原因。