DDD實踐反思

五嶽發表於2021-04-29

某大型網際網路公司於2019年開始在XX中臺財務域進行DDD實踐。事後回顧,整體並沒有達到預期的效果,個人也做了很多的反思和總結,形成此文。

1. 背景

為什麼當時要實踐DDD?其中的緣由比較複雜,可以從外部和內部兩個視角來看。

首先,從外部也即整個BU的視角來看,最先開始實踐DDD的是A域,並在該域誕生了一套在公司現有RPC框架之上的業務SPI框架(以下簡稱為【N框架】)。相較於dubbo這樣純技術的服務框架,它有以下特點:

  • 標榜serverless,自身提供了程式碼託管和執行容器,可以直接進行服務的開發、部署及運維,也可以將現有docker應用的服務以SPI的形式註冊到這個框架上併發布,實現“服務市場”的效果
  • 可以進行服務的熱插拔,不停機升級回滾不同版本的服務
  • 業務SPI管控,可以將SPI按照業務域劃分到不同的路徑下,方便管理和業務發現、服務編排

在BU內部推行N框架時,對於現有的應用,需要將核心服務用SPI的方式註冊上去;對於新接入的業務,則推薦使用serverless形式的bundle提供服務。對於哪些是“核心服務”,這些“核心服務”是否足夠標準化,亟待一輪梳理甚至重構。

其次,從本域來看,在組織架構調整後,線上同時執行的多套系統已經日漸成為瓶頸。由於歷史原因,XX中臺財務域對接多個業務域時,往往會新搭一套系統,造成了當時一共有五套財務系統執行線上上,系統間相互依賴,系統內又有大量的if...else硬編碼來處理定製化的需求。在這種情況下,無論對於新業務、新需求的支援,還是對於現有業務的運維,都需要付出巨大的人力成本,並且存在著很多隱患,多版本歸一化迫在眉睫。

因為供應商發票在整個財務域的最下游,相對比較獨立,並且對於現有的系統業務方也有開票流程線上化的訴求(原先的流程存在大量的人工步驟:是將對賬單匯出為excel,匯入到開票系統,然後再將開票結果資料導回系統),所以選定了供應商發票這個子域作為第一個試點,目標是新建一套銷項發票系統,釋出可擴充套件的SPI,並將已有的業務線流量逐步切過來,降低運維成本,提高研發效率。

2. 實踐

2.1 佈道

對於DDD,其中有很多的概念和方法,需要所有開發認知達成一致,這裡就不重複那些經典的概念了。

這裡想稍微聊一下”六邊形架構“。為什麼是六邊形,不是正方形、五邊形、八邊形?我思考了很久,得出的結論是:六邊形比較好看,而且也好畫。具體這六個邊除了表示邊界,並沒有什麼實際的含義,比如六大金剛什麼的。使用”六邊形架構“,一是為了和傳統的分層結構進行區分,二是為了表示這個系統不是孤立的,而是在一個大網格中的一個節點。

對於實體,最初我的理解是現實中存在的物件,在DDD裡則被定義為”有識別符號來區分的物件“。從哲學的角度,則是”是客觀存在並可相互區別的事物“。這樣理解思路就開闊多了, ”訂單“是實體,”抽獎活動“也可以抽象為實體[1]

需要注意的是,一定要遵守現有的或形成一套統一的命名規範。比如DO,一般指的是DataObject,但是DomainObject也能縮寫成DO,是不是很神奇?

2.2 建模

首先使用用例分析法,分析現有系統+短期未來中需求的用例。“發票”這一實體在現實世界中相對固定,所以建模相對比較簡單——實際上並不完全正確。在子域劃分中可以看到,不僅僅只有一個發票實體。

但是僅通過用例分析,又顯得比較單薄——甚至很多業務場景都能套進這個模板中,因此需要在後續的環節(分析流程中缺失的實體)中豐富一些細節進來。

