我試圖透過這篇文章,教會你一種閱讀原始碼的方式。

why技術發表於2023-03-27

你好呀,我是歪歪。

是的,正如標題描述的這樣,我試圖透過這篇文章,教會你如何閱讀原始碼。

事情大概是這樣的,前段時間,我收到了一個讀者發來的類似於這樣的示例程式碼:

他說他知道這三個案例的回滾情況是這樣的:

  • insertTestNoRollbackFor:不會回滾
  • insertTestRollback:會回滾
  • insertTest:會回滾

他說在沒有執行程式碼之前,他也知道前兩個為什麼一個不會回滾,一個會回滾。因為丟擲的異常和 @Transactional 裡面的註解呼應上了。

但是第三個到底會不會回滾,沒有執行之前,他不知道為什麼會回滾。執行之後,回滾了,他也不知道為什麼回滾了。

我告訴他:原始碼之下無秘密。

讓他去看看這部分原始碼,理解它的原理,不然這個地方丟擲一個其他的異常,又不知道會不會回滾了。

但是他說他完全不會看原始碼,找不到下手的角度。

所以,就這個問題,我打算寫這樣的一篇文章,試圖教會你一種閱讀原始碼的方式。讓你找到一個好的切入點,或者說突破口。

但是需要事先說明的是,閱讀原始碼的方式非常的多,這篇文章只是站在我個人的角度介紹閱讀原始碼的眾多方式中的一種,滄海一粟,就像是一片樹林裡面的一棵樹的樹幹上的一葉葉片的葉脈中的一個小分叉而已。

對於啃原始碼這件事兒,沒有一個所謂的“一招吃遍天下”的秘訣,如果你非要讓我給出一個秘訣的話,那麼就只有一句話:

啃原始碼的過程,一定是非常枯燥的,特別是啃自己接觸不多的框架原始碼的時候,千頭萬緒,也得下手去捋,所以一定要耐得住寂寞才行。

然後,如果你非得讓我再補充一句的話,那麼就是:

除錯原始碼,一定要親!自!動!手!只是去看相關的文章,而沒有自己一步步的去除錯原始碼,那你相當於看了個寂寞。

親自動手的第一步就是搞個 Demo 出來。用“黑話”來說,這個 Demo 就是你的抓手,有了抓手你才能打出一套理論結合實際的組合拳。抓手多了,就能沉澱出可複用的方法論,最終為自己賦能。

搭建 Demo

所以,第一步肯定是先把 Demo 給搭建起來,專案結構非常的簡單,標準的三層結構:

主要是一個 Controller,一個 Service,然後搞個本地資料庫給接上,就完全夠夠的了:

Student 物件是從表裡面對映過來的,隨便弄了兩個欄位,主要是演示用:

就這麼一點程式碼,給你十分鐘,你是不是就能搭建好了?中間甚至還能摸幾分鐘魚。

要是隻有這麼一點東西的、極其簡單的 Demo 你都不想自己親自動手搭一下,然後自己去除錯的話,僅僅是透過閱讀文章來肉眼除錯,那麼我只能說:

在正式開始除錯程式碼之前,我們還得明確一下除錯的目的:想要知道 Spring 的 @Transactional 註解對於異常是否應該回滾的判斷邏輯具體是怎麼樣的。

帶著問題去除錯原始碼,是最容易有收穫的,而且你的問題越具體,收穫越快。你的問題越籠統,就越容易在原始碼裡面迷失。

方法論之關注呼叫棧

自己 Debug 的過程就是不斷的打斷點的過程。

我再說一次:自己 Debug 的過程就是不斷的打斷點的過程。

打斷點大家都會打,斷點打在哪些地方,這個玩意就很講究了。

在我們的這個 Demo 下,第一個斷點的位置非常好判斷,就打在事務方法的入口處:

一般來說,大家除錯業務程式碼的時候,都是順著斷點往下除錯。但是當你去閱讀框架程式碼的時候,你得往回看。

什麼是“往回看”呢?

當你的程式在斷點處停下的時候,你會發現 IDEA 裡面有這樣的一個部分:

這個呼叫棧是你在除錯的過程中,一個非常非常非常重要的部分。

它表示的是以當前斷點位置為終點的程式呼叫鏈路。

為了讓你徹底的明白這句話,我給你看一張圖:

我在 test6 方法中打上斷點,呼叫棧裡面就是以 test6 方法為終點到 main 方法為起點的程式呼叫連結。

當你去點選這個呼叫棧的時候,你會發現程式也會跟著動:

“跟著動”的這個動作,你可以理解為你站著斷點處“往回看”的過程。

當你理解了呼叫棧是幹啥的了之後,我們再具體看看在當前的 Demo 下,這個呼叫棧裡面都有寫啥:

標號為 ① 的地方,是 TestController 方法,也就是程式的入口。

標號為 ② 的地方,從包名稱可以看出是 String AOP 相關的方法。

標號為 ③ 的地方,就可以看到是事務相關的邏輯了。

標號為 ④ 的地方,是當前斷點處。

好,到這裡,我想讓你簡單的回顧一下你來除錯程式碼的目的是什麼?

是不是想要知道 Spring 的 @Transactional 註解對於異常是否應該回滾的判斷邏輯具體是怎麼樣的。

那麼,我們是不是應該主要把關注的重點放在標號為 ③ 的地方?

也就是對應到這一行:

這個地方我一定要特別的強調一下:要保持目標清晰,很多人在原始碼裡面迷失的原因就是不知不覺間被原始碼牽著走遠了。

比如,有人看到標號為 ② 的部分,也就是 AOP 的部分,一想著這玩意我眼熟啊,書上寫過 Spring 的事務是基於 AOP 實現的,我去看看這部分程式碼吧。

當你走到 AOP 裡面去的時候,路就開始有點走偏了。你明白我意思吧?

即使在這個過程中,你翻閱了這部分的原始碼,確實瞭解到了更多的關於 AOP 和事務之間的關係,但是這個部分並不解決你“關於回滾的判斷”這個問題。

然而更多更真實的情況可能是這樣的,當你點到 AOP 這部分的時候,你一看這個類名稱是 CglibAopProxy:

你一細嗦,Cglib 你也熟悉啊,它和 JDK 動態代理是一對好兄弟,都是老八股了。

然後你可能又會點選到 AopProxy 這個介面,找到 JdkDynamicAopProxy:

接著你恍然大悟:哦,我在什麼都沒有配置的情況下,當前版本的 SpringBoot 預設使用的是 Cglib 作為動態代理的實現啊。

誒,我怎麼記得我背的八股文預設是使用 JDK 呢?

網上查一下,查一下。

哦,原來是這麼一回事兒啊:

  • SpringBoot 1.x,預設使用的是 JDK 動態代理。
  • SpringBoot 2.x 開始,為了解決使用 JDK 動態代理可能導致的型別轉化異常而預設使用 CGLIB。
  • 在 SpringBoot 2.x 中,如果需要預設使用 JDK 動態代理可以透過配置項spring.aop.proxy-target-class=false來進行修改,proxyTargetClass配置已無效。

剛剛提到了一個 spring.aop.proxy-target-class 配置,這是個啥,咋配置啊?

查一下,查一下...

喂,醒一醒啊,朋友,走遠了啊。還記得你除錯原始碼的目的嗎?

如果你對於 AOP 這個部分感興趣,可以先進行簡單的記錄,但是不要去深入的追蹤。

不要覺得自己只是隨便看看,不要緊。反正正是因為這些“隨便看看”導致你在原始碼裡面忙了半天感覺這波學到了,但是停下來一想:我 TM 剛剛看了些啥來著?我的問題怎麼還沒解決?

我為什麼要把這部分非常詳盡,甚至於接近囉嗦的寫一遍,就是因為這個就是初看原始碼的朋友最容易犯的錯誤。

特別強調一下:抓住主要矛盾,解決主要問題。

好,回到我們透過呼叫棧找到的這個和事務相關的方法中:

org.springframework.transaction.interceptor.TransactionInterceptor#invoke

這個方法,就是我們要打第二個斷點,或者說這才是真正的第一個斷點的地方。

然後,重啟專案,重新發起請求,從這個地方就可以進行正向的除錯,也就是從框架程式碼一步步的往業務程式碼執行。

比如這個方法接著往下 Debug,就來到了這個地方:

org.springframework.transaction.interceptor.TransactionAspectSupport#invokeWithinTransaction

找到了這個地方,你就算是無限的接近於問題的真相了。

這個部分我肯定會講的,但是在這裡先按下不表,畢竟這並不是本文最重要的東西。

