淺談如何快速看懂原始碼?仰賴兩道架構思維(以 Spring Framework 為例)

如果不能用一個個的細胞組出一朵花,那為什麼我們又會想要一行一行程式碼地去理解複雜系統的原始碼呢?這篇文提出開放原始碼最系統性的分析及快速理解架構的兩道關鍵思維(意圖 x 問題)。
欸水球!在讀原始碼的時候,總會覺得特別傷眼,一直盯著一堆密密麻麻的程式,而且看到一半還會忘記自己到底看了什麼,那就更別提要貢獻開源專案了。水球你到底是如何在短時間內準備這份演講的呢?

這是某位會眾在我 JCConf “Spring Framework IoC 原始碼設計” 演講結束後的一個提問。

於是我決定今天來分享一下我認為「快速掌握原始碼設計的必要能力」,而這主要仰賴了「兩道架構思維」,我們就以 Spring Framework 的原始碼為例吧!

如果想要高效率地吸收原始碼(理解/分析/歸納洞見)和化為輸出 (貢獻/複刻系統/整合原始碼),那麼你可以參考一下我提出的套路。

一、如果不能用細胞組出一朵花,那就不能用「每一行程式」去理解一個系統

試想一下,如果你看見了一朵極為鮮美但構造極為複雜、精緻且十分稀有的花朵,而你想製造出這朵花來送給自己心儀的對象,那你該怎麼辦呢?

此時你會花大把時間去研究那朵花的每一個細胞嗎?甚至拿著「鑷子」把花中的每一塊細胞一個一個細胞夾起來,然後再把這些細胞一顆一顆疊起組出另一朵花嗎?當然不可能,這實在是太不可思議。

如果不能用一個個的細胞組出一朵花,那為什麼我們又會想要一行一行程式碼地去理解複雜系統的原始碼呢?細胞無法組出有機生命體,每一行程式也無法在你的腦中組出有意義的設計脈絡。很顯然地,若只花費心力讀每一行程式碼,基本上會是徒勞無功。

如果你想要造出這朵花,那做法只能是先取得「種子 (seed)」,然後再讓這朵花從種子中自然誕生。是的,如果你想要掌握原始碼的設計脈絡,那就得先思考「能使大量原始碼誕生的『基因』」究竟是什麼。

這個段子是 Christopher Alexander 設計模式起源著作者中的一個段子。

二、掌握原始碼的「基因」:展開兩道架構思維(意圖x問題)

既然我們不該直接陷入系統的程式細節,那該花時間在掌握什麼呢?

我認為要把精力放在掌握原始碼的設計脈絡,可能會是 What → How → Why → What → How → Why 的理解過程。好比「系統的使用案例有哪些? (What)」→「它提供了什麼 API 來允許用戶完成某個使用案例?(What/How)」→「原始碼如何圍繞在這些使用案例來設計? (How)」→「為什麼要這樣設計? (Why)」。

圍繞在 What→How→Why 而不一頭陷入逐行程式的細節,才能先以高階觀點避開雜訊、快速定位。不過,What/How/Why 僅代表三種高階觀點,並不是一個「容易模仿的套路」,還很仰賴個人的思考習慣。

所以接著我要分享兩道套路,如下:

  1. 針對 What/How 的部分,從「高層次的意圖」開始由高至低走訪原始碼,並順手繪製類別圖——凡走過必留下意圖。
  2. 針對 Why 的部分,在類別圖上整理設計情境 (Context),並反推面臨的設計難題 (Problem)——凡想過必留下洞見。

這兩個分析套路各代表一道「架構思維」、兩者可同時進行。

前者分析出「意圖衍生的軟體架構」,後者則分析出「問題架構」。

掌握這兩種架構就如同掌握了原始碼設計的基因,因為每個系統都一定是誕生於「一連串的意圖 + 一連串的問題」才逐漸發展成至今數十萬、數百萬行的程式規模。這也便是為啥我稱之為掌握原始碼的基因,是大量資訊的濃縮,幫助自己快速定位和洞悉設計。


「意圖衍生的軟體架構」和「問題架構」兩者相輔相成,並由於人腦更習慣在架構上思考,將關注點擺在同時去推演這兩種架構,思考會遠比在程式細節中思考來得高效。

三、分析套路 1:用「使用者意圖」展開「軟體架構」