至於當時為什麼沒有考慮四色分析法和事件風暴法,事後回想這個問題,可能原因是:老系統的程式碼和場景比較複雜,新系統可以按業務線維度做進行遷移的時候再進行擴充套件,第一版不需要考慮大而全的所有場景,因此只需要梳理好SOP即可。而四色分析法,事件風暴法對於老系統的分析成本過高,即使將所有開發集中到一起也難以將所有事件羅列出來。

建模的結果簡單表示如下:

各域職責如下:

  • 費用域:對接上游不同形式的單據,統一轉化成可以用來開票的賬款單
  • 開票申請域:抽象開票的過程資訊,包括髮票待填充欄位和單據拆分規則
  • 開票執行域:對接不同的開票ISV,提供統一的開票能力。

把發票域再劃分成更細的子域,是出於以下考慮:

  • 限界上下文更清晰,一個聚合根就是一個子域
  • 將高內聚低耦合、單一職責原則極致化,外部應用可以自由選擇呼叫哪個子域的服務,從而決定自己的應用邊界
  • 後續團隊的擴張,更加專人專職,在需求迭代時降低併發度,避免十幾個人同時修改一個系統的局面

2.3 編碼

  1. 按照應用邊界劃分以後,按子域分別建立應用、申請主機例項、DB資源等。
  2. 由於充血模型的repository注入會導致寫測試用例比較麻煩,採用了貧血模型,發票本身也沒有什麼領域方法,再加上子域已經拆到最細了,跨子域操作都需要通過RPC來互動,當時只抽取了業務校驗和計算金額的方法出來。

實踐期間遇到的最大問題是,原先面向DO的CRUD程式式程式設計(當然其中也有一定的抽象和封裝),要轉到不關注儲存結構、所有物件都在記憶體中的DDD,會很不適應。並且,你會發現如何處理底層儲存又是繞不開的,諸如模型轉換和事務處理,不可能完全照搬DDD,也不能重回CRUD的老路上去,非常的彆扭,只能做一定程度的折中。

