AOP避坑指南

无论是使用AspectJ语法,还是配合Annotation,使用AOP,实际上就是让Spring自动为我们创建一个Proxy,使得调用方能无感知地调用指定方法,但运行期却动态“织入”了其他逻辑,因此,AOP本质上就是一个代理模式

因为Spring使用了CGLIB来实现运行期动态创建Proxy,如果我们没能深入理解其运行原理和实现机制,就极有可能遇到各种诡异的问题。

我们来看一个实际的例子。

假设我们定义了一个UserService的Bean:

  1. @Component
  2. public class UserService {
  3. // 成员变量:
  4. public final ZoneId zoneId = ZoneId.systemDefault();
  5. // 构造方法:
  6. public UserService() {
  7. System.out.println("UserService(): init...");
  8. System.out.println("UserService(): zoneId = " + this.zoneId);
  9. }
  10. // public方法:
  11. public ZoneId getZoneId() {
  12. return zoneId;
  13. }
  14. // public final方法:
  15. public final ZoneId getFinalZoneId() {
  16. return zoneId;
  17. }
  18. }

再写个MailService,并注入UserService

  1. @Component
  2. public class MailService {
  3. @Autowired
  4. UserService userService;
  5. public String sendMail() {
  6. ZoneId zoneId = userService.zoneId;
  7. String dt = ZonedDateTime.now(zoneId).toString();
  8. return "Hello, it is " + dt;
  9. }
  10. }

最后用main()方法测试一下:

  1. @Configuration
  2. @ComponentScan
  3. public class AppConfig {
  4. public static void main(String[] args) {
  5. ApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class);
  6. MailService mailService = context.getBean(MailService.class);
  7. System.out.println(mailService.sendMail());
  8. }
  9. }

查看输出,一切正常:

  1. UserService(): init...
  2. UserService(): zoneId = Asia/Shanghai
  3. Hello, it is 2020-04-12T10:23:22.917721+08:00[Asia/Shanghai]

下一步,我们给UserService加上AOP支持,就添加一个最简单的LoggingAspect

  1. @Aspect
  2. @Component
  3. public class LoggingAspect {
  4. @Before("execution(public * com..*.UserService.*(..))")
  5. public void doAccessCheck() {
  6. System.err.println("[Before] do access check...");
  7. }
  8. }

别忘了在AppConfig上加上@EnableAspectJAutoProxy。再次运行,不出意外的话,会得到一个NullPointerException

  1. Exception in thread "main" java.lang.NullPointerException: zone
  2. at java.base/java.util.Objects.requireNonNull(Objects.java:246)
  3. at java.base/java.time.Clock.system(Clock.java:203)
  4. at java.base/java.time.ZonedDateTime.now(ZonedDateTime.java:216)
  5. at com.itranswarp.learnjava.service.MailService.sendMail(MailService.java:19)
  6. at com.itranswarp.learnjava.AppConfig.main(AppConfig.java:21)

仔细跟踪代码,会发现null值出现在MailService.sendMail()内部的这一行代码:

  1. @Component
  2. public class MailService {
  3. @Autowired
  4. UserService userService;
  5. public String sendMail() {
  6. ZoneId zoneId = userService.zoneId;
  7. System.out.println(zoneId); // null
  8. ...
  9. }
  10. }

我们还故意在UserService中特意用final修饰了一下成员变量:

  1. @Component
  2. public class UserService {
  3. public final ZoneId zoneId = ZoneId.systemDefault();
  4. ...
  5. }

final标注的成员变量为null?逗我呢?

怎么肥四?

为什么加了AOP就报NPE,去了AOP就一切正常?final字段不执行,难道JVM有问题?为了解答这个诡异的问题,我们需要深入理解Spring使用CGLIB生成Proxy的原理:

第一步,正常创建一个UserService的原始实例,这是通过反射调用构造方法实现的,它的行为和我们预期的完全一致;

第二步,通过CGLIB创建一个UserService的子类,并引用了原始实例和LoggingAspect

  1. public UserService$$EnhancerBySpringCGLIB extends UserService {
  2. UserService target;
  3. LoggingAspect aspect;
  4. public UserService$$EnhancerBySpringCGLIB() {
  5. }
  6. public ZoneId getZoneId() {
  7. aspect.doAccessCheck();
  8. return target.getZoneId();
  9. }
  10. }

如果我们观察Spring创建的AOP代理,它的类名总是类似UserServiceEnhancerBySpringCGLIBEnhancerBySpringCGLIB1c76af9d(你没看错,Java的类名实际上允许$字符)。为了让调用方获得UserService的引用,它必须继承自UserService。然后,该代理类会覆写所有publicprotected方法,并在内部将调用委托给原始的UserService实例。

这里出现了两个UserService实例:

一个是我们代码中定义的原始实例,它的成员变量已经按照我们预期的方式被初始化完成:

  1. UserService original = new UserService();

第二个UserService实例实际上类型是UserService$$EnhancerBySpringCGLIB,它引用了原始的UserService实例:

  1. UserService$$EnhancerBySpringCGLIB proxy = new UserService$$EnhancerBySpringCGLIB();
  2. proxy.target = original;
  3. proxy.aspect = ...

注意到这种情况仅出现在启用了AOP的情况,此刻,从ApplicationContext中获取的UserService实例是proxy,注入到MailService中的UserService实例也是proxy。

