深入 Spring 16:切点的静态筛选与运行时匹配
实参是 String,为什么 execution 不匹配
订单接口有一个 Object submit(Object input) 方法。调用者传入字符串 "order",args(java.lang.String) 的通知执行了,execution(* submit(java.lang.String)) 的通知没有执行。两条表达式观察的对象不同:前者约束这次调用的实参类型,后者约束方法声明的签名。
这个差异使切点成为可以验证的筛选算法。只盯着表达式的英文含义,会把声明类型、运行时类型、目标类型和代理类型混为一谈。本实验把方法固定为 submit(Object),分别改变表达式、实参和代理种类,观察通知计数。
环境为 Spring Framework 6.2.11、AspectJ Weaver 1.9.22.1、JDK 21。这里使用 AspectJ 的表达式解析器,没有启动字节码 weaving。Spring 源码 SHA 为 4c134254642d88e058aa004bdaf44168e1be7bb2,完整入口为 examples/spring-framework-lab/src/main/java/blog/spring/Chapter16.java。
两个匹配阶段处理不同输入
Pointcut 暴露 ClassFilter 与 MethodMatcher。类过滤器可以先排除不相关目标类;MethodMatcher 的静态方法接受 Method 和目标 Class,运行时重载额外接受实参数组。isRuntime() 表示这条匹配规则是否还需要运行时检查,而不是表示前面的静态检查已经可以省略。
1 | |
submit(Object) 的声明允许 String,也允许 Integer。静态阶段不能从 Object 断定所有调用都满足 String 条件,因此保留待检查的路径。实际调用带上 Integer 时,动态检查返回 false,只跳过该通知,目标方法仍可执行。这里的“未匹配”没有转化成业务异常。
对于 execution(* submit(..)),目标方法的信息已经足够;本文断言静态匹配为 true 且 isRuntime 为 false。不能把所有 AspectJ 表达式都称作每次反射解释一遍。框架会保留解析结果与方法匹配状态,具体动态条件才在调用时处理。表达式实现
六种切点放到同一个目标上比较
OrderTarget 实现 Orders,submit 的实现方法带运行时保留的 @Audited。下表中的每个命中都通过实际代理调用和通知计数验证,不仅调用匹配器的辅助方法。
| 表达式 | 本实验回答的问题 | 结果 |
|---|---|---|
execution(* submit(..)) |
方法声明是否符合签名模式 | String、Integer 都命中 |
within(blog.spring.Chapter16.OrderTarget) |
方法执行对应的目标声明类型是否在范围中 | 命中 |
this(blog.spring.Chapter16.OrderTarget) |
当前代理是否是实现类的实例 | JDK 不命中,CGLIB 命中 |
target(blog.spring.Chapter16.OrderTarget) |
代理背后的目标是否是实现类的实例 | JDK 也命中 |
args(java.lang.String) |
本次实参是否满足类型条件 | String 命中,Integer 不命中 |
@annotation(blog.spring.Chapter16.Audited) |
被解析的目标方法是否有该注解 | 接口代理调用也命中 |
within 按类型的词法范围描述连接点,this/target 则对对象身份对应的类型进行约束。它们在这个简单类中可能都命中,但不能因此互换。继承方法、引入接口、接口代理和运行时子类都会改变需要检查的对象。
Spring AOP 中 this 绑定代理,target 绑定目标。JDK 代理只暴露接口,不是 OrderTarget 的子类,因此 this(OrderTarget) 不成立,而 target(OrderTarget) 成立。切成 CGLIB 后,代理本身也是 OrderTarget 的实例,this 条件随之成立。官方切点语义
为什么实验安装 ExposeInvocationInterceptor
手工 ProxyFactory 不等于已经走完 AspectJ 自动代理的完整基础设施装配。需要 this/target 的运行时匹配上下文时,AspectJExpressionPointcut.matches(method, targetClass, args) 会尝试从 ExposeInvocationInterceptor 取得当前 invocation,再分别取得目标与 proxy。
本实验在业务 Advisor 前加入 ExposeInvocationInterceptor.ADVISOR,使直接构造的工厂具备这项上下文。若省略它,某些只需参数的表达式仍能匹配,但不能据此断言所有 this/target 行为也经过完整验证。一个通知偶然命中不足以证明上下文安装无关紧要。
该拦截器使用当前调用期间的上下文,并在 finally 中恢复先前 invocation,避免嵌套调用结束后污染外层。本文单线程同步调用,不由它推导线程切换后仍有同样的上下文。第 34 篇另验跨线程传播。
静态匹配结果如何成为实际调用链
DefaultAdvisorChainFactory.getInterceptorsAndDynamicInterceptionAdvice() 遍历 Advisor。PointcutAdvisor 必须先通过类过滤和方法匹配。匹配失败时,不会把这条通知加入当前方法的链;匹配成功且 isRuntime 为 true 时,加入的是“拦截器与动态匹配器”的组合对象。链构建源码
ReflectiveMethodInvocation.proceed() 取下一项时识别这个组合对象,并用当前实参再做 matches。true 执行通知,false 递进到下一项。此时目标方法未必被跳过;只有某个通知主动不调用 proceed,才可能使后续链和目标都不执行。切点拒绝与通知短路是不同的控制流。
状态变化是:候选 Advisor → 当前方法可用的链项 → 本次实参决定是否执行这一项 → 最终目标结果。本实验同时调用匹配 API 和真实代理,使静态结果与外部调用结果相互校验。
上游 AspectJExpressionPointcutTests.testMatchWithArgs 对 Number 参数分别传 Double 和 Integer,检验 isRuntime 与参数检查;testDynamicMatchingProxy 进一步在代理中验证通知计数。这与本文把签名筛选和调用断言分开的做法一致。这里阅读了源码,没有执行上游完整测试集。相关上游测试
注解与接口方法不必在同一个反射对象上
JDK 代理入口收到的 Method 通常来自接口,而注解常写在实现方法。表达式实现会结合目标类取得更具体的方法信息;本文接口未声明 Audited,实际代理调用仍命中实现方法的 @annotation。这不是 Java 自动把实现类注解继承给了接口。
自定义 MethodMatcher 若只对入口 Method 调用 getAnnotation,可能错过实现上的注解。第 18 篇加入泛型 Store 接口与编译器桥接方法,说明“找到同名方法”仍可能不足够:需要处理目标类解析与桥接关系。生产扩展优先使用 Spring 已有的方法解析工具,避免自己遍历名称后忽略参数与桥接。
Spring AOP 不支持所有 AspectJ 连接点
call(* *(..)) 在本实验直接对方法执行匹配时抛出 UnsupportedPointcutPrimitiveException。这项反例阻止把 AspectJ 语法解析依赖等同于启用了完整 AspectJ 编织。Spring AOP 主要拦截经过代理的方法执行。任意字段访问、构造器调用和对象内部调用位置不属于这个代理入口。
异常的出现位置也要记录。本文直接调用方法匹配 API,观察解析异常;源码的类过滤路径会捕获部分解析失败、记录标记并返回 false。因此不能承诺“任何装配方式下一写 call 都会在同一启动阶段抛同一种异常”。表达式不受支持是范围结论,具体传播路径依调用 API 而定。
复现与练习
1 | |
原始输出保存在 evidence/16/local-20261002/run.txt,每个场景以 CHECK PASS 记录,全部满足才输出 RESULT。程序没有创建线程池、网络客户端或 Context,资源边界为普通 Java 对象。
反例题:把 execution(* submit(String)) 改为 args(String) 是无条件等价的简写吗?submit(Object) 接收 String 的场景已经反证。前者询问声明,后者询问此次参数,两者会选中不同方法集合。
可执行练习:把 submit 的参数从 Object 改成 CharSequence,继续传 String,并新增 StringBuilder。保留 args(String) 与 execution(* submit(CharSequence)) 两项断言,观察前者只接受 String 而后者接受两次调用。再从实现方法移除 Audited,确认只有注解切点的命中改变。
