2387 字
约 7 分钟
14
S06-J07 · AOP 与代理:切面为什么会静默失效

切面失效最气人的地方是它不报错。日志切面漏了几条记录、事务没回滚、@Async 变成同步执行、重试没触发——代码上注解都在,编译通过,单测有时还绿。原因只有一个:这些注解都靠代理拦截,而那次调用没有经过代理,或者切点压根没匹配上。

下面按"代理怎么来的 → 谁调谁 → 切点匹配 → 执行顺序 → 织入方式"拆,最后给一张对照表。


1. 代理的选择规则

条件 结果 备注
目标类实现接口,proxyTargetClass=false JDK 动态代理(只代理接口方法) Boot 2.x 起默认 proxyTargetClass=true
目标类实现接口,proxyTargetClass=true CGLIB 子类代理 能代理实现类新增的 public 方法
目标类无接口 CGLIB 无选择余地
类是 final CGLIB 无法生成子类 启动即报错,不会静默
方法是 final 子类无法覆写 静默失效,注解形同不存在
方法是 private/static 不在代理覆盖范围 静默失效
@Configuration
@EnableAspectJAutoProxy(proxyTargetClass = true, exposeProxy = false)
public class AopConfig { }
// Boot 对应:spring.aop.proxy-target-class=true(默认)

注入方式要注意:JDK 代理只能按接口注入,按实现类注入会抛 BeanNotOfRequiredTypeException。用 CGLIB 时可注入实现类,但 final 类连这一步都过不去。


2. this 与代理对象:一个解释覆盖所有注解

容器里的 Bean 是代理,this 是目标对象本身。自调用时 this.method() 直接进目标方法,拦截器链被跳过——这对 @Transactional、@Async、@Cacheable、@Retryable 是同一件事,不是各自的 bug。

@Service
public class ReportService {

    @Autowired private ReportService self;
    @Autowired private ApplicationContext ctx;

    @Cacheable(cacheNames = "report", key = "#id")
    public Report load(Long id) { return repo.selectById(id); }

    @Retryable(retryFor = IOException.class, maxAttempts = 3)
    public void push(Long id) throws IOException { client.push(id); }

    public void warm(Long id) {
        load(id);                                            // 失效:自调用
        self.load(id);                                       // 生效:过代理
        ctx.getBean(ReportService.class).push(id);            // 生效
    }
}
现象 是否经过代理 判断方法
同类 this.m() 否 断点看栈里有没有 CglibAopProxy/JdkDynamicAopProxy
注入兄弟 Bean 调用 是 同上
从容器 getBean 是 同上
父类方法被子类调用 取决于调用点 被 this 调就不过代理
@Bean 里 new 出来 否 根本不是容器管理的对象

3. 切点写错的四种典型

