書摘《Clean Architecture 實作篇》

Du Spirit
個人書摘
Published in
15 min readMay 8, 2024
圖片來源:PChome

Chapter 1 可維護性

即使在一家公司擁有大量資金可以花費的情況下,該公司也會意識到,他們可以透過投資「可維護性」來降低維護的成本。 p. 6

我們需要進行常被認為是一種瀑布開發方式的 BDUF (big design up front,前期大設計) 嗎?不,我們不需要。但是,我們確實需要做一些前期設計 (some design up front,或許我們應該稱之為 SDUF?),藉此在軟體中種下一顆「可維護性」的種子,隨著時間的推移,這可以讓架構在需要的時候更容易演進。 p. 6

即使我們的軟體可維護性可能跟預期有落差 (老實說,一直以來都是這樣),但如果我們有機會隨著時間改善可維護性,我們會更快樂,也更具生產力。 p. 8

如果我們觀察這些以及許多著名的模式,它們的效果 (effect) 是什麼呢?在許多情況下,主要的效果是它們使未來的程式碼更容易變更 (也就是說,它們使它更易於維護)。可維護性已經內建於我們每天自動做出的許多決策當中。 p. 9

面對更困難的決策時,我們也可以利用這個原則:『每當我們需要多種選項之間做出抉擇時,我們可以選擇未來更容易修改程式碼的那一個。』 p. 9

這句有點危險,也可能每天都在做 over design。

Chapter 2 階層式架構的問題點

這種所有東西都奠基於儲存曾之上的情形,從各種叫度上來看都會造成問題。 p. 15

建模的主要對象則是行為 (behavior),不是狀態 (state)。雖然對所有應用程式來說狀態都是很重要的事情,但行為才是導致這些狀態改變的原因,而且是業務的驅動力! p. 15

突然發現這個小節的標題,正是資料庫驅動設計耶 (參閱 閒談軟體設計:Database Driven Design?)

而且在這種一資料庫為中心的架構中,主要依賴使用所謂的 ORM (object-relational mapping,物件關係對應) 框架來作為驅動力。這樣講並不是反對這類框架,反之,筆者自己就熱愛並時常使用這些框架。但要是把「ORM 框架」與「階層式架構」結合起來,就很容易落入把「業務規則」與「儲存層觀點」混淆在一起的陷阱。 p. 16

這裡並不是在輕視各位開發者,覺得你們就是一群動不動想「偷吃步」(shortcut,或稱走捷徑) 的人。但只要有這種可能性存在,就無法排除有人會這麼做,尤其是當工作限期越來越接近的時候。 p. 17

當我們想要把某個元件下推到儲存層,好讓領域層與儲存層都能存取時,也會讓領域邏輯出現在儲存層中。這種情形會使得要加入新功能時,決定實作的歸屬位置越加困難。 p. 19

Chapter 3 依賴反轉

Clean Architecture 在其架構設計上,必須讓「業務規則」可以在沒有框架、資料庫、使用者介面技術或任何其他外部應用程式或介面的情況下,進行測試。 p. 28

我個人在寫測試時,也是喜歡不用資料庫,原因很簡單,執行起來很快~非常快~

最重要的原則,就是限制依賴關係的方向 (Dependency Rule) — — 所有架構層的依賴關係都必須是「指向內部」的。 p. 29

這邊的「使用案例」,指的就是先前我們看到的那些「服務」,只是更加精簡 (fine-grained service),是僅有單一職責的服務 (也就是僅有一種會被修改的理由),以便免前面提到的「過廣服務」的問題 (broad service)。

我個人實作 clean architecture 時,嚴格遵守的其實只有分層跟制依賴關係方向,很少用單一職責的服務。

我們也有可能會考慮導入應用程式服務的概念。應用程式服務 (application service) 負責協調 (coordinate) 「對使用案例 (領域服務) 的呼叫」。 p. 33

Chapter 4 程式結構

比起圓心「以架構層為結構」的做法,「以功能為結構」的作法卻反而使得架構消失在了眼界之中 — — 我們仍然無法透過套件名稱找出轉接器在哪裡。 p. 38

我個人幾乎不用 Port 作為介面或類別的命名 XD

「架構與程式鴻溝」指的是在大多數的軟體開發專案中,架構這種東西其實僅止於抽象概念的階段,而無法真正地與實際程式做對應。而如果無法從套件結構或其他細節上反映出架構得話,那麼隨著時間演進,程式只會越來越偏離原先設定好的架構。 p. 41

