关于Spring循环依赖可能存在的坑
场景重现
问题初现
今天项目编译上线出现一个问题,项目启动时,报了:
***************************
APPLICATION FAILED TO START
***************************
Description:
The dependencies of some of the beans in the application context form a cycle:
初步排查
相信搞过Java的老铁一看到这个就知道是除了啥问题,循环依赖了呗。所以看到这里我也是一点都不慌,看了下日志就去代码里排查了。
- 看代码
发现确实存在循环依赖,但是在之前提交的一个版本里我已经把循环依赖的service改为通过setter方法注入了,按理来说不会再有问题:@Autowired public void setTenantService(TenantService tenantService) { this.tenantService = tenantService; }
- Debug
然后本地拉了main分支最新代码,编译启动,竟然成功启动了,毫无问题。 - 甩锅
于是判断是运维大佬拉的代码是修复之前的,就让他去排查代码版本是不是拉错了,就没管了。
进一步排查
- 甩锅失败
运维大佬经过精心检查之后,确认代码就是最新的,并不是代码问题,于是锅又回到了我的头上。 - 对比代码
然后我让运维大佬把jenkins打包的jar发给我,打算反编译出来作对比。结果反编译和本地代码一对比,竟然是一样的....到了这里我已经慌了,事情渐渐向着玄学前进... - 控制变量
作为一个合格的小学生,想起了当年老师教我们的控制变量法。然后就开始了疯狂的控制变量。
- 代码问题? 代码确认都是git拉的最新的,排除
- Java版本问题?都是openjdk11(build11+28),排除
- maven问题?都是maven3.6.3,排除
- 电脑问题?
- Windows下,我们三个开发都拉了最新代码下来编译跑,没有问题
- Linux下,jenkins编译出来就有问题。然而,运维大佬换了个linux服务器,同样的jdk,同样的maven版本,编译出来竟然就可以跑了。所以这是什么玄学?
- 垂死挣扎
接下来把本地编译的jar包和有问题的jar包解压出来对比,除了MANIFEST文件的timestamp不一样,其他竟然所有文件的md5都是一模一样的???!!!
怀疑人生
那么问题来了,同样的代码,同样的jdk,同样的maven版本,两台linux服务器编译,出来的jar包除了MANIFEST文件的时间戳不一样,其他完全一致。但是跑起来,一个可以正常运行,一个却报循环依赖错误。这是什么玄学?
找到同样的受害者
抱着疑问,我找到了程序员导师:Google来求助,最终兜兜转转找到了github里spring-framework的一个issue,提的就是这个问题:
https://github.com/spring-projects/spring-framework/issues/18879
可以看到这个issue从2016年首次被提出,到2019年reopen,实际上一直都没有找到过原因。issue里好几个人遇到了和我一样的问题:一样的代码,在不同的环境上编译,出来的jar包有的能运行,有的却报错。
spring的维护人员可能是觉得循环依赖不应当在程序中出现,甚至目前springboot2.6版本已经完全不允许循环依赖了,所以对这个issue也就没有动力去解决。
如何解决
关于怎么解决spring的循环依赖问题,网络上一般就三种说法:
- 不使用构造器来注入bean
- 直接在field上添加@Autowired来注入
- 使用setter方法来注入
总体来讲,以上方法都不推荐。
- 直接在field上添加@Autowired来注入,不使用构造器来注入bean,是最懒惰的做法。实际上从spring4.0开始就已经不再推荐使用field injection,IDEA也会给出warning。至于为什么不推荐field injection,文章很多,不赘述
- 使用setter方法来注入:setter方法注入的本意是表示这个bean对当前的bean是选择性依赖的,不提供也不影响bean的正常运行。这次我就是因为出现了循环依赖,而偷懒不想通过代码结构优化来解决,使用了setter方法来注入。但是目前看来实际上是有一定几率触发spring的bug,导致在不同环境编译,出来的jar包触发循环依赖报错的。
根本上解决
根本上的解决方案,就是不要出现循环依赖。只要不出现循环依赖,就不会有几率触发这个bug。从程序设计的角度来说,循环依赖无论如何也不是个很好的设计,如果程序里存在循环依赖,我们应当反思。
实际上spring早就为我们想好了什么才是良好的程序设计,只要我们遵循他们推荐的方式来注入bean:通过构造器注入Bean。
通过构造器注入Bean,有以下两个好处:
- 绝对杜绝循环依赖,因为这时候只要存在循环依赖,必然会报BeanCurrentlyInCreationException。只要强制所有bean都通过构造器注入,我们的程序可以从根本上杜绝循环依赖。
- 更能暴露出耦合性问题:往往随着业务开发,bean注入的依赖越来越多。如果使用Field注入,往往这个问题会被忽视。但是当我们使用构造器注入时,越来越庞大的构造方法将使我们无法忽视这个问题:我们是不是违反了类的单一性原则?我们是不是违背了高内聚,低耦合的原则?这时候就是该反思程序的结构,做重构的时候了。
- 更利于mock测试,使用field注入,单元测试就会依赖spring容器来注入依赖,但是使用构造器注入bean时,可以脱离spring而直接构造。
实在没办法的办法
如果真的循环依赖无法避免,那就在注入的setter上添加@Lazy注解,进行懒加载。但是这个方法可能还是会触发SPR-14307这个Bug(由于目前尚未找到bug原因,实际上任何循环依赖都有可能触发这个bug,有不确定性)。