軟體設計中的一個基礎元素是「意圖 (Intent)」,也可視為是元件/類別的「能力 (Capability)」,但我更喜歡稱之為意圖,畢竟所有類別都是衍生自使用者的意圖。意圖有高低層次之分,充斥在原始碼中。

了解意圖如何衍生就掌握了主要程式運行流程。

最高階的意圖正是「系統使用者的意圖」,也是系統的核心價值所在,好比 Web Framework 的主要價值就是提高 Web App 開發者的產能,讓開發者能專注在描述業務邏輯就好而非處理 Web 技術細節;Search Engine 的主要價值則是能讓使用者透過有彈性的查詢語法,高效率地查出客製化的搜尋結果。

以 Web Framework 來舉個例子,意圖層次「由高至低」分別會是:

  1. 該如何輕鬆地訂定我的 RESTFul API?
  2. 用 Controller 訂定 RESTFul API 時,該如何訂定好 Http method / URL 等規格?
  3. API 中如何自動序列化/解序列化 Http Request / Response body?
  4. 我該如何管理我 Controller 所需的依賴?
  5. 該如何自動化依賴注入組成所有的依賴?
  6. 該如何配置不同的依賴生命週期,好比 singleton 和 always create 等不同生命週期? 等等⋯⋯

順著意圖「由高至低」來去走訪原始碼,同時歸納這些意圖至對應的「模組/類別」中:首先從原始碼的「使用入口」會找到最高階意圖的模組/類別,通常會簡稱高階模組/類別,就像是飯店的櫃檯人員。接著觀察此類別依賴的第二層類別,就會是衍生意圖的類別,好比是幫你提行李上樓的小弟。

四、順著意圖層次前進,並同時用類別圖來記錄類別和依賴路線

順著意圖由高至低前進,那關注點就不再是去鑽研每一行程式碼,而是把「不同意圖」歸納到「不同類別」。

此時你一定要邊探索邊做紀錄,打開「專業的 UML Editor」,並繪製類別圖來做紀錄。把走訪過程中拜訪的類別們都依序捕捉至類別圖上,順著意圖路線去標註好每個類別的職責以及類別之間的依賴關係。

(非常不建議使用 draw.io、PlantUML 和 Mermaid,一定要用專業 UML Editor,不然會很痛苦。)

如此一來,你能快速把整條意圖的依賴路線摸清,以 Spring Framework 的 "IoC" 為例來歸納一下 IoC 容器的使用者意圖的話,最高層次的意圖為「自動化掃描依賴並做好依賴注入、管理依賴生命週期」

——那接著由高至低列出衍生意圖則可歸納出以下:

  1. IoC 容器的入口:AnnotationConfigApplicationContext
  2. 依賴 (Bean) 相關的掃描機制:ClassPathBeanDefinitionScanner、各種 Bean meta resolvers、Bean Definition 等等
  3. IoC 容器的配置和預設行為:AnnotationConfigApplicationContext 的 scan & refresh 方法
  4. IoC 容器的具體實作:DefaultListableBeanFactory
  5. 取得 or 創建依賴的機制:DefaultListableBeanFactory 的 getBean & createBean 方法
  6. createBean 時建構子的選擇策略:AnnotationBeanPostProcessor, ConstructorResolver。等等。

邊讀邊畫類別圖,那以上述為例的話,我最後就能畫出如下圖的類別圖。


五、觀察:複雜系統的設計都必定要遵守「依賴反轉原則 (DIP)」

若把這些類別/介面們之間畫上依賴關係,那在這條依賴路線上,你會反反覆覆經歷「具體類別 → 介面 ← (抽象類別 ← 具體類別) → 介面 ← (抽象類別 ← 具體類別)⋯⋯」的依賴模式(注意我的箭頭方向)。

這是因為高複雜度的軟體設計都必須高度遵守「依賴反轉原則 (Dependency Inversion Principle, DIP)」 !

這個原則要求的是「依賴的方向應從低階往高階的方向依賴,並且高階邏輯不被低階邏輯影響」。很剛好地,DIP 中所說的「高階/低階」可直接對應到使用者意圖。做好 DIP 的目的是在程式中保持擴充性(這是另一個話題,改天再聊)。

六、有了意圖衍生的軟體架構後,接著定位自己的意圖來下去「動手拼拼圖」

