DDD 落地:從美團抽獎平臺,看 DDD 在大廠如何落地?

美團抽獎平臺 DDD 架構實操之路

作者:文彬、子維,美團點評資深研發工程師,畢業於南京大學,現從事美團外賣營銷相關的研發工作。

至少 30 年以前,一些軟件設計人員就已經認識到領域建模和設計的重要性,並形成了一種潮流,後來由 Eric Evans 將其命名爲領域驅動設計(Domain-Driven Design,簡稱 DDD)。在互聯網開發環境中,DDD 似乎顯得有些 “陳舊且緩慢”。然而,隨着互聯網公司逐漸深入實體經濟,業務日趨複雜,我們在開發過程中也遇到了越來越多的傳統行業軟件開發所面臨的問題。本文將首先探討這些問題,然後嘗試在實踐中運用 DDD 的思想來解決這些問題。

過度耦合

在業務初期,我們的功能相對簡單,普通的 CRUD 操作就能滿足需求,此時系統結構清晰易懂。然而,隨着迭代演進,業務邏輯變得越來越複雜,系統也逐漸變得龐大。各個模塊之間的關聯日益緊密,以至於難以明確劃分某個模塊的具體功能意圖。在修改某個功能時,不僅要花費大量時間回溯所需修改的點,還需擔憂修改帶來的未知影響。

下圖是一個常見的系統耦合病例。

服務耦合示意圖

在訂單服務接口中,提供了查詢、創建訂單相關的接口,以及訂單評價、支付、保險等接口。同時,我們的表格也是一個包含大量字段的訂單大表。在維護代碼時,一個小變動可能會影響到整個系統。很多時候,我們只想修改評價相關的功能,卻意外地影響了創建訂單的核心路徑。儘管我們可以通過測試確保功能的完備性,但在訂單領域有大量需求同時進行開發時,修改重疊、惡性循環,導致我們疲於奔命地修復各種問題。

這些問題本質上源於系統架構不清晰,劃分出來的模塊內聚度低、耦合度高。

爲解決這一問題,我們可以採用演進式設計的理念,讓系統設計隨着實現的增長而增長。我們無需提前設計,只需讓系統隨業務成長而演進。敏捷實踐中的重構、測試驅動設計和持續集成可以幫助我們應對各種混亂問題。重構可以改善代碼質量,同時保持行爲不變;測試驅動設計可以確保對系統的修改不會導致現有功能丟失或破壞;持續集成則讓團隊共享同一代碼庫。

在這三種實踐中,重構是克服演進式設計中大雜燴問題的關鍵,通過在單獨的類及方法級別上進行一系列小步重構來實現。我們可以很容易地將通用邏輯重構爲一個獨立的類,但你會發現這個類很難用業務含義來描述,只能給予一個技術維度上的解釋。這會帶來什麼問題呢?新同學並不總是知道對通用邏輯的改動或獲取來自該類。顯然,制定項目規範並不是一個好主意。這種情況下,我們再次嗅到了代碼即將腐敗的氣味。

實際上,你可能已經意識到問題所在。在解決現實問題時,我們將問題映射到腦海中的概念模型,然後在模型中解決問題,最後將解決方案轉換爲實際代碼。上述問題在於我們解決了設計到代碼之間的重構,但提煉出來的設計模型缺乏實際的業務含義,導致在開發新需求時,其他成員無法自然地將業務問題映射到設計模型。設計似乎變成了重構者的獨角戲,代碼繼續惡化,不斷重構…… 形成惡性循環。

領域驅動設計(DDD)能有效地解決領域模型到設計模型的同步和演化問題,最終將反映業務領域的設計模型轉化爲實際代碼。

注:模型是我們解決實際問題所抽象出的概念模型,領域模型描述與業務相關的事實;設計模型則表示了要構建的系統。

貧血症和失憶症

貧血領域對象

貧血領域對象(Anemic Domain Object)指的是僅具備數據承載功能,而缺乏行爲和操作的領域對象。

在習慣於 J2EE 開發模式(如 Action/Service/DAO)的情況下,我們很容易寫出過程式代碼,從而使得所學的面向對象(OO)理論無法發揮其作用。在這種開發方式中,對象僅作爲數據的載體,缺乏實際的行爲。以數據爲核心,數據庫實體關係(ER)設計成爲驅動。在這種開發模式下,分層架構可以理解爲數據移動、處理和實現的過程。

以筆者最近開發的系統抽獎平臺爲例:

獎池中設有多種獎項,我們需要根據運營預先設置的概率抽取其中一個獎項。實現方法很簡單,生成一個隨機數,然後匹配符合該隨機數生成概率的獎項即可。

先設計獎池和獎項的庫表配置。

抽獎 ER 圖

class AwardPool {
    int awardPoolId;
    List<Award> awards;
    public List<Award> getAwards() {
        return awards;
    }
  
    public void setAwards(List<Award> awards) {
        this.awards = awards;
    }
    ......
}

class Award {
   int awardId;
   int probability;//概率
  
   ......
}

設計一個 LotteryService,在其中的 drawLottery() 方法寫服務邏輯

AwardPool awardPool = awardPoolDao.getAwardPool(poolId);//sql查詢,將數據映射到AwardPool對象
for (Award award : awardPool.getAwards()) {
   //尋找到符合award.getProbability()概率的award
}

相較而言,領域驅動設計(DDD)將數據和行爲封裝在一起,並與現實世界中的業務對象相對應。各類對象分工明確,領域邏輯分散在領域對象中。以抽獎爲例,概率選擇和對應獎品的處理應放入 AwardPool 類中。

通過引入領域驅動設計,我們能更好地應對複雜業務場景,提高代碼的可讀性和可維護性。

軟件系統複雜性應對

解決複雜和大規模軟件的武器可以被粗略地歸爲三類:抽象、分治和知識。

分治:將問題領域劃分爲若干較小的、易於處理的子問題。這些子問題要足夠簡單,以便個人能夠獨立解決。同時,要考慮如何將這些子部分整合爲整體。合理的分割能降低問題的複雜性,使整體裝配過程中的細節追蹤更爲簡便,從而更容易設計各部分的協作方式。評價分治效果的好壞,即判斷其高內聚低耦合的程度。

抽象:運用抽象能簡化問題空間,越小的抽象問題越容易理解。以從北京到上海的出差爲例,我們可以先將其理解爲使用交通工具出行,而不必一開始就詳細考慮是乘坐高鐵還是飛機,以及乘坐過程中應注意的事項。

知識 顧名思義,DDD 可以認爲是知識的一種。

DDD 提供了這樣的知識手段,讓我們知道如何抽象出限界上下文以及如何去分治。

與微服務架構相得益彰

微服務架構與 DDD 在應對複雜性方面有相似之處。在創建微服務時,我們需要構建高內聚、低耦合的微服務。DDD 中的限界上下文理念與微服務不謀而合,可以將限界上下文視爲一個微服務進程。

上述是從更直觀的角度來描述兩者的相似處。

在系統複雜之後,我們都需要用分治來拆解問題。一般有兩種方式,技術維度和業務維度。技術維度是類似 MVC 這樣,業務維度則是指按業務領域來劃分系統。

微服務架構更強調從業務維度去做分治來應對系統複雜度,而 DDD 也是同樣的着重業務視角。 如果兩者在追求的目標(業務維度)達到了上下文的統一,那麼在具體做法上有什麼聯繫和不同呢?

我們將架構設計活動精簡爲以下三個層面:

以上三種活動,實際開發中,這些活動有先後順序,但不一定固定。在面對常規問題時,我們自然會採用熟悉的分層架構(先確定系統架構),或選擇合適的編程語言(先確定技術架構)。在業務不復雜的情況下,這種做法是合理的。

然而,跳過業務架構設計出來的架構關注點不在業務響應上,可能就是一個大泥球。在面臨需求迭代或響應市場變化時,這種架構就會讓人痛苦不堪。

DDD 的核心訴求就是將業務架構映射到系統架構上,在響應業務變化調整業務架構時,也隨之變化系統架構。而微服務追求業務層面的複用,設計出來的系統架構和業務一致;在技術架構上則系統模塊之間充分解耦,可以自由地選擇合適的技術架構,去中心化地治理技術和數據

綜上所述,領域驅動設計(DDD)和微服務架構在應對複雜性方面有諸多相似之處,但它們在具體實踐中有各自的側重點和優勢。在實際項目中,我們可以根據業務需求和系統複雜度,靈活運用這兩種方法,實現更高效、易於維護的軟件系統。

可以參見下圖來更好地理解雙方之間的協作關係:

DDD 與微服務關係

我們將通過一個抽獎平臺的實例,詳細闡述如何運用領域驅動設計(DDD)來拆解一個基於微服務架構的中型系統,實現系統的高內聚和低耦合。