3. 問題

  1. 在建立領域模型時,產品側很少參與,業務方則基本沒有直接參與。整個用例分析和建模的過程技術團隊反覆的開會討論,耗費了很多時間,但是對於使用者來說並沒有相應的體感。實際上這違反了DDD的一大前提:需要技術團隊和業務團隊__共同__建立一套領域語言,才能減少後續的交流溝通成本。這導致了後續的需求溝通仍停留在原先的水平上,沒有提升什麼效率。更嚴重的是,有的需求上線了,但是業務發現並不好用(甚至不如老系統好用)。舉一個例子:業務方想知道哪些賬單開了哪些發票,開票狀態是什麼。老系統直接就能展示(當然這也和老系統實際開票鏈路是人工的,系統層面很簡單有關);新系統卻要先找到當時的申請單,申請單上找到對應的發票。可是業務方視角下完全是沒有申請單的概念的,他們也不關心申請單ID,只要把發票開出來就行了。
  2. 拆分出子域過多,並沒有帶來其好處,反而帶來了一些副作用:
    1. 專案聯調成本比較高,特別是專案剛上線時問題比較多,和有新人加入需要上手。由於開票流程至少跨越了子域的三個系統,出問題時為了定位,可能要在這三個系統之間來回穿梭,檢視日誌、反覆debug,效率很低
    2. 由於人員變動,原先規劃的每個應用2人互為backup,在相當一段長的時間變成了1人同時需要負責5個系統的運維和需求,嚴重擠佔了個人時間
    3. 分散式系統的一致性問題被放大,核心的三個域既要各自進行的冪等,又要做跨系統核對來保證最終一致性。
    4. 額外的RPC開銷使得業務失敗機率變大,在單據數目較多的情況下提交開票很容易超時,只能進行限制。
  3. 上游系統的域也在做DDD實踐,他們的模型變了好幾版,導致發票域的待開票單據模型很多欄位都廢棄了。因為是大而全的模型,欄位不夠用倒是未出現。
  4. 不同子域使用了不同的分庫分表規則,增加了問題的排查難度。
  5. 由於不同子域牽頭的開發不一樣,對於技術選型在架構組也沒有給出結論,自由發揮的空間很大,有的子域用了一些事後發現有坑的技術,舉幾個例子:
    • JPA。之前全組慣用的是mybatis,JPA”看上去“更適合直接繫結領域模型,但是其門檻比較高,需要花很多的時間去學習。同時,在分庫分表的環境下,想寫定時任務處理,只能用entityManager來寫原生SQL拼接分表名
    • Guava EventBus(實際是上游域用的),場景是用於傳送領域事件。Guava EventBus實現了觀察者模式,可以將流程解耦,同步或非同步地進行後續處理,看上去很適合做領域事件的載體。但是它是單機JVM範圍內的,如果發生當機或重啟,未處理的事件直接丟失。【思考:如果仍然想用Guava EventBus來傳送領域事件,如何防止這個問題?答案是先將領域事件持久化。】
  6. 照搬現實世界的實體,帶來了一些意想不到的問題。如上圖的發票模型,並沒有儲存business_parnter_id,畢竟現實中是不存在的。如果需要查詢,直接從申請單的invoice_id去查即可。但是當需要做鑑權時,也即需要判斷這張發票是否屬於該使用者時,無法直接從發票實體上判斷,不得不繞道到發票申請域。
  7. 預想的用來方便擴充套件的設計,實際上並沒有用到。比如開票執行域,預期能夠對接不同的開票ISV,但是該系統上線近兩年都只接入了一家ISV,並且在可預見的一年以內,都沒有接入其他ISV的需求。又如CQRS,除了OpenSearch和MySql這兩個資料來源,沒有其他資料來源,並且所使用的MySql例項的主從結構目前對於開發是透明的,所謂CQRS只不過是程式碼層面寫操作繼續走repository,讀操作繞過repository直接查詢DAO而已。
  8. 另外一個預想的讓業務接入方/行業研發來編寫防腐層、直接呼叫下層服務,則基本沒有影子,畢竟業務方開發資源也有限,平臺還沒有發展到那麼強大的程度。
  9. 與現有技術框架是否能相容。這個看上去不像是DDD的問題,因為DDD更關注業務,實踐中遇到的問題是:兩個實體有迴圈引用(就像Father和Son兩個類相互持有對方一樣),在通過自定義日誌攔截器打日誌時,由於使用的是fastjson做序列化,迴圈引用直接stackoverflow了。【思考:除了去除迴圈引用外,如何解決這個問題?答:可以關閉fastjson的迴圈引用功能,更好的實踐是手寫實體的toString()方法,選擇要列印的資訊】

4. 反思

  • DDD最關鍵的一點是和產品、業務保持溝通,才能形成共識也即領域語言,不能僅僅是技術自high和閉門造車,這樣是無意義和浪費的。此外,除了自己的域也要關注上下游,才能讓模型高內聚低耦合。

  • DDD不能教條化,需要因地制宜。其實很早之前業界推行的分層架構本身已經有DDD的影子了,不要為了拆分出更多的應用而劃分子域。同一個應用是可以包含多個子域的,是否需要進一步拆分要由業務現狀和發展來確定。

  • 在發票域這個具體的場景裡,可以看出領域模型本身並不是易於變化的,經常變更的是規則和接入層。那麼基於這個來擴充套件規則的配置和運轉、以及接入層的校驗和填充,會取得更好的結果。

  • DDD是一個持續的過程,不能一蹴而就,要隨著業務的發展而持續迭代。

5. 再談......

5.1 再談CQRS

CQRS全稱是Command Query Responsibility Segration,即命令與查詢分離。一般架構圖如下:

(圖源: https://zhuanlan.zhihu.com/p/115685384)

最最簡化的實現方式,是程式碼層面繞過Repository,直接查詢DAO,然後轉化成VO傳給呼叫方。這樣做初看並沒有什麼卵用,但是結合到具體的業務場景來看,就有用處了,舉幾個例子:

  • 應用使用了多個資料來源,如MySql+ES/opensearch。在分庫分表場景下,如果需要查詢”所有使用者+狀態為未開票 的 所有對賬單“,直接走MySql是無法查詢的,只能使用搜尋引擎預先建立的索引,這是一種讀寫不分離也得分離的情況。

  • 資料來源的拓撲結構。MySql的讀寫分離是透明的,假如要自己造輪子,在應用層面指定寫庫和讀庫,並自己提供同步機制,那麼此時就可以分別對寫庫和讀庫做操作,”強行“讀寫分離。

  • 保持領域物件和Repository的純潔性。有很多讀操作,都是領域方法和Application層的業務邏輯用不到的,僅僅是提供給外部系統做查詢。如果在Repository中加入了大量的查詢方法,會增加維護成本。同時,領域物件之間操作時,載入的物件一般是完整的;但是對於外部查詢,考慮到領域物件和DB表結構並不完全一致,需要進行定製化的簡化和組合,如下圖中如果只查商品基礎資訊,是沒必要載入整個領域模型的

【思考】如果一個領域物件A需要操作另一個領域物件B,那麼載入B是否走的讀操作?

【個人見解】否,應該通過Repository載入。

5.2 再談架構分層

img

不管是三層架構,還是六邊形架構,其核心總是Application-Domain-Infrastructure。這並不意味著程式碼的組織形式必須完全照搬,我們仍可以使用更細的劃分方法和層次結構,以下提供一種劃分和命名方式作為參考:

  • User Interface,一般命名為client/facade,包括對外暴露的服務介面和DTO。雖然從分層的視角來看User Interface屬於Application層,但是為了打二方包提供給外部使用,只能單獨拆分出來

  • Application

    • assmbler,用於DTO和domain model的轉換
    • service,應用服務,編排領域服務,可以提供事務等,不包含業務邏輯
  • Domain

    • core-service,領域服務,完成跨多個領域模型時的操作
    • core-model,領域模型,用來承載聚合根、實體。如果包含多個子域,可以用包路徑來區分
  • Infrastructure

    • common,通用工具,包含一些常量、相對獨立的工具(如PDF轉換、MD5加解密等)、通用的類(自行封裝的Exception)、通用配置等
    • message,對接訊息中介軟體
    • dal,對接DB持久化層
    • 其他基礎設施

在邏輯上分層之後,實際編碼中也會遇到一些問題,需要採取折中,比如:

  • 首先也是最關鍵的一點:在Maven專案中,程式碼分層是通過pom的組織關係實現的。如果想按照更明顯的依賴關係,如Application bundle包含assmbler和service兩個bundle,會發現配置起來很麻煩也很容易出錯,其他層bundle跨層依賴時也很難受。方便起見,可以僅僅通過命名(甚至是約定俗成)來體現哪個bundle屬於哪一層,bundle之間的依賴通過pom解決。

  • DO和Domain Model的Convert放在哪裡?假如Repository介面放在Domain層,實現放在Infrastructure層,會發現Repository操作的是Domain Model,強行讓Infrastructure的dal層不得不依賴Domain Model層,破壞了上層依賴下層的關係。因此只能將Convert放在Domain層,直接對DO的操作顯得很刺眼,單獨再抽一層又十分的冗餘。

6. DDD最佳實踐?

DDD真的有最佳實踐嗎?就目前來看,沒有。DDD不能只靠閱讀就能充分理解,需要通過真正的實踐,也會遇到挫折和懷疑,需要及時回顧和反覆的學習。即使是聚合邊界和聚合根的尋找,也是一件有難度的事情。

一種直覺性的實踐方法是,看看程式碼是否有”壞味道“。舉例幾個例子:

  1. 一個實體持有了大量的其他的實體,比如School類中包含了一個的List<Student>,那麼這個實體是不是會顯得很笨重?即使用lazy-load來處理Student,仍然是反模式的。那麼不如在領域模型層面將二者的引用關係解除掉。當需要操作這個School的所有Student時,在School類的領域方法中再進行處理。
  2. 如果DDD後的程式碼可讀性變差了,那麼這和DDD的初衷也是背離的。
  3. 對某類實體A批量操作(如果沒有關聯到另一個和A有關係的實體B上去)不得不在A中完成也是一種”壞味道“。Evans建議,當你在懷疑是否應該在一個類中放入”壞味道“的方法、其原因是你覺得它不屬於這個類時,用一個ServiceFoo類來放這個方法。
  4. 貧血模型和充血模型,究竟選哪個。這兩種模型的最大區別是Repository是否屬於領域物件。充血模型需要走點彎路去注入Repository,並且測試起來會難一些。其實領域驅動在建模時更關注的是如何提取領域方法並和領域模型整合

個人認為DDD的最佳實踐是,不斷的重構。尋找模型中有問題或蹩腳的地方,如物件應從關聯導航還是倉儲獲取?聚合設計是否正確?模型效能是否OK?然後停下來重構。

但是重構和滿足業務當前需求在時間上是有衝突的,大規模重構會帶來回歸測試的工作量和一定的風險,如何平衡也是個問題。小心過度設計。

7. 新知

在編寫本文的同時,也讀了一些其他文章,補充了現有的認知。

  • 不應該給實體定義太多的屬性或行為,而應該尋找關聯,發現其他一些實體或值物件,將屬性或行為轉移到其他關聯的實體或值物件上。
  • 跨實體的互動放在領域方法裡。
  • 為什麼Repository層不應該使用insert、update等作為方法命名?因為這些名稱和SQL是強繫結的,而對於快取如Redis,SET就是插入或更新。因此使用更加通用的命名如find、save、remove,再在實現裡選擇具體的DAO方法做處理。
  • “使用者需求”不能等同於“使用者”,捕捉“使用者心中的模型”也不能等同於“以使用者為核心設計領域模型”。 《老子》書中有個觀點:有之以為利,無之以為用。在這裡,有之利,即建立領域模型;無之用,即包容使用者需求。舉些例子,一個杯子要裝滿一杯水,我們在製作杯子時,製作的是空杯子,即要把水倒出來,之後才能裝下水;再比如,一座房子要住人,我們在建造房子時,建造的房子是空的,唯有空的才能容納人的居住。因此,建立領域模型時也要將使用者置於模型之外,這樣才能包容使用者的需求。[2]
    • 我們設計領域模型時不能以使用者為中心作為出發點去思考問題,不能老是想著使用者會對系統做什麼;而應該從一個客觀的角度,根據使用者需求挖掘出領域內的相關事物,思考這些事物的本質關聯及其變化規律作為出發點去思考問題。
    • 領域模型是排除了人之外的客觀世界模型,但是領域模型包含人所扮演的參與者角色,但是一般情況下不要讓參與者角色在領域模型中佔據主要位置,如果以人所扮演的參與者角色在領域模型中佔據主要位置,那麼各個系統的領域模型將變得沒有差別,因為軟體系統就是一個人機互動的系統,都是以人為主的活動記錄或跟蹤;比如:論壇中如果以人為主導,那麼領域模型就是:人發帖,人回帖,人結貼,等等;DDD的例子中,如果是以人為中心的話,就變成了:託運人託運貨物,收貨人收貨物,付款人付款,等等;因此,當我們談及領域模型時,已經預設把人的因素排除開了,因為領域只有對人來說才有意義,人是在領域範圍之外的,如果人也劃入領域,領域模型將很難保持客觀性。領域模型是與誰用和怎樣用是無關的客觀模型。歸納起來說就是,領域建模是建立虛擬模型讓我們現實的人使用,而不是建立虛擬空間,去模仿現實。
  • 聚合根如何設計?關於領域驅動設計(DDD)中聚合設計的一些思考

其他參考文獻


  1. 美團技術團隊:領域驅動設計在網際網路業務開發中的實踐 ↩︎

  2. 領域驅動設計之領域模型
    阿里盒馬領域驅動設計實踐-InfoQ ↩︎

相關文章