啟動軟體專案時,我們永遠無法預知使用者實際使用後會丟來哪些需求。軟體專案總伴隨著冒險與有根據的猜測(我們愛稱之為「假設」以顯得專業)。專案環境太多變,無法事先得知一切會如何演變——這份多變正是敏捷(Agile)運動誕生的原因,敏捷實務讓組織有足夠彈性適應改變。
那麼,如何打造一個能應付這種敏捷環境的軟體架構?若一切隨時可變,我們還該費心搞架構嗎?
該。如第 1 章所述,我們應確保軟體架構促成可維護性。可維護的程式庫能隨時間演進、適應外部因素。六角架構朝可維護性邁出一大步:它在應用與外界之間建立邊界,六邊形內是領域程式碼、向外提供專屬 port,port 連接到與外界對話的 adapter。只要 port 不變,我們就能演進應用內部的任何東西以回應敏捷環境的變化。
但如第 13 章所學,六角架構無法幫我們在應用核心內部建立邊界,這方面我們可能想在核心內套用另一種架構。此外,作者也常聽到「六角架構感覺很難」,尤其對剛起步的專案——不是每個人都理解依賴反轉、以及領域模型與外界之間對映的價值,難以說服整個團隊上船。對羽翼未豐的應用,六角架構可能殺雞用牛刀。
對這類情況,不妨先從更簡單的架構風格起步:它仍提供我們所需的模組性、足以日後演進成別的架構,又簡單到能讓每個人上船。作者認為**以元件為基礎的架構(component-based architecture)**是不錯的起點,本章就來討論它。
透過元件達成模組性#
可維護性的驅動因素之一是模組性(modularity)。模組性讓我們把軟體系統的複雜度,藉由切分成更簡單的模組來征服:
- 不必理解整個系統,就能在某個特定模組上工作——只需聚焦該模組、以及與它介接的模組。
- 只要模組間的介面定義清晰,模組就能大致獨立演進。
- 我們大概能把一個模組的心智模型裝進工作記憶;但若程式庫中根本沒有模組,要建立心智模型就難了,只能在程式碼中無助地跳來跳去。
唯有模組性讓人類得以建造複雜系統。Dave Farley 在《Modern Software Engineering》中談到阿波羅太空計畫的模組性:每個元件能聚焦問題的一部分、設計上少做妥協;不同團隊(此例中甚至是完全不同的公司)只要在「模組之間如何介接」上達成共識,就能大致獨立地解決各自模組的問題。模組性讓我們登上月球,也讓我們造車、造飛機、蓋大樓——它幫助我們建構複雜軟體,自不令人意外。
但什麼是「模組」?作者覺得這個詞在(物件導向)軟體開發中被過度濫用了,阿貓阿狗都叫「模組」,哪怕只是一堆隨意湊在一起做點有用事的類別。作者偏好用「元件(component)」來描述「一組經深思熟慮設計、用以實作某功能、且能與其他類別群組合成複雜系統的類別」。
「組合(composition)」這個面向意味著元件能組合成更大的整體,甚至能重新組合以回應環境變化。可組合性要求元件定義清晰的介面,說明它對外提供什麼、需要什麼(聽起來像不像輸入與輸出 port?)。想想樂高積木:一塊樂高提供特定排列的凸點供其他積木接上,也需要特定排列的凸點才能接上別的積木。你愛用「模組」一詞作者也不會評判你,但本章後續一律稱「元件」。
本章對「元件」的定義是:一組擁有專屬命名空間(namespace)與明確定義 API 的類別。 其他元件若需要它的功能,可透過其 API 呼叫,但不得伸手進它的內部。一個元件可由更小的元件構成;這些子元件預設住在父元件的內部、不可從外部存取,但若實作了應對外開放的功能,也能對父元件的 API 有所貢獻。
如同任何架構風格,以元件為基礎的架構關鍵也在於「哪些依賴被允許、哪些被勸阻」。以兩個頂層元件 A、B 為例(A 含子元件 A1、A2,B 含子元件 B1):
- 若 A1 需要 B 的功能,可呼叫 B 的 API;但不能存取 B1 的 API,因為 B1 作為子元件屬於父元件 B 的內部、對外隱藏。B1 仍可藉由實作父元件 API 中的介面,對父元件 API 有所貢獻。
- 同樣規則適用於同層的 A1 與 A2:A1 可呼叫 A2 的 API,但不能呼叫 A2 的內部。

Figure 14.1: 對 internal 套件的依賴無效,但對 API 套件的依賴有效(前提是 API 套件未嵌套於 internal 套件中)
以元件為基礎的架構就這麼簡單,可歸納為四條規則:
- 元件有專屬命名空間,以便被定址。
- 元件有專屬的 API 與內部(internals)。
- 元件的 API 可從外部呼叫,但其內部不可。
- 元件可在其內部包含子元件。
案例研究:打造「Check Engine」元件#
作者從一個真實專案中抽取出一個元件、放進獨立的 GitHub 倉庫作為案例。
光是「能以相對少的力氣抽取出這個元件、且不需了解它來自哪個專案就能推理它」這個事實,就證明我們已透過模組性成功征服了複雜度!此元件以物件導向 Kotlin 撰寫,但概念適用於任何物件導向語言。
這個名為「check engine」的元件是一種網頁爬蟲,走訪網頁並對它們執行一組檢查(從「檢查該網頁 HTML 是否有效」到「回傳該網頁所有拼字錯誤」皆可)。由於爬網頁時很多事可能出錯,團隊決定非同步執行檢查。這意味著元件需提供:
- 一個用來排程檢查的 API。
- 一個用來在檢查執行後取回結果的 API。
- 一個儲存傳入檢查請求的佇列(queue),與一個儲存檢查結果的資料庫(database)。
從外部看,check engine 是「一整塊」還是拆成子元件並不重要——只要它有專屬 API,這些細節便對外隱藏。但上述需求勾勒出子元件的自然邊界,沿這些邊界拆分能管理 check engine 內部的複雜度。團隊想出三個子元件:
- queue 元件:包裝對佇列的存取,用來排入與取出檢查請求。
- database 元件:包裝對資料庫的存取,用來儲存與取回檢查結果。
- checkrunner 元件:知道要跑哪些檢查,並在佇列傳入檢查請求時執行它們。

