DDD(領域驅動設計)是 Eric Evans 於 2003 年提出的解決複雜的中大型軟體的方法,開始一直不慍不火。直到 Martin Fowler 於 2014 年發表的論文《Microservices》引起大家對微服務的關註,DDD 才重新慢慢的回到了大眾的視野中。 DDD 這幾年升溫的同時,... ...
嘗試用大家都能聽得懂的話,結合我們在增值業務中的具體實現,分享一下我們從入門到實踐 DDD 的一些心得。
0. 寫在前面的
DDD(領域驅動設計)是 Eric Evans 於 2003 年提出的解決複雜的中大型軟體的方法,開始一直不慍不火。直到 Martin Fowler 於 2014 年發表的論文《Microservices》引起大家對微服務的關註,DDD 才重新慢慢的回到了大眾的視野中。
DDD 這幾年升溫的同時,也收到了很多行業人員對 DDD 的負面意見,主要原因大概有“晦澀難懂過於抽象”、“很難找到實際的案例參考”、“不知道怎麼落地”等。
筆者在學習 DDD 的過程中,也遇到了這些問題。不過在經過幾個月的學習-實踐,逐漸掌握了 DDD 的一些思想後,感覺還是確實有所受益,所以這裡嘗試用白話去總結我們從入門到實踐的過程,儘量每一個概念都用我們的具體實現做出例子,希望能對想一起學習 DDD 的同事有所幫助。
1.一個維護中的業務系統引出的思考
我們後臺+前端大概 6-8 個開發同事這幾年一起維護了一個帶貨類的項目,這個項目我們用了最傳統的三層模型來搭建,大概是如下的模型:
當這個項目維護幾年之後,逐漸出些了一些有意思的情況,我挑選一些主要環節發現的代表性問題介紹下:
情況 1(代碼層面):少部分代碼可讀性在長期不同人員的修改下變得越來越差。如某個帶貨的核心 rpc 邏輯沒有任何嵌套平鋪在一個函數,單函數代碼行數達到幾百行,可讀性和維護性極差,成功化身為“技術護城河”。
情況 2(微服務層面): 某些微服務初始職能劃分較為簡單,導致少量模塊在後續快速的迭代中快速膨脹。如其中的 mp 模塊,原本職能是用來承接 B 端門戶的功能,當我們決定拆分這個龐大的模塊時,這個模塊已經承載了 204 個 rpc。過多的能力承擔讓它編譯變慢、變成鏈路單點、改動較多、一旦出現問題影響較大。
情況 3(業務團隊層面):帶貨項目會使用一些其他業務系統的介面和數據結構,當這些業務系統想要修改這些介面和數據結構的時候 ,偶爾可能沒有察覺這裡的依賴導致線上問題, 或者溝通過來發現耦合處比較多不容易改動。
對這個項目的維護引出了我們一些思考,在一個複雜業務系統中:代碼結構要如何設計、微服務的橫/縱向職能要如何劃分、業務團隊之間如何交互,才能持續在長期快速、多人協作的迭代中保證系統可維護性、拓展性、高內聚低耦合和穩定性。
而傳統的開發模式不管是面向過程(POP)還是面向對象(OOP)的思維,都沒辦法從微服務層面指導我們找到這些問題的答案。大概有兩種方法解決這個問題:
1)尋找一個總是有時間、總能做出正確決策的中心節點同事,介入每一處全局/細節的設計並統一做出決策。2)尋找一個新的規則/規範來做指導,讓每一位開發都能有做出正確決策的依據。在 Tencent 的氛圍和環境中,2)無疑是更合理的,所以我們想到了領域驅動設計(DDD)。
2.DDD 的分層架構
DDD 最有標誌性的一點,就是將傳統軟體設計三層模型轉化為了四層模型,這個轉化如下圖所示:
乍看之下,四層架構引入了很多概念,如領域服務、領域對象、 DTO、倉儲等等。我們先不用在意這些細節概念,因為下一節我們會逐個分析併列舉我們的實現例子。我們先關註這幾個關鍵的層:用戶界面層、應用層、領域層、基礎設施層。我們來看下他們的職能分工:
用戶界面層:網路協議的轉化/統一鑒權/Session 管理/限流配置/前置緩存/異常轉換
應用層:業務流程編排(僅編排,不能存在業務邏輯)/ DTO 出入轉化
領域層:領域模型/領域服務/倉儲和防腐層的介面定義
基礎設施層:倉儲和防腐層介面實現/存儲等基礎層能力
這裡必須要說的是,這四層不一定是指物理四層,也可以在一個微服務中拆分邏輯四層。四層架構有很多變種,如六邊形架構、洋蔥架構、整潔架構、清晰架構等等。這些繁多的概念我們這裡不過多討論,僅以洋蔥架構為例,著重強調 DDD 中的依賴倒置(DIP),以便後面更容易介紹倉儲/防腐層等概念。
依賴倒置(DIP): 1.高級模塊不應依賴於低級模塊。兩者都應依賴抽象。 2.抽象不應依賴細節。細節應依賴於抽象。
如上,洋蔥架構越往裡依賴越低,越是核心能力。基礎設施層在最外面,依賴其他層,這是是因為 DDD 中其他層等需要定義自己需要的基礎能力介面,而基礎設施層負責依賴並實現這些介面,從而實現整體依賴倒置。這體現了 DDD 的由全局入細微、自頂層向下層的設計思維。
3.DDD 的概念和實踐
1)戰略和戰術
DDD 的落地過程,其實就是戰略建模和戰術建模。
戰略建模,是指:通過 DDD 的理論,對業務需求進行拆解分析,劃分子域,梳理限界上下文,通過領域語言從戰略層面進行領域劃分以及構建領域模型。並且在在構建領域模型的過程中梳理出業務對應的聚合、實體、以及值對象。
戰術建模,是指:以領域模型基礎,通過限界上下文作為服務劃分的邊界進行微服務拆分,在每個微服務中進行領域分層,實現領域服務,從而實現領域模型對於代碼映射目的,最終實現 DDD 的落地實施。
當然,戰略和戰術的建模除了要考慮業務形態,還要考慮到組織架構,就如同康威定律中的表達,溝通架構會影響技術架構。
康威定律:任何組織在設計一套系統(廣義概念上的系統)時,所交付的設計方案在結構上都與該組織的溝通結構保持一致。
2)領域
DDD 在解決複雜的問題的時候,使用的是分而治之的思想。而這個分而治之的思想,就是從領域開始,一個領域就是一個問題空間,而我們在拆分這個問題空間的時候,也就是在劃分子領域和尋找它的解系統的過程。
實踐例子:
如我們某個新的增值業務,就是看成是的大的增值業務域,接下來我們通過 DDD 來指導拆分它。
3)子域
如果一個領域太大太複雜,涉及到的業務規則、交互流程、領域概念太多,就不能直接針對這個大的領域進行建模。這時就需要將領域進行拆分,本質上就是把大問題拆分為小問題,把一個大的領域劃分為了多個小的領域(子域)。
子域可以分為三類:
核心子域:業務成功的核心競爭力。
通用子域:不是核心,但被整個業務系統所使用 。
支撐子域:不是核心,不被整個系統使用,完成業務的必要能力。
子域的劃分除了分治了大的問題空間,也劃定了工作的優先順序。我們應該給予核心域最高的優先順序和最大的資源。在實施 DDD 的過程中,我們也是主要關註於核心域。
實踐例子:
子域的劃分,需要比較強的業務知識和產品研發集體討論,準確和深入的業務見解在這一階段尤為重要。這裡我們不對業務知識深入討論,僅展示下我們的對增值業務域的拆解結果。
這裡要說的是,套餐域在實現的過程中由於產品需求變化概念被廢棄了,但是由於我們的子域拆分,套餐域和其他域實現上沒有任何耦合,所以廢棄套餐域概念的廢棄就像拆掉一個積木一樣,對整套系統沒有任何影響,也不會遺留任何不必要的包袱代碼。
4)限界上下文
要理解限界上下文,首先要先介紹通用語言。通用語言是 DDD 非常重要的一點。比如商品這個概念,在商品域里是指備上架的商品, 包含了 id、介紹、文檔等。在交易域里其實是指訂單中被交易的實體,關註的是 id、成交時刻的售價等參數、成交數量。而如果不能明確這些概念和他們的關係就會讓開發人員的實現變的隨心所欲和模糊。
而限界上下文是就是劃分一個邊界,當領域模型被一個顯示的邊界所包圍時,其中每個概念的含義應該是明確且有唯一的含義。
我覺得初學者最常碰到的問題,肯定"明明已經有子域了,為什麼還會有限界上下文這個概念"。子域是一個子問題空間,而限界上下文的作用是指導如何設計這個問題空間的解系統。換句話說,限界上下文才是真正用來指導微服務劃分。一般來說一個子域對應一個或多個限界上下文。
劃分限界上下文可以參考如下的規則:1) 概念是否有歧義:如果一個模型在一個上下文裡面有歧義,就說明可以繼續拆分限界上下文。
2)外部系統:可以把與外部系統交互的那部分拆分出去降低外部系統對我們我們的核心業務邏輯的影響。
3)組織架構:不同團隊最好在不同的限界上下文裡面開發,避免溝通不順暢、集成困難等問題。可以參考上述"康威定律"。
實踐例子 1:
如上所述,商品這個概念,是需要用限界上下文在不同場景區分開的。當然這也會導致兩個限界上下文之間會有依賴。通過 DDD 的概念可以指導我們進行如下實現。
其中 gateway/gatewayimpl 是防腐層的實現,DTO 是指數據傳輸對象,APP 是指商品應用層。兩個不同顏色的商品是指兩個上下文中分別進行定義的不同的實體或值對象。
實踐例子 2:
交易域中,有兩個訂單的概念,其中第一個訂單的概念是指業務層訂單, 第二個訂單的概念是指內部基礎層訂單。業務訂單更關註發生交易的成交商品信息,這個訂單是用戶需要的。基礎層訂單更關註交易底層的過程信息,這個訂單更多是我們內部人員需要的,用戶不理解。
當時有個思路是想讓基礎層團隊的同學額外開發直接支持基礎層訂單存儲業務信息,這明顯是不符合 DDD 限界上下文劃分規則 1)和 3)的,是需要通過限界上下文解耦開的。所以我們在交易域中拆分兩個上下文,後續從微服務層面也是相互獨立的微服務,各自管理各自的領域實體和值對象。
5)防腐層
當兩個限界上下文相互調用的時候,需使用防腐層(ACL)來進行兩個限界上下文的隔離,並實現 value object 的轉換。避免不同上下文直接互相調用,不然一旦被調用上下文被修改則可能產生較大影響。
實踐例子:
實現鏈路可以參考 3.4 的例子 1,在商品域中,我們的防腐層是按照如下的目錄方式實現的, 領域層來定義領域層需要的防腐介面,基礎設施層繼承並實現防腐介面,在基礎設施層直接調用其他限界上下文。
productdomainsvr (商品限界上下文) ├── domain(領域層) │ ├── aggregate │ │ ├── spu.cpp //1)spu領域對象需要調用其他限界上下文生成id │ │ └── spu.h │ └── gateway │ └── gen_id_gateway.h //2)領域層定義調用其他限界上下文生成id的防腐介面 ├── infrastructure(基礎設施層) │ └── gatewayimpl │ └── acl(防腐層) │ ├── gen_id_gateway_impl.cpp //3)基礎設施層實現領域層定義的防腐介面,真實調用其他上下文 │ └── gen_id_gateway_impl.h
6)領域事件
兩個限界上下文除了通過使用防腐層直接調用,更多的時候是通過領域事件來進行解耦。
並不是所有領域中發生的事情都需要被建模為領域事件,我們只關註有業務價值的事情。領域事件是領域專家所關心的(需要跟蹤的、希望被通知的、會引起其他模型對象改變狀態的)發生在領域中的一些事情。
其實,領域事件的本質就是事件,我們常見的 kafka、wq 等都可以作為領域事件的實現基建。通過領域事件,可以把很輕鬆兩個限界上下文解耦
實踐例子:
在我們的增值業務中,交易域的"支付成功"就是一個領域事件,計費域訂閱這個領域事件,從而可以根據這個事件調整客戶的計費資源包實體。
可以想象,如果這裡沒有採用領域事件, 而是交易域直接調用計費域的 rpc 通知交易成功,那麼當後續有其他域需要接受“支付成功”這個事件,或者,計費域被調用的介面出現故障。都會讓交易域陷入麻煩,前者需要交易域不停的堆疊調用外部 rpc 的代碼並讓系統變得不穩定,後者則直接會讓計費域的故障影響到用戶交易。
7)實體/值對象
實體是指上下文中唯一的且可持續變化的基礎單元,在其生命周期中可以通過穩定的唯一 id 來標識。實體在我們代碼中以領域對象的形態存在,同時具備屬性和方法,實體是 DDD 用來實現充血編程、解決貧血症的關鍵。
與實體相對應的就是值對象,如果沒有唯一標識就是值對象。值對象一般是嵌套在實體裡面的。
實踐例子:
商品域中的實體和值對象如下
實體 | 描述 | 關鍵值對象 |
---|---|---|
SPU | 指一個被上架的服務。 | spu_id, spu_type,狀態等。 |
SKU | 指一個服務具體的單項套餐。 | sku_id, 規格,價格等。 |
折扣 | 自定義折扣。 | 折扣 id,折扣類型,折扣比例等。 |
8)聚合/聚合根
把關係緊密的實體放到一個聚合中,每個聚合中有一個實體作為聚合根,所有對於聚合內對象的訪問都通過聚合根來進行,外部對象只能持有對聚合根的引用。每個聚合都可以有一個獨立的上下文邊界。
聚合應劃分的儘量小,一個聚合只包含一個聚合根實體和密不可分的實體,實體中只包含最小數量的屬性。設計這樣的小聚合有助於進行後續微服務的拆分。
如果一個 rpc 所實現的功能是跨聚合的,那跨聚合的編排協調工作應該放在應用層來實現。
實踐例子:
我們可以在 6)中的例子劃分如下的聚合。
聚合 | 實體 | 是否是根 |
---|---|---|
聚合 1 | 服務 SPU | 是 |
服務 SKU | 否 | |
聚合 2 | 折扣 | 是 |
在底層存儲落表上, spu 實體/折扣實體作為表的一行, 而 sku 實體在這種聚合建模的指引下我們設計成 spu 聚合根的一列。
在微服務拆分上,如果想拆到最細粒度, 可以把兩個聚合按照各自上下文拆成獨立的微服務。當然這種落地實現並不是 DDD 強行要求的,我認為一些時候我們也可以從開發維護效率的角度考慮, 將一些有關聯的小上下文放在一個為微服務上。我們在處理商品域上選擇了後者。
9)DTO/領域對象/Data object
當一個請求進入 DDD 所設計的系統中,這個請求的形態會根據所在的層級發生如下變換,DTO<->領域對象<->Data object。
DTO 是指對外傳輸的其他服務需要理解的結構,領域對象是指同時包含了屬性和方法的領域實體封裝,Data object 則是真正用於最終存儲的數據結構。
這裡其實很容易發現,DTO 的存在雖然符合其他調用方最少知識原則(LKP),但如果連最簡單的查詢請求都需要做這三級的轉換,那無疑是會加重開發的複雜度,變成為了設計模式而設計模式。
最少知識原則(迪米特法則,LKP):一個軟體實體應當儘可能少地與其他實體發生相互作用。這裡的軟體實體是一個廣義的概念,不僅包括對象,還包括系統、類、模塊、函數、變數等。
所以 DDD 在這裡一般會使用 CQRS(讀寫責任分離)架構,來保證一些簡單的查詢請求不會因為領域建模而變得過於複雜。CQRS(讀寫責任分離)基於 CQS(讀寫分離),使用了 CQRS 的 DDD 對象轉換流程如下:
實踐例子:
我們的實現是在領域對象中封裝了轉換的 convert 函數(當然也可以在基礎設施層將 convert 方法拆分出來做單獨的封裝),用於將 DTO 轉換為領域對象,或者將領域對象轉換為 DO。下麵是我們明細域的實際轉換代碼和轉換過程。
//1.領域對象中定義convert方法 class DetailRecord { public: int ConvertFromDTO(const google::protobuf::Message& oDto); int ConvertToDO(detailrecordinfrastructure::DetailRecordDO & oDo); /*...*/ }; //2.應用層調用方法將DTO轉化為領域對象, 然後調用倉儲介面進行持久化 int DetailrecordApplication::InsertDetailRecord(unsigned int head_uin, const InsertDetailRecordReq& req, InsertDetailRecordResp* resp) { int iRet = 0; class DetailRecord oRecord; iRet = oRecord.ConvertFromDTO(req); //生成領域對象,可以同時利用領域對象的方法進行自檢等操作 /*...*/ iRet = m_oDetailRecordGateway->Save(oRecord); //調用倉儲介面進行持久化 /*...*/ return iRet; } //3.在倉儲中將領域對象轉化為Dataobject,進行落存儲操作,併發布領域事件 int DetailRecordGatewayImpl::Save(DetailRecord & oEntity){ detailrecordinfrastructure::DetailRecordDO oDo; int iRet = oEntity.ConvertToDO(oDo); /*...*/ iRet = oKvMapper.insert(oDo); //實際落存儲 /*...*/ iRet = oEventMapper.publish(oDo); //發送領域事件 /*...*/ return iRet; }
10)倉儲
倉儲是領域層由定義介面,它抽象了業務邏輯中對實體的訪問(包括讀取和存儲)的技術細節。它的作用就是通過隔離具體的存儲層技術實現來保證業務邏輯的穩定性。註意,倉儲只是介面的定義是在領域層,但是它的實現是在基礎設施層。
倉儲不是資料庫 Dao!!!
倉儲不是資料庫 Dao!!!
倉儲不是資料庫 Dao!!!
重要的事情說三遍,倉儲是從業務邏輯的角度抽象出來的介面,所以倉儲的介面在實現上,一般是一個聚合對應一個倉儲實現,倉儲的需要用領域對象做參數。倉儲介面的命名也可以取 save 這種更業務的命名, 而避免傳統 dao 的 insert/set 等這種明明。
實踐例子:
通過 3.9 的例子,我們可以發現,倉儲用於持久化的介面里,不但包含了寫 kv 的操作,還包含了發佈領域事件等操作,這就是因為倉儲是從業務邏輯角度抽象出來的介面,領域層只需要理解 save 這個業務操作,而不應該理解 save 的過程包含了落存儲、發佈領域事件等具體流程。
//1.領域層定義DetailRecord倉儲的介面 class DetailRecordGateway { public: /*...*/ virtual int Save(DetailRecord & oEntity) = 0; /*...*/ }; //2.基礎設施層繼承領域層的倉儲介面進行實現 class DetailRecordGatewayImpl : public DetailRecordGateway { public: /*...*/ virtual int Save(DetailRecord & oEntity); /*...*/ }; //3.倉儲save介面具體實現 int DetailRecordGatewayImpl::Save(DetailRecord & oEntity){ detailrecordinfrastructure::DetailRecordDO oDo; int iRet = oEntity.ConvertToDO(oDo); /*...*/ iRet = oKvMapper.insert(oDo); //實際落存儲 /*...*/ iRet = oEventMapper.publish(oDo); //發佈領域事件 /*...*/ return iRet; }
11)領域服務
當一些能力不適合放在某個領域對象中實現,又因為過於複雜不應該放在應用層來實現。可以把這些操作封裝成領域服務的中方法,由應用層編排領域層的領域對象和領域服務方法來完成具體的業務功能。
4.DDD 的代碼腳手架
我們基於對 DDD 的理解和 WXG 的 svrkit 框架,設定我們的代碼腳手架。腳手架的目錄如下所示,希望可以給想一起實踐的同事拋磚引玉,也歡迎大家來找我們一起討論:
項目目錄
├── adapter(物理用戶界面模塊)
├── domainsvr(領域微服務)
│ ├── detailrecorddomainsvr(明細域微服務)
│ │ ├── adapter(用戶界面層)
│ │ ├── application(應用層)
│ │ │ ├── detailrecord_application.cpp(應用層方法)
│ │ ├── domain(領域層)
│ │ │ ├── aggregate(聚合根)
│ │ │ │ ├── detail_record.cpp(領域對象)
│ │ │ │ └── detailrecordaggregate.proto(聚合根的值對象)
│ │ │ ├── entity(非根實體)
│ │ │ │ └── detailrecordentity.proto(非根實體的值對象)
│ │ │ ├── gateway
│ │ │ │ └── detail_record_gateway.h(倉儲介面)
│ │ │ └── detailrecord_domain_service.cpp(領域服務)
│ │ ├── infrastructure(基礎設施層)
│ │ │ ├── gatewayimpl
│ │ │ │ ├── acl(防腐層實現)
│ │ │ │ └── detail_record_gateway_impl.cpp(倉儲實現)
│ │ │ └── detailrecordinfrastructure.proto(Data object定義)
│ │ └── detailrecord.proto(DTO定義)
└── infrastructuresvr(物理基礎設施模塊)
作者:kunqian
本文來自博客園,作者:古道輕風,轉載請註明原文鏈接:https://www.cnblogs.com/88223100/p/Advanced-background-development_vernacular-DDD-from-introduction-to-practice.html