基於不能出現「由應用程式層直接對轉接埠類別」的依賴關係,我們不可以在應用程式層中直接手動地來建立這些物件。這時候就需要用到依賴注入 (dependency injection) 了。 p. 43

這段有一個前提:應用程式層指的是應用程式服務,但如果不是,那應用程式層要建立任何轉接埠類別是可以的。

Chapter 5 使用案例實作

使用案例應該要負擔的職責其實是「業務規則」(business rule) 的驗證。這是與領域實體共同承擔的職責。 p. 49

為何不讓呼叫方的轉接器在將「輸入資料」傳遞給「使用案例」之前就先驗證好呢?問題就在於,我們是否真能完全信任「呼叫方」替「使用案例」執行了所有必要的驗證?一個使用案例有可能被一個到多個轉接器呼叫,這樣一來,驗證就要由各個轉接器來實作。但百密一疏,總有機會出現錯誤的驗證,抑或是忘了驗證。 p. 51

但既然不應由使用案例類別承擔這項職責,那到底該在哪裡做輸入驗證才好呢?答案是要讓「輸入模型」(input model) 來負責才對。 p. 51

我確實使用過 Command 來命名輸入模型,當初是想實驗在 controller 層,將輸入直接當成 command 來用,是真正 command pattern 的實作,後來就很少這樣用了。但這本書中 command 的意思是指這個使用案例會改變狀態,我個人沒有很喜歡。

將輸入驗證安插在輸入模型中,便能有效地在使用案例時作的周圍,建立起一到防腐層 (anti-corruption layer)。 p. 54

產生器允許我們僅定義必須的參數。但是,若要使用產生器,我們必須非常確定,在我們忘記定義必需的參數時,build() 方法會明確失敗,因為編譯器並不會幫我們檢查這一點! p. 56

這一點我和作者持相同看法,除非是相當複雜的物件,相較於 builder pattern,我個人盡可能使用建構子。

針對每一種使用案例,請安排特定的輸入模型,這樣才能讓使用案例更精確、更具體,也能避免使用案例之間出現耦合,導致意料之外的副作用影響。 p. 57

業務邏輯在驗證時需要根據「領域模型的當前狀態 (current state)」而定,輸入驗證則不用。 p. 58

輸入驗證是使用案例的「語法驗證」(syntactical validation),而業務邏輯驗證則是使用案例的「語意驗證」(semantical validation)。 p. 58

在充血領域模型中,領域邏輯應盡可能地實作在應用程式核心一部分的實體內。於是這些實體就具備了改變狀態的能力,並且透過業務規則驗證的方式,確保只有在合乎業務規則的情況下才能進行改變。 p. 60

這些問題沒有標準答案。唯一的原則是,應該盡可能地讓使用案例精確而具體。如果不知道該怎麼辦才好,就想辦法讓「回傳的資料量」越少越好。 p. 62

若是在不同使用案例之間共用同樣的輸出模型,也會導致這些使用案例偶合在一起。當其中一者需要往輸出模型中加入新的欄位時,即使與另一者無關,它們也必須被迫做出應對。 p. 62

Chapter 6 網頁層轉接器實作

其實有許多事情根本不應該由「應用程式層」來負責的。比方說,任何與 HTTP 相關的職責,都不該擴散到「應用程式層」中。要是應用程式核心對於自己正在透過 HTTP 與外部溝通有所認知,就會使兩者綁在一起,導致其他非採用 HTTP 協定的輸入轉接器無法共用到同樣的一份領域邏輯程式。任何設計良好的架構都應該要避免這種事情發生,保持技術選項的彈性。 p. 70

要建立一個帳戶的方式,就是要請使用者「註冊」(或稱「開通」) 帳戶。所以在類別名稱上使用 Register (註冊) 會比 Create (建立) 要來的適當,才能傳達出語意。 p. 74

Chapter 7 儲存層轉接器實作

這些轉接埠實際上就是在「領域服務」與「儲存程式碼」之間,間接地架起一層架構層。而這個間接架構層 (layer of indirection) 的用途,則是為了消除對儲存層的依賴關係,讓「領域程式碼」不會受到儲存層技術問題的干擾。 p. 78

轉接埠的重點在於,只要雙方都遵循轉接埠訂下的規範 (contract,合約),那麼就能在不影響核心的前提下,自由地抽換儲存層轉接器。 p. 79

這種極端精確的小型轉接埠介面,會讓你在撰寫程式時擁有「即插隨用」(plug-and-play) 的感受。要讓某項服務可以運作,就只要往該服務所需的轉接埠中「插入」