错误 写法 实际匹配 修法
包名不匹配 execution(* com.acme.service.*.*(..)) 只匹配该包下一层,子包全漏 用 ..:com.acme.service..*.*(..)
注解在接口上 @annotation(com.acme.Log) 标在接口方法 CGLIB 代理实现类时,接口上的注解不在实现方法上 注解标到实现类/实现方法
混用 execution 与 @annotation execution(* *..*(..)) && @annotation(Log) 与预期一致,但 `
参数类型写错 args(java.lang.String) 只匹配唯一参数为 String 的方法 需要首参用 args(String, ..)
@Pointcut("execution(public * com.acme..service..*.*(..))")
public void serviceLayer() { }

@Pointcut("@annotation(com.acme.audit.Audited)")
public void audited() { }

@Around("serviceLayer() && audited()")   // 交集:既在 service 层又标了注解
public Object around(ProceedingJoinPoint pjp) throws Throwable { return pjp.proceed(); }

切点不匹配是零报错的:容器正常启动,切面正常注册,只是永远不触发。排查靠 @Aspect 上加临时日志,或在启动时开 logging.level.org.springframework.aop=TRACE 看 advisor 匹配了哪些方法。


4. 执行顺序:@Order 能管什么,管不了什么

组合 顺序规则 是否可控
不同 @Aspect 类 按 @Order 值升序;无 @Order 时顺序不确定 可控
同一 @Aspect 内的多个通知 同一连接点上的同类通知按声明顺序,跨类型(before/after)由 AspectJ 语义决定 不建议依赖
切面与 @Transactional 事务拦截器 order 默认 Ordered.LOWEST_PRECEDENCE 可通过 @Order 调到事务外层/内层
切面与 @Async 两者默认都是 LOWEST_PRECEDENCE,顺序取决于注册先后 需显式 @Order 才能定
@Aspect
@Order(1)                    // 数值越小越靠外
@Component
public class TraceAspect { }

同一 @Aspect 里写多个 @Around 时,别指望声明顺序稳定——重构、编译器差异都可能改变结果。要确定顺序就拆成两个 @Aspect 并给 @Order。


5. @Around 的两个坑

@Around("audited()")
public Object bad(ProceedingJoinPoint pjp) throws Throwable {
    try { return pjp.proceed(); }
    catch (Exception e) { log.warn("skip", e); return null; }   // 吞掉异常
}

后果有三层:异常被吞 → 调用方以为成功;返回 null → 调用方 NPE 或拿到空数据;若外层是 @Transactional,事务拦截器根本看不到异常,照常提交。@Around 里返回 null 对 void 方法无害,对返回值方法等于伪造结果。

第二个坑是顺序。@Around 不调 pjp.proceed() 就等于彻底短路:

@Around("serviceLayer()")
public Object cache(ProceedingJoinPoint pjp) throws Throwable {
    Object v = local.get(key(pjp));
    if (v != null) return v;
    return pjp.proceed();      // 不写这句,目标方法永远不会执行
}

@Around 与 @Transactional 叠加时,若切面 order 比事务拦截器更靠外,切面里抛的异常在事务 commit 之后才出现——数据已提交,异常却返回给调用方,形成"报错但数据在"的假象。判断方法:看 TransactionSynchronizationManager.isActualTransactionActive() 在切面进入时是否已为 true。


6. 注解继承:@Inherited 帮不到切面

场景 注解是否可见 说明
父类方法上有注解,子类继承不覆写 方法被继承,注解随方法一起可见 切点按方法匹配,能切到
类上标 @Inherited 的注解,子类未标 getAnnotation 可见 仅对类注解有效,且需注解自身带 @Inherited
接口方法上有注解,实现类覆写 默认不可见 Spring 有 AnnotationUtils.findAnnotation 等回退查找,但切点匹配不保证
子类覆写父类方法 子类方法上的注解生效,父类注解不继承 覆写即替换
@Inherited
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface Audited { }     // 只对类注解有意义,方法注解加 @Inherited 无效

方法级注解无法通过 @Inherited 继承。想在子类覆写后仍被切,把注解放到接口并由框架做回退查找,或直接在实现类上重复标注。


7. AopContext 与编译期织入

// 前提:exposeProxy = true,否则抛 IllegalStateException: Cannot find current proxy
@EnableAspectJAutoProxy(exposeProxy = true)

AopContext.currentProxy() 从 ThreadLocal 取当前代理,代价是每次调用一次 ThreadLocal 读写,以及在嵌套调用时可能取到非预期的那一层代理;exposeProxy 打开后所有代理都会写入,长期开启有轻微开销。单测里没有代理上下文,直接调用会抛异常。

编译期/加载期织入(AspectJ)绕开代理,因此自调用、final、private 都能被切到:

<plugin>
  <groupId>dev.aspectj</groupId>
  <artifactId>aspectj-maven-plugin</artifactId>
  <configuration>
    <complianceLevel>17</complianceLevel>
    <aspectLibraries>
      <aspectLibrary><groupId>com.acme</groupId><artifactId>audit-aspects</artifactId></aspectLibrary>
    </aspectLibraries>
  </configuration>
</plugin>
织入方式 生效时机 能切 private/final 代价
Spring 代理 运行时 否 无构建改动,自调用失效
LTW(-javaagent) 类加载时 是 需 agent 与 aop.xml
CTW(编译期) 编译时 是 构建插件、IDE 需配置、调试信息变化

排错

现象 根因 处理
注解在 final 方法上无效 CGLIB 无法覆写 去掉 final 或改接口代理
私有方法切不到 代理只覆盖可见方法 提到 public 或改织入方式
同类内部调用不触发 this 不过代理 拆类、注入自身、exposeProxy
切面里再调代理导致无限递归 切面内 self.method() 又进同一通知 在通知里调目标对象或加条件跳过
启动报 BeanNotOfRequiredTypeException JDK 代理按实现类注入 按接口注入或开 proxyTargetClass
切点全不匹配 包名少 ..、注解在接口上 TRACE 日志确认 advisor 命中
事务提交了但接口报错 @Around 在事务外层吞异常 调整 @Order 或重抛

边界

场景 更合适的手段 理由
只对自家 public 方法增强 Spring 代理 零构建成本
需要切 private/构造器/字段 AspectJ CTW 代理模型做不不到
第三方库方法 包装一层自己的 Bean 代理切不到别人的类
高频极低开销场景 手写装饰器/模板方法 反射与拦截链有固定成本
需要精确顺序的多个增强 显式 @Order + 拆分切面 同类内顺序不可依赖

一句话:注解是给拦截器看的,拦截器只存在于代理里;找不到代理,注解就只是注释。


自测

  1. 目标类实现了接口,什么配置下会走 CGLIB?
  2. final 方法上的 @Transactional 为什么不生效,且为什么不报错?
  3. execution(* com.acme.service.*.*(..)) 为什么切不到子包?
  4. 同一 @Aspect 内两个 @Around 的执行顺序能否依赖?
  5. @Inherited 对方法级注解是否有效?
  6. 开 exposeProxy=true 之后,AopContext.currentProxy() 在单测里为什么会抛异常?
S06-J07 · AOP 与代理:切面为什么会静默失效
https://www.clxhxhhr.top/posts/6046/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。