1。前言这两天遇到了一个很有意思的Spring的循环依赖问题,不过这个和以往遇到的循环依赖问题不太一样。Hidden比较隐蔽,网上很少看到其他人遇到类似的问题。这里姑且称他为非典型的Spring循环依赖问题吧。但是我相信,我绝对不是第一个踩这个坑的,也绝对不会是最后一个。可能是因为踩过的人比较少,记录也很少。所以这里权记录下这个坑,以供后人查阅。就像鲁迅(我)说的,“这个世界上没有陷阱,踩的人多了,就会变成陷阱”。2、典型场景很多人在查看别人的代码时,经常会听到这样的评价:“你设计的时候,这些类之间怎么会存在循环依赖,你这样做会报错的!”。其实这句话的前半句肯定没有错。循环依赖的出现确实是一个设计问题。理论上,循环依赖应该是分层的,把公共的部分抽出来,然后每个功能类都依赖公共的部分。但是在复杂的代码中,各个管理器类相互调用过多,一不小心总会出现一些类之间循环依赖的问题。但是有时候我们发现,在使用Spring进行依赖注入的时候,虽然bean之间存在循环依赖,但是代码本身却有很高的概率可以正常工作,而且似乎没有什么bug。很多敏感的同学,心里肯定有些愧疚。怎么会发生这种违反因果律的事情呢?是的,这一切其实并不是那么自然。3、什么是依赖其实,不管场景如何,说A依赖B是不够准确的,至少不够详细。我们可以简单地定义什么是依赖。所谓A依赖B,可以理解为A中某些功能的实现需要调用B中的其他功能来实现。这里也可以拆分成两种意思:A强依赖B。创建一个A本身的实例需要B参与。在现实生活中,就像你的母亲生下你一样。A弱依赖B,创建A的实例不需要B参与,但A需要调用B的方法实现功能。相比之下,在现实生活中,就像男耕女织。那么,所谓的循环依赖其实有两个意思:强依赖之间的循环依赖。弱依赖之间的循环依赖。说到这个层面,我想大家应该知道我想说什么了。4.什么是依赖调解?对于强依赖,A和B不能成为彼此存在的前提,否则宇宙会爆炸。因此,这种依赖性目前是不可调和的。对于弱依赖,A和B的存在没有前提条件,A和B只是互相配合。所以,一般情况下,是不会违反因果律的。那么循环依赖的中介是什么?我的理解是:把原来是弱依赖的两个误改回弱依赖,变成强依赖的过程。基于以上分析,我们基本知道Spring是如何调解循环依赖的(只有弱依赖,只有上帝才能自动调解强依赖)。5、为什么要靠注入?经常在网上看到很多介绍IOC容器的科普文章。大多数人只是将IOC容器实现为“存储bean的映射”,将DI实现为“将bean分配给类中的字段”。其实很多人都忽略了DI的依赖中介的作用。而帮助我们进行依赖调解本身就是我们使用IOC+DI的一个重要原因。在没有依赖注入的年代,很多人会通过构造函数来传递类之间的依赖(实际上构成了强依赖)。当项目越来越大时,很容易产生无法调和的循环依赖。这时候开发者就被迫重新抽象,很麻烦。其实我们之所以把原来的弱依赖变成强依赖,完全是因为我们把类构造、类配置、类初始化逻辑这三个功能耦合到了构造函数中。而DI就是帮助我们解耦构造函数的功能。那么Spring是如何解耦的呢?6、Spring的依赖注入模型,网上有很多相关的内容。我的理解是关于上面提到的三个步骤:类构造,调用构造函数,解析强依赖(通常无参构造),创建类实例。类配置,根据Field/GetterSetter中的依赖注入相关注解,解析弱依赖,填写所有需要注入的类。类的初始化逻辑调用生命周期中的初始化方法(如@PostConstruct注解或InitializingBean的afterPropertiesSet方法),执行真正的初始化业务逻辑。这样构造函数的功能就从原来的三个弱化为一个,只负责类的构造。并且类的配置交给了DI,类的初始化逻辑交给了生命周期。想到这一层,顿时解决了一直卡在心里的问题。刚开始学习Spring的时候一直想不通:为什么Spring除了构造函数之外,还要在Bean的生命周期中多一个初始化方法?这种初始化方法和构造函数有什么区别?为什么Spring建议把初始化逻辑写在生命周期的初始化方法中?现在结合依赖中介,解释的很清楚了:为了进行依赖中介,Spring在调用构造函数的时候不注入依赖。也就是说,通过DI注入的bean不能在构造函数中使用(也许可以,但Spring不保证这一点)。如果在构造函数中不使用dependency-injectedbean而只使用构造函数中的参数,虽然没有问题,但这会导致bean强依赖它的输入bean。当循环依赖随之而来时,没有办法进行调解。7.非典型问题结论?根据以上分析,我们应该有以下共识:通过构造函数传递依赖的做法可能会造成无法自动调解的循环依赖。完全通过Field/GetterSetter依赖注入引起的循环依赖可以完全自动调解。所以这样我得出了一个我认为是正确的结论。这个结论一直在反复试验,直到我发现了我这次遇到的场景:在Spring中对bean注入依赖时,在纯粹考虑循环依赖的情况下,只要不使用构造函数注入,就永远不会出现不可调和的循环依靠。当然,我并不是说任何“不推荐构造函数注入”。相反,我认为能够“优雅地使用构造函数注入而不引入循环依赖”是一种要求更高、更优雅的方法。实现这种方式需要更高的抽象能力,自然会解耦各种功能。问题简化如下(以下类在同一个包中):@SpringBootApplication@Import({ServiceA.class,ConfigurationA.class,BeanB.class})publicclassTestApplication{publicstaticvoidmain(String[]args){SpringApplication.run(TestApplication.class,args);}}publicclassServiceA{@AutowiredprivateBeanAbeanA;@AutowiredprivateBeanBeanB;}publicclassConfigurationA{@AutowiredpublicBeanBbeanB;@BeanpublicBeanAbeanA(){returnnewBeanA();}}publicclassBeanAbeanAbe{}public}首先声明我没有使用@Component、@Configuration等注解,但是为了方便指定bean的初始化顺序,使用了@Import手动扫描bean。Spring会按照我@Import的顺序依次加载bean。同时,在加载每个bean的时候,如果这个bean有需要注入的依赖,就会尝试去加载它所依赖的bean。简单梳理一下,整个依赖链大致是这样的:我们可以发现BeanA、BeanB、ConfigurationA之间存在循环依赖,但是不要慌,所有的依赖都是通过非构造函数注入实现的,貌似是理论上可以自动调解。但实际上,这段代码会报如下错误:这显然是Spring无法调解的循环依赖。这已经有点奇怪了。但是,如果你尝试调换ServiceA类中声明的BeanA和BeanB的位置,你会发现这段代码突然起作用了!!!显然,调换这两个bean的依赖顺序的本质就是调整Spring加载bean的顺序(众所周知,Spring创建的bean是单线程的)。说明一下,相信你已经找到问题所在了,是的,问题的症结就出在ConfigurationA配置类上。配置类和普通bean的一个区别是,除了作为bean来管理之外,配置类还可以在内部声明其他bean。这样一来,就有问题了。配置类中声明的其他bean的构建过程,其实也是配置类业务逻辑的一部分。也就是说,只有满足配置类的所有依赖,我们才能创建自己声明的其他bean。(如果不加这个限制,那么在创建其他自己声明的bean时,如果使用自己的依赖,有空指针的风险。)这样,BeanA就不再是对ConfigurationA的弱依赖,而是一个真正的deal强依赖(也就是说ConfigurationA的初始化不仅会影响BeanA的依赖填充,还会影响BeanA的实例构造)。有了这样的认识,我们来分别分析一下这两个初始化路径。先加载BeanASpring在尝试加载ServiceA时,先构造ServiceA,然后发现它依赖于BeanA,于是尝试加载BeanA;Spring要构造BeanA,但是发现BeanA在ConfigurationA内部,于是尝试再次加载ConfigurationA(此时BeanA还没有构造);Spring构造了一个ConfigurationA的实例,然后发现它依赖于BeanB,于是尝试去加载BeanB。Spring构造了一个BeanB的实例,然后发现它依赖于BeanA,于是尝试加载BeanA。Spring发现BeanA还没有被实例化,Spring发现自己回到了第2步。。.GG。..先加载BeanBSpring在尝试加载ServiceA时,先构造ServiceA,然后发现它依赖于BeanB,于是尝试加载BeanB;Spring构造了一个BeanB的实例,然后发现它依赖于BeanA,于是尝试加载BeanA。Spring发现BeanA在ConfigurationA里面,于是尝试去加载ConfigurationA(此时BeanA还没有构造好);Spring构造了一个ConfigurationA的实例,然后发现它依赖于BeanB,而BeanB的一个实例已经存在,于是将这个依赖填充到ConfigurationA中。Spring发现ConfigurationA已经构造完成,并填充了依赖,所以它记得构造BeanA。Spring发现BeanA已经有了一个实例,于是把它交给了BeanB,BeanB填充的依赖就完成了。Spring回到为ServiceA填充依赖的过程中,发现BeanA还存在依赖,于是又将BeanA填充给ServiceA。Spring成功完成了初始化操作。结论总结这个问题,结论是:除了构造注入造成的强依赖外,一个Bean还强依赖暴露它的配置类。糟糕的代码味道写到这里,我已经觉得有点恶心了。谁在写代码的时候没事做还需要这样分析依赖关系,太容易出锅了!那么有什么办法可以避免分析这个恶心的问题呢?其实是有办法的,那就是遵守如下代码规范——不要对@Configuration注解的配置类进行Field级依赖注入。没错,配置类的依赖注入几乎相当于给配置类中的所有bean都添加了强依赖,大大增加了不可调和循环依赖的风险。我们应该尽可能的减少依赖,所有的依赖只能直接依赖真正需要的bean。