(plug in) 轉接器即可,沒有多餘、額外的包袱。 p. 82

但這種幾乎是「一個轉接埠只有一個方法」的做法,不一定適用於所有情況。畢竟,有時就是會遇到一整組內聚力強、彼此高度相關的資料庫作業,讓我們想要把這些方法都綁在同一個介面上。 p. 82

要是我們不想被「底層的儲存層技術」限制、想要自由地建立一個充血領域模型,就必須得這樣來回地在「領域模型」與「儲存模型」之間轉換對應。 p. 92

然後很多框架會跟你說,直接把儲存模型當領域模型用最有生產力 XD

只是對於儲存層轉接器來說,並不會知道那些資料庫作業是屬於同一個使用案例下的作業,所以無法代為決定一個交易階段的開始與結束。於是這項職責就落到了呼叫儲存層轉接器的「服務」身上。 p. 92

先不提 @Transactional 會讓領域服務有對框架的依賴,如果使用的儲存技術,本身就不支援交易,那交易這個概念會是服務該關心的嗎?或是反過來說交易這個概念會不會根本上就是技術細節,不是服務該關心的。

Chapter 8 架構測試

單元測試 … (中略) … 就算該類別存在對其他類別的依賴關係,也不會在測試時產生臉所的物件建立,而會是用模擬 (mock) 的方式來取代那些依賴對象,據此來模擬該依賴對象「類別」的行為。 p. 97

整合測試會將跨越兩個架構層之間的邊界。所以與單元測是不同,整合測試中的依賴對象不一定是模擬出來的,有可能會是實際在測試中建立的類別物件。 p. 97

若使用強型別語言,我基本上是不太寫整合測試的,原因很單純,強型別已經保護很多事情了。另外,就是不想在測試時用資料庫,原本只要 1~2 ms 的測試,加入資料庫就可能會是 10 ~ 200 ms,即便是領域服務的單元測試,我都是用 mock 的 repository,只有儲存層的單元測試會用真的資料庫,目標就是讓測試盡可能省時間,因為省時間,就會願意常在本機上跑全部的測試,那種要跑上半小時或是數小時的測試,大家最後都是丟到 CI 上跑,錯了再來修,浪費時間也浪費錢 (如果 CI 是用付費服務)。但如果是非強型別語言,我會寫蠻大量的整合測試,為了就是確保跨層之間的協議沒有被破壞 (這明明是編譯器該做的事…)。

最好還是要以「實際的資料庫」來測試「儲存層轉接器」。… (中略) … 以「實際的資料庫」進行測試,就可以不用在「測試」與「正式」兩種環境之間處理不同資料庫的議題。 p. 107

這邊指的測試資料庫類似 H2 之類的 in-memory 資料庫,速度非常快,可以模擬多種方言,但確實有時會有些問題,是 H2 會發生但實際資料庫不會,反之亦然。

筆者的建議是以發佈軟體的流暢度來評估「測試」是否有效。要是在執行一次測試作業之後,就讓你能夠安心地發佈軟體,那當然很好。 p. 113

在那個靠 cherry pick 釋出的歲月裡,雖然要跑半小時 (CI 上靠數十台機器分散跑縮短到半小時內),但我對於那龐大測試案例是心存感謝的,給我強大的信心,我沒有漏挑任何的 PR,或是 resolve conflicts 時出錯。

Chapter 9 架構層之間的對應策略

「雙向對應策略」的另一個好處是,比起其他的動應策略,它可以說是在概念上最單純的策略了,畢竟要做的事情非常明確:

  • 處於外層的架構層或轉接器,要負責幫內層的架構層處理好模型應對。
  • 處於內層的架構層,只需要專心管理好自身的模型及領域邏輯,而無須煩惱與外層的架構層之間的應對。 p. 122

「雙向對應策略」往往會產生大量的「重複程式碼」(boilerplate code,又稱樣板程式碼)。就算利用市面上的各式對應框架,來想辦法減少這類程式碼的數量,也還是需要一定的時間和心力,去時做模型之間的對應。 p. 122

對應策略是「可以」而且也「應該」視情況混用的。千萬不要輕易地把任何對應策略捧上天,一體適用地套用在所有的架構層中。 p. 124

Chapter 10 應用程式組裝

設定元件要承擔的職責可說是一籮筐 (也就是先前曾提及的「會被修改的理由」)。但這樣不會違反單一職責原則嗎?當然違反了,但為了維護應用程式其他部分的整潔,就需要一份「處於外場的元件」來幫忙處理好這些骯髒事。這個元件需要對組裝時的這些元件無所不知,才能把應用程式組裝起來。 p. 132