做完這步、繪製出初版類別圖後,就會掌握程式運行的脈絡和主要類別之間的依賴。此時回想一下,你自己的意圖又是什麼呢?你是想要整合這份原始碼的哪一個部分呢?是他某個演算法嗎?還是想修理某一部分的 Bug?

既然你一開始就是以「意圖」來從系統入口展開這份軟體架構,現在只要你把自己的意圖給定清楚了,就能在類別圖上找到對應的意圖、快速定位「你該研究」的模組/類別有哪些。定位後再下去仔細閱讀原始碼,就能省掉很多在細節中打轉的時間。

就像拼拼圖般,一定是不斷地把新的拼圖拼進已有的拼圖上,而不是拼一片就打破一片、拼一片就打破一片、毫無進展。這張類別圖就等同於你既有的拼圖,接下來你想要拼的究竟是哪一片就會顯而易見。

只要找到了新的重要的相關拼圖,就能把它拼回去類別圖——進一步記錄更多有重要意義的屬性、行為概述和演算法等等,進一步整理重要類別的職責。如此一來就肯定能一直「所見即所得」且創造大量進展,永不迷失方向、也不浪費時間。

到這就算是把第一項「使用者意圖衍生的架構式思維」做得很好,凡走過必留下意圖。

七、分析套路 2:從軟體架構上回推「問題架構」來記住設計洞見

這樣還不夠,如果你想從原始碼設計中領悟更多「設計洞見」,想站在巨人的肩膀上來一氣呵成地加強自己的設計能力,那你就要接著釐清「問題架構」。

「釐清問題架構」指的是試著去回推當初在這份原始碼在 0\~1 開發階段時,究竟是遇到了哪些設計問題才提出了這些設計決策?

由於設計問題也有層級之分、大問題可被切割成小問題、解決某問題後還會有衍生問題,所以能展開所謂的「問題架構」。

「釐清問題架構後,你才能增加自己理解的『質』,而不只是『量』,真的下去做原始碼貢獻時,才不會到處踩雷。」

那在程式設計中,什麼叫「問題」?主要是涉及到諸多非功能性需求 (non-functional requirement) 的議題,好比希望能在某方面做到極大的彈性、在某部份想大幅避免 overhead、亦或是希望能在某個功能上做到極好的易用性,更或是綜合以上的 tradeoffs 等等。

發展到後期後,還有可能會是多種不同功能需求彼此衝突、使你改不動的問題,這類問題是複雜系統中最有價值的問題。

試著察覺這些問題,並描述好這些問題究竟落到了哪些模組或類別上,便是在軟體架構上去釐清問題架構的基本功。

「問題架構」是說得比做得容易,因為設計問題往往是多道約束扭打在一塊、混淆不清,使得最後我們所描述出的「問題」往往顆粒度過粗。

好比一個粗顆粒度的問題/解法描述聽起來就像是:「為什麼你沒錢?因為我不努力」這種粗淺的描述。但是「為什麼你不努力?」這背後卻有非常多因素彼此衝突,使你「有理說不清」。

複雜系統的原始碼中涉及的設計議題往往顆粒度十分地細,非常非常地細,必須用更細的顆粒度來定義問題。

八、用「約束條件」來去定義「問題」,才能帶走更細膩的原始碼設計洞見

舉個例子,好比 Spring Framework 中的 IoC 容器入口類別 “AnnotationConfigApplicationContext” 有 「Scan 和 Refresh」 這兩個方法。那接著你就要去想「這是為什麼?」、「是受制於什麼需求才最後得到這份設計?」「有必要提供 Refresh 嗎?只有 Scan 不就好了嗎?」。

好比想說,提供 ”Scan” 是為了表明 IoC 容器能幫忙「掃描所有的依賴」,那 Refresh 呢?是因為有可能開發者會想要在掃描完畢後,還馬後砲地更新依賴配置嗎?難道我不能再呼叫一次 Scan 就好嗎?