首先看下抽獎系統的大致需求: 運營——可以配置一個抽獎活動,該活動面向一個特定的用戶羣體,並針對一個用戶羣體發放一批不同類型的獎品(優惠券,激活碼,實物獎品等)。 用戶 - 通過活動頁面參與不同類型的抽獎活動。

設計領域模型的一般步驟如下:

  1. 根據需求劃分出初步的領域和限界上下文,以及上下文之間的關係;

  2. 進一步分析每個上下文內部,識別出哪些是實體,哪些是值對象;

  3. 對實體、值對象進行關聯和聚合,劃分出聚合的範疇和聚合根;

  4. 爲聚合根設計倉儲,並思考實體或值對象的創建方式;

  5. 在工程中實踐領域模型,並在實踐中檢驗模型的合理性,倒推模型中不足的地方並重構。

通過以上步驟,我們能夠將複雜系統分解爲更具內聚性和耦合度的模塊。在實際開發過程中,團隊成員需密切協作,不斷積累和共享領域知識,以確保項目的順利進行。此外,在面臨需求變更或市場波動時,我們應靈活調整業務架構,實現系統的高效響應和持續優化。

戰略建模

在領域驅動設計(DDD)中,戰略和戰術設計有着明確的劃分。戰略設計主要關注高層次和宏觀層面的限界上下文劃分與集成,而戰術設計則聚焦於運用建模工具對上下文進行更爲具體的細化。

領域

現實世界中,領域包含了問題域和解系統。軟件開發可以視爲對現實世界的部分模擬。在 DDD 中,解系統可映射爲多個限界上下文,每個上下文代表針對問題域的一個特定、有限解決方案。

限界上下文

限界上下文

一個由顯式邊界限定的特定職責範圍。領域模型位於此邊界內。在此範圍內,每個模型概念(包括其屬性和操作)都具有特殊含義。

一個給定的業務領域包含多個限界上下文。要與某個上下文進行通信,需通過顯式邊界進行交互。系統通過確定性的限界上下文實現解耦,使每個上下文內部組織緊密、職責明確,具備較高的內聚性。

一個形象的隱喻:細胞質之所以存在,是因爲細胞膜限定了細胞內外的物質,並確定了何種物質可通過細胞膜。

劃分限界上下文

劃分限界上下文的方法在 Eric Evans 和 Vaughn Vernon 的著作中並未詳細提及。

我們不應根據技術架構或開發任務來創建限界上下文,而應關注語義邊界。

我們的實踐是,首先考慮產品所使用的通用語言,提取一些術語稱爲概念對象,並探討對象之間的聯繫;或者從需求中提取動詞,觀察動詞與對象之間的關係;我們將緊密耦合的元素圈在一起,研究其內在聯繫,從而確定相應的界限上下文。形成後,我們可以嘗試用語言描述界限上下文的職責,看其是否清晰、準確、簡潔和完整。簡而言之,限界上下文應從需求出發,根據領域進行劃分

如前所述,我們將用戶分爲運營和用戶。運營負責複雜但相對低頻的抽獎活動配置,而用戶對配置的使用則呈高頻且無感知。根據這一業務特點,我們將抽獎平臺劃分爲面向 C 端的抽獎和麪向 M 端的抽獎管理平臺兩個子域,實現完全解耦。

抽獎平臺領域

在明確了 M 端和 C 端的界限上下文後,我們進一步對各自主體內進行界限上下文的劃分。此處,我們以 C 端爲例進行說明。

產品的需求概述如下:

  1. 抽獎活動有活動限制,例如用戶的抽獎次數限制,抽獎的開始和結束的時間等;

  2. 一個抽獎活動包含多個獎品,可以針對一個或多個用戶羣體;

  3. 獎品有自身的獎品配置,例如庫存量,被抽中的概率等,最多被一個用戶抽中的次數等等;

  4. 用戶羣體有多種區別方式,如按照用戶所在城市區分,按照新老客區分等;

  5. 活動具有風控配置,能夠限制用戶參與抽獎的頻率。

依據產品需求,我們提煉出關鍵概念作爲子域,構建限界上下文。

C 端抽獎領域

首先,抽獎上下文作爲整個領域的核心,負責處理用戶抽獎業務,涵蓋獎品和用戶羣體概念。

針對活動限制,我們定義了活動准入的通用語言,將活動開始 / 結束時間、活動參與次數等限制條件納入活動准入上下文中。

