切面失效最气人的地方是它不报错。日志切面漏了几条记录、事务没回滚、@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 + 拆分切面 |
同类内顺序不可依赖 |
一句话:注解是给拦截器看的,拦截器只存在于代理里;找不到代理,注解就只是注释。
自测
- 目标类实现了接口,什么配置下会走 CGLIB?
final方法上的@Transactional为什么不生效,且为什么不报错?execution(* com.acme.service.*.*(..))为什么切不到子包?- 同一
@Aspect内两个@Around的执行顺序能否依赖? @Inherited对方法级注解是否有效?- 开
exposeProxy=true之后,AopContext.currentProxy()在单测里为什么会抛异常?