邊思考邊筆記,直到收斂完畢後,就把這些條件寫下來,標示在 “AnnotationConfigApplicationContext” 類別旁:

  1. 意圖:自動化掃描來實現依賴注入,以及管理依賴生命週期。
  2. 約束條件 1:開發者希望容器能自動化地從類路徑上直接「掃描」所有符合條件的「依賴」。
  3. 約束條件 2:開發者可能會想要在「掃描完畢後」還馬後砲地更新依賴配置,必須要讓開發者自主決定什麼時候才算是「真的完成了掃描」。
  4. 約束條件 3:掃描完畢後,會從事一連串很耗能的自動化依賴注入,所以要讓開發者盡可能意識到要減少這部分重複的運算。
  5. 解法:Scan & Refresh,呼叫 Scan 我就幫你掃描、呼叫 Refresh 我就當作你掃描完畢,開始執行耗能的自動化依賴注入。
  6. 解法詳細描述:<類別圖>

當你把這些約束條件寫下來之後,你就算是嚴謹地定義了一則「設計問題」和「對應解法」。

你覺得這種描述方式很像什麼?就是在軟體中常常會提到的「設計模式 (Design Pattern)」,不過這並不是「四人幫設計模式」的描述方式,這種多了「約束條件」的模式是更經典的 “Christopher Alexander 設計模式”。

在 Christopher Alexander 筆下,約束力被稱之為 Force,並主張能用一組互相衝突的 Forces 來定義 Problem。

做到這裡,你應該會發現自己就算不記得一大堆的程式碼細節,但至少也會有個印象是「Scan & Refresh 模式」是怎麼演化而來的,雖然是自己假定出來的「問題」,但只要你能自圓其說,和列出足夠多約束條件,基本上就對「設計洞見」的掌握有很強的基礎。


九、同時兼具這兩道架構思維的通用框架:「模式語言 (Pattern Language)」


要握有種子般的力量,必須兼具「意圖 x 問題」兩種架構,而 Christopher Alexander 提出的「模式語言 (Pattern Language)」 再配合著類別圖,就構成這兩種架構的集合。

「模式語言」為一組相關模式的集合 (a coherent set of patterns)。模式是什麼呢?好比上述的 Scan & Refresh 就是一個完整的設計模式,他為特定問題提出特定解。每個模式都會以 Context / Problem / Forces / Solution(Form) 去描述,而嚴格一點的 Canonical pattern form 則是以 Context / Problem / Forces / Solution(Form) / Resulting Context / Rationale 等要素來描述清楚模式的有效性,和套用模式的結果。

「模式語言」主張我們要能說出「模式的語言」。比如說把「模式」視為是「符號」,然後試著去找到符號之間的「文法」,就能組出無限多種句子。英文說溜了,你就快速地看好多篇艱深的英文文章、寫出無限多種英文詩篇;樂譜看溜了,你就能邊看譜邊彈琴、寫出無限多種樂譜。

「模式語言說溜了,你就能快速地理解或是設計出各種複雜系統的設計。」

先分享到這裡,再打下去就要好幾千字,先用比喻的方式分享「模式語言」的價值主張,改天再分享細節,各位可以先從我公開的簡報中去知曉一二!

十、總結:使用模式語言來高效率分析原始碼設計

總結以下幾點步驟是高效率分析原始碼設計的重點:

  1. 用以下兩道架構思維切入分析 (1) 使用者意圖衍生的軟體架構 (2) 問題架構。
  2. 順著使用者意圖層次走訪軟體架構,從入口(高層次類別)一路走到低層次類別,並把過程中的所有類別和依賴關係繪製於類別圖上。
  3. 有了有形的類別圖後,接著再順著自己的「意圖」定位對應的類別來開始研究原始碼,邊研究邊捕捉資訊回到類別圖上,保持「持續拼拼圖」的架構式思維。
  4. 若要帶走這份複雜原始碼的設計洞見,提升理解的「質」,則要列出每個部分設計是受制於何等「約束條件 (Forces)」,並整理成各個設計模式。(分享於簡報中)
  5. 順著「問題架構」整理出多個設計模式之後,就會自然而然找到模式之間的關係,此時能繪製出此份系統的模式語言。(分享於簡報中)


參考資源

課程宗旨很簡單:就是每週在線上接受軟設特訓,用大量題目和 Code Review 來讓你掌握複雜系統設計的高效率思路,最後輸出高強度的作品作為實力證明。

訂閱 水球軟體學院:部落格

立即訂閱水球軟體學院部落格的電子報,接收最前沿的軟體工程方法學問
jamie@example.com
訂閱