對領(lǐng)域驅(qū)動設(shè)計中關(guān)鍵的一些概念,大家有了更為深入的認(rèn)識是不夠的,在具體實踐中我們還會面臨諸如代碼如何分層、不同上下文之間如何集成,以及某些時候還會用到CQRS。本文就來補(bǔ)齊領(lǐng)域驅(qū)動設(shè)計中剩余的一些內(nèi)容,希望能夠助你更游刃有余地應(yīng)對開發(fā)中遇到的各種問題。
作者 | 于振
責(zé)編 | 韓楠
你好,今天我想與你聊聊DDD中的應(yīng)用架構(gòu)。在過往我分享的幾篇文章中,我們介紹了領(lǐng)域驅(qū)動設(shè)計中的一些基本概念,這里,再做一個簡單的回顧。
·《基礎(chǔ)問題不簡單|怎么合理使用值對象,讓你的代碼更清晰、更安全?》
·《不想只做Cruder?實體、聚合根,還不快去了解下》
·《如何通過倉儲,對實體進(jìn)行持久化處理?》
·《實體表達(dá)力不夠?那你應(yīng)該試試領(lǐng)域服務(wù)》
·《如何使用工廠,進(jìn)一步解耦領(lǐng)域?qū)ο蟮穆氊?zé)》
·《領(lǐng)域模型細(xì)節(jié)太多不便使用?那就加個應(yīng)用服務(wù)吧》
·《DDD在Go中如何落地|如何在業(yè)務(wù)中使用領(lǐng)域事件?》
使用值對象和實體幫助我們構(gòu)建了具有豐富行為的領(lǐng)域模型,實體創(chuàng)建出來后需要通過倉儲進(jìn)行持久化,如果領(lǐng)域模型跟數(shù)據(jù)模型存在差異,就還需要通過 Converter 進(jìn)行轉(zhuǎn)換,以及通過 Snapshot 對實體進(jìn)行追蹤。
如果某些行為不適合放到某個實體上,就需要使用領(lǐng)域服務(wù),同時,為了一定程度地防止領(lǐng)域服務(wù)的濫用,我們規(guī)定領(lǐng)域服務(wù)在命名上必須有一個動詞。
為了解耦領(lǐng)域?qū)ο蟮膭?chuàng)建過程和其自身行為,我們又介紹了工廠方法。
對于外部用戶來說,領(lǐng)域之內(nèi)的各個對象描述的,都是細(xì)粒度的領(lǐng)域概念,為了方便外部調(diào)用,同時屏蔽領(lǐng)域?qū)ο蟮木唧w細(xì)節(jié),就又有了應(yīng)用服務(wù)。
最后,通過領(lǐng)域事件,進(jìn)一步解耦了不同上下文之間的依賴,即使在同一邊界之內(nèi)的不同的聚合根,也可以實現(xiàn)數(shù)據(jù)的最終一致性。
至此,大家應(yīng)該對領(lǐng)域驅(qū)動設(shè)計中關(guān)鍵的一些概念,有了更為深入的認(rèn)識。但僅僅是這些應(yīng)該是還不夠的,在具體實踐中,我們還面臨著諸如代碼如何分層、不同上下文之間如何集成,以及某些時候還會用到CQRS。
在這篇文章中,我們就來補(bǔ)齊領(lǐng)域驅(qū)動設(shè)計中剩余的一些內(nèi)容。
首先,我們從代碼的分層開始說起。
01? DDD的分層架構(gòu)
分層架構(gòu)作為一種歷史悠久的架構(gòu)模式,在很多的場景中都得到了應(yīng)用。
大家比較熟悉的應(yīng)該就是 MVC 對應(yīng)用三層架構(gòu)的拆分。MVC 這種分層是自上而下的。
隨著業(yè)務(wù)越來越復(fù)雜,人們逐漸發(fā)現(xiàn), MVC 架構(gòu)在應(yīng)對復(fù)雜的業(yè)務(wù)問題時會顯得力不從心。
于是,后面逐漸演化出了六邊形架構(gòu)、洋蔥架構(gòu)、整潔架構(gòu)等架構(gòu)模式。這幾種架構(gòu)也是一種分層架構(gòu),但這種分層不是由上而下的,而是由內(nèi)而外的。
我們以洋蔥架構(gòu)為例:
可以看到,最關(guān)鍵的是中心的領(lǐng)域模型,它包括了所有的應(yīng)用邏輯與規(guī)則。在這一層中不會直接引用技術(shù)實現(xiàn),這樣就能夠確保在技術(shù)層面的改動不會影響到領(lǐng)域核心。
在領(lǐng)域?qū)又庥职祟I(lǐng)域服務(wù)層、應(yīng)用服務(wù)層,而具體的技術(shù)實現(xiàn)則是被置于最外層的。
這種架構(gòu)的好處就在于,它屏蔽掉了應(yīng)用程序在UI層、DB層,以及各種中間件層的本質(zhì)區(qū)別,所有的這些外部資源都被抽象成了對系統(tǒng)的輸入輸出,然后我們就能夠以一致的方式來處理不同的請求類型,并且,在與實際運(yùn)行的設(shè)備和數(shù)據(jù)庫相隔離的情況下,也可以先行開發(fā)和測試。
在 DDD 的技術(shù)實現(xiàn)中,就用到了這種分層方式。
下圖是 Eric Evans 在其經(jīng)典著作《領(lǐng)域驅(qū)動設(shè)計》中給出的一個典型的 DDD 系統(tǒng)所采用的分層架構(gòu):
在上圖中可以看到,整個架構(gòu)劃分成了四個層,各層所表示的含義及其職責(zé)描述如下:
1、用戶接口層
這一層主要負(fù)責(zé)直接面向外部用戶或者系統(tǒng),接收外部輸入,并返回結(jié)果。
用戶接口層是比較輕的一層,不含業(yè)務(wù)邏輯。可以做一些簡單的入?yún)⑿r灒部梢杂涗浺幌略L問日志,對異常進(jìn)行統(tǒng)一的處理。同時,對返回值的封裝也應(yīng)當(dāng)在這層完成。
2、應(yīng)用層
應(yīng)用層,通常是用戶接口層的直接使用者。
但是在應(yīng)用層中并不實現(xiàn)真正的業(yè)務(wù)規(guī)則,而是根據(jù)實際的 use case 來協(xié)調(diào)領(lǐng)域?qū)犹峁┑哪芰Γ部梢哉f,應(yīng)用層主要做的是編排工作。
另外,應(yīng)用層還負(fù)責(zé)了事務(wù)這個比較重要的功能。
3、領(lǐng)域?qū)?/strong>
領(lǐng)域?qū)邮钦麄€業(yè)務(wù)的核心層。我們一般會使用充血模型來建模實際的領(lǐng)域?qū)ο蟆?/p>
同時,由于業(yè)務(wù)的核心價值在于其運(yùn)作模式,而不是具體的技術(shù)手段或?qū)崿F(xiàn)方式。因此,領(lǐng)域?qū)拥木幋a原則上不允許依賴其他外部對象。
4、基礎(chǔ)設(shè)施層
基礎(chǔ)設(shè)施層,是在技術(shù)上具體的實現(xiàn)細(xì)節(jié),它為上面各層提供通用的技術(shù)能力。
比如我們使用了哪種數(shù)據(jù)庫,數(shù)據(jù)是怎么存儲的,有沒有用到緩存、消息隊列等,都是在這一層要實現(xiàn)的。
對于這四個層次的劃分,大家通常都沒有太多的異議。但是在層與層之間的依賴關(guān)系上,后續(xù)又衍生出了很多的改良版本。比如在 IDDD 一書中,就給出了下圖所示的分層架構(gòu):
這里最大的不同,就是將領(lǐng)域?qū)臃诺搅苏麄€架構(gòu)的最下面,也即領(lǐng)域?qū)又戮筒辉儆腥魏蔚钠渌蕾嚒_@么做是沒有問題的,但是最上面的基礎(chǔ)設(shè)施層看起來卻怪怪的。
在實際開發(fā)中,領(lǐng)域?qū)拥念I(lǐng)域服務(wù)往往需要訪問持久化組件,以及基礎(chǔ)設(shè)施層中的其他組件,而對于持久化組件來說,不可避免地需要依賴領(lǐng)域?qū)拥膶嶓w對象。如此一來,領(lǐng)域?qū)雍突A(chǔ)設(shè)施層,就產(chǎn)生了雙向依賴關(guān)系。
實際的解決方式,就是讓領(lǐng)域?qū)雍突A(chǔ)設(shè)施層 都依賴一個統(tǒng)一的抽象,比如對于模型的持久化有 Repository 接口,對其他外部資源的訪問也可以通過接口的形式來解耦合。但是 Repository 接口跟其他接口 又有些不太一樣,Repository 因為需要參與到實體的整個生命周期中,所以在很多時候 Repository 都被看作是領(lǐng)域?qū)又械囊粏T。而對基礎(chǔ)設(shè)施層中其他組件的抽象,是不適合定義到領(lǐng)域?qū)拥摹?/p>
?? DDD代碼模型
結(jié)合上面的描述,這個時候再來看代碼的組織形式,就比較清晰了。默認(rèn)情況下,一個上下文對應(yīng)了一個服務(wù),我們這里以包含單個上下文的情況為例,給出如下的代碼目錄結(jié)構(gòu):
對上面的代碼結(jié)構(gòu)做一個簡短的說明:
• Application,對應(yīng)到架構(gòu)里的應(yīng)用層,其內(nèi)可能包含一些 assembler 和 DTO,assembler 主要用于將領(lǐng)域?qū)ο筠D(zhuǎn)換成返回需要的數(shù)據(jù)格式,這些數(shù)據(jù)格式以DTO的形式進(jìn)行定義,這些DTO沒有任何的業(yè)務(wù)邏輯,就是單純的數(shù)據(jù)對象。
• domAIn,對應(yīng)的是領(lǐng)域?qū)樱瑐}儲的接口也是放在這一層的。
• handler,對應(yīng)的是架構(gòu)里的用戶接口層,但其本質(zhì)上還是屬于基礎(chǔ)設(shè)施層的一部分,這里單獨(dú)提出來也僅僅是為了凸顯它的重要性。在這一層,只可以直接訪問應(yīng)用層。
• infra,對應(yīng)的是基礎(chǔ)設(shè)施層,根據(jù)對不同資源的繼承需求,可以在 infra 下繼續(xù)分包。
• interfaces,是對基礎(chǔ)設(shè)施層中除持久化以外的中間件的抽象,也即我們在這里定義訪問中間件的接口,具體的實現(xiàn)還是放在基礎(chǔ)設(shè)施層。這里將接口單獨(dú)放到一個包中,為的是避免在領(lǐng)域?qū)优c應(yīng)用層對基礎(chǔ)設(shè)施層的直接依賴,如此就通過依賴反轉(zhuǎn)解耦了具體的技術(shù)細(xì)節(jié)。
至此,我們就明確了代碼的分層組織結(jié)構(gòu),以及彼此之間的依賴關(guān)系。
我們在文章開頭提到的第二個問題是上下文的集成,在實際工作中,相信大家都會使用到微服務(wù),這樣一來,如何集成就成為我們必須要考慮的問題。
02? 與其他上下文集成
上下文的集成無外乎兩種方式, 一種是通過RPC進(jìn)行集成,另一種是通過領(lǐng)域事件進(jìn)行集成。
通過領(lǐng)域事件集成,也就是領(lǐng)域事件的發(fā)送和消費(fèi),這個我們在前面的文章中已經(jīng)做了比較詳細(xì)的介紹,這里不再贅述。
接下來主要說說通過 RPC 進(jìn)行集成。
?? 開放主機(jī)與發(fā)布語言
我們先來看一個在 DDD 中,經(jīng)常用來表示集成方式的示例圖:
其中,被集成方(A上下文,U 是 Upstream 的縮寫)采用了開放主機(jī)和發(fā)布語言的方式,而集成方(B上下文,D 是 Downstream 的縮寫)則使用了防腐層。幾個縮寫的含義如下:
• OHS(Open Host Service):開放主機(jī)服務(wù),即定義一種協(xié)議,子系統(tǒng)可以通過該協(xié)議來訪問你的服務(wù)。
• PL(Published Language):發(fā)布語言,通常跟 OHS 一起使用,用于定義開放主機(jī)的協(xié)議。
• ACL(Anticorruption Layer):防腐層,一個上下文通過一些適配和轉(zhuǎn)換,來跟另一上下文交互。
我們平時大多數(shù)時候的開發(fā)工作,都是跟 Grpc/Kitex 等 RPC 框架打交道的,不同的框架在設(shè)計之初都會定義一份協(xié)議,只有符合協(xié)議要求的請求 才能被正確地識別和處理。比如 Grpc 使用 HTTP2 作為傳輸協(xié)議,而 Kitex 則主要使用自定義的 TTHeader 協(xié)議。
這些框架在使用上,一個共同特點就是需要通過 IDL(Interface description language) 來定義服務(wù)可以提供的能力。IDL 中可以定義多個接口,每個接口都有一個方法名,同時需要指定傳遞什么參數(shù),返回什么數(shù)據(jù)。這樣的一份 IDL 就可以認(rèn)為是我們?yōu)橄到y(tǒng)定義的發(fā)布語言。
還是以前面多次提到的商品服務(wù)為例,商品服務(wù)作為上下文集成中的被集成方,通過 thrift 定義了其可以提供的服務(wù),比如下面是對 GetProductDetail 接口的定義:
所以,如果我們是一個服務(wù)的提供方,只要我們使用 Grpc/Kitex,那么就可以認(rèn)為我們是使用 OSH 和 PL 方式來進(jìn)行集成的。
?? 防腐層
防腐層一般用在下游上下文中,可以用來隔絕上游上下文中可能發(fā)生的變化。
在上面的例子中,商品服務(wù)提供了一個 GetProductDetail 接口,用以返回關(guān)于 Product 的全量信息。但是對于其他集成方來說,可能只是想拿到產(chǎn)品的很少一部分信息,比如在訂單服務(wù)中要展示訂單的詳情,而詳情只需要產(chǎn)品的圖片和名稱即可。
可以看到,作為服務(wù)的提供方,其具有追求普適性和靈活性的特點,而服務(wù)的調(diào)用方,在使用時卻想要能夠集中滿足特定需求的接口。
這種張力是導(dǎo)致在邊界上出現(xiàn)問題的主要原因,是無法避免的,但是卻是可以解決的,應(yīng)對的方法就是使用防腐層。
從圖中可以看出,Subsystem A 和 Subsystem B 的調(diào)用關(guān)系并不是直接產(chǎn)生的,都要通過中間的一個ACL,ACL 除了負(fù)責(zé)執(zhí)行具體的技術(shù)性調(diào)用,還將 A 和 B 的領(lǐng)域模型隔離開來,并承擔(dān)了彼此模型之間的翻譯轉(zhuǎn)換功能。
除此以外,還可以在 ACL 做緩存、兜底、開關(guān)等功能。
對于集成方來說,一般采用獨(dú)立接口的形式,接口的定義放在 interfaces 中,上面這個例子就可以這樣定義:
因為實現(xiàn)是跟具體的技術(shù)相關(guān)的,所以實現(xiàn)需要放到基礎(chǔ)設(shè)施層。整體的目錄層級如下:
具體的實現(xiàn)可以參考下面的代碼,簡單來說就是將通過 RPC 獲取到的上游模型,轉(zhuǎn)換為自己領(lǐng)域內(nèi)的模型:
在傳統(tǒng)意義的防腐層實現(xiàn)中,會有一個適配器和一個對應(yīng)的翻譯器,其中適配器的作用是適配對其他上下文的調(diào)用,而翻譯器就是將調(diào)用的結(jié)果轉(zhuǎn)換成本地上下文中的元素。
在這里,我們?yōu)榱吮3执a的簡單,沒有特意聲明這樣兩個對象,rpc的方法在這里起到了適配器的作用,至于翻譯器,我們只是簡單的提出了一個方法,在方法名上做了特殊的前綴修飾。
最后,ProductRpcClient 會作為 ProductClient 的實現(xiàn)類,最終被注入到服務(wù)中。
03? CQRS 簡單實現(xiàn)
我們在看一些資料時,可能會看到有的地方叫CQS有的又叫CQRS。CQS 和 CQRS 都表示命令與查詢的分離,本質(zhì)上沒有太大的區(qū)別。
CQS 是在《面向?qū)ο筌浖軜?gòu)》一書中提出來的概念,作者 Bertrand Meyer 認(rèn)為,一個方法原則上不應(yīng)該既修改數(shù)據(jù)又返回數(shù)據(jù),所以就有了兩類方法:
1、查詢:返回數(shù)據(jù),但不修改數(shù)據(jù),不會產(chǎn)生副作用;
2、命令:修改數(shù)據(jù),但不返回數(shù)據(jù),存在副作用。
CQRS 是對 CQS 概念的升華,因為查詢端只返回數(shù)據(jù),完全不修改數(shù)據(jù),所以我們所有的查詢不需要走領(lǐng)域?qū)嶓w,甚至沒必要使用 ORM 框架,總之,我們可以通過各種手段來提升查詢的效率。
關(guān)于CQS與CQRS的更多信息,可以參考這篇文章,和這篇。
下圖是在各種技術(shù)文章中你會經(jīng)常看到的一個非常典型的 CQRS 架構(gòu)示例:
圖中左側(cè)部分代表的是對 Command 的處理,右側(cè)是對 Query 的執(zhí)行。很明顯的一個區(qū)別是,在 Query 中不再強(qiáng)制必須走領(lǐng)域模型,而是在應(yīng)用層可以直接訪問基礎(chǔ)設(shè)施層。
在實際開發(fā)中,對 Query 的處理其實是比較靈活的,其目的無外乎是提高查詢的效率,另一方面也可以保證領(lǐng)域模型職責(zé)的單一。通常在查詢相對簡單的時候會復(fù)用領(lǐng)域模型,在稍微復(fù)雜時,會直接訪問底層的數(shù)據(jù)模型,如果查詢變得更加復(fù)雜,會將數(shù)據(jù)的存儲也獨(dú)立出來。
下面我們就依次說說這幾種情況要如何處理。
?? 復(fù)用領(lǐng)域模型
這種是最簡單的情況,對應(yīng)的讀模型就是領(lǐng)域模型,要查詢的數(shù)據(jù)基本上都是模型里的屬性。
比如,我們有一個庫存的聚合根:
展示的數(shù)據(jù)如下:
這個時候,就可以通過 assembler 直接轉(zhuǎn)成對應(yīng)的 view:
因為聚合根和倉儲是一一對應(yīng)的,所以,在應(yīng)用服務(wù)中直接通過 Repository 獲取領(lǐng)域模型即可:
?? 使用數(shù)據(jù)模型
在分頁查詢,或者是需要多個實體聚合查詢的場景,如果直接通過 Repository 獲取領(lǐng)域模型再組裝,可能會產(chǎn)生很多無關(guān)查詢,影響效率。
這個時候,可以根據(jù)要展示的數(shù)據(jù)直接使用數(shù)據(jù)模型,或者通過 sql 只獲取指定的某幾個字段。
比如,我們有 Product 和 Category 兩個聚合根,它們都包含了大量的屬性和業(yè)務(wù)邏輯,但是我們要展示的數(shù)據(jù)比較簡單:
這個時候就可以通過直接 sql 的形式來繞過領(lǐng)域模型:
?? 使用獨(dú)立的讀模型
這種情況下,一般對應(yīng)的查詢場景都比較豐富,通常都會有一個獨(dú)立的查詢服務(wù),各種數(shù)據(jù)在聚合處理之后統(tǒng)一放到查詢服務(wù)中。
如下所示,訂單在創(chuàng)建后,會使用 EventPublisher 來發(fā)布相應(yīng)的事件:
在訂單查詢服務(wù)中,會對訂單創(chuàng)建這個事件進(jìn)行監(jiān)聽,當(dāng)收到對應(yīng)的消息時,會將訂單信息存儲到ES里。
如此一來,訂單數(shù)據(jù)就同時存在于 MySQL 以及 ES 中。而在查詢的時候會只通過 ES。
04? 結(jié)語
在這篇文章中,我們介紹了實踐領(lǐng)域驅(qū)動設(shè)計的時候應(yīng)該如何組織代碼結(jié)構(gòu)、如何進(jìn)行上下文的集成,以及在復(fù)雜查詢場景中使用CQRS。這些內(nèi)容我同樣是用腦圖的形式為你總結(jié):
希望通過今天的講解,你能夠更游刃有余地應(yīng)對開發(fā)中遇到的各種問題。但總地來說,DDD只是一種思想,所謂的分層架構(gòu)也并不是事實上的標(biāo)準(zhǔn),在實際應(yīng)用時,還要結(jié)合自身的理解,可以適當(dāng)?shù)厝?chuàng)新或進(jìn)行改進(jìn)。
到目前為止,關(guān)于領(lǐng)域驅(qū)動設(shè)計的所有內(nèi)容就都已經(jīng)介紹完了。在下一篇文章中,我們會結(jié)合一個虛構(gòu)的商城系統(tǒng),帶你實戰(zhàn)領(lǐng)域驅(qū)動設(shè)計。
【技術(shù)專家】
于振
現(xiàn)于某大型互聯(lián)網(wǎng)公司,負(fù)責(zé)架構(gòu)工作
曾就職于美團(tuán)、快手等一線互聯(lián)網(wǎng)公司