那么最终的问题来了:proxy实例的成员变量,也就是从UserService继承的zoneId,它的值是null

原因在于,UserService成员变量的初始化:

  1. public class UserService {
  2. public final ZoneId zoneId = ZoneId.systemDefault();
  3. ...
  4. }

UserService$$EnhancerBySpringCGLIB中,并未执行。原因是,没必要初始化proxy的成员变量,因为proxy的目的是代理方法。

实际上,成员变量的初始化是在构造方法中完成的。这是我们看到的代码:

  1. public class UserService {
  2. public final ZoneId zoneId = ZoneId.systemDefault();
  3. public UserService() {
  4. }
  5. }

这是编译器实际编译的代码:

  1. public class UserService {
  2. public final ZoneId zoneId;
  3. public UserService() {
  4. super(); // 构造方法的第一行代码总是调用super()
  5. zoneId = ZoneId.systemDefault(); // 继续初始化成员变量
  6. }
  7. }

然而,对于Spring通过CGLIB动态创建的UserService$$EnhancerBySpringCGLIB代理类,它的构造方法中,并未调用super(),因此,从父类继承的成员变量,包括final类型的成员变量,统统都没有初始化。

有的童鞋会问:Java语言规定,任何类的构造方法,第一行必须调用super(),如果没有,编译器会自动加上,怎么Spring的CGLIB就可以搞特殊?

这是因为自动加super()的功能是Java编译器实现的,它发现你没加,就自动给加上,发现你加错了,就报编译错误。但实际上,如果直接构造字节码,一个类的构造方法中,不一定非要调用super()。Spring使用CGLIB构造的Proxy类,是直接生成字节码,并没有源码-编译-字节码这个步骤,因此:

Spring通过CGLIB创建的代理类,不会初始化代理类自身继承的任何成员变量,包括final类型的成员变量!

再考察MailService的代码:

  1. @Component
  2. public class MailService {
  3. @Autowired
  4. UserService userService;
  5. public String sendMail() {
  6. ZoneId zoneId = userService.zoneId;
  7. System.out.println(zoneId); // null
  8. ...
  9. }
  10. }

如果没有启用AOP,注入的是原始的UserService实例,那么一切正常,因为UserService实例的zoneId字段已经被正确初始化了。

如果启动了AOP,注入的是代理后的UserServiceEnhancerBySpringCGLIBEnhancerBySpringCGLIB实例,那么问题大了:获取的UserServiceEnhancerBySpringCGLIB实例的zoneId字段,永远为null

那么问题来了:启用了AOP,如何修复?

修复很简单,只需要把直接访问字段的代码,改为通过方法访问:

  1. @Component
  2. public class MailService {
  3. @Autowired
  4. UserService userService;
  5. public String sendMail() {
  6. // 不要直接访问UserService的字段:
  7. ZoneId zoneId = userService.getZoneId();
  8. ...
  9. }
  10. }

无论注入的UserService是原始实例还是代理实例,getZoneId()都能正常工作,因为代理类会覆写getZoneId()方法,并将其委托给原始实例:

  1. public UserService$$EnhancerBySpringCGLIB extends UserService {
  2. UserService target = ...
  3. ...
  4. public ZoneId getZoneId() {
  5. return target.getZoneId();
  6. }
  7. }

注意到我们还给UserService添加了一个public+final的方法:

  1. @Component
  2. public class UserService {
  3. ...
  4. public final ZoneId getFinalZoneId() {
  5. return zoneId;
  6. }
  7. }

如果在MailService中,调用的不是getZoneId(),而是getFinalZoneId(),又会出现NullPointerException,这是因为,代理类无法覆写final方法(这一点绕不过JVM的ClassLoader检查),该方法返回的是代理类的zoneId字段,即null

实际上,如果我们加上日志,Spring在启动时会打印一个警告:

  1. 10:43:09.929 [main] DEBUG org.springframework.aop.framework.CglibAopProxy - Final method [public final java.time.ZoneId xxx.UserService.getFinalZoneId()] cannot get proxied via CGLIB: Calls to this method will NOT be routed to the target instance and might lead to NPEs against uninitialized fields in the proxy instance.

上面的日志大意就是,因为被代理的UserService有一个final方法getFinalZoneId(),这会导致其他Bean如果调用此方法,无法将其代理到真正的原始实例,从而可能发生NPE异常。

因此,正确使用AOP,我们需要一个避坑指南:

  1. 访问被注入的Bean时,总是调用方法而非直接访问字段;
  2. 编写Bean时,如果可能会被代理,就不要编写public final方法。

这样才能保证有没有AOP,代码都能正常工作。

思考

为什么Spring刻意不初始化Proxy继承的字段?

如果一个Bean不允许任何AOP代理,应该怎么做来“保护”自己在运行期不会被代理?

练习

AOP避坑指南 - 图1下载练习:修复启用AOP导致的NPE (推荐使用IDE练习插件快速下载)

小结

由于Spring通过CGLIB实现代理类,我们要避免直接访问Bean的字段,以及由final方法带来的“未代理”问题。

遇到CglibAopProxy的相关日志,务必要仔细检查,防止因为AOP出现NPE异常。

读后有收获可以支付宝请作者喝咖啡,读后有疑问请加微信群讨论:

AOP避坑指南 - 图2 AOP避坑指南 - 图3