關於抽獎獎品庫存量,庫存行爲與獎品本身相對獨立,庫存關注點更多在於庫存覈銷,且庫存具備通用性,可被非獎品內容使用。因此,我們設立了獨立的庫存上下文。

鑑於 C 端存在刷單行爲,我們根據產品需求設立了風控上下文,用於對活動進行風險控制。最後,活動准入、風控、抽獎等領域均涉及次數限制,故我們設立了計數上下文。

通過領域驅動設計(DDD)的限界上下文劃分,我們明確了抽獎、活動准入、風控、計數、庫存等五個上下文,確保每個上下文在系統中具備高度內聚性。

上下文映射圖

在進行上下文劃分之後,我們還需要進一步梳理上下文之間的關係。

康威(梅爾 · 康威)定律

任何組織在設計一套系統(廣義概念上的系統)時,其提交的設計方案在結構上都會與該組織的溝通架構保持一致。

康威定律表明,系統結構應儘可能與組織結構保持一致。在這裏,我們認爲團隊結構(無論是內部團隊還是跨團隊)都屬於組織結構,而限界上下文則是系統的業務結構。因此,團隊結構應與限界上下文保持一致。

梳理清楚上下文之間的關係,從團隊內部的關係來看,有如下好處:

  1. 任務更好拆分,一個開發人員都能全力以赴地投入到某個單一的上下文中;

  2. 溝通交流更爲順暢,每個上下文都能明確自己對其他上下文的依賴關係,使團隊內部開發工作更好地相互對接。

從團隊間的關係來看,明確的上下文關係能帶來以下幫助:

  1. 每個團隊在自己的上下文中能更明確地理解自己領域內的概念,因爲上下文是領域的子系統;

  2. 對於限界上下文之間的交互,團隊與上下文的一致性保證了我們有明確的對接團隊和依賴的上下游。

限界上下文之間的映射關係

  • 合作關係(Partnership):兩個上下文緊密合作的關係,一榮俱榮,一損俱損。

  • 共享內核(Shared Kernel):兩個上下文依賴部分共享的模型。

  • 客戶方 - 供應方開發(Customer-Supplier Development):上下文之間有組織的上下游依賴。

  • 遵奉者(Conformist):下游上下文只能盲目依賴上游上下文。

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

  • 開放主機服務(Open Host Service):定義一種協議來讓其他上下文來對本上下文進行訪問。

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

  • 大泥球(Big Ball of Mud):混雜在一起的上下文關係,邊界不清晰。

  • 另謀他路(SeparateWay):兩個完全沒有任何聯繫的上下文。

上述內容定義了上下文之間的映射關係。經過仔細考慮,抽獎平臺上下文的映射關係圖如下:

上下文映射關係

由於抽獎、風控、活動准入、庫存、計數五個上下文均屬於抽獎領域內部,因此它們之間形成了 “同甘共苦,共進退” 的合作關係(Partnership,簡稱 PS)。

同時,抽獎上下文在發放獎品時,會依賴券碼、平臺券、外賣券三個上下文。抽獎上下文通過防腐層(Anticorruption Layer,ACL)與這三個上下文隔離,而三個券上下文則通過開放主機服務(Open Host Service)作爲發佈語言(Published Language)爲抽獎上下文提供訪問機制。

通過上下文映射關係,我們明確的限制了限界上下文的耦合性,即在抽獎平臺中,無論是上下文內部交互(合作關係)還是與外部上下文交互(防腐層),耦合度都限定在數據耦合(Data Coupling)的層級。

戰術建模——細化上下文

梳理清楚上下文之間的關係後,我們需要從戰術層面上剖析上下文內部的組織關係。首先看下 DDD 中的一些定義。

實體

當一個對象的識別(而非屬性)使其具有獨特性時,該對象被稱爲實體(Entity)。

例:最簡單的,公安系統的身份信息錄入,對於人的模擬,即認爲是實體,因爲每個人是獨一無二的,且其具有唯一標識(如公安系統分發的身份證號碼)。

在實踐上建議將屬性的驗證放到實體中。

值對象

當一個對象用於對事務進行描述而沒有唯一標識時,它被稱作值對象(Value Object)。

