前幾章談了許多架構,有一個目標架構來指引「該怎麼寫程式、把程式放哪」感覺很好。然而在任何稍具規模的軟體專案中,架構往往隨時間侵蝕:層間邊界弱化、程式越來越難測試,實作新功能也越來越花時間。本章討論幾種強制架構邊界、藉以對抗架構侵蝕的手段。

邊界與依賴#

先釐清架構中的邊界在哪、「強制一條邊界」究竟是什麼意思。我們的六角架構元素可分布在四層(呼應第 3 章的通用整潔架構):

  • 最內層:領域實體與領域服務。
  • 應用層:環繞其外,存取那些實體與服務以實作使用案例,通常透過應用服務。
  • adapter:透過輸入 port 存取那些服務,或被那些服務透過輸出 port 存取。
  • 設定層:包含建立 adapter 與服務物件、並提供給依賴注入機制的工廠。

每一層與其內外鄰層之間都有一條邊界。依依賴規則(Dependency Rule),跨越層邊界的依賴必須永遠指向內側。本章就是要強制依賴規則,確保不存在方向錯誤的非法依賴。

Figure 12.1: 強制架構邊界意味著強制依賴指向正確方向

可見性修飾詞#

先從物件導向語言(尤其 Java)最基本的邊界強制工具開始:可見性修飾詞(visibility modifier)

作者在面試中常問「Java 提供哪些可見性修飾詞、差別是什麼」。多數人只列出 publicprotectedprivate,只有少數知道 package-private(即 default)。

為什麼 package-private 如此重要?因為它讓我們能用 Java 套件把類別組成內聚的「模組」:模組內的類別可互相存取,但無法從套件外存取;我們再選擇性地把特定類別設為 public 作為模組的進入點。這降低了「意外引入方向錯誤的依賴、違反依賴規則」的風險。以第 4 章的套件結構為例:

  • persistence 套件中的類別可設為 package-private——它們不需被外界存取,持久化 adapter 是透過它實作的輸出 port 被存取。同理 SendMoneyService 也可設為 package-private。依賴注入機制通常用反射實例化類別,即使 package-private 仍能實例化它們。
  • 但在 Spring 中,這只在使用第 10 章的 classpath 掃描時可行;其他方式需要我們自己建立物件實例,那就需要 public 存取。
  • 其餘類別則依架構必須是 public:domain 套件需被其他層存取,應用層需被 Web 與持久化 adapter 存取。

編譯後適應度函式#

一旦某類別用了 public,編譯器就會允許任何其他類別使用它,即使依賴方向與架構相悖。既然編譯器幫不上忙,我們得另尋他法檢查依賴規則沒被違反。

一種方法是引入適應度函式(fitness function):一個以我們的架構為輸入、判定其在某特定面向是否「適應」的函式。在這裡,「適應」的定義就是「依賴規則未被違反」。理想上編譯器在編譯期就替我們執行它;缺乏這種能力時,我們可在編譯後的執行期執行——這類執行期檢查最好放在持續整合(CI)建置的自動化測試中跑。

支援這種架構適應度函式的 Java 工具是 ArchUnit。它提供 API 檢查依賴是否指向預期方向,發現違規就拋出例外。最好從 JUnit 等單元測試框架的測試中執行,依賴違規時讓測試失敗。

例如可檢查「領域模型不依賴領域模型以外的任何東西」。

上述規則的問題在於:若領域模型用了某函式庫程式碼,每引入一個依賴就得為規則加一條例外(如範例中為 lombokjava 所做的)。第 14 章會看到一條沒有這個問題的規則。

Figure 12.2: 領域模型可存取自身與部分函式庫套件,但不得存取其他套件(如配接器所在套件)

稍加努力,甚至能在 ArchUnit API 之上打造一種領域特定語言(DSL),讓我們指定六角架構中所有相關套件,再自動檢查它們之間的依賴是否都指向正確方向:

  • 先指定應用的父套件,再指定 domain、adapter、application、configuration 各層的子套件,最後呼叫 check() 執行一組檢查,驗證套件依賴依依賴規則皆有效。

編譯後檢查雖有助於對抗非法依賴,卻不是萬無一失。若把套件名 buckpal 拼錯,測試將找不到任何類別、因而找不到任何依賴違規。一個拼字錯誤、或更要緊地——一次重新命名套件的重構,就能讓整個測試形同虛設。應力求讓這些測試「重構安全」,或至少在重構弄壞它們時讓測試失敗(例如:當提到的某套件不存在時讓測試失敗)。

建置產物#

至今我們在程式庫中劃分架構邊界的唯一工具是套件,所有程式碼都屬於同一個單體建置產物(build artifact)。建置產物是(但願是自動化的)建置流程的結果——Java 世界目前最受歡迎的建置工具是 Maven 與 Gradle,把程式編譯、測試、打包成單一 JAR 檔。

建置工具的一大特性是依賴解析:把程式庫轉成建置產物前,會先檢查所依賴的產物是否都可用,若否則嘗試從產物倉庫載入;再失敗則在編譯前就讓建置失敗。我們可善用此機制來強制模組與層之間的依賴(即強制邊界):為每個模組/層建立獨立的建置模組,各有自己的程式庫與建置產物(JAR),並在各模組的建置腳本中只宣告架構所允許的依賴。開發者再也無法不經意地製造非法依賴——因為那些類別根本不在 classpath 上,會直接撞上編譯錯誤。

把架構切分成獨立建置產物有多種方式(以下並非全部):

Figure 12.3: 將架構切分成多個建置產物以禁止非法依賴的不同方式

  • 基本三模組:configuration、adapter、application 各一個建置產物。configuration 可存取 adapter,adapter 可存取 application(configuration 也因隱含的遞移依賴而能存取 application)。
  • 拆分 adapter:上述 adapter 模組同時含 Web 與持久化 adapter,建置工具不會禁止兩者間的依賴。雖然依賴規則並未嚴格禁止(兩者同屬外層),但多數情況下讓 adapter 彼此隔離才合理——我們不希望持久化層的變動洩漏進 Web 層,反之亦然(記得單一職責原則!)。因此可把單一 adapter 模組拆成「每個 adapter 一個建置模組」。
  • 拉出 api 模組:application 模組目前含輸入輸出 port、實作或使用 port 的服務、以及領域實體。若決定領域實體不得在 port 中當作傳輸物件(即禁用第 9 章的「不對映」策略),可套用依賴反轉原則,拉出只含 port 介面的 api 模組。adapter 與 application 可存取 api,反之不可;api 不能存取領域實體、也不能在 port 介面中使用它們;adapter 也不再直接存取實體與服務,必須經由 port。
  • 再拆 api:把 api 拆成「只含輸入 port」與「只含輸出 port」兩部分,藉由只宣告對其一的依賴,就能明確表達某 adapter 是輸入還是輸出 adapter。
  • 再拆 application:拆出「只含服務」與「只含領域模型」的模組,確保領域模型不存取服務,並讓其他應用(不同使用案例、不同服務)只需宣告對 domain 建置產物的依賴即可重用同一領域模型。

切得越細,越能強力控制模組間的依賴;但切得越細,模組間要做的對映也越多,等於強制執行第 9 章的某種對映策略。

用建置模組劃分邊界相較於單純用套件,還有幾項優勢:

  1. 杜絕循環依賴。循環依賴很糟,因為環中某模組的變動可能牽動環中所有模組,違反單一職責原則。建置工具不允許循環依賴(否則解析時會陷入無窮迴圈),因此可確保建置模組間無循環依賴;而 Java 編譯器則完全不在意套件間是否循環依賴。
  2. 允許模組內的隔離變更。假設要在應用層做大重構、暫時造成某 adapter 編譯錯誤:若 adapter 與應用層在同一建置模組,某些 IDE 會堅持先修好 adapter 的所有編譯錯誤才能跑應用層測試(即使測試根本不需要 adapter 編譯);若應用層獨立成建置模組,IDE 就不會理會 adapter,可隨意跑應用層測試。Maven/Gradle 建置亦然。我們甚至能把每個模組放進各自的程式碼倉庫,讓不同團隊維護不同模組。
  3. 新增依賴成為有意識的行為。每條跨模組依賴都在建置腳本中明確宣告,需要存取某個目前無法存取的類別的開發者,但願會在把依賴加進建置腳本前,先思考這個依賴是否真的合理。

這些優勢的代價是得維護建置腳本,因此架構應先相當穩定,再拆成不同建置模組。此外,建置模組往往隨時間變得較不易變更:一旦選定,我們傾向沿用最初定義的模組;若一開始模組切分得不對,日後也較不會修正,因為重構成本更高——當所有程式碼都在單一建置模組中時,重構反而更容易。

這如何幫助我打造可維護的軟體?#

軟體架構基本上就是在管理架構元素之間的依賴。一旦依賴變成一團爛泥,架構也就變成一團爛泥。 因此要長期保全架構,必須持續確保依賴指向正確方向。本章三種手段可以組合使用:

  • 寫新程式或重構時,謹記套件結構,盡可能使用 package-private 可見性,避免依賴到「不該從套件外存取」的類別。
  • 若需在單一建置模組內強制邊界、而套件結構不允許 package-private 奏效,可動用 ArchUnit 等編譯後工具。
  • 每當覺得架構夠穩定,就把架構元素抽取成各自的建置模組,藉此明確控制依賴。

下一章我們將從另一個角度繼續探討架構邊界:如何在同一應用中管理多個領域(有界脈絡),同時保持它們之間邊界分明。