場景重現
問題初現
今天項目編譯上線出現一個問題,項目啟動時,報了:
***************************
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,有不確定性)。