Chapter 11 理性看待偷吃步

每當有這種刻意為之的做法出現時,都應該謹慎地記錄下來。這部分讀者或許可以參考 Michael Nygard 在他的部落格中所提出的架構決策紀錄 (Architecture Decision Records,ADR) 方法論。 p. 144

要是使用案例本身並無相關性,且應該獨自發展的話,那這種做法明顯就屬於偷吃步了。在這種情況下,應該打從一開始就確實地分離使用案例,即使這會讓 code base 中多出一堆看似重複的輸入與輸出模型類別,也應該這樣做。 p. 146

Chapter 12 強化架構中的邊界

撇除那些規模極小的軟體專案不說,幾乎毫無例外地,架構內容都會隨著時間膨脹增長。架構層之間的邊界會越來越模糊不清,導致測試越來越困難,於是每次開發新功能所需要的時間就會越來越長。 p. 152

建置工具的其中一項主要功能就是「依賴關係解析」(dependency resolution)… (中略) … 我們可以利用這項機制來強化架構中「模組與模組」、「架構層與架構層」之間的依賴方向原則 (也就是強化架構邊界的意思)。 p. 160

個人也是比較喜歡用 maven 模組管理依賴關係,而不是 package。

Chapter 13 管理多個 Bounded Context

許多應用程式包括不只一個領域,或者,用 DDD (Domain-Driven Design,領域驅動設計) 的語言來說,許多應用程式包括不只一種 Bounded Context。不同的領域之間應該設定邊界。 p. 168

每個轉接器都必須從一個領域模型對應到另一個領域模型。這很快就變成了一種都要開發和維護的繁重工作。如果這變成了一項繁重工作,且需要的工作量超過了它帶來的價值,團隊將為了避免做這項工作而投機取巧,採取偷吃步的做法,導致架構乍看之下像是一個六角形架構,但實際上並不具備它所承諾的好處和優勢。 p. 171

回顧一下最初介紹六角形架構的原始文章,我們可以看出,六角形架構的初衷不是把一個 Bounded Context 封裝 (encapsulate) 在轉接埠與轉接器之中。反之,其意圖 (目的) 是封裝一個應用程式。 p. 171

在這種情況下,我們可以使用「領域事件」(domain event)。 p. 174

另一種可能的解決方案是引入一個應用程式服務 (application service),作為「使用者管理」和「交易」context 之間的協調器 (orchestrator)。 p. 174

除了協調對 Bounded Context 的呼叫之外,應用程式服務還充當交易邊界 (transaction boundary),這樣我們就可以在同一個資料庫交易中呼叫多個領域服務。 p. 175

Chapter 14 以元件為基礎的軟體架構方法

軟體專案的環境實在太變幻莫測,無法提前預知所有事情的發展。這種不穩定性和挑戰性正式「敏捷 (Agile) 運動」誕生的原因。敏捷實踐讓組織有足夠的彈性,能夠適應變化。 p. 178

模組 (module) 究竟是什麼呢?我覺得這個數與在 (物件導向) 軟體開發中使用得太過頻繁。幾乎每樣東西都被稱為「模組」,即便它只是一堆「隨意拼湊在一起」用來時做某種有用功能得類別。 p. 180

我只能說一點也不易外。

Chapter 15 選擇你的架構風格

像六角形架構這類以「領域」為主軸的架構設計,裁示能夠讓 DDD 成功的推手。 p. 191

如果領域程式碼不是處於應用程式中的核心地位,那麼就不一定要遵循這類架構設計。 p. 191

我自己是先從 OOAD 開始,搭配幾個常見的 principles 和 patterns,慢慢建構出我覺得好的架構,後來才讀到《Clean Architecture》這本書,然後拍手叫好,原來大家的想法是這麼接近的,後來我大致上還是維持自己過去的習慣,並沒有特別在意其他人是怎麼實作 Clean Architecture,會找這本書來看,確實是對其他人的實作有點好奇,書中提到的內容,除了少數命名和程式結構上的習慣不同,大原則是相似的,我反倒是比較好奇不同的考量是什麼。

這本書如果是剛看完《Clean Architecture》這本書,想要直接看答案怎麼實作,是可以當作一個起始點,但我會比較建議,自己循著書中前幾個章節提到的 principles,思考看看怎麼設計出一個符合大多數 principles 的方案,這會比較有趣。

--

--