例:比如顏色信息,我們只需要知道 {“name”:“黑色”,”css”:“#000000”} 這樣的值信息就能夠滿足要求了,這避免了我們對標識追蹤帶來的系統複雜性。

值對象很重要,尤其在習慣使用數據庫進行數據建模後,容易將所有對象都視爲實體。使用值對象可以優化系統性能、簡化設計。

它具有不變性、相等性和可替換性。

實踐中,值對象創建後不應再允許外部修改其屬性。在不同上下文整合時,可能會出現模型概念的共享,如商品模型在電商的各個上下文中都有。若在訂單上下文中僅關注下單時的商品信息快照,將商品對象視爲值對象是合理的選擇。

聚合根

Aggregate(聚合)是一組相關對象的集合,作爲一個整體被外界訪問,聚合根(Aggregate Root)是這個聚合的根節點。

聚合是一個非常重要的概念,核心領域往往都需要用聚合來表達。其次,聚合在技術上有非常高的價值,可以指導詳細設計。

聚合由根實體,值對象和實體組成。

如何創建好的聚合?

聚合內部多個組成對象的關係可以用來指導數據庫創建,但不可避免存在一定的抗阻。如聚合中存在List<值對象>,那麼在數據庫中建立 1:N 關聯需將值對象單獨建表,此時應有 id,建議不要將該 id 暴露到資源庫外部,對外隱蔽。

領域服務

一些重要的領域行爲或操作可以歸爲領域服務。領域服務既不屬於實體,也不屬於值對象的範疇。

採用微服務架構風格後,所有領域邏輯的對外暴露都需通過領域服務進行。例如,原本由聚合根暴露的業務邏輯也需要依賴領域服務。

領域事件

領域事件是對領域內發生的活動進行的建模。

抽獎平臺的核心上下文是抽獎上下文,接下來介紹下我們對抽獎上下文的建模。

抽獎上下文

在抽獎上下文中,我們通過抽獎 (DrawLottery) 這個聚合根來控制抽獎行爲,可以看到,一個抽獎包括了抽獎 ID(LotteryId)以及多個獎池(AwardPool),而一個獎池針對一個特定的用戶羣體(UserGroup)設置了多個獎品(Award)。

此外,在抽獎領域中,我們還使用抽獎結果(SendResult)作爲輸出信息,以及用戶領獎記錄(UserLotteryLog)作爲領獎憑據和存根。

謹慎使用值對象

在實踐中,我們發現部分領域對象符合值對象理念,但隨着業務變化,許多原有定義需調整。值對象可能在業務層面具有唯一標識需求,而對這類值對象的重構成本較高。因此,在特定場景下,我們要根據實際情況權衡領域對象選擇。

DDD 工程實現

在對上下文進行細化後,我們開始在工程中真正落地 DDD。

模塊

在 DDD 中,模塊(Module)被明確作爲控制限界上下文的一種方法,我們一般在工程中力求用一個模塊來體現一個領域的限界上下文。

如代碼中所示,一般的工程中包的組織方式爲{com.公司名.組織架構.業務.上下文.*},這樣的結構可以明確地將一個上下文限制在包內。

import com.company.team.bussiness.lottery.*;//抽獎上下文
import com.company.team.bussiness.riskcontrol.*;//風控上下文
import com.company.team.bussiness.counter.*;//計數上下文
import com.company.team.bussiness.condition.*;//活動准入上下文
import com.company.team.bussiness.stock.*;//庫存上下文

代碼演示 1 模塊的組織

對於模塊內的組織結構,一般情況下我們是按照領域對象、領域服務、領域資源庫、防腐層等組織方式定義的。

import com.company.team.bussiness.lottery.domain.valobj.*;//領域對象-值對象
import com.company.team.bussiness.lottery.domain.entity.*;//領域對象-實體
import com.company.team.bussiness.lottery.domain.aggregate.*;//領域對象-聚合根
import com.company.team.bussiness.lottery.service.*;//領域服務
import com.company.team.bussiness.lottery.repo.*;//領域資源庫
import com.company.team.bussiness.lottery.facade.*;//領域防腐層

代碼演示 2 模塊的組織

每個模塊的具體實現,我們將在下文中展開。

領域對象

前文提到,領域驅動的一個重要目標是解決對象的貧血問題。這裏,我們用之前定義的抽獎(DrawLottery)聚合根和獎池(AwardPool)值對象來進行具體說明。

抽獎聚合根保留了抽獎活動的 id 以及該活動下所有可用獎池的列表,其核心領域功能是根據抽獎場景(DrawLotteryContext)選擇合適的獎池,即 chooseAwardPool 方法。

chooseAwardPool 的邏輯如下:DrawLotteryContext 會攜帶用戶抽獎時的場景信息(如抽獎得分或所在城市),DrawLottery 根據這些場景信息,匹配一個能爲用戶發放獎品的 AwardPool。

package com.company.team.bussiness.lottery.domain.aggregate;
import ...;
  
public class DrawLottery {
    private int lotteryId; //抽獎id
    private List<AwardPool> awardPools; //獎池列表
  
    //getter & setter
    public void setLotteryId(int lotteryId) {
        if(id<=0){
            throw new IllegalArgumentException("非法的抽獎id"); 
        }
        this.lotteryId = lotteryId;
    }
  
    //根據抽獎入參context選擇獎池
    public AwardPool chooseAwardPool(DrawLotteryContext context) {
        if(context.getMtCityInfo()!=null) {
            return chooseAwardPoolByCityInfo(awardPools, context.getMtCityInfo());
        } else {
            return chooseAwardPoolByScore(awardPools, context.getGameScore());
        }
    }
     
    //根據抽獎所在城市選擇獎池
    private AwardPool chooseAwardPoolByCityInfo(List<AwardPool> awardPools, MtCifyInfo cityInfo) {
        for(AwardPool awardPool: awardPools) {
            if(awardPool.matchedCity(cityInfo.getCityId())) {
                return awardPool;
            }
        }
        return null;
    }
  
    //根據抽獎活動得分選擇獎池
    private AwardPool chooseAwardPoolByScore(List<AwardPool> awardPools, int gameScore) {...}
}

代碼演示 3 DrawLottery

在匹配到一個具體的獎池之後,需要確定最後給用戶的獎品是什麼。這部分的領域功能在 AwardPool 內。

package com.company.team.bussiness.lottery.domain.valobj;
import ...;
  
public class AwardPool {
    private String cityIds;//獎池支持的城市
    private String scores;//獎池支持的得分
    private int userGroupType;//獎池匹配的用戶類型
    private List<Awrad> awards;//獎池中包含的獎品
  
    //當前獎池是否與城市匹配
    public boolean matchedCity(int cityId) {...}
  
    //當前獎池是否與用戶得分匹配
    public boolean matchedScore(int score) {...}
  
    //根據概率選擇獎池
    public Award randomGetAward() {
        int sumOfProbablity = 0;
        for(Award award: awards) {
            sumOfProbability += award.getAwardProbablity();
        }
        int randomNumber = ThreadLocalRandom.current().nextInt(sumOfProbablity);
        range = 0;
        for(Award award: awards) {
            range += award.getProbablity();
            if(randomNumber<range) {
                return award;
            }
        }
        return null;
    }
}

代碼演示 4 AwardPool

與以往的僅有 getter、setter 的業務對象不同,領域對象擁有了行爲,變得更加豐滿。同時,相較於將此類邏輯寫在服務中(例如 **Service),領域功能的內聚性更強,職責更加明確。

資源庫

領域對象需要存儲資源,存儲方式多種多樣,如數據庫、分佈式緩存、本地緩存等。資源庫(Repository)負責對領域的存儲和訪問進行統一管理。在抽獎平臺中,我們採用如下方式組織資源庫。

//數據庫資源
import com.company.team.bussiness.lottery.repo.dao.AwardPoolDao;//數據庫訪問對象-獎池
import com.company.team.bussiness.lottery.repo.dao.AwardDao;//數據庫訪問對象-獎品
import com.company.team.bussiness.lottery.repo.dao.po.AwardPO;//數據庫持久化對象-獎品
import com.company.team.bussiness.lottery.repo.dao.po.AwardPoolPO;//數據庫持久化對象-獎池
  
import com.company.team.bussiness.lottery.repo.cache.DrawLotteryCacheAccessObj;//分佈式緩存訪問對象-抽獎緩存訪問
import com.company.team.bussiness.lottery.repo.repository.DrawLotteryRepository;//資源庫訪問對象-抽獎資源庫

代碼演示 5 Repository 組織結構

資源庫對外的整體訪問由 Repository 提供,它聚合了各資源庫的數據信息,同時也承擔了資源存儲的邏輯(如緩存更新機制等)。

在抽獎資源庫中,我們避免了直接訪問底層獎池和獎品,只對抽獎的聚合根進行資源管理。代碼示例展示了抽獎資源獲取的方法(最常見的 Cache Aside Pattern)。

與過去將資源管理放在服務中的做法相比,由資源庫負責管理資源,職責更明確,代碼可讀性和可維護性更強。

package com.company.team.bussiness.lottery.repo;
import ...;
  
@Repository
public class DrawLotteryRepository {
    @Autowired
    private AwardDao awardDao;
    @Autowired
    private AwardPoolDao awardPoolDao;
    @AutoWired
    private DrawLotteryCacheAccessObj drawLotteryCacheAccessObj;
  
    public DrawLottery getDrawLotteryById(int lotteryId) {
        DrawLottery drawLottery = drawLotteryCacheAccessObj.get(lotteryId);
        if(drawLottery!=null){
            return drawLottery;
        }
        drawLottery = getDrawLotteyFromDB(lotteryId);
        drawLotteryCacheAccessObj.add(lotteryId, drawLottery);
        return drawLottery;
    }
  
    private DrawLottery getDrawLotteryFromDB(int lotteryId) {...}
}

代碼演示 6 DrawLotteryRepository

防腐層

亦稱適配層。在一個上下文中,有時需要對外部上下文進行訪問,通常會引入防腐層的概念來對外部上下文的訪問進行一次轉義。

有以下幾種情況會考慮引入防腐層:

如果內部多個上下文需要訪問外部上下文,可以考慮將其納入通用上下文中。

在抽獎平臺中,我們定義了用戶城市信息防腐層(UserCityInfoFacade),用於處理外部用戶城市信息上下文(在微服務架構下表現爲用戶城市信息服務)。。

以用戶信息防腐層爲例,它接收抽獎請求參數(LotteryContext)作爲輸入,輸出城市信息(MtCityInfo)。

package com.company.team.bussiness.lottery.facade;
import ...;
  
@Component
public class UserCityInfoFacade {
    @Autowired
    private LbsService lbsService;//外部用戶城市信息RPC服務
     
    public MtCityInfo getMtCityInfo(LotteryContext context) {
        LbsReq lbsReq = new LbsReq();
        lbsReq.setLat(context.getLat());
        lbsReq.setLng(context.getLng());
        LbsResponse resp = lbsService.getLbsCityInfo(lbsReq);
        return buildMtCifyInfo(resp);
    }
  
    private MtCityInfo buildMtCityInfo(LbsResponse resp) {...}
}

代碼演示 7 UserCityInfoFacade

領域服務

上文中,我們將領域行爲封裝至領域對象,資源管理行爲納入資源庫,外部上下文交互行爲通過防腐層實現。如此一來,領域服務的職責變得更加明確,即充當領域內對象行爲(如領域對象、資源庫和防腐層等)的串聯,爲其他上下文提供交互接口。

我們以抽獎服務(issueLottery)爲例,省略防禦性邏輯(如異常處理、空值判斷等)後,領域服務邏輯簡潔明瞭。

package com.company.team.bussiness.lottery.service.impl
import ...;
  
@Service
public class LotteryServiceImpl implements LotteryService {
    @Autowired
    private DrawLotteryRepository drawLotteryRepo;
    @Autowired
    private UserCityInfoFacade UserCityInfoFacade;
    @Autowired
    private AwardSendService awardSendService;
    @Autowired
    private AwardCounterFacade awardCounterFacade;
  
    @Override
    public IssueResponse issueLottery(LotteryContext lotteryContext) {
        DrawLottery drawLottery = drawLotteryRepo.getDrawLotteryById(lotteryContext.getLotteryId());//獲取抽獎配置聚合根
        awardCounterFacade.incrTryCount(lotteryContext);//增加抽獎計數信息
        AwardPool awardPool = lotteryConfig.chooseAwardPool(bulidDrawLotteryContext(drawLottery, lotteryContext));//選中獎池
        Award award = awardPool.randomChooseAward();//選中獎品
        return buildIssueResponse(awardSendService.sendAward(award, lotteryContext));//發出獎品實體
    }
  
    private IssueResponse buildIssueResponse(AwardSendResponse awardSendResponse) {...}
}

代碼演示 8 LotteryService

數據流轉

數據流轉

在抽獎平臺實踐中,數據流動如圖所示。首先,領域開放服務通過數據傳輸對象(DTO)與外界進行數據交互;領域內部利用領域對象(DO)作爲數據和行爲載體;資源庫內部則沿用原有數據庫持久化對象(PO)進行數據庫資源交互。DTO 與 DO 的轉換髮生在領域服務內,DO 與 PO 的轉換則在資源庫內完成。

相較於傳統業務服務,當前編碼規範可能多了一次數據轉換,但各數據對象職責清晰,數據流動更加明確。

上下文集成

通常有多種方法可以集成上下文,常見的包括開放領域服務接口、開放 HTTP 服務以及消息發佈 - 訂閱機制。

在抽獎系統中,我們使用開放服務接口進行交互。最明顯的例子是計數上下文,它作爲一個通用上下文,爲抽獎、風控、活動准入等上下文提供了訪問接口。同時,如果在集成一個上下文時需要一定的隔離和適配,可以引入防腐層的概念。這部分的示例可以參考前文的防腐層代碼示例。

分離領域

接下來討論如何在實施領域模型的過程中將系統架構應用到實際項目中。

我們採用微服務架構風格,與 Vernon 在《實現領域驅動設計》中的觀點略有差異。具體差異可以參考他的書籍。

如果我們維護一個從前到後的應用系統:

在下圖中,領域服務採用微服務技術剝離,獨立部署,對外僅暴露服務接口。領域對外的業務邏輯需依託於領域服務。而在 Vernon 的著作中,未假設微服務架構,因此領域層除領域服務外,還包括聚合、實體和值對象等。應用服務層相對簡單,負責接收接口層請求參數,調度多個領域服務以實現界面層功能。

DDD - 分層

隨着業務發展,業務系統快速膨脹,我們的系統處於核心地位時:

應用服務雖然沒有領域邏輯,但涉及了對多個領域服務的編排。當業務規模龐大到一定程度,編排本身就富含了業務邏輯(除此之外,應用服務在穩定性、性能方面所做的措施也希望統一起來,而非散落各處),那麼此時應用服務對於外部來說是一個領域服務,整體看起來則是一個獨立的限界上下文。

此時應用服務對內仍屬於應用服務,對外已是領域服務的概念,需要將其暴露爲微服務。

DDD - 系統架構圖

注:具體架構實踐可根據團隊和業務實際情況進行,此處僅爲作者業務實踐。除分層架構外,CQRS 架構也是不錯的選擇。

以下爲一個示例。我們定義了抽獎、活動准入、風險控制等多個領域服務。在本系統中,需集成多個領域服務,爲客戶端提供功能完備的抽獎應用服務。這個應用服務的組織如下:

package ...;
  
import ...;
  
@Service
public class LotteryApplicationService {
    @Autowired
    private LotteryRiskService riskService;
    @Autowired
    private LotteryConditionService conditionService;
    @Autowired
    private LotteryService lotteryService;
     
    //用戶參與抽獎活動
    public Response<PrizeInfo, ErrorData> participateLottery(LotteryContext lotteryContext) {
        //校驗用戶登錄信息
        validateLoginInfo(lotteryContext);
        //校驗風控 
        RiskAccessToken riskToken = riskService.accquire(buildRiskReq(lotteryContext));
        ...
        //活動准入檢查
        LotteryConditionResult conditionResult = conditionService.checkLotteryCondition(otteryContext.getLotteryId(),lotteryContext.getUserId());
        ...
        //抽獎並返回結果
        IssueResponse issueResponse = lotteryService.issurLottery(lotteryContext);
        if(issueResponse!=null && issueResponse.getCode()==IssueResponse.OK) {
            return buildSuccessResponse(issueResponse.getPrizeInfo());
        } else {   
            return buildErrorResponse(ResponseCode.ISSUE_LOTTERY_FAIL, ResponseMsg.ISSUE_LOTTERY_FAIL)
        }
    }
  
    private void validateLoginInfo(LotteryContext lotteryContext){...}
    private Response<PrizeInfo, ErrorData> buildErrorResponse (int code, String msg){...}
    private Response<PrizeInfo, ErrorData> buildSuccessResponse (PrizeInfo prizeInfo){...}
}

代碼演示 9 LotteryApplicationService

在本文中,我們採用了分治的思想,從抽象到具體探討了領域驅動設計(DDD)在實際互聯網業務系統中的應用。通過這一有力工具,我們使得系統架構更加合理。

然而,需要指出的是,如果你的系統相對簡單,或者只是類似於 SmartUI 這樣的項目,那麼可能並不需要採用 DDD。儘管本文對貧血模型、演進式設計提出了一些觀點,但在特定範圍和場景下,它們可能更爲高效。讀者應根據自身實際情況做出選擇,找到最適合自己的方案。

本文通過介紹 DDD 的軟件設計原則和方法,旨在實現高內聚低耦合,緊密貼合本質。讀者可以根據自己的理解和團隊狀況實踐 DDD。

參考書籍

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