Figure 14.2: check engine 元件由三個子元件構成,它們都對父元件的 API 有所貢獻
這些子元件多半引入的是技術邊界。與六角架構的輸出 adapter 非常相似,我們把「存取外部系統(佇列與資料庫)的細節」藏進子元件。check engine 本身是個技術性很強、幾乎沒有領域程式碼的元件,唯一可算「領域程式碼」的是充當控制器角色的 checkrunner。技術性元件很適合以元件為基礎的架構,因為它們之間的邊界比不同功能領域之間的邊界更清楚。
架構圖反映程式碼結構:每個方框可想成一個 Java 套件(其他語言則是原始碼資料夾),方框內的方框是子套件,最底層的方框是類別。
- check engine 的公開 API 由
CheckScheduler與CheckQueries兩個介面構成,分別用於排程網頁檢查與取回結果。 CheckScheduler由住在 queue 元件內部的SqsCheckScheduler實作——queue 元件因此對父元件 API 有所貢獻。只有看這個類別的名稱才知道它用了 Amazon 的 SQS(Simple Queue Service),這個實作細節不會洩漏到 check engine 之外,連同層元件都不知道用了哪種佇列技術。你或許注意到 queue 元件甚至沒有 API 套件,所有類別都是內部的!CheckRequestListener監聽佇列傳入的請求,每收到一個就呼叫 checkrunner 子元件 API 中的CheckRunner介面。DefaultCheckRunner實作它:從請求讀出網頁 URL、決定要跑哪些檢查、然後執行。- 檢查完成後,
DefaultCheckRunner呼叫 database 子元件 API 的CheckMutations介面把結果存進資料庫。該介面由CheckRepository實作,處理連接與操作資料庫的細節,資料庫技術同樣不外洩。CheckRepository也實作屬於 check engine 公開 API 的CheckQueries介面,提供查詢檢查結果的方法。
把 check engine 拆成三個子元件,就把複雜度切分開了:每個子元件解決整體問題的較簡單部分、大致能各自演進;因成本、擴展性等理由更換佇列或資料庫技術,不會洩漏到其他子元件;測試時甚至能用簡單的記憶體內實作替換子元件。這一切,都來自「把程式碼組織成元件、遵循專屬 API 與內部套件的慣例」。
強制元件邊界#
有慣例是好事,但若只有慣例,總會有人破壞它、架構便會侵蝕。我們需要強制元件架構的慣例。元件架構的好處是:能套用一條相對簡單的適應度函式來確保沒有意外依賴滲入:
只要把元件的所有內部放進名為「internal」(或以其他方式標記為內部)的套件,就只需檢查「該套件內的類別沒有從套件外被呼叫」。在 JVM 專案中,可用 ArchUnit 把這條適應度函式編成程式碼。
我們只需在每次建置時設法辨識出內部套件、餵給上述函式,一旦不慎引入對內部類別的依賴,建置就會失敗。這條適應度函式甚至不需要知道架構中有哪些元件——只要遵循辨識內部套件的慣例、把那些套件餵進函式即可。這意味著新增或移除元件時都不必更新跑這條函式的測試,非常方便!
這條適應度函式是第 12 章那條的反向版:第 12 章驗證「某套件的類別不存取該套件外的類別」,這裡則驗證「套件外的類別不存取套件內的類別」。後者穩定得多,因為不必為所用的每個函式庫添加例外。
當然,只要不遵循內部套件的慣例,仍能引入不想要的依賴。而且規則還留有一個漏洞:若把類別直接放進某頂層元件的「internal」套件,其任何子元件的類別都能存取它。因此我們或許還想再加一條規則,禁止任何類別直接置於頂層元件的「internal」套件中。
這如何幫助我打造可維護的軟體?#
以元件為基礎的架構非常簡單。只要每個元件有專屬命名空間、專屬的 API 與內部套件,且內部套件的類別不被外部呼叫,我們就得到一個由眾多「可組合、可重新組合」元件構成的、極可維護的程式庫。再加上「元件可由其他元件構成」這條規則,就能用越來越小、各自解決更簡單問題的零件組裝出整個應用。
即使元件架構的規則有漏洞可鑽,架構本身卻簡單到極易理解與溝通。易於理解就易於維護;易於維護,漏洞就較不可能被利用。
六角架構關心應用層級的邊界,以元件為基礎的架構關心元件層級的邊界。我們可以把元件嵌入六角架構中,也可以選擇先從簡單的元件架構起步、需要時再演進成任何其他架構——以元件為基礎的架構天生模組化,而模組易於移動與重構。
下一章也是最後一章,我們將為架構的討論收尾,試著回答:何時該選擇哪種架構風格。