Spring AOP 代理链:一次方法调用如何穿过 Advisor 与拦截器
一个标有 @Transactional 的 public 方法从 Controller 调用时能回滚,从同一个类的另一个方法里调用却不回滚;把实现类强制转换后又出现 ClassCastException;线上采样看到同一业务方法外包着监控、鉴权、重试和事务,却没人说得清它们的先后。三个现象都指向同一件事:调用者拿到的不是目标对象本身,而是一个负责选择并执行增强链的代理入口。
Spring AOP 是基于代理的运行时方法拦截,不是把任意 Java 语句重写成切面。它擅长处理由 Spring 容器管理、从对象边界进入的方法调用;构造过程、字段读写、对象内部 this 调用以及不可能被覆写的方法,不会因为声明了切点就天然经过代理。官方 Spring AOP 参考文档 给出能力边界,工程诊断还必须继续追到代理类型、Advisor 集合和实际调用入口。
代理保存调用策略,目标对象只负责业务实现
容器创建 Bean 时,自动代理创建器在初始化后阶段检查候选 Advisor。Advisor 由 Pointcut 与 Advice 组成:Pointcut 回答“哪些类、哪些方法需要增强”,Advice 被适配成 MethodInterceptor 后回答“命中时做什么”。代理持有 AdvisedSupport 一类的配置状态,包括目标来源、接口、Advisor、是否冻结配置以及是否暴露代理。调用到达代理后,才根据当前方法构造或读取缓存的拦截器链。
拦截器不是简单的“前置回调列表”。每个拦截器获得一个 invocation,并决定是否调用 proceed():调用一次代表继续链条,不调用代表短路,调用多次会让后续链乃至目标执行多次。缓存拦截器实例时必须假定它会并发复用,不能把一次请求的开始时间、租户或异常存进实例字段。状态应留在栈帧、显式上下文或受控的上下文载体中。
examples/backend-development/spring-framework/aop-proxy/ProxyInvocationDemo.java 用最小模型保留了链式调用的关键语义:
javac --release 17 -Xlint:all -Werror examples/backend-development/spring-framework/aop-proxy/ProxyInvocationDemo.java examples/backend-development/spring-framework/aop-proxy/SelfInvocationFailureDemo.java
java -cp examples/backend-development/spring-framework/aop-proxy ProxyInvocationDemoresult=ok trace=[before:security, target, after:metrics]真实框架还会处理动态方法匹配、引介、桥接方法和异常传播,但这个模型固定了最重要的不变量:增强围绕 proceed() 嵌套;目标只在链条走到底时执行。若安全拦截器在鉴权失败后仍调用 proceed(),它不是记录失败,而是在放行业务。
JDK 代理与类代理改变的是可见类型和可拦截集合
JDK 动态代理实现一组接口,代理对象可赋给接口,却不是目标实现类的子类。类代理通过生成目标类的子类覆写可拦截方法,因此可以按具体类注入,但目标类和目标方法必须允许继承与覆写。现代 Spring 会按配置、接口情况和基础设施需求选择代理策略;业务代码不应依赖代理生成类的名字,更不应把 $$SpringCGLIB$$ 当作稳定 API。
两种策略的关键差异如下:
| 维度 | JDK 动态代理 | 类代理 |
|---|---|---|
| 对外类型 | 代理接口 | 目标类子类型 |
| 需要业务接口 | 是 | 否 |
final 类/方法 | 接口方法可进入代理;目标实现照常调用 | 无法通过覆写拦截 |
| 按实现类注入 | 通常失败 | 通常可行 |
| 设计倾向 | 面向能力契约 | 兼容无接口组件 |
不论采用哪一种,private 方法都不是从代理契约进入的可覆写业务边界,static 方法属于类而非实例分派,构造器发生在最终代理暴露之前。把这些位置标上事务、缓存或异步注解,代码可以编译,运行语义却不会因此出现。Kotlin 默认 final、Java record 与封闭类型也要求在设计时先确认代理可行性。
类型诊断要同时记录三项:注入点声明类型、最终暴露对象的代理类型、代理背后的 target class。只打印 bean.getClass() 会把生成类当成业务类;只看 target class 又会忽略调用者实际持有的接口集合。序列化、反射扫描、方法注解查找和框架缓存都应使用 Spring 提供的目标类/桥接方法解析工具,而不是手写 getSuperclass() 猜测。
方法匹配决定链条,表达式越宽成本与风险越大
一个 Advisor 是否进入链条,通常经历类过滤、静态方法匹配,必要时再做运行时参数匹配。AspectJ 表达式中的 execution 描述执行连接点,within 限制声明位置,this 面向代理类型,target 面向目标类型,args 面向运行时参数;这些概念相似却不等价。把 @annotation 用在接口注解上时,还要确认注解是否能从实际被解析的方法取得。
宽泛切点例如覆盖整个根包,可能把配置类、健康检查、定时任务和框架适配器一起代理。它会增加启动期匹配量、运行期链长度和对象类型复杂度,并可能形成“监控切面监控监控组件”的递归。切点应以稳定的业务边界、明确注解或窄接口为准,并通过负例测试证明不该命中的方法确实没有链条。
同一方法命中多个 Advisor 时,顺序直接决定语义:重试在事务外,通常每次重试新开事务;重试在事务内,可能在已标记 rollback-only 的事务上继续;指标在缓存外统计请求次数,在缓存内只统计真实计算次数。@Order 只是排序输入之一,基础设施 Advisor 也有自己的优先级。验收必须读取最终 Advisor 列表并执行集成测试,不能凭注解在源码中的上下位置推断。
this 自调用绕过代理,不是注解偶发失效
代理只拦截“调用者 → 代理”这条边。目标方法进入后,this.otherMethod() 使用当前目标对象做普通虚方法分派,不会倒退回外层代理。因此 @Transactional、@Cacheable、@Async、方法安全等基于代理的能力都会在自调用处绕开。
第二个实验把差异变成可执行证据:
java -cp examples/backend-development/spring-framework/aop-proxy SelfInvocationFailureDemoafterSelfInvocation=0 afterProxyInvocation=1 selfInvocationBypassed=true最清晰的修复是按边界拆 Bean,让协作者经代理调用需要增强的方法。也可以显式注入接口代理,但会引入自引用并模糊职责;AopContext.currentProxy() 要求暴露代理、依赖线程上下文且让业务代码绑定框架,通常只适合遗留兼容。切换到编译期或加载期织入属于另一种执行模型,必须单独评估构建、类加载、可观测性和故障面,不能当成一个布尔开关。
自调用诊断不应停在“把方法改 public”。需要依次确认:对象是否由当前容器创建;调用引用是否确实是代理;方法是否能被所选代理策略拦截;切点是否命中解析后的具体方法;调用是否从代理外部进入;是否被另一个 Advisor 短路;异常是否在链内被吞掉。六项证据齐全,才谈框架缺陷。
异常、返回值与异步边界都属于链的契约
拦截器必须用 try/finally 对称关闭计时器、日志上下文和资源。只在正常返回后清理会让异常路径污染后续线程;捕获异常后包装时必须保留 cause,并理解事务回滚规则看到的是包装后的异常类型。修改返回值或异常是允许的,但这会成为公开语义,需要测试空值、原始类型、泛型桥接方法和协变返回值。
当拦截器提交任务到线程池并提前返回,后续工作已经离开原调用栈。事务资源、请求属性、安全身份和日志上下文通常以线程绑定方式保存,不会自动迁移。异步拦截器若只复制 ThreadLocal 而不在 finally 清理,会在线程复用时产生跨租户泄漏;若把可变上下文对象原样共享,又会引入并发竞争。正确做法是定义最小、不可变的上下文快照和明确的传播/清理协议。
反应式返回类型则不能用“方法返回即业务完成”的同步假设计时。拦截器要在订阅、成功、错误、取消信号上挂接生命周期,并区分创建 publisher 与真正执行。把同步事务拦截器套在返回 Publisher 的方法外层,可能在订阅前就提交;应使用与反应式上下文及事务管理器匹配的基础设施。
代理诊断要从调用入口还原整条链
生产问题可以按四层收集证据:
身份层:bean name、BeanFactory、代理种类、目标类、接口集合和是否为 scoped proxy。匹配层:调用方法、解析后的最具体方法、命中的 Advisor 名称与顺序、切点判断结果。执行层:每个拦截器进入/退出/异常耗时、是否调用 proceed()、目标执行次数。
上下文层:线程、事务名、安全身份、trace id,以及异步或反应式边界前后的变化。
诊断日志必须限流并避免打印参数秘密。为每次调用完整输出切点判断会制造新的性能事故;更合适的是对指定 bean/method 临时采样,或在启动期输出稳定的代理清单与哈希。指标至少包括代理 Bean 数、每类 Advisor 命中数、链长度分布、目标与全链耗时、短路次数和异常类型。
代理不是隐藏魔法,而是一条可枚举、可排序、可测试的调用路径。设计时明确代理边界,编码时避免内部调用误穿透,验收时断言最终 Advisor 链,生产中保留代理与目标的双重身份,AOP 才能从“注解似乎生效”变成可治理的运行时机制。
