@AspectJ 全解:切点表达式与五种通知
先说人话。你的业务代码里总会反复出现同一批「跟业务无关的活儿」:打日志、算耗时、查权限、发告警。如果不做处理,这些代码就得手抄进每一个方法,几十个方法抄几十遍,规则一改就要翻全项目。切面(AOP)干的事就一件:把这些重复的杂活从方法里抽出来,集中写一份,然后由容器在运行期自动把它们垫到方法调用的前后。你写的业务方法一个字都不用改,也不用知道自己被垫了什么。
要用这套机制,你必须先分清五个词。连接点(join point)是「一次真实的方法调用」这个时刻;切点(pointcut)是一段表达式,用来圈定「哪些连接点归我管」;通知(advice)是你真正想多做的这段代码;切面(aspect)是切点 + 通知打包成的那个类;织入(weaving)是容器把通知接到调用路径上的动作。这五个词的关系只有一句话:切面 = 在哪拦(切点)+ 拦下来做什么(通知),织入负责把它们接上。
给手机套壳讲的是「通知与切面」。手机(业务对象)本身没被拆开、没被改造,套上一个壳(切面)之后,它多了防摔、支架、卡槽三样功能——这三样功能不属于手机厂商,却长在每一台同款手机上。切面就是那个壳:@Before 是壳顶的镜头保护圈,@Around 是整圈包边,壳可以随时拆下来换,手机毫发无伤。代价也要记住:壳会让按键隔着层按(性能),而且裸机(自己 new 的对象)没法套壳。
快递分拣讲的是「切点求值」。包裹(方法调用)到达分拣中心时,机器会按三道筛子决定它进哪条滑道:先看城市与小区(哪个包、哪个类)、再看门牌与收件人(方法名、返回类型)、最后看包裹规格(参数表)。三道必须道道对上,光对上城市不发车。而切点表达式写错最阴的地方在于:分错了不报错,只会安静地不进滑道——这正是第七节那张表的头号症状。

学完这一篇,你要能回答三个问题:
execution( com.example.service...*(..))里每个符号各自管什么?少写一个点会怎样?- 五种通知谁先进、谁后出?为什么只有
@Around能改返回值、能把异常吃掉? - 切面「静默不生效」有哪四种成因,第一步该看什么才能确定不是自己的错觉?
写切面之前先得把开关打开。纯 Spring 项目里通常是这一行:
@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 即可。
切点是切面里最需要练手感的部分。核心是 execution,它的完整语法可以先记成这样一个骨架:
execution( 修饰符? 返回类型 声明类型?.方法名(参数) 抛出的异常? )除了「返回类型、方法名、参数」这三段是必需的,其余都可以省略或用 *、.. 通配。下面五个例子覆盖了日常绝大多数写法:
// 例 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,只有签名完全匹配的方法才会被选中,写(..)则会命中任何参数
除了 execution,还有一批按「更省事」的场景设计的切点指示符,区别在于它们匹配的「维度」不同:
| 指示符 | 匹配维度 | 示例 | 使用场景 |
|---|---|---|---|
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) | 拦「类上打了某注解」的全部方法 |
this 和 target 最容易混。this 判断的是「代理对象」是否为某类型,target 判断的是「被代理的原始对象」是否为某类型。在引入(introduction)场景里两者会不一致,日常业务里大多等价,但面试爱考这个区别。
多个切点可以用逻辑运算符拼起来,读起来很像自然语言:
// 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..* 才匹配所有层级。如果你的切点死活包不到某个子包下的类,先检查是不是少写了一个点。
通知就是插在切点匹配到的方法上的那段代码。Spring AOP 提供了五种,关键区别在于它们的「权限」:
| 通知 | 能否改参数 | 能否改返回值 | 能否吞异常 | 典型用途 |
|---|---|---|---|---|
@Before | 否 | 否 | 否 | 参数校验、调用日志 |
@AfterReturning | 否 | 否(只能读取) | 否 | 记录成功结果、做后置统计 |
@AfterThrowing | 否 | 否 | 否(能捕获但不能吞) | 异常记录、告警、补日志 |
@After | 否 | 否 | 否 | 资源清理(无论成败都执行) |
@Around | 是 | 是 | 是 | 事务、缓存、耗时、重试 |
一句话提炼:只有 @Around 是「全能选手」——它拿到 ProceedingJoinPoint,可以先改参数、决定要不要调用目标、改写返回值、甚至把异常吞掉。其余四种都只能「观察」和「记录」,改不了入参也改不了出参。
这张对照图把上面那行的意思摆成了两侧:左边是「只能看不能动」,右边是「什么都能动,但责任也在你」。选型的判断依据从来不是「哪个更高级」,而是「这次到底要不要改结果」:

通知方法怎么拿到「当前调用的信息」?靠参数绑定。下面是一个五脏俱全的切面类:
@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 里的名字是「形参名」,写错不会报编译错,而是启动时或运行时报绑定失败。命名时务必和形参保持一致,别看花眼。
「按名字对齐」这一步到底怎么发生的,六帧动画拆开:注意第 ④ 帧——注解里的字符串和形参名逐个比对,这里没有表达式语言参与,纯靠名字:

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

@Around 前置@Before==== 目标方法执行 ====@AfterReturning@After@Around 后置 / 返回@Around的前半段最先进入,后半段最后离开,像一对括号把其余通知包在中间- 目标是成功返回时走
@AfterReturning;一旦抛异常,@AfterReturning被换成@AfterThrowing - 无论成功还是异常,
@After都会执行,所以它最适合做「清理」类工作
多个切面同时匹配一个方法时,顺序由 @Order 决定:
@Aspect@Order(1) // 数字越小 = 越"外层"@Componentpublic class AuthAspect { /* 权限校验,应该最先拦 */ }@Aspect@Order(2)@Componentpublic class LogAspect { /* 日志记录,排在权限之后 */ }@Order数字小 = 更外层:AuthAspect的@Before先执行、@After后执行- 如果不写
@Order,顺序未定义,不要依赖「代码里的书写顺序」 - 想让「鉴权失败直接挡住请求、不进入后续日志」,就应让鉴权切面排在外层
「明明写了切面,怎么就是不执行」是切面开发的头号问题。按下面这张表逐行排查,八成能定位:
| 症状 | 原因 | 修复 |
|---|---|---|
| 只在被别的 Bean 调用时生效,自己内部调用不生效 | 方法内部自调用绕过代理 | 抽出到另一个 Bean,或注入自身代理 |
| private / final 方法上的通知完全不触发 | 这些方法不可被代理 | 改为 public、非 final;或改用 AspectJ |
| 启动无报错,但一个方法都没被拦截 | 切点表达式写错(包层级、签名不对) | 打开 debug 日志核对切点匹配结果 |
目标对象是容器外 new 出来的 | 非容器 Bean 不会有代理 | 交给容器管理(@Component / @Bean) |
强转实现类时报 ClassCastException | 用了 JDK 代理却按实现类接收 | 注入改用接口类型,或开启 proxyTargetClass |
排查的第一手段永远是日志:把 logging.level.org.springframework.aop=DEBUG 打开,容器会打印出每个 Bean 被哪些切面、用什么代理类型处理,切点匹配不上时会给出「does not match」的原因。
@Around 通知里如果没有调用 pjp.proceed(),目标方法就永远不会执行——但程序也不会报错,只是「什么都没发生」。这种 bug 极其难查,因为日志里前前后后都正常,唯独业务逻辑消失了。写 @Around 时请把 proceed() 当成必填项,并习惯用 try/finally 包住,确保后置逻辑一定执行、异常也一定向上抛。
下面这个演示把通知链的执行顺序可视化出来。切到「异常路径」再跑一遍,可以直观看到 @AfterThrowing 是怎么替换掉 @AfterReturning、而 @After 又是怎么在两条路径上都执行的:
上面那一支看的是「代理怎么转」,第六节那张时序表还得配一支专门跑通知顺序的实验。advices 把五种通知全挂上同一个方法,normal 与 error 各跑一遍,两条路径的差异一眼就出来:

前面全是文字,这一节全部可以点。四个实验按「先看到现象、再理解机制、最后搞清顺序」排列,每个都能自己切参数。
第一个实验回答第〇节那个「快递分拣」类比:同一段包路径写进 execution 和 within,含义完全不同。切到「execution 各段」能看到逐段核对的过程;切到「为什么没匹配上」会列出静默失配的五种成因——这是新手最容易卡住的一格,因为它不报任何错:
「段与段怎么核对」这件事,光看动画还是太快。把它摊成单步台:左边是一个候选方法与一条表达式,右边同步刷新「此刻判到哪一段、结论是什么」。连点下一步,盯住第 ⑤ 步——整条表达式在这里就已经短路了,后面两段根本没被判断:
String c = "com.example.service.impl.UserServiceImpl.save(User)"; // ① 候选方法@Pointcut("execution(* com.example.service.*.*(..))") // ② 你写的表达式段1 返回类型 * -> 任意返回类型都算命中 // ③段2 声明类型 com.example.service.* // ④ 单个星号只吃直接子级,impl 是孙级 -> 本段判 false // ⑤段3 方法名 * -> 根本没轮到它判 // ⑥段4 参数 (..) -> 同上,任意个任意参数 // ⑦@Pointcut("execution(* com.example.service..*.*(..))") // ⑧ 改:一个点变两个点| 候选方法 | UserServiceImpl#save(User) |
| 类全名 | com.example.service.impl.UserServiceImpl |
| 切点 | execution(* com.example.service.*.*(..)) |
matchOrNotexecution 求值第二个实验专门处理「注解到底贴在方法上还是类上」。@annotation 与 @within 名字只差一个词,效果南辕北辙;这一支还会告诉你一个隐形杀手:Java 的方法注解不会被实现类自动继承,只写在接口方法上常常匹配不到:
第三个实验是织入前后的对照——同样是 userService.save("张三") 这一次调用,没有切面时调用栈只有一层、方法体里混着 log/try-catch/计时;织入切面后调用栈多了三层横切通知,而业务代码一行未改。切到「代理的代价」能看清套壳要付什么(启动更慢、final/private/自调用四种情况增强不了):
第四个实验解决第六节末尾那个追问:多个切面同时命中一个方法时谁在外层。切到「嵌套执行时序」会把完整链条一步步列出来(Around1 前 → Around2 前 → Before → 目标 → AfterReturning → After → Around2 后 → Around1 后);切到「顺序颠倒的坑」会展示忘写 @Order 为什么会变成「我机器上好的,CI 上过不去」:

四支实验跑完,剩下的就是把「表达式」和「它圈到的人」练成条件反射。第二节那张表讲的是维度,这一局练的是读一条表达式立刻说出它命中谁、漏掉谁——左右两列各不重复,靠位置猜不出来:
实验做到这里,可以换成命令行自己敲。这台控制台连着浏览器里的同一个容器,回显全部由内核算出来——boot 起容器,beans 看谁被套了壳,lab pointcut nomatch 就是上面那个单步台的完整版:
沙盘左边逐个改「返回类型 / 包深度 / 方法名 / 参数表」四段,右边立刻给出这次能命中哪些方法、以及为什么命不中。把「少写一个点」「() 当成 (..)」这两格亲手选一次,比背十条规则管用:
execution(* com.example.service..*.*(..))命中:save(User) / findById(Long) / deleteAll() —— 含 impl 子包匹配数:该类全部可代理的 public 方法
沙盘里 one-star 那一格是高频事故点。判断口诀:. 分隔符之间的**一个 只吃一层*,两个点 .. 才吃「本包及其所有子孙包」;而参数括号里的 .. 又是另一回事——它表示「任意个任意类型的参数」。同一个符号在两个位置上两种含义,这是 execution 最难背的地方,也是第十三节速查表第三行的来源。
下面每一行的「报错原文」都可以整段粘进搜索框,别意译、别缩写:
| 报错原文(片段) | 真实原因 | 30 秒自救 | 深挖看第几篇 | ||
|---|---|---|---|---|---|
| 无任何异常,通知就是不执行(症状:日志里一行都没有) | 切点表达式求值为 false 是合法结果,Spring 认为「你不该被拦」,于是安静地什么都不做 | 临时放宽成 @Around("execution( (..))") 试一次:能命中就说明问题在你的表达式;再开 logging.level.org.springframework.aop=TRACE 数候选 Advisor | 本篇第十一节 pointcut 实验 · #14 AOP 内核 | ||
Invalid binding type: long. Method: public void ...afterThrowing(long) / ConflictingAdviceReqArgException | returning = "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 · 第十二节沙盘 |
与 .. 混用是这张表里出现频率最高的两类错的共同根源。 只吃一层(一个包名段、一个类名、一个参数),.. 在类型位吃「任意层级子包」、在参数位吃「任意个任意参数」。所以 com.example..Service.(..) 读作:com.example 及任意深度子包下、类名以 Service 结尾的类的所有方法、任意参数。写长表达式时建议逐段念出来核对,别整条凭感觉。
这张表第一行是「静默」,而切点写歪还有另一种命运:当场炸给你看,而且栈顶在 Spring 里、真凶在 AspectJ 的解析器里,读起来格外绕。练一次:
只是把 execution 的包名段改了一下,应用起不来了;报错里既没有你的类名,也看不出哪个字符写错。
目标:三分钟跑出一个「能看见日志」的完整切面,确认你的工程里 AOP 真的通了。以下五个文件放进一个新建的 Spring Boot 项目即可运行(pom.xml 需包含 spring-boot-starter-aop,或至少有 org.springframework.boot:spring-boot-starter + org.aspectj:aspectjweaver)。
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 + " ===="); }}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()); }}预期输出(==== 那行由 System.out 打印,其余是 slf4j 日志,前缀会带时间与类名):
[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出来的那个对象
只做一件事,然后观察结论:
- 把切点从
com.example..Service.(..)改成com.example..Service.(..)(少一个点)。你会观察到:UserService若正好位于com.example这一层仍会命中,但只要挪进com.example.service.impl,通知就全灭且不报错——回到第十二节沙盘的one-star那一格对照,你会一眼看懂「单层只吃一层」。 - 删掉
@Around里的return pjp.proceed();,只留log.info。你会观察到:==== 目标方法执行 ====这行彻底消失,接口/返回值变成null,而程序一句异常都不抛。这就是第十四节第二道自测的现场版。 - 给
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」的养成方式。 - 把
save改成private。你会观察到:通知再次全部消失,同样不报错。原因见 #14 AOP 内核的代理边界。
做一个「耗时归因切面」:不只打印耗时,还要按阈值分级并把慢调用汇总起来。
- 定义注解
@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,并额外记一条「失败次数」
验收清单:① 同一个方法分别构造出 50ms / 300ms / 1200ms 三种耗时,日志级别依次为 INFO / WARN / ERROR;② 抛异常时上层仍能收到原异常(用 @AfterThrowing 或 try/catch 验证堆栈未被替换);③ 把 @SlowWatch 贴到 private 方法上,你能说清为什么不生效且不靠猜;④ 关掉这个切面(去掉 @Component),业务输出与之前完全一致——证明它确实只是「套在外面的壳」。
不看上文,写出 execution( com.example.service...*(..)) 从左到右五段各自管什么,并说出哪两段可以省略。
(..)、()、(*)、(Long) 四种参数写法各自匹配什么?哪一种最容易和第一种搞混?
@annotation(com.example.anno.Log) 和 @within(com.example.anno.Log) 的判定依据分别落在哪个元素上?注解只写在接口方法上会发生什么?
一次成功调用里,五种通知的执行顺序是什么?抛异常时哪一条会被替换掉?
两个切面都没写 @Order 时会发生什么?为什么说这是「换台机器才复现」的 bug?
**切点管在哪拦、通知管拦了干啥、@Around 必须有 proceed、.. 吃子包 吃一层*。
这一节把切面开发里最容易绊倒人的地方过了一遍——execution 的骨架是「修饰符 返回类型 类?.方法(参数) 异常?」,.. 与 * 决定匹配层级;within / this / target / args / @annotation / @within 各自匹配不同维度;五种通知里只有 @Around 能改参数、改返回值、吞异常;通知顺序上 @Around 像括号包住其余四种,多切面用 @Order(数字小更外层)排队。最后记住两条铁律:@Around 必须调用 proceed(),切面不生效先去查「自调用、private/final、切点写错、非容器对象」这四个高频原因。