當今多數應用都有某種 Web 介面——可能是透過瀏覽器互動的 UI,也可能是供其他系統呼叫的 HTTP API。在我們的目標架構中,所有與外界的溝通都經過 adapter。本章討論如何實作一個提供 Web 介面的 adapter。

依賴反轉#

Web adapter 是一個「驅動(driving)」或「輸入(incoming)」adapter:它接收外界請求,把它們轉譯成對應用核心的呼叫,告訴核心該做什麼。控制流從 Web adapter 中的 controller 流向應用層的服務。

應用層提供特定的 port 供 Web adapter 溝通,每個 port 就是上一章所稱的「使用案例」,由應用層的領域服務實作。仔細看會發現這正是依賴反轉原則的運作。

Figure 6.1: 輸入配接器透過專屬輸入 port(由領域服務實作的介面)與應用層對話

既然控制流由左向右,我們大可讓 Web adapter 直接呼叫使用案例,為什麼還要在 adapter 與使用案例之間多加一層間接?

Figure 6.2: 我們可以移除 port 介面、直接呼叫服務

因為 port 是「外界可與應用核心互動之處」的規格說明。有了 port,我們就確切知道發生了哪些對外溝通——這對維護遺留程式庫的工程師是極有價值的資訊。知道驅動應用的輸入 port,也讓我們能為應用打造「測試驅動器」:一個呼叫輸入 port 來模擬與測試特定使用情境的 adapter(測試詳見第 8 章)。

當然,第 11 章會談到的捷徑之一,正是「省略輸入 port、直接呼叫應用服務」。

不過對高度互動的應用還有一個問題。想像一個伺服器應用透過 WebSocket 即時推送資料到使用者瀏覽器,應用核心如何把即時資料送到 Web adapter,再由它送到瀏覽器?

這個情境一定需要 port:沒有 port,應用就得依賴 adapter 實作,破壞了「讓應用不依賴外部」的努力。此處的 port 由 Web adapter 實作、由應用核心呼叫——左側的 WebSocketController 實作 out 套件中的 port 介面,核心的服務呼叫此 port 來推送即時資料。技術上這是一個輸出 port,使 Web adapter 同時身兼輸入與輸出 adapter;同一個 adapter 兼任兩者並無不可。本章後續仍假設 Web adapter 只是輸入 adapter,因為這是最常見的情況。

Figure 6.3: 若應用須主動通知 Web 配接器,需經由輸出 port 以保持依賴方向正確

Web 配接器的職責#

假設我們要為 BuckPal 提供 REST API,Web adapter 的職責從哪開始、到哪結束?它通常做這些事:

  1. 把傳入的 HTTP 請求對映成物件。
  2. 執行授權檢查。
  3. 驗證輸入。
  4. 把請求物件對映成使用案例的輸入模型。
  5. 呼叫使用案例。
  6. 把使用案例的輸出對映回 HTTP。
  7. 回傳 HTTP 回應。

具體而言:

  • Web adapter 必須監聽符合特定條件(URL 路徑、HTTP 方法、內容型別)的 HTTP 請求,並把參數與內容反序列化成可操作的物件。
  • 通常接著做身分驗證與授權檢查,失敗則回傳錯誤。
  • 然後驗證傳入物件的狀態。

但輸入驗證不是已在使用案例的輸入模型中討論過了嗎?是的,但那是使用案例的輸入模型;這裡談的是 Web adapter 的輸入模型,兩者結構與語意可能完全不同。作者不主張在 Web adapter 中重複實作使用案例已做的驗證,而是驗證「能否把 Web adapter 的輸入模型轉換成使用案例的輸入模型」——任何阻礙這個轉換的,就是驗證錯誤。

接著 Web adapter 以轉換後的輸入模型呼叫使用案例,再把輸出序列化成 HTTP 回應送回呼叫端。途中若出錯拋出例外,Web adapter 必須把錯誤轉譯成回傳給呼叫端的訊息。

這些職責很重,但它們也正是應用層不該操心的職責。任何與 HTTP 有關的東西都不得洩漏進應用層。一旦應用核心知道外界在用 HTTP,我們就失去了「從其他非 HTTP 輸入 adapter 執行相同領域邏輯」的選項。可維護的架構要保留選項。

若從領域層與應用層、而非 Web 層開始開發,這條邊界會自然形成:先實作使用案例、不去想任何特定輸入 adapter,就不會受誘惑去模糊這條邊界。

切分 Controller#

多數 Web 框架(如 Java 世界的 Spring MVC)以 controller 類別承擔上述職責。那麼要不要用單一 controller 回應所有請求?不必——Web adapter 當然可以由多個類別組成,但應如第 4 章所述,把它們放進同一套件階層以標示其歸屬。

該建多少個 controller?作者主張寧多勿少:讓每個 controller 實作 Web adapter 中盡可能狹窄的切片,並盡量不與其他 controller 共用。

以 BuckPal 帳戶實體上的操作為例,常見做法是用單一 AccountController 接收所有與帳戶相關的請求。所有東西集中在一個類別感覺不錯,但有以下缺點:

  • 每個類別程式碼越少越好。作者曾經手最大類別達三萬行的遺留專案,毫無樂趣。即使一個 controller 多年只累積到 200 行,仍比 50 行難以掌握。
  • 測試程式碼同理。production 程式碼多,測試程式碼就多;而測試程式碼往往比 production 更抽象、更難懂。我們也希望某段 production 程式碼的測試容易尋找,這在小類別中更容易。
  • 鼓勵了資料結構的重用。所有操作擠在一個 controller 時,往往共用同一個模型類別(如 AccountResource),它變成「裝下任何操作所需一切」的水桶。例如 AccountResource 可能有 id 欄位,但建立操作並不需要,反而造成混淆;又如 AccountUser 是一對多關係,建立或更新帳戶時要不要含 User?列表操作會回傳 user 嗎?任何稍具規模的專案遲早會問這些問題。

作者主張為每個操作建立獨立的 controller(必要時放在獨立套件),並讓方法與類別命名盡量貼近使用案例。輸入可以用原始型別(如範例中的 sourceAccountIdtargetAccountIdamount),但每個 controller 也能有自己的輸入模型:與其用泛用的 AccountResource,不如用 CreateAccountResourceUpdateAccountResource 這類使用案例專屬模型,甚至設為 controller 套件私有以防意外重用。controller 仍可共用模型,但「使用來自另一套件的共用類別」會迫使我們多想一下,或許就發現其實只需一半欄位、進而建立自己的模型。

命名也值得斟酌:與其叫 CreateAccount,叫 RegisterAccount 是否更好?在 BuckPal 中,建立帳戶的唯一途徑是使用者註冊,所以用「register」更能傳達意義。Create...Update...Delete... 有時足以描述使用案例,但動用前值得三思。

這種切分風格的另一好處是讓平行開發變得輕鬆:兩名開發者處理不同操作時不會產生合併衝突。

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

打造 Web adapter 時要謹記:我們建的是一個把 HTTP 協定轉譯成對使用案例方法呼叫、再把結果轉譯回 HTTP 的 adapter,它本身不做任何領域邏輯

  • 應用層另一方面不該碰 HTTP,務必確保 HTTP 細節不外洩,如此 Web adapter 才能在需要時被另一個 adapter 取代。
  • 切分 controller 時,不要害怕建立許多不共用模型的小類別——它們更易掌握與測試,也支援平行開發。建立這種細粒度 controller 初期較費工,但會在維護階段獲得回報。

看完應用的輸入側後,下一章我們來看輸出側——如何實作持久化 adapter。