「後端」探秘微信業務優化:DDD從入門到實踐

架構思考 發佈 2022-12-09T18:27:19.106572+00:00

引言 | 本文作者從微信團隊維護的帶貨類項目所遇卡點出發,嘗試用領域驅動設計方法,保障在快節奏、多人協作的項目疊代中,維持系統的可維護性、可拓展性、高內聚低耦合和穩定性。

引言 | 本文作者從微信團隊維護的帶貨類項目所遇卡點出發,嘗試用領域驅動設計方法(簡稱DDD),保障在快節奏、多人協作的項目疊代中,維持系統的可維護性、可拓展性、高內聚低耦合和穩定性。作者首先剖解相關概念原理,之後代入親身參與的微信團隊實際項目、圍繞DDD方法進行優化實操。

DDD 全稱 Domain-Driven Design,中文叫領域驅動設計,是一套應對複雜軟體系統分析和設計的面向對象建模方法論。它由Eric Evans於2003年提出,但一開始不慍不火。直到MartinFowler於2014年發表論文《Microservices》,引起大家對微服務的關注,至此DDD重新慢慢的回到了大眾的視野中。

DDD這幾年升溫的同時,也受到了很多行業人員對DDD的負面意見。主要原因大概有「晦澀難懂過於抽象」、「很難找到實際的案例參考」、「不知道怎麼落地」等。

在學習DDD的過程中,我們也遇到上述卡點。但經過幾個月持續學習和實踐DDD,我們對其思想、價值、應用方法有更深入了解。這裡嘗試用白話去總結我們DDD從入門到實踐的全過程,儘量每一個概念都用我們的具體實現做出例子,希望能對想一起學習DDD的開發者們有所幫助。

一個維護中的業務系統引出的思考

我所在微信團隊由後台和前端工程師一起維護某帶貨類的項目,這個項目我們用了最傳統的三層模型來搭建,大概是如下的模型:

當這個項目維護幾年之後,逐漸出些了一些有意思的情況,我挑選一些主要環節發現的代表性問題介紹下:

情況1(代碼層面):少部分代碼可讀性在長期不同人員的修改下變得越來越差。如某個帶貨的核心rpc邏輯沒有任何嵌套平鋪在一個函數,單函數代碼行數達到幾百行,可讀性和維護性極差,成功化身為「技術護城河」。

情況2(微服務層面): 某些微服務初始職能劃分較為簡單,導致少量模塊在後續高頻疊代中快速膨脹。如其中的mp模塊,原本職能是用來承接B端門戶的功能;當我們決定拆分這個龐大的模塊時,這個模塊已經承載了204個rpc。過多的能力承擔讓它編譯變慢、變成鏈路單點、改動較多、一旦出現問題影響較大。

情況3(業務團隊層面):帶貨項目會使用一些其他業務系統的接口和數據結構。當這些業務系統想要修改這些接口和數據結構的時候 ,一方面可能未察覺這裡的依賴將導致線上問題, 另一方面可能在業務間溝通後發現耦合處比較多,不容易改動。

維護這個項目的過程中,我們進行了一些思考:在一個複雜業務系統中,代碼結構要如何設計、微服務的橫/縱向職能要如何劃分、業務團隊之間如何交互,才能保障在快節奏、多人協作的項目疊代中,維持系統的可維護性、可拓展性、高內聚低耦合和穩定性。

而傳統的開發模式不管是面向過程(POP)還是面向對象(OOP)的思維,都沒辦法從微服務層面指導我們找到這些問題的答案。但我們發現,有兩種方法解決這個問題:1.尋找一個總是有時間、總能做出正確決策的中心節點同事,介入每一處全局/細節的設計並統一做出決策。2.尋找一個新的規則/規範來做指導,讓每一位開發工作者都能有做出正確決策的依據。

在Tencent的氛圍和環境中,第二個方法無疑是更合理的,所以我們想到了領域驅動設計(DDD)。

DDD的分層架構

DDD最有標誌性的一點,就是將傳統軟體設計三層模型轉化為了四層模型,這個轉化如下圖所示:


乍看之下,四層架構引入了很多概念,如領域服務、領域對象、 DTO、倉儲等等。我們先不用在意這些細節概念,因為下一節會逐個分析並列舉我們的實現例子。我們先關注這幾個關鍵的層:用戶界面層、應用層、領域層、基礎設施層。我們來看下他們的職能分工:

  • 用戶界面層:網絡協議的轉化/統一鑒權/Session管理/限流配置/前置緩存/異常轉換

  • 應用層: 業務流程編排(僅編排,不能存在業務邏輯)/ DTO出入轉化

  • 領域層:領域模型/領域服務/倉儲和防腐層的接口定義

  • 基礎設施層:倉儲和防腐層接口實現/存儲等基礎層能力

