AOP踩坑实录(Spring生成Proxy)
Posted 圆圆的球
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了AOP踩坑实录(Spring生成Proxy)相关的知识,希望对你有一定的参考价值。
AOP踩坑实录(Spring生成Proxy)
--洱涷zZ
背景:
因为想深层次的去看看Spring的AOP机制,所以查阅了很多资料,其中在复现廖雪峰老师写的demo的时候遇到的一个 NullPointerException
记录一下。
AOP:
无论是使用AspectJ语法,还是配合Annotation,使用AOP,实际上就是让Spring自动为我们创建一个Proxy,使得调用方能无感知地调用指定方法,但运行期却动态“织入”了其他逻辑进行增强,因此,AOP本质上就是一个代理模式。
因为Spring使用了CGLIB来实现运行期动态创建Proxy,如果我们没能深入理解其运行原理和实现机制,就极有可能遇到各种诡异的问题。
踩坑的栗子:
首先我们先复现一下遇到空指针的这个栗子,假设我们定义了一个userServiceDto
的Bean:
再写一个Service
,并且注入userServiceDto
:
最后用main()
方法测试一下:
查看输出结果,一切正常:
UserService(): init...
UserService(): zoneId = Asia/Shanghai
Hello, it is 2021-11-15T14:40:22.917721+08:00[Asia/Shanghai]
下一步,我们给userServiceDto
加上AOP支持,就添加一个最简单的LoggingAspect
:
别忘了在AppConfig
上加上@EnableAspectJAutoProxy
。再次运行,不出意外的话,我们会得到一个NullPointerException
:
Exception in thread "main" java.lang.NullPointerException: zone
at java.base/java.util.Objects.requireNonNull(Objects.java:246)
at java.base/java.time.Clock.system(Clock.java:203)
at java.base/java.time.ZonedDateTime.now(ZonedDateTime.java:216)
at com.example.bigselfkeng.service.mainService.sendMail(MailService.java:19)
at com.example.bigselfkeng.appConfig.main(AppConfig.java:21)
把代码debug后发现,问题出在这一行
然而我们在dto中还特意加了final
进行了修饰:
怎么肥四!!!????
为什么加了AOP就报空指针,去了AOP就一切正常?final
字段不执行,难道JVM有问题?为了解答这个诡异的问题,我们需要深入理解Spring使用CGLIB
生成Proxy
的原理:
第一步,正常创建一个userServiceDto
的原始实例,这是通过反射调用构造方法实现的,它的行为和我们预期的完全一致;
第二步,通过CGLIB创建一个userServiceDto
的子类,并引用了原始实例和LoggingAspect
:
public userServiceDto$$EnhancerBySpringCGLIB extends UserService {
userServiceDto target;
LoggingAspect aspect;
public userServiceDto$$EnhancerBySpringCGLIB() {
}
public ZoneId getZoneId() {
aspect.doAccessCheck();
return target.getZoneId();
}
}
如果我们观察Spring创建的AOP代理,它的类名总是类似userServiceDto$$EnhancerBySpringCGLIB$$1c76af9d
(你没看错,Java的类名实际上允许$
字符)。为了让调用方获得userServiceDto
的引用,它必须继承自userServiceDto
。然后,该代理类会覆写所有public
和protected
方法,并在内部将调用委托给原始的userServiceDto
实例。
这里出现了两个userServiceDto
实例:
一个是我们代码中定义的原始实例,它的成员变量已经按照我们预期的方式被初始化完成:
userServiceDto original = new userServiceDto();
第二个userServiceDto
实例实际上类型是userServiceDto$$EnhancerBySpringCGLIB
,它引用了原始的userServiceDto
实例:
userServiceDto$$EnhancerBySpringCGLIB proxy = new userServiceDto$$EnhancerBySpringCGLIB();
proxy.target = original;
注意到这种情况仅出现在启用了AOP的情况,此刻,从ApplicationContext
中获取的userServiceDto
实例是proxy,注入到mainService
中的userServiceDto
实例也是proxy。
那么最终的问题来了:proxy实例的成员变量,也就是从userServiceDto
继承的zoneId
,它的值为什么是null
?。
原因在于,userServiceDto
成员变量的初始化:
public class userServiceDto {
public final ZoneId zoneId = ZoneId.systemDefault();
}
在userServiceDto$$EnhancerBySpringCGLIB
中,并未执行。原因是,没必要初始化proxy的成员变量,因为proxy的目的是代理方法。
实际上,成员变量的初始化是在构造方法中完成的。这是我们看到的代码:
public class userServiceDto {
public final ZoneId zoneId = ZoneId.systemDefault();
public userServiceDto() {
}
}
这是编译器实际编译的代码:
public class userServiceDto {
public final ZoneId zoneId;
public userServiceDto() {
super(); // 构造方法的第一行代码总是调用super()
zoneId = ZoneId.systemDefault(); // 继续初始化成员变量
}
}
然而,对于Spring通过CGLIB动态创建的userServiceDto$$EnhancerBySpringCGLIB
代理类,它的构造方法中,并未调用super()
,因此,从父类继承的成员变量,包括final
类型的成员变量,统统都没有初始化。
但是不是Java语言规定,任何类的构造方法,第一行必须调用super()
,如果没有,编译器会自动加上嘛?怎么Spring的CGLIB就可以搞特殊?
这是因为自动加super()
的功能是Java编译器实现的,它发现你没加,就自动给加上,发现你加错了,就报编译错误。但实际上,如果直接构造字节码,一个类的构造方法中,不一定非要调用super()
。Spring使用CGLIB构造的Proxy类,是直接生成字节码,并没有源码-编译-字节码这个步骤,因此:
Spring通过CGLIB创建的代理类,不会初始化代理类自身继承的任何成员变量,包括final类型的成员变量!
再考察mainService
的代码:
@Component
public class mainService {
@Autowired
userServiceDto userServiceDto;
public String sendMail() {
ZoneId zoneId = userServiceDto.zoneId;
System.out.println(zoneId); // null
...
}
}
如果没有启用AOP,注入的是原始的userServiceDto
实例,那么一切正常,因为userServiceDto
实例的zoneId
字段已经被正确初始化了。
如果启动了AOP,注入的是代理后的userServiceDto$$EnhancerBySpringCGLIB
实例,那么问题大了:获取的userServiceDto$$EnhancerBySpringCGLIB
实例的zoneId
字段,永远为null
。
那么问题来了:启用了AOP,如何修复?
修复很简单,只需要把直接访问字段的代码,改为通过方法访问:
@Component
public class mainService {
@Autowired
userServiceDto userServiceDto;
public String sendMail() {
// 不要直接访问UserService的字段:
ZoneId zoneId = userServiceDto.getZoneId();
...
}
}
无论注入的userServiceDto
是原始实例还是代理实例,getZoneId()
都能正常工作,因为代理类会覆写getZoneId()
方法,并将其委托给原始实例:
public userServiceDto$$EnhancerBySpringCGLIB extends userServiceDto {
userServiceDto target = ...
...
public ZoneId getZoneId() {
return target.getZoneId();
}
}
注意到我们还给userServiceDto
添加了一个public
+final
的方法:
@Component
public class userServiceDto {
...
public final ZoneId getFinalZoneId() {
return zoneId;
}
}
如果在mainService
中,调用的不是getZoneId()
,而是getFinalZoneId()
,又会出现NullPointerException
,这是因为,代理类无法覆写final
方法(这一点绕不过JVM的ClassLoader检查),该方法返回的是代理类的zoneId
字段,即null
。
实际上,如果我们加上日志,Spring在启动时会打印一个警告:
10:43:09.929 [main] DEBUG org.springframework.aop.framework.CglibAopProxy - Final method [public final java.time.ZoneId xxx.userServiceDto.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.
上面的日志大意就是,因为被代理的userServiceDto
有一个final
方法getFinalZoneId()
,这会导致其他Bean如果调用此方法,无法将其代理到真正的原始实例,从而可能发生NPE异常。
因此,正确使用AOP,我们需要明白:
- 访问被注入的Bean时,总是调用方法而非直接访问字段;
- 编写Bean时,如果可能会被代理,就不要编写
public final
方法。
以上是关于AOP踩坑实录(Spring生成Proxy)的主要内容,如果未能解决你的问题,请参考以下文章