天天看點

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

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

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

作者 | 于振

責編 | 韓楠

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

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

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

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

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

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

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

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

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

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

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

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

對于外部使用者來說,領域之内的各個對象描述的,都是細粒度的領域概念,為了友善外部調用,同時屏蔽領域對象的具體細節,就又有了應用服務。

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

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

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

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

01⎪ DDD的分層架構

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

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

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

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

我們以洋蔥架構為例:

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

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

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

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

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

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

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

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

1、使用者接口層

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

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

2、應用層

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

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

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

3、領域層

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

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

4、基礎設施層

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

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

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

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

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

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

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

▶︎ DDD代碼模型

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

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

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

• application,對應到架構裡的應用層,其内可能包含一些 assembler 和 DTO,assembler 主要用于将領域對象轉換成傳回需要的資料格式,這些資料格式以DTO的形式進行定義,這些DTO沒有任何的業務邏輯,就是單純的資料對象。

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

• handler,對應的是架構裡的使用者接口層,但其本質上還是屬于基礎設施層的一部分,這裡單獨提出來也僅僅是為了凸顯它的重要性。在這一層,隻可以直接通路應用層。

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

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

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

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

02⎪ 與其他上下文內建

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

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

接下來主要說說通過 RPC 進行內建。

▶︎ 開放主機與釋出語言

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

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 接口的定義:

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

是以,如果我們是一個服務的提供方,隻要我們使用 Grpc/Kitex,那麼就可以認為我們是使用 OSH 和 PL 方式來進行內建的。

▶︎ 防腐層

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

在上面的例子中,商品服務提供了一個 GetProductDetail 接口,用以傳回關于 Product 的全量資訊。但是對于其他內建方來說,可能隻是想拿到産品的很少一部分資訊,比如在訂單服務中要展示訂單的詳情,而詳情隻需要産品的圖檔和名稱即可。

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

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

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

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

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

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

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

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

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

具體的實作可以參考下面的代碼,簡單來說就是将通過 RPC 擷取到的上遊模型,轉換為自己領域内的模型:

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

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

在這裡,我們為了保持代碼的簡單,沒有特意聲明這樣兩個對象,rpc的方法在這裡起到了擴充卡的作用,至于翻譯器,我們隻是簡單的提出了一個方法,在方法名上做了特殊的字首修飾。

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

03⎪ CQRS 簡單實作

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

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

1、查詢:傳回資料,但不修改資料,不會産生副作用;

2、指令:修改資料,但不傳回資料,存在副作用。

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

關于CQS與CQRS的更多資訊,可以參考這篇文章,和這篇。

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

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

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

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

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

▶︎ 複用領域模型

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

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

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

展示的資料如下:

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

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

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

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

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

▶︎ 使用資料模型

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

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

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

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

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

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

▶︎ 使用獨立的讀模型

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

如下所示,訂單在建立後,會使用 EventPublisher 來釋出相應的事件:

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

在訂單查詢服務中,會對訂單建立這個事件進行監聽,當收到對應的消息時,會将訂單資訊存儲到ES裡。

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

如此一來,訂單資料就同時存在于 MySQL 以及 ES 中。而在查詢的時候會隻通過 ES。

04⎪ 結語

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

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

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

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

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

【技術專家】

于振

現于某大型網際網路公司,負責架構工作

曾就職于美團、快手等一線網際網路公司

繼續閱讀