這裡必須要說的是,這四層不一定是指物理四層,也可以在一個微服務中拆分邏輯四層。四層架構有很多變種,如六邊形架構、洋蔥架構、整潔架構、清晰架構等等。這些繁多的概念我們這裡不過多討論,而是僅以洋蔥架構為例。此處我們將著重強調DDD中的依賴倒置(DIP),以便後面更容易介紹倉儲/防腐層等概念。

依賴倒置(DIP):

1.高級模塊不應依賴於低級模塊。兩者都應依賴抽象。

2.抽象不應依賴細節。細節應依賴於抽象。

如上,洋蔥架構越往裡依賴越低,越是核心能力。基礎設施層在最外面,依賴其他層,這是是因為DDD中其他層等需要定義自己需要的基礎能力接口,而基礎設施層負責依賴並實現這些接口,從而實現整體依賴倒置。這體現了DDD的由全局入細微、自頂層向下層的設計思維。

DDD的概念和實踐

一、戰略和戰術

DDD的落地過程,其實就是戰略建模和戰術建模。

戰略建模,是指:通過DDD的理論,對業務需求進行拆解分析,劃分子域,梳理限界上下文,通過領域語言從戰略層面進行領域劃分以及構建領域模型。並且在在構建領域模型的過程中梳理出業務對應的聚合、實體、以及值對象。

戰術建模,是指:以領域模型基礎,通過限界上下文作為服務劃分的邊界進行微服務拆分,在每個微服務中進行領域分層,實現領域服務,從而實現領域模型對於代碼映射目的,最終實現DDD的落地實施。

當然,戰略和戰術的建模除了要考慮業務形態,還要考慮到組織架構,就如同康威定律中的表達,溝通架構會影響技術架構

康威定律:任何組織在設計一套系統(廣義概念上的系統)時,所交付的設計方案在結構上都與該組織的溝通結構保持一致。

二、領域

DDD在解決複雜的問題的時候,使用的是分而治之的思想。而這個分而治之的思想,就是從領域開始,一個領域就是一個問題空間,而我們在拆分這個問題空間的時候,也就是在劃分子領域和尋找它的解系統的過程。

實踐例子:

如我們某個新的增值業務,就是看成是的大的增值業務域,接下來我們通過DDD來指導拆分它。

三、子域

如果一個領域太大太複雜,涉及到的業務規則、交互流程、領域概念太多,就不能直接針對這個大的領域進行建模。這時就需要將領域進行拆分,本質上就是把大問題拆分為小問題,把一個大的領域劃分為了多個小的領域(子域)。

子域可以分為三類:

核心子域:業務成功的核心競爭力。

通用子域:不是核心,但被整個業務系統所使用 。

支撐子域:不是核心,不被整個系統使用,完成業務的必要能力。

子域的劃分除了分治了大的問題空間,也劃定了工作的優先級。我們應該給予核心域最高的優先級和最大的資源。在實施DDD的過程中,我們也是主要關注於核心域。

實踐例子:

子域的劃分,需要比較強的業務知識和產品研發集體討論,準確和深入的業務見解在這一階段尤為重要。這裡我們不對業務知識深入討論,僅展示下我們的對增值業務域的拆解結果。

這裡要說的是,套餐域在實現的過程中由於產品需求變化概念被廢棄了,但是由於我們的子域拆分,套餐域和其他域實現上沒有任何耦合,所以廢棄套餐域概念的廢棄就像拆掉一個積木一樣,對整套系統沒有任何影響,也不會遺留任何不必要的包袱代碼。

四、限界上下文

要理解限界上下文,首先要先介紹通用語言。通用語言是DDD非常重要的一點。比如商品這個概念,在商品域裡是指備上架的商品, 包含了id、介紹、文檔等。在交易域裡其實是指訂單中被交易的實體,關注的是id、成交時刻的售價等參數、成交數量。而如果不能明確這些概念和他們的關係就會讓開發人員的實現變的隨心所欲和模糊。

而限界上下文是就是劃分一個邊界,當領域模型被一個顯示的邊界所包圍時,其中每個概念的含義應該是明確且有唯一的含義。

我覺得初學者最常碰到的問題,肯定有」明明已經有子域了,為什麼還會有限界上下文這個概念「。子域是一個子問題空間,而限界上下文的作用是指導如何設計這個問題空間的解系統。換句話說,限界上下文才是真正用來指導微服務劃分。一般來說一個子域對應一個或多個限界上下文。

劃分限界上下文可以參考如下的規則:

1.概念是否有歧義:如果一個模型在一個上下文裡面有歧義,就說明可以繼續拆分限界上下文。

2.外部系統:可以把與外部系統交互的那部分拆分出去降低外部系統對我們我們的核心業務邏輯的影響。

3.組織架構:不同團隊最好在不同的限界上下文裡面開發,避免溝通不順暢、集成困難等問題。可以參考上述"康威定律"。

實踐例子1

如上所述,商品這個概念,是需要用限界上下文在不同場景區分開的。當然這也會導致兩個限界上下文之間會有依賴。通過DDD的概念可以指導我們進行如下實現。



