DDD 中關於應用架構的那些事

對領域驅動設計中關鍵的一些概念,大家有了更爲深入的認識是不夠的,在具體實踐中我們還會面臨諸如代碼如何分層、不同上下文之間如何集成,以及某些時候還會用到 CQRS。本文就來補齊領域驅動設計中剩餘的一些內容,希望能夠助你更遊刃有餘地應對開發中遇到的各種問題。

作者 | 于振

責編 | 韓楠

你好,今天我想與你聊聊 DDD 中的應用架構。在過往我分享的幾篇文章中,我們介紹了領域驅動設計中的一些基本概念,這裏,再做一個簡單的回顧。

·《基礎問題不簡單 | 怎麼合理使用值對象,讓你的代碼更清晰、更安全?》

·《不想只做 Cruder?實體、聚合根,還不快去了解下》

·《如何通過倉儲,對實體進行持久化處理?》

·《實體表達力不夠?那你應該試試領域服務》

·《如何使用工廠,進一步解耦領域對象的職責》

·《領域模型細節太多不便使用?那就加個應用服務吧》

·《DDD 在 Go 中如何落地 | 如何在業務中使用領域事件?》

使用值對象和實體幫助我們構建了具有豐富行爲的領域模型,實體創建出來後需要通過倉儲進行持久化,如果領域模型跟數據模型存在差異,就還需要通過 Converter 進行轉換,以及通過 Snapshot 對實體進行追蹤。

如果某些行爲不適合放到某個實體上,就需要使用領域服務,同時,爲了一定程度地防止領域服務的濫用,我們規定領域服務在命名上必須有一個動詞。

爲了解耦領域對象的創建過程和其自身行爲,我們又介紹了工廠方法。

對於外部用戶來說,領域之內的各個對象描述的,都是細粒度的領域概念,爲了方便外部調用,同時屏蔽領域對象的具體細節,就又有了應用服務。

最後,通過領域事件,進一步解耦了不同上下文之間的依賴,即使在同一邊界之內的不同的聚合根,也可以實現數據的最終一致性。

至此,大家應該對領域驅動設計中關鍵的一些概念,有了更爲深入的認識。但僅僅是這些應該是還不夠的,在具體實踐中,我們還面臨着諸如代碼如何分層、不同上下文之間如何集成,以及某些時候還會用到 CQRS。

在這篇文章中,我們就來補齊領域驅動設計中剩餘的一些內容。

首先,我們從代碼的分層開始說起。

01 DDD 的分層架構

分層架構作爲一種歷史悠久的架構模式,在很多的場景中都得到了應用。

大家比較熟悉的應該就是 MVC 對應用三層架構的拆分。MVC 這種分層是自上而下的。

隨着業務越來越複雜,人們逐漸發現, MVC 架構在應對複雜的業務問題時會顯得力不從心。

於是,後面逐漸演化出了六邊形架構、洋蔥架構、整潔架構等架構模式。這幾種架構也是一種分層架構,但這種分層不是由上而下的,而是由內而外的。

我們以洋蔥架構爲例:

可以看到,最關鍵的是中心的領域模型,它包括了所有的應用邏輯與規則。在這一層中不會直接引用技術實現,這樣就能夠確保在技術層面的改動不會影響到領域核心。

在領域層之外又包裹了領域服務層、應用服務層,而具體的技術實現則是被置於最外層的。

這種架構的好處就在於,它屏蔽掉了應用程序在 UI 層、DB 層,以及各種中間件層的本質區別,所有的這些外部資源都被抽象成了對系統的輸入輸出,然後我們就能夠以一致的方式來處理不同的請求類型,並且,在與實際運行的設備和數據庫相隔離的情況下,也可以先行開發和測試。

在 DDD 的技術實現中,就用到了這種分層方式。

下圖是 Eric Evans 在其經典著作《領域驅動設計》中給出的一個典型的 DDD 系統所採用的分層架構:

在上圖中可以看到,整個架構劃分成了四個層,各層所表示的含義及其職責描述如下:

1、用戶接口層

這一層主要負責直接面向外部用戶或者系統,接收外部輸入,並返回結果。

用戶接口層是比較輕的一層,不含業務邏輯。可以做一些簡單的入參校驗,也可以記錄一下訪問日誌,對異常進行統一的處理。同時,對返回值的封裝也應當在這層完成。

2、應用層

應用層,通常是用戶接口層的直接使用者。

但是在應用層中並不實現真正的業務規則,而是根據實際的 use case 來協調領域層提供的能力,也可以說,應用層主要做的是編排工作。

另外,應用層還負責了事務這個比較重要的功能。

3、領域層

領域層是整個業務的核心層。我們一般會使用充血模型來建模實際的領域對象。

同時,由於業務的核心價值在於其運作模式,而不是具體的技術手段或實現方式。因此,領域層的編碼原則上不允許依賴其他外部對象。

4、基礎設施層

基礎設施層,是在技術上具體的實現細節,它爲上面各層提供通用的技術能力。

比如我們使用了哪種數據庫,數據是怎麼存儲的,有沒有用到緩存、消息隊列等,都是在這一層要實現的。

對於這四個層次的劃分,大家通常都沒有太多的異議。但是在層與層之間的依賴關係上,後續又衍生出了很多的改良版本。比如在 IDDD 一書中,就給出了下圖所示的分層架構:

這裏最大的不同,就是將領域層放到了整個架構的最下面,也即領域層之下就不再有任何的其他依賴。這麼做是沒有問題的,但是最上面的基礎設施層看起來卻怪怪的。

在實際開發中,領域層的領域服務往往需要訪問持久化組件,以及基礎設施層中的其他組件,而對於持久化組件來說,不可避免地需要依賴領域層的實體對象。如此一來,領域層和基礎設施層,就產生了雙向依賴關係。

實際的解決方式,就是讓領域層和基礎設施層 都依賴一個統一的抽象,比如對於模型的持久化有 Repository 接口,對其他外部資源的訪問也可以通過接口的形式來解耦合。但是 Repository 接口跟其他接口 又有些不太一樣,Repository 因爲需要參與到實體的整個生命週期中,所以在很多時候 Repository 都被看作是領域層中的一員。而對基礎設施層中其他組件的抽象,是不適合定義到領域層的。

DDD 代碼模型

結合上面的描述,這個時候再來看代碼的組織形式,就比較清晰了。默認情況下,一個上下文對應了一個服務,我們這裏以包含單個上下文的情況爲例,給出如下的代碼目錄結構:

對上面的代碼結構做一個簡短的說明:

• application,對應到架構裏的應用層,其內可能包含一些 assembler 和 DTO,assembler 主要用於將領域對象轉換成返回需要的數據格式,這些數據格式以 DTO 的形式進行定義,這些 DTO 沒有任何的業務邏輯,就是單純的數據對象。

• domain,對應的是領域層,倉儲的接口也是放在這一層的。

• handler,對應的是架構裏的用戶接口層,但其本質上還是屬於基礎設施層的一部分,這裏單獨提出來也僅僅是爲了凸顯它的重要性。在這一層,只可以直接訪問應用層。

• infra,對應的是基礎設施層,根據對不同資源的繼承需求,可以在 infra 下繼續分包。

• interfaces,是對基礎設施層中除持久化以外的中間件的抽象,也即我們在這裏定義訪問中間件的接口,具體的實現還是放在基礎設施層。這裏將接口單獨放到一個包中,爲的是避免在領域層與應用層對基礎設施層的直接依賴,如此就通過依賴反轉解耦了具體的技術細節。

至此,我們就明確了代碼的分層組織結構,以及彼此之間的依賴關係。

我們在文章開頭提到的第二個問題是上下文的集成,在實際工作中,相信大家都會使用到微服務,這樣一來,如何集成就成爲我們必須要考慮的問題。

02與其他上下文集成

上下文的集成無外乎兩種方式, 一種是通過 RPC 進行集成,另一種是通過領域事件進行集成。

通過領域事件集成,也就是領域事件的發送和消費,這個我們在前面的文章中已經做了比較詳細的介紹,這裏不再贅述。

接下來主要說說通過 RPC 進行集成。

開放主機與發佈語言

我們先來看一個在 DDD 中,經常用來表示集成方式的示例圖:

其中,被集成方(A 上下文,U 是 Upstream 的縮寫)採用了開放主機和發佈語言的方式,而集成方(B 上下文,D 是 Downstream 的縮寫)則使用了防腐層。幾個縮寫的含義如下:

• OHS(Open Host Service):開放主機服務,即定義一種協議,子系統可以通過該協議來訪問你的服務。

• PL(Published Language):發佈語言,通常跟 OHS 一起使用,用於定義開放主機的協議。

• ACL(Anticorruption Layer):防腐層,一個上下文通過一些適配和轉換,來跟另一上下文交互。

我們平時大多數時候的開發工作,都是跟 Grpc/Kitex 等 RPC 框架打交道的,不同的框架在設計之初都會定義一份協議,只有符合協議要求的請求 才能被正確地識別和處理。比如 Grpc 使用 HTTP2 作爲傳輸協議,而 Kitex 則主要使用自定義的 TTHeader 協議。

這些框架在使用上,一個共同特點就是需要通過 IDL(Interface description language) 來定義服務可以提供的能力。IDL 中可以定義多個接口,每個接口都有一個方法名,同時需要指定傳遞什麼參數,返回什麼數據。這樣的一份 IDL 就可以認爲是我們爲系統定義的發佈語言。

還是以前面多次提到的商品服務爲例,商品服務作爲上下文集成中的被集成方,通過 thrift 定義了其可以提供的服務,比如下面是對 GetProductDetail 接口的定義:

所以,如果我們是一個服務的提供方,只要我們使用 Grpc/Kitex,那麼就可以認爲我們是使用 OSH 和 PL 方式來進行集成的。

防腐層

防腐層一般用在下游上下文中,可以用來隔絕上游上下文中可能發生的變化。 

在上面的例子中,商品服務提供了一個 GetProductDetail 接口,用以返回關於 Product 的全量信息。但是對於其他集成方來說,可能只是想拿到產品的很少一部分信息,比如在訂單服務中要展示訂單的詳情,而詳情只需要產品的圖片和名稱即可。

可以看到,作爲服務的提供方,其具有追求普適性和靈活性的特點,而服務的調用方,在使用時卻想要能夠集中滿足特定需求的接口。

這種張力是導致在邊界上出現問題的主要原因,是無法避免的,但是卻是可以解決的,應對的方法就是使用防腐層。

從圖中可以看出,Subsystem A 和 Subsystem B 的調用關係並不是直接產生的,都要通過中間的一個 ACL,ACL 除了負責執行具體的技術性調用,還將 A 和 B 的領域模型隔離開來,並承擔了彼此模型之間的翻譯轉換功能。

除此以外,還可以在 ACL 做緩存、兜底、開關等功能。

對於集成方來說,一般採用獨立接口的形式,接口的定義放在 interfaces 中,上面這個例子就可以這樣定義:

因爲實現是跟具體的技術相關的,所以實現需要放到基礎設施層。整體的目錄層級如下:

具體的實現可以參考下面的代碼,簡單來說就是將通過 RPC 獲取到的上游模型,轉換爲自己領域內的模型:

在傳統意義的防腐層實現中,會有一個適配器和一個對應的翻譯器,其中適配器的作用是適配對其他上下文的調用,而翻譯器就是將調用的結果轉換成本地上下文中的元素。

在這裏,我們爲了保持代碼的簡單,沒有特意聲明這樣兩個對象,rpc 的方法在這裏起到了適配器的作用,至於翻譯器,我們只是簡單的提出了一個方法,在方法名上做了特殊的前綴修飾。

最後,ProductRpcClient 會作爲 ProductClient 的實現類,最終被注入到服務中。

03 CQRS 簡單實現

我們在看一些資料時,可能會看到有的地方叫 CQS 有的又叫 CQRS。CQS 和 CQRS 都表示命令與查詢的分離,本質上沒有太大的區別。

CQS 是在《面向對象軟件架構》一書中提出來的概念,作者 Bertrand Meyer 認爲,一個方法原則上不應該既修改數據又返回數據,所以就有了兩類方法:

1、查詢:返回數據,但不修改數據,不會產生副作用;

2、命令:修改數據,但不返回數據,存在副作用。

CQRS 是對 CQS 概念的昇華,因爲查詢端只返回數據,完全不修改數據,所以我們所有的查詢不需要走領域實體,甚至沒必要使用 ORM 框架,總之,我們可以通過各種手段來提升查詢的效率。

關於 CQS 與 CQRS 的更多信息,可以參考這篇文章,和這篇。

下圖是在各種技術文章中你會經常看到的一個非常典型的 CQRS 架構示例:

圖中左側部分代表的是對 Command 的處理,右側是對 Query 的執行。很明顯的一個區別是,在 Query 中不再強制必須走領域模型,而是在應用層可以直接訪問基礎設施層。

在實際開發中,對 Query 的處理其實是比較靈活的,其目的無外乎是提高查詢的效率,另一方面也可以保證領域模型職責的單一。通常在查詢相對簡單的時候會複用領域模型,在稍微複雜時,會直接訪問底層的數據模型,如果查詢變得更加複雜,會將數據的存儲也獨立出來。

下面我們就依次說說這幾種情況要如何處理。

複用領域模型

這種是最簡單的情況,對應的讀模型就是領域模型,要查詢的數據基本上都是模型裏的屬性。

比如,我們有一個庫存的聚合根:

展示的數據如下:

這個時候,就可以通過 assembler 直接轉成對應的 view:

因爲聚合根和倉儲是一一對應的,所以,在應用服務中直接通過 Repository 獲取領域模型即可:

使用數據模型

在分頁查詢,或者是需要多個實體聚合查詢的場景,如果直接通過 Repository 獲取領域模型再組裝,可能會產生很多無關查詢,影響效率。

這個時候,可以根據要展示的數據直接使用數據模型,或者通過 sql 只獲取指定的某幾個字段。

比如,我們有 Product 和 Category 兩個聚合根,它們都包含了大量的屬性和業務邏輯,但是我們要展示的數據比較簡單:

這個時候就可以通過直接 sql 的形式來繞過領域模型:

使用獨立的讀模型

這種情況下,一般對應的查詢場景都比較豐富,通常都會有一個獨立的查詢服務,各種數據在聚合處理之後統一放到查詢服務中。

如下所示,訂單在創建後,會使用 EventPublisher 來發布相應的事件:

在訂單查詢服務中,會對訂單創建這個事件進行監聽,當收到對應的消息時,會將訂單信息存儲到 ES 裏。

如此一來,訂單數據就同時存在於 MySQL 以及 ES 中。而在查詢的時候會只通過 ES。

04 結語

在這篇文章中,我們介紹了實踐領域驅動設計的時候應該如何組織代碼結構、如何進行上下文的集成,以及在複雜查詢場景中使用 CQRS。這些內容我同樣是用腦圖的形式爲你總結:

希望通過今天的講解,你能夠更遊刃有餘地應對開發中遇到的各種問題。但總地來說,DDD 只是一種思想,所謂的分層架構也並不是事實上的標準,在實際應用時,還要結合自身的理解,可以適當地去創新或進行改進。

到目前爲止,關於領域驅動設計的所有內容就都已經介紹完了。在下一篇文章中,我們會結合一個虛構的商城系統,帶你實戰領域驅動設計。

【技術專家】

于振

現於某大型互聯網公司,負責架構工作

曾就職於美團、快手等一線互聯網公司

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/mxs4MtaH1u3pbgNF5jETWw