你很可能開發過分層(layered)的 Web 應用程式,甚至現在的專案就是如此。「分層思維」被反覆灌輸在資訊科學課堂、教學文章、最佳實務乃至各種書籍裡。

最常見的是三層架構:

  • Web 層:接收請求,並轉送給領域層的服務(service)。
  • 領域層(domain layer,又稱業務層):執行業務邏輯,呼叫持久化層來查詢或修改領域實體的狀態。
  • 持久化層(persistence layer):負責與資料庫溝通。

本書中「領域(domain)」與「業務(business)」是同義詞,指解決業務問題的程式碼,相對於處理技術問題(如資料庫持久化、Web 請求)的程式碼。

Figure 2.1: 傳統 Web 應用架構由 Web 層、領域層與持久化層組成

平心而論,分層本身是一個紮實的架構模式。若運用得當,領域邏輯可以獨立於 Web 與持久化層;需要時能抽換 Web 或持久化技術而不影響領域邏輯,也能在不影響既有功能的前提下新增功能。一個好的分層架構是可維護的。

那麼問題出在哪?作者的經驗是:分層架構對變更非常脆弱,因而難以維護。它讓不良依賴有機可乘,使軟體隨時間越來越難改動。分層提供的護欄不足,必須過度仰賴人的紀律與自覺才能維持可維護性。以下逐一說明原因。

它助長以資料庫為中心的設計#

依定義,傳統分層架構的根基是資料庫:Web 層依賴領域層,領域層依賴持久化層,而持久化層依賴資料庫。一切都疊在持久化層之上,這帶來幾個問題。

退一步想:幾乎任何應用程式想做的,都是把支配業務的規則或「政策」建模出來,讓使用者更容易與之互動。我們主要在建模行為(behavior),而非狀態(state)。狀態固然重要,但驅動業務的是改變狀態的行為。

既然如此,為什麼要把資料庫——而不是領域邏輯——當作架構的根基?回想你最近實作的使用案例:是先做領域邏輯,還是先設計資料庫結構?多數人會先想資料表長什麼樣,再在其上實作領域邏輯。這順著分層的依賴方向很自然,但從業務角度毫無道理。我們應該先打造領域邏輯,確認自己正確理解了業務規則,再圍繞它建立持久化與 Web 層。

助長這種以資料庫為中心設計的,還有物件關聯對映(object-relational mapping,ORM)框架。ORM 本身很好用,但若與分層架構結合,就容易把業務規則與持久化面向混在一起:

  • ORM 管理的實體屬於持久化層。
  • 由於上層可存取下層,領域層被允許使用這些實體——而只要被允許,遲早就會用到。
  • 結果領域層與持久化層強耦合:業務服務直接拿持久化模型當業務模型,除了領域邏輯外還得處理 eager/lazy loading、資料庫交易、快取清除等雜務。

持久化程式碼幾乎與領域程式碼融為一體,改一個就得動另一個,這與「保持彈性、保留選項」的架構目標背道而馳。

Figure 2.2: 在領域層使用資料庫實體導致與持久化層的強耦合

Martin Fowler 在《重構》中稱這種症狀為「發散式變更(divergent change)」:為了實作單一功能,卻得修改看似不相關的多處程式碼。這是一種應觸發重構的程式碼壞味道。

它容易出現抄捷徑#

傳統分層架構唯一的全域規則是:某一層只能存取同層或更下層的元件。團隊或許還約定了其他規則,甚至用工具強制執行,但分層風格本身並不強加這些規則。

因此,若我們需要存取上層的某個元件,只要把它「往下推一層」就能合法存取了。問題解決——但這麼做一次,就為第二次打開了大門。「別人能這樣做,我為什麼不行?」

開發者並非輕率地抄捷徑,但只要有路可走,總會有人在期限逼近時走上去。而一旦有人做過,再有人這麼做的機率就大幅上升。這是心理學上的「破窗效應(Broken Windows Theory)」,詳見第 11 章。