其中gateway/gatewayimpl是防腐層的實現,DTO是指數據傳輸對象,APP是指商品應用層。兩個不同顏色的商品是指兩個上下文中分別進行定義的不同的實體或值對象。

實踐例子2

交易域中,有兩個訂單的概念,其中第一個訂單的概念是指業務層訂單, 第二個訂單的概念是指內部基礎層訂單。業務訂單更關注發生交易的成交商品信息,這個訂單是用戶需要的。基礎層訂單更關注交易底層的過程信息,這個訂單更多是我們內部人員需要的,用戶不理解。

當時有個思路是想讓基礎層團隊的同學額外開發直接支持基礎層訂單存儲業務信息,這明顯是不符合DDD限界上下文劃分規則1)和3)的,是需要通過限界上下文解耦開的。所以我們在交易域中拆分兩個上下文,後續從微服務層面也是相互獨立的微服務,各自管理各自的領域實體和值對象。

五、防腐層

當兩個限界上下文相互調用的時候,需使用防腐層(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

六、領域事件

兩個限界上下文除了通過使用防腐層直接調用,更多的時候是通過領域事件來進行解耦。

並不是所有領域中發生的事情都需要被建模為領域事件,我們只關注有業務價值的事情。領域事件是領域專家所關心的(需要跟蹤的、希望被通知的、會引起其他模型對象改變狀態的)發生在領域中的一些事情。

其實,領域事件的本質就是事件,我們常見的kafka、wq等都可以作為領域事件的實現基建。通過領域事件,可以把很輕鬆兩個限界上下文解耦。

實踐例子

在我們的增值業務中,交易域的"支付成功"就是一個領域事件,計費域訂閱這個領域事件,從而可以根據這個事件調整客戶的計費資源包實體。

可以想像,如果這裡沒有採用領域事件, 而是交易域直接調用計費域的rpc通知交易成功,那麼當後續有其他域需要接受「支付成功」這個事件,或者,計費域被調用的接口出現故障。都會讓交易域陷入麻煩,前者需要交易域不停的堆疊調用外部rpc的代碼並讓系統變得不穩定,後者則直接會讓計費域的故障影響到用戶交易。

七、實體/值對象

實體是指上下文中唯一的且可持續變化的基礎單元,在其生命周期中可以通過穩定的唯一id來標識。實體在我們代碼中以領域對象的形態存在,同時具備屬性和方法,實體是DDD用來實現充血編程、解決貧血症的關鍵

與實體相對應的就是值對象,如果沒有唯一標識就是值對象。值對象一般是嵌套在實體裡面的。

實踐例子

商品域中的實體和值對象如下

實體

描述

關鍵值對象

SPU

指一個被上架的服務。

spu_id, spu_type,狀態等。

SKU

指一個服務具體的單項套餐。

sku_id, 規格,價格等。

折扣

自定義折扣。

折扣id,折扣類型,折扣比例等。

八、聚合/聚合根

把關係緊密的實體放到一個聚合中,每個聚合中有一個實體作為聚合根,所有對於聚合內對象的訪問都通過聚合根來進行,外部對象只能持有對聚合根的引用。每個聚合都可以有一個獨立的上下文邊界。

聚合應劃分的儘量小,一個聚合只包含一個聚合根實體和密不可分的實體,實體中只包含最小數量的屬性。設計這樣的小聚合有助於進行後續微服務的拆分。

如果一個rpc所實現的功能是跨聚合的,那跨聚合的編排協調工作應該放在應用層來實現。

實踐例子

我們可以在6)中的例子劃分如下的聚合。

聚合

實體

是否是根

聚合1

服務SPU

服務SKU

聚合2

折扣

在底層存儲落表上, spu實體/折扣實體作為表的一行, 而sku實體在這種聚合建模的指引下我們設計成spu聚合根的一列。

在微服務拆分上,如果想拆到最細粒度, 可以把兩個聚合按照各自上下文拆成獨立的微服務。當然這種落地實現並不是DDD強行要求的,我認為一些時候我們也可以從開發維護效率的角度考慮, 將一些有關聯的小上下文放在一個為微服務上。我們在處理商品域上選擇了後者。

九、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;
}

十、倉儲

倉儲是領域層由定義接口,它抽象了業務邏輯中對實體的訪問(包括讀取和存儲)的技術細節。它的作用就是通過隔離具體的存儲層技術實現來保證業務邏輯的穩定性。注意,倉儲只是接口的定義是在領域層,但是它的實現是在基礎設施層

倉儲不是資料庫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;
}

十一、領域服務

當一些能力不適合放在某個領域對象中實現,又因為過於複雜不應該放在應用層來實現。可以把這些操作封裝成領域服務的中方法,由應用層編排領域層的領域對象和領域服務方法來完成具體的業務功能。

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(物理基礎設施模塊)


文章來源:錢坤_騰訊雲開發者_https://mp.weixin.qq.com/s/kFjfzwTOdaKA2ym63VR3DQ

關鍵字: