深入 Spring E01:AspectJ weaving 与代理之外的连接点
同一次自调用,通知为何会出现两种结果
outer() 调用同一个对象的 inner()。Spring 代理可以通知外部进入的 outer,却不会因此再次经过代理来通知目标对象内部的 inner。换成 AspectJ 编译期织入,inner 方法体内的通知调用已经存在于 class 文件中,内部调用也会执行它。
这个差异来自通知代码所在的位置。代理要求调用穿过一个额外对象;织入会改写选定类的字节码。只看到 @Aspect 注解,无法判断使用了哪一种机制;Spring 对该注解的支持仍然可以完全基于代理。
实验固定 JDK 21、Spring Framework 6.2.11 和 AspectJ 1.9.22.1。独立的 ecosystem-lab 使用 Boot 3.5.6 管理其他依赖,显式把 aspectjtools、aspectjrt、aspectjweaver 统一冻结到 1.9.22.1。版本组合是教学复现基线。AspectJ 官方兼容表列出这一版本支持 Java 22,编译器运行最低要求为 JDK 17;实验使用 -21 输出 Java 21 字节码。AspectJ 兼容表
连接点、切点与通知分别决定什么
连接点描述程序执行中的一个事件,切点描述哪些事件满足条件,通知描述匹配事件发生时增加的行为。Spring AOP 的常用连接点是经代理的方法执行;AspectJ 还提供构造器执行、字段读取、字段写入等连接点。
同一个字段自增表达式包含读取和写入。value++ 需要先取得旧值,再写入新值,因此 get(int WovenTarget.value) 与 set(int WovenTarget.value) 可以分别匹配。构造器里的 value = 1 只发生一次写入。把“字段访问”笼统当成一种事件,会导致计数预期错误。连接点定义
call 与 execution 也不是同义词。方法 call 对应调用发生的位置,execution 对应被调方法的执行。字段 get/set 的织入位置同样与包含访问表达式的类有关。若只处理目标类,没有处理外部调用者,外部调用者里的字段访问未必被增强;匹配表达式成立并不能代替织入范围检查。切点语义
实验把目标类、调用者和 aspect 一起交给 ajc。这样,构造器字段赋值、inner 内自增以及 main 里的外部读取,都在受控的编译输入中。只增加运行时依赖、仍用 javac 编译这些输入,不能得到相同结果。
编译期织入的输入与输出
实验目录包含三个输入文件:WovenTarget.java 定义普通 Java 对象,Trace.aj 定义语言形式的 aspect,WeavingMain.java 同时运行织入对象和 Spring 代理对象。aspect 没有注册成 Spring Bean,也没有通过容器获得 WovenTarget。
完整工程从系列入口的实验包取得,在 examples/spring-framework-lab/ 目录执行:
1 | |
JAVA_HOME 需要指向 JDK 21。第一条命令编译公共 Java 类并生成依赖 classpath;第二条命令调用 org.aspectj.tools.ajc.Main,传入 -21 -showWeaveInfo,输出到 target/woven,再运行 javap 和普通 java 进程。
这里没有使用 -javaagent,也没有配置 META-INF/aop.xml。织入发生在启动实验 JVM 之前。运行时仍需要 aspectjrt,因为织入产物会引用 AspectJ 运行时类型以及生成的 aspect 方法。启动命令的完整参数记录在 evidence/E01/local-20261002/run.txt,不依赖 IDE 插件的隐式操作。ajc 编译器参数
构建阶段与运行阶段应分别失败。ajc 返回非零时,验证脚本停止;javap 输出找不到 woven/Trace.aspectOf 时,也判失败。只有字节码检查和后续运行时断言都成立,才能说明这个构建产物包含了预期通知。
构造器、字段与自调用的实际计数
WovenTarget 的构造器把 value 设为 1,outer 直接调用 inner,inner 对 value 自增。Trace 使用 after-returning 通知计数构造器完成,使用 before 通知计数字段 get/set 和 inner 执行。构造器通知不包含构造器异常退出的情况,不能把这一计数解释为所有构造尝试。
实际执行顺序及观测如下:
| 操作 | 构造器完成 | 字段写入 | 字段读取 | inner 执行 |
|---|---|---|---|---|
new WovenTarget() |
1 | 1 | 0 | 0 |
target.outer() |
1 | 2 | 1 | 1 |
int value = target.value |
1 | 2 | 2 | 1 |
最后一次读取来自 WeavingMain,不来自 WovenTarget。这个断言检查了外部字段访问表达式所在的调用者也经过织入。最终 value 为 2;计数器的变化没有替代业务状态断言,二者同时检查。
计数器是实验进程内的静态整数,执行路径是单线程。它们用于识别通知位置,不是生产监控实现;并发场景需要单独处理计数器同步和 aspect 实例模型。当前结果也没有测量织入相对于代理的吞吐量或延迟。
javap 中哪条指令证明通知已经进入类文件
javap -c -p 的输出保存在 evidence/E01/local-20261002/javap.txt。WovenTarget 的构造器、inner 方法及生成的访问辅助路径可以看到对 Trace 的调用,包括 Trace.aspectOf 与 aspect 通知方法。
这些调用与源码中的 value++ 共存于构建产物内。运行时从 outer 进入 inner 时,JVM 执行 inner 字节码,因此会执行其中插入的通知调用。推理不需要依赖“AspectJ 特殊处理 this”这一额外假设。
证据目录还保留 woven-classes/ 下的实际 class 文件,manifest 保存它们的 SHA-256。读者可以把这些文件与重新编译的产物比较,也可以独立对证据 class 执行 javap。二进制文件用于审计这个冻结实验,不是建议把业务 class 提交到普通源码仓库。
只有编译日志里的匹配提示仍不充分。提示说明编译器认为某个连接点匹配,实际部署可能拿错目录、被后续插件重新覆盖,或者运行了旧包。将字节码哈希、运行命令和计数输出放在同一份证据中,可以把“匹配了”与“执行了这个产物”连接起来。
Spring 代理对照为什么使用另一个目标类型
对照类 ProxyTarget 保留相同的 outer/inner 调用关系,但切点只匹配 WovenTarget。ProxyFactory 给 ProxyTarget 创建 CGLIB 代理,MethodInterceptor 每被调用一次就计数。这样可以避免一个对象既被织入又被代理,导致两种通知混在同一个计数中。
外部调用 proxy.outer() 后,拦截器计数为 1,原始目标 value 为 1。业务方法确实调用了 inner,但 inner 的内部调用没有增加代理拦截计数。随后外部调用 proxy.inner(),计数增至 2,原始目标 value 也增至 2。
实验再直接写 raw.value、直接 new 一个 ProxyTarget,拦截计数都保持 2。它们不是经代理的方法调用。Spring Framework 的 CglibAopProxy 在方法拦截路径中建立 MethodInvocation;这个机制没有把所有目标字段访问或所有构造器调用改写成代理调用。固定源码 CglibAopProxy
Spring 官方也明确区分这种边界:编译期或加载期 AspectJ 织入不依赖经代理的自调用路径。@EnableAspectJAutoProxy 名称中包含 AspectJ,实际启用的仍然是 Spring 自动代理支持;它不会独自启动 ajc 或 Java instrumentation。Spring AspectJ 支持
什么时候需要把织入引入构建
当需求明确涉及非容器对象、字段连接点或构造器连接点时,单纯调整 JDK/CGLIB 代理类型无法提供等价能力。织入可以覆盖这些执行位置,但也把构建产物、类加载和依赖版本纳入正确性边界。
编译期织入能够在构建目录直接检查结果;加载期织入需要进一步确认 agent、类加载顺序、织入配置以及目标类是否在 transformer 生效前加载。当前实验只验证编译期方式,不能作为某个应用服务器上的 LTW 验收结果。Spring 与 AspectJ 集成
对于仅需容器服务方法的事务、审计或权限拦截,代理通常让边界更显式,也减少构建过程的额外变量。若唯一问题是一个事务方法从同类内部调用,拆分服务边界可能比引入全局织入更易维护;是否需要织入,应由所需连接点决定。
混用时需要定义每类通知的归属。一个方法可以既经过 Spring 代理,又包含织入通知。重复统计、重复开启事务以及通知顺序问题都需要按实际执行链检查,不能仅凭两个 aspect 类上的 order 值推定跨机制的完整次序。
修改一个切点,预测哪些断言改变
把 Trace.aj 的 execution(void woven.WovenTarget.inner()) 改为 execution(void woven.WovenTarget.outer())。业务 value 仍应为 2,字段读写计数不变;名为 innerExecutions 的计数器此时语义已经改变,应该重命名为 outerExecutions,并把断言说明改为 outer 通知。这个练习检验切点改变与业务执行改变的区别。
另一个练习是把 WeavingMain 改为在 ajc 完成后单独由 javac 编译,再运行;保留 WovenTarget 与 Trace 的织入产物。目标内部读取仍应被通知,但 main 中 target.value 的读取不再经过原来的织入过程,最后 reads 为 2 的断言应失败。这是对织入范围的推导练习,当前交付日志没有运行该变体。
冻结基线实际通过 9 条运行时断言,ajc 和 javap 进程均成功。阅读代理边界可回到 深入 Spring(00):从手动组装到可验证的容器实验,完整代码位于实验包的 ecosystem-lab;所有预期计数都可从目标源码和织入输出独立核对。