長年累積下來,最底層(持久化層)會不斷「長胖」——尤其是那些看似不屬於任何特定層的 helper 或 utility 元件,都會被往下推。若要關閉架構的「捷徑模式」,分層並非好選擇,至少在沒有額外規則強制執行的情況下不是。這裡的「強制」不是指資深開發者做程式碼審查,而是指違反時會讓建置失敗的自動化規則

Figure 2.3: 任何層都能存取持久化層的一切,使其隨時間長胖

它越來越難測試#

分層架構常見的演化是「跳過某一層」。例如只想改實體的單一欄位,就從 Web 層直接存取持久化層,何必驚動領域層?這在最初幾次感覺沒問題,但頻繁發生時有兩個缺點:

  • 領域邏輯散落到 Web 層:即使只是改一個欄位,未來使用案例擴張時,多半會繼續往 Web 層堆領域邏輯,混淆職責,使核心領域邏輯散布到各層。
  • 測試複雜度上升:Web 層的單元測試不僅要管理對領域層的依賴,還得管理對持久化層的依賴。若用 mock,就得為兩層都建立 mock。複雜的測試設定是「乾脆不寫測試」的第一步——當理解依賴、建立 mock 比寫測試本身還花時間時,測試就被放棄了。

Figure 2.4: 跳過領域層使領域邏輯散落程式庫各處

它隱藏了使用案例#

開發者喜歡寫實作嶄新使用案例的程式碼,但實際上我們花在修改既有程式碼的時間,遠多於寫新程式碼——這不僅適用於數十年的遺留專案,也適用於初始使用案例完成後的全新專案。

既然我們如此頻繁地在尋找「該在哪裡新增或修改功能」,架構就應該幫助我們快速在程式庫中導航。分層架構在這點表現如何?

  • 如前所述,領域邏輯容易散落各層:可能在 Web 層(為了「簡單」使用案例跳過領域層),也可能在持久化層(某元件被下推)。光是這點就讓「找對地方」變難。
  • 分層架構不限制領域服務的「寬度」。久而久之常出現服務多個使用案例的過寬服務(broad service):它依賴許多持久化元件,又被許多 Web 層元件依賴,既難測試,也難以從中找到負責特定使用案例的程式碼。

Figure 2.5: 「過寬」服務讓人難以在程式庫中找到特定使用案例

如果改用高度專一、狹窄、各自只服務單一使用案例的領域服務會輕鬆得多:與其在 UserService 裡翻找使用者註冊邏輯,不如直接打開 RegisterUserService 動手。

它讓平行開發變得困難#

管理者通常期望在某個日期前、某個預算內完成軟體,而「準時」通常意味著多人平行作業。你大概聽過《人月神話》的名言:為延誤的軟體專案增加人力,只會讓它更延誤。

即便專案尚未延誤,這在一定程度上也成立。架構必須支援平行開發,而分層架構在這點幫不上忙:

  • 想像為應用新增一個使用案例,有三位開發者:一人做 Web 層、一人做領域層、一人做持久化層,可行嗎?
  • 通常不行。由於一切疊在持久化層上,必須先開發持久化層,再領域層,最後 Web 層——所以同一時間只有一人能動工。

「那可以先定義介面,各自針對介面開發。」確實可行,但前提是我們沒有像前面討論的那樣混淆領域與持久化邏輯。而若程式庫中存在過寬服務,連平行開發不同功能都困難——不同使用案例會同時編輯同一個服務,導致合併衝突與潛在回歸。

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

如果你建過分層架構,大概能體會本章討論的問題,甚至能再補上幾個。

分層架構若運用得當、並施加額外規則,可以非常可維護,讓修改與擴充程式庫變得輕鬆。然而本章的討論顯示,它容許太多出錯的空間:缺乏良好自律時,它容易隨時間退化、可維護性下降。而每當團隊成員輪替、或管理者畫下新期限,我們的自律往往就受到一次衝擊。

牢記分層架構的這些陷阱,下次當你想說服別人別抄捷徑、改為打造更可維護的方案時——無論是在分層架構或其他架構風格中——都會更有底氣。