本文最重要的是,我再次重申一遍:我試圖想要教會你一種閱讀原始碼的方式,讓你找到一個好的切入點,或者說突破口。

由於這個案例比較簡單,所以很容易找到真正的第一個利於除錯的斷點。

如果遇到一些複雜的場景、響應式的程式設計、非同步的呼叫等等,可能會迴圈往復的執行上面的動作。

分析呼叫棧,打斷點,重啟。

再分析呼叫棧,再打斷點,再重啟。

方法論之死盯日誌

其實我發現很少有人會去注意框架列印的日誌,就像是很少有人會去仔細閱讀原始碼上的 Javadoc 一樣。

但是其實透過觀察日誌輸出,也是一個很好的尋找閱讀原始碼突破口的方式。

我們要做的,就是保證 Demo 儘量的單純,不要有太多的和本次排查無關的程式碼和依賴引入。

然後把日誌級別修改為 debug:

logging.level.root=debug

接著,就是發起一次呼叫,然後耐著性子去看日誌。

還是我們的這個 Demo,發起一次呼叫之後,控制檯輸出了很多的日誌,我給你搞個縮圖看看:

我們已知的是這裡面大機率是有線索的,有沒有什麼方法儘量快的找出來呢?

有,但是通用性不強。所以如果經驗不夠豐富的話,那麼最好的方法就是一行行的去找。

前面我也說過了:啃原始碼的過程,一定是非常枯燥的。

所以你一定會找到這樣的日誌輸出:

Acquired Connection [HikariProxyConnection@982684417 wrapping com.mysql.cj.jdbc.ConnectionImpl@751a148c] for JDBC transaction
Switching JDBC Connection [HikariProxyConnection@982684417 wrapping com.mysql.cj.jdbc.ConnectionImpl@751a148c] to manual commit
...
==>  Preparing: insert into student ( name, home ) values ( ?, ? ) 
HikariPool-1 - Pool stats (total=1, active=1, idle=0, waiting=0)
==> Parameters: why(String), 草市街199號-insertTestNoRollbackFor(String)
<==    Updates: 1
...
Committing JDBC transaction on Connection [HikariProxyConnection@982684417 wrapping com.mysql.cj.jdbc.ConnectionImpl@751a148c]
Releasing JDBC Connection [HikariProxyConnection@982684417 wrapping com.mysql.cj.jdbc.ConnectionImpl@751a148c] after transaction

這幾行日誌,不就是正對應著 Spring 事務的開啟和提交嗎?

有了日誌,我們完全可以基於日誌去找對應的日誌輸出的地方,比如我們現在要找這一行日誌輸出對應的程式碼:

o.s.j.d.DataSourceTransactionManager : Acquired Connection [HikariProxyConnection@982684417 wrapping com.mysql.cj.jdbc.ConnectionImpl@751a148c] for JDBC transaction

首先,我們可以根據日誌知道對應輸出的類是 DataSourceTransactionManager 這個類。

然後找到這個類,按照關鍵詞搜尋:

不就找到這一行程式碼了嗎?

或者我們直接秉承大力出奇跡的真理,來一個暴力的全域性搜尋,也是能搜到這一行程式碼的:

再或者修改一下日誌輸出格式,把行號也搞出來嘛。

當我們把日誌格式修改為這樣之後:

logging.pattern.console=%d{dd-MM-yyyy HH:mm:ss.SSS} %magenta([%thread]) %highlight(%-5level) %logger.%M:%L - %msg%n

控制檯的日誌就變成了這樣:

org.springframework.jdbc.datasource.DataSourceTransactionManager.doBegin:263 - Acquired Connection [HikariProxyConnection@1569067488 wrapping com.mysql.cj.jdbc.ConnectionImpl@19a49539] for JDBC transaction

很直觀的就看出來了,這行日誌是 DataSourceTransactionManager 類的 doBegin 方法,在 263 行輸出的。

然後你找過去,發現沒有任何毛病,這就是案發現場:

我前面給你說這麼多,就是為了讓你找到這一行日誌輸出的地方。

現在,找到了,然後呢?

然後肯定就是在這裡打斷點,然後重啟程式,重新發起呼叫了啊。

這樣,你又能得到一個呼叫棧:

然後,你會從呼叫棧中看到一個我們熟悉的東西:

朋友,這不就和前面寫的“方法論之關注呼叫棧”呼應起來了嗎?

這不就是一套組合拳嗎,不就是沉澱出的可複用的方法論嗎?

黑話,我們們也是可以整兩句的。

方法論之檢視被呼叫的地方

除了前面兩種方法之外,我有時候也會直接看我要閱讀部分的方法,在框架中被哪些地方呼叫了。

比如在我們的 Demo 中,我們要閱讀的程式碼非常的明確,就是 @Transactional 註解。

於是直接看一下這個註解在哪些地方用到了:

有的時候呼叫的地方會非常的少,甚至只有一兩處,那麼直接在呼叫的地方打上斷點就對了。

雖然 @Transactional 註解一眼望去也是有很多的呼叫,但是仔細一看大多是測試類。排除測試類、JavaDoc 裡面的備註和自己專案中的使用之後,只剩下很明顯的這三處:

看起來很接近真相,但是很遺憾,這裡只是在專案啟動的時候解析註解而已。和我們要調研的地方,差的還有點遠。

這個時候就需要一點經驗了,一看苗頭不對,立馬轉換思路。

什麼是苗頭不對呢?

你在這幾個地方打上斷點了,只是在專案啟動的過程中斷點起作用了,發起呼叫的時候並沒有在斷點處停下,說明發起呼叫的時候並不會觸發這部分邏輯,苗頭不對。

順著這個思路想,在我的 Demo 中丟擲了異常,那麼 rollbackFor 和 noRollbackFor 這兩個引數大機率是會在呼叫的時候被用到,對吧?

所以當你去看 rollbackFor 被呼叫的時候只有我們自己寫的業務程式碼在呼叫:

怎麼辦呢?

這個時候就要靠一點運氣了。

是的,靠運氣。

你都點到 rollbackFor 這個方法來了,你也看了它被呼叫的地方,在這個過程中你大機率會瞟到幾眼它對應的 JavaDoc:

org.springframework.transaction.annotation.Transactional#rollbackFor

然後你會發現在 JavaDoc 裡面提到了 rollbackOn 這個方法:

org.springframework.transaction.interceptor.DefaultTransactionAttribute.rollbackOn(Throwable)

到這裡一看,你發現這是一個介面,它有好多個實現類:

怎麼辦呢?

早期的時候,由於不知道具體的實現類是哪個,我是在每個實現類的入口處都打上斷點,雖然是笨辦法,但是總是能起作用的。

後來我才發現,原來可以直接在介面上打斷點:

然後,重啟專案,發起呼叫,第一次會停在我們方法的入口:

F9,跳過當前斷點之後,來到了這個地方:

這裡就是我前面在介面上打的方法斷點,走到了這個實現類中:

org.springframework.transaction.interceptor.DelegatingTransactionAttribute

然後,關鍵的就來了,我們又有一個呼叫棧了,又從呼叫棧中看到一個我們熟悉的東西:

朋友,組合拳這不又打起來了?突破口不就又找到了?

關於“瞟到幾眼對應的 JavaDoc ,然後就可能找到突破口”的這個現象,早期對我來說確實是運氣,但是現在已經是一個習慣了。一些知名框架的 JavaDoc 真的寫的很清楚的,裡面隱藏了很多關鍵資訊,而且是最權威的正確資訊,讀官網文件,比讀技術部落格穩當的多。

探索答案

前面我介紹的都是找到程式碼除錯突破口的方法。

現在突破口也有了,接下來應該怎麼辦呢?

很簡單,除錯,反覆的除錯。從這個方法開始,一步一步的除錯:

org.springframework.transaction.interceptor.TransactionInterceptor#invoke

如果你真的想要有所收穫的話,這是一個需要你親自去動手的步驟,必須要有逐行閱讀的一個過程,然後才能知道大概的處理流程。

我就不進行詳細解讀了,只是把重點給大家畫一下:

框起來的部分,就是去執行業務邏輯,然後基於業務邏輯的處理結果,去走不同的邏輯。

拋異常了,走這個方法:completeTransactionAfterThrowing

正常執行完畢了,走這個方法:commitTransactionAfterReturning

所以,我們問題的答案就藏在 completeTransactionAfterThrowing 裡面。

繼續除錯,進入這個方法之後,可以看到它拿到了事務和當前異常相關的資訊:

在這個方法裡面,大體的邏輯是當標號為 ① 的地方為 true 的時候,就在標號為 ② 的地方回滾事務,否則就在標號為 ③ 的地方提交事務:

因此,標號為 ① 的部分就很重要了,這裡面就藏著我們問題的答案。

另外,在這裡多說一句,在我們的案例中,這個方法,也就是當前除錯的方法是不會回滾的:

而這個方法是會回滾的:

也就是這兩個方法在這個地方會走不同的邏輯,所以你在除錯的時候遇到 if-else 就需要注意,去構建不同的案例,以覆蓋儘量多的程式碼邏輯。

繼續往下除錯,會進入到標號為 ① 的 rollbackOn 方法裡面,來到這個方法:

org.springframework.transaction.interceptor.RuleBasedTransactionAttribute#rollbackOn

這裡,就藏著問題的終極答案,而且這裡面的程式碼邏輯相對比較的繞。

核心邏輯就是透過迴圈 rollbackRules,這裡面裝的是我們在程式碼中配置的回滾規則,在迴圈體中拿 ex,也就是我們程式丟擲的異常,去匹配規則,最後選擇一個 winner:

如果 winner 為空,則走預設邏輯。如果是 RuntimeException 或者是 Error 的子類,就要進行回滾:

如果有 winner,判斷 winner 是否是不用回滾的配置,如果是,則取反,返回 false,表示不進行回滾:

那麼問題的冠軍就在於:winner 怎麼來的?

答案就藏著這個遞迴呼叫中:

一句話描述就是:看當前丟擲的異常和配置的規則中的 rollbackFor 和 noRollbackFor 誰距離更近。這裡的距離指的是父類和子類之間的關係。

比如,還是這個案例:

我們丟擲的是 RuntimeException,它距離 noRollbackFor=RuntimeException.class 為 0。RuntimeException 是 Exception 的子類,所以距離 rollbackFor = Exception.class 為 1。

所以,winner 是 noRollbackFor,能明白吧?

然後,我們再看一下這個案例:

根據前面的“距離”的分析,NullPointerException 是 RuntimeException 的子類,它們之間的距離是 1。而 NullPointerException 到 Exception 的距離是 2:

所以,rollbackFor=RuntimeException.class 這個的距離更短,所以 winner 是 rollbackFor。

而把 winner 放到這個判斷中,返回是 true:

return !(winner instanceof NoRollbackRuleAttribute);

所以,這就是它為什麼會回滾的原因:

好了,到這裡你有可能是暈的,暈就對了,去除錯這部分程式碼,親自摸一遍,你就搞的明明白白了。

最後,再給“死盯日誌”的方法論打個補丁吧。

前面我說了,日誌級別調整到 Debug 也許會有意外發現。現在,我要再給你說一句,如果 Debug 沒有查到資訊,可以試著調整到 trace:

logging.level.root=trace

比如,當我們調整到 trace 之後,就可以看到“ winner 到底是誰”這樣的資訊了:

當然了,trace 級別下日誌更多了。

所以,來,再跟我大聲的讀一遍:

啃原始碼的過程,一定是非常枯燥的,特別是啃自己接觸不多的框架原始碼的時候,千頭萬緒,也得下手去捋,所以一定要耐得住寂寞才行。

作業

我前面主要是試圖教你一種閱讀原始碼時,尋找突破點的技能。這個突破點,說白了就是第一個有效的斷點到底應該打在哪裡。

你用前面我教的方法,也能把 @Cacheable 和 @Async 都玩明白。因為它們的底層邏輯和 @Transactional 是一樣的。

所以,現在佈置兩個作業。

拿著這套組合拳,去上手玩一玩 @Cacheable 和 @Async 吧,沉澱出屬於自己的方法論。

@Cacheable:

@Async:

最後,再附上幾個我之前寫過的文章,裡面也用到了前面提到的幾個方法定位原始碼,老舒服了。有興趣可以看看:

《我是真沒想到,這個面試題居然從11年前就開始討論了,而官方今年才表態。》

《確實很優雅,所以我要扯下這個註解的神秘面紗。》

《關於Request複用的那點破事兒。研究明白了,給你彙報一下。》

《千萬千萬不要在方法上打斷點!太坑了!》

好了,本文就到這裡啦。如果你覺得對你有一絲絲幫助的話,求個免費的贊,不過分吧?

相關文章