:從限界上下文到聚合設(shè)計,避開微服務(wù)大泥球)
團隊里老有人問DDD到底怎么落地代碼和業(yè)務(wù)始終對不上賬微服務(wù)拆了一堆又變成分布式大泥球這個領(lǐng)域驅(qū)動設(shè)計Domain-Driven DesignDDD看著高大上真上手卻總覺得隔著一層紙。這篇文章我就把DDD從概念到落地掰開揉碎講講那些書里不會細(xì)寫的坑和實操經(jīng)驗。不管你是剛聽說DDD這個名字想了解一下還是已經(jīng)在項目里試過但卡在某一步這篇文章都會對你有用——我會用真實項目里踩過的坑、總結(jié)出來的套路把戰(zhàn)略設(shè)計和戰(zhàn)術(shù)設(shè)計怎么結(jié)合、代碼怎么組織、常見問題怎么排查都講透。1. 內(nèi)容整體設(shè)計與思路拆解1.1 為什么DDD總在“聽過”和“會用”之間卡殼我見過太多團隊學(xué)DDD的路徑先買本藍皮書翻兩章然后開會討論“聚合”和“值對象”討論了兩個月還是不知道代碼放哪個包最后又回到面向數(shù)據(jù)庫寫代碼的老路上。問題不全在學(xué)習(xí)者身上DDD這個概念本身確實容易讓人覺得飄。DDD說起來其實只有兩件事理清業(yè)務(wù)邊界和讓代碼表達業(yè)務(wù)。理清業(yè)務(wù)邊界是戰(zhàn)略設(shè)計對應(yīng)限界上下文、上下文映射、防腐層這些東西讓代碼表達業(yè)務(wù)是戰(zhàn)術(shù)設(shè)計對應(yīng)實體、值對象、聚合、領(lǐng)域服務(wù)、領(lǐng)域事件這些模式。很多團隊卡殼是因為一上來就扎進戰(zhàn)術(shù)模式里整天糾結(jié)實體和值對象怎么區(qū)分忘了DDD最值錢的部分其實在最開始。拿我之前參與過一個訂單系統(tǒng)的模擬項目X來說當(dāng)時團隊成員對“訂單”這個詞的理解都不一致。運營說的訂單是用戶付款的記錄倉儲說的訂單是要發(fā)貨的單據(jù)財務(wù)說的訂單是收入確認(rèn)的依據(jù)。如果大家不先把“訂單”在不同場景下的含義厘清直接在代碼里建一個全字段大Order表后續(xù)所有需求都會在這個大泥球上不斷打補丁。DDD解決問題的思路是不先從數(shù)據(jù)結(jié)構(gòu)出發(fā)而是從業(yè)務(wù)事件和業(yè)務(wù)角色出發(fā)把整個系統(tǒng)按業(yè)務(wù)邊界切分成若干個獨立的小世界——也就是限界上下文。每個小世界內(nèi)部有自己的語言和數(shù)據(jù)模型小世界之間通過明確接口通信。這樣不同部門對“訂單”的不同理解就可以被顯式地建模成不同上下文里的不同模型而不是拼湊在一張表里。1.2 戰(zhàn)略設(shè)計先于技術(shù)選型很多團隊在做架構(gòu)評審時喜歡一開始就對技術(shù)棧爭論不休用MySQL還是PostgreSQL消息隊列用這個還是那個服務(wù)發(fā)現(xiàn)用哪個組件。這些當(dāng)然是需要定的但不應(yīng)該是DDD的起點。正確的順序應(yīng)該倒過來先完成業(yè)務(wù)建模畫出限界上下文和上下文映射關(guān)系再根據(jù)業(yè)務(wù)邊界來決定系統(tǒng)怎么拆。比如訂單上下文和庫存上下文之間是防腐層關(guān)系還是共享內(nèi)核關(guān)系直接決定了兩個服務(wù)之間是走接口調(diào)用還是共享數(shù)據(jù)庫也決定了后續(xù)的團隊分工和發(fā)布節(jié)奏。我自己做DDD項目有一條鐵律前兩周不允許寫一行代碼所有人都在做業(yè)務(wù)分析、事件風(fēng)暴、術(shù)語表梳理、邊界劃分。很多程序員一開始不適應(yīng)覺得在浪費時間可一旦邊界畫清楚了后面寫代碼的效率非??旄膭右粋€業(yè)務(wù)規(guī)則不需要牽一發(fā)動全身。該快的時候反而不能急著寫代碼這個反直覺的順序就是DDD和傳統(tǒng)以數(shù)據(jù)表為起點的設(shè)計最大的不同。2. 核心概念解構(gòu)與實操要點2.1 實體和值對象這兩個概念決定了數(shù)據(jù)建模的格局DDD的戰(zhàn)術(shù)設(shè)計里最基礎(chǔ)也最重要的選擇就是模型里哪些東西是實體哪些是值對象。實體是有唯一標(biāo)識、有生命周期、需要被追蹤的東西。比如一個訂單它有訂單號狀態(tài)從待支付變成已支付再變成已發(fā)貨這個過程里訂單一直存在必須能隨時找到它。值對象則不同它描述的是某個維度上的特征不需要唯一標(biāo)識兩個值對象如果屬性相同就是同一個東西。比如金額50元這個對象不存在“兩個不同的50元”這種說法。這兩個概念放到傳統(tǒng)關(guān)系型數(shù)據(jù)庫里看會非常清楚實體對應(yīng)有主鍵的表值對象通??梢耘c實體共享一張表做嵌入式存儲或者被序列化存儲也可以獨立成表但沒有業(yè)務(wù)主鍵只有值。實操時有一個很實用的判斷技巧如果兩個對象被你放進同一個集合比較時你期望它們“相等”的判斷標(biāo)準(zhǔn)是“所有屬性都相等”那這就是值對象如果期望“唯一標(biāo)識一樣就相等”那這就是實體。比如訂單明細(xì)里的商品名稱、單價這些內(nèi)容如果訂單改了舊的明細(xì)內(nèi)容作廢了新的明細(xì)就是一個新的值對象不需要給它單獨搞一個明細(xì)ID去追蹤歷史。這里我再補充一個踩過的坑。我們曾經(jīng)想當(dāng)然地把訂單明細(xì)設(shè)計成實體給每個明細(xì)行加了自增主鍵結(jié)果發(fā)現(xiàn)大量業(yè)務(wù)邏輯被吸引到了明細(xì)這個實體上修改數(shù)量、拆分行、記錄贈品來源。這些操作讓明細(xì)模型變得越來越重但業(yè)務(wù)上根本不需要追溯明細(xì)行的狀態(tài)變化。后來把它改成值對象集合訂單只聚合一組快照數(shù)據(jù)整個模型輕了不止一個量級改數(shù)量就是替換集合里的一個值對象用不到任何狀態(tài)流轉(zhuǎn)。2.2 聚合設(shè)計把一致性邊界劃在正確的位置聚合是DDD戰(zhàn)術(shù)設(shè)計里最難的環(huán)節(jié)難點不在于概念本身而在于約束業(yè)務(wù)規(guī)則和一致性范圍時要敢于取舍。聚合要解決的核心問題是哪些對象必須被作為一個整體來更新如果訂單狀態(tài)變?yōu)椤耙讶∠蹦怯唵卫锩娴乃忻骷?xì)、配送信息、支付信息是否都要同時滿足某個不變量如果能接受稍后一致那么把關(guān)聯(lián)對象拆成獨立聚合是合理的如果不能接受就必須讓它們待在同一個聚合內(nèi)通過同一個事務(wù)保證一致性。聚合設(shè)計有幾個實操原則都是我用代價換來的第一聚合要盡量小。一個大聚合在并發(fā)修改時沖突概率高性能瓶頸明顯業(yè)務(wù)規(guī)則很容易堆成迷宮。不要貪心把所有能關(guān)聯(lián)的都裝進一個聚合里。只需要問一個問題“這條業(yè)務(wù)規(guī)則被違反時最壞后果是什么一秒后修復(fù)可以嗎”如果可以就可以拆出來。第二聚合內(nèi)通過根實體訪問內(nèi)部對象外部不能直接修改內(nèi)部對象。訂單聚合如果暴露了“直接修改第三行明細(xì)數(shù)量”的方法本質(zhì)上就是讓外部破壞聚合的不變量。正確做法是暴露“修改第幾行、新數(shù)量是多少”這樣的業(yè)務(wù)方法由聚合內(nèi)部判斷該行是否存在、數(shù)量是否合法再決定是否修改。第三聚合間的最終一致性要落在領(lǐng)域事件上。訂單取消后通知庫存釋放預(yù)占庫存這個操作不能放在一個本地事務(wù)里把庫存表和訂單表一起update正確姿勢是訂單聚合發(fā)布一個領(lǐng)域事件庫存上下文訂閱后自己處理。很多團隊一開始不習(xí)慣覺得事務(wù)跨了服務(wù)不保險但行業(yè)標(biāo)準(zhǔn)做法就是通過可靠事件投遞機制比如事務(wù)發(fā)件箱模式來保證最終一致。第四與數(shù)據(jù)庫反問這個聚合根對應(yīng)一張表嗎沒有這種必然關(guān)系。一個聚合可能對應(yīng)多張表也可能一個聚合的數(shù)據(jù)就對應(yīng)表里的一行。持久化模型和領(lǐng)域模型是不同的關(guān)注點ORM映射時要明確區(qū)分。2.3 領(lǐng)域事件讓模型轉(zhuǎn)身成為消息的來源領(lǐng)域事件在DDD里是重要但容易被誤用的概念。它不是通用消息隊列里的那種消息而是“過去已經(jīng)發(fā)生、對業(yè)務(wù)有意義的事情”在模型層面的表達。比如訂單已取消這就是一個領(lǐng)域事件它的名字用過去時態(tài)里面裝的是事件發(fā)生那一刻的快照數(shù)據(jù)訂單號、取消原因、取消時間、操作人。下游關(guān)心的是事件本身而不是去訂單表里查最新狀態(tài)。最容易犯的錯誤是把領(lǐng)域事件當(dāng)命令用。有人發(fā)布OrderCancelRequested這種事件打算讓下游執(zhí)行某個動作這混淆了“事件”和“命令”的語義。事件表達的是已發(fā)生事實命令表達的是請求意圖。領(lǐng)域事件應(yīng)該貨真價實地記錄事實否則事件溯源和訂閱方調(diào)試時都會混亂。實操中我建議在應(yīng)用層的事務(wù)邊界內(nèi)發(fā)布領(lǐng)域事件發(fā)布方式采用事務(wù)發(fā)件箱模式把領(lǐng)域事件和業(yè)務(wù)數(shù)據(jù)變更放在同一個本地事務(wù)里先寫業(yè)務(wù)表再寫一條事件記錄到發(fā)件箱表然后由后臺任務(wù)或CDC變更數(shù)據(jù)捕獲機制把事件發(fā)布到消息中間件。這樣既能保證事件不丟又不要求把兩個不同服務(wù)的數(shù)據(jù)放在跨庫事務(wù)里。2.4 領(lǐng)域服務(wù)與貧血模型別把領(lǐng)域邏輯全部堆到應(yīng)用層企業(yè)應(yīng)用里最常見的壞味道就是貧血模型實體只有一堆getter和setter業(yè)務(wù)邏輯全部寫在Service類里。這種代碼表面上分層清晰實際上把最重要的業(yè)務(wù)規(guī)則都散落在事務(wù)腳本里復(fù)用了實體也復(fù)用不了邏輯最后service類動輒幾千行每一個新人來了都要花一周捋清楚。DDD要求把業(yè)務(wù)邏輯放入領(lǐng)域?qū)臃彩悄芊胚M實體的邏輯就放入實體方法凡是涉及多個聚合協(xié)作的純業(yè)務(wù)邏輯放入領(lǐng)域服務(wù)應(yīng)用層只負(fù)責(zé)編排、事務(wù)邊界、權(quán)限控制、DTO轉(zhuǎn)換這些低價值工作。舉一個很常見的例子計算訂單總金額。很多團隊把總金額簡化成一個字段下單時在service里統(tǒng)一算好存進表里。這在DDD里是很可疑的設(shè)計如果總金額是一個可以通過明細(xì)行金額和優(yōu)惠規(guī)則推導(dǎo)出來的值為什么不直接在聚合內(nèi)部提供一個業(yè)務(wù)方法來計算這樣當(dāng)優(yōu)惠規(guī)則變化時可以保證邏輯收斂在訂單聚合內(nèi)而不是散落在各個調(diào)用方。不過要提醒一點不要把領(lǐng)域服務(wù)濫用成新的上帝類。正確的領(lǐng)域服務(wù)應(yīng)該是粒度很小的、專注于“多個聚合協(xié)調(diào)產(chǎn)生一個結(jié)果”的純邏輯操作如果一個領(lǐng)域服務(wù)里做了十幾件事那多半是漏了什么模型概念應(yīng)該回頭重新審視模型。3. 實操過程與核心環(huán)節(jié)實現(xiàn)3.1 從業(yè)務(wù)流程出發(fā)做事件風(fēng)暴工作坊DDD落地第一步我不建議直接打開IDE建工程更建議先組織一次事件風(fēng)暴工作坊。這個東西聽起來門檻高操作起來就是一群人圍在一面大墻前用不同顏色的便簽紙分別記錄領(lǐng)域事件、命令、角色、聚合、外部系統(tǒng)然后一張張鋪開排成時間線。我操盤過一個典型的模擬項目X某電商平臺重構(gòu)訂單核心系統(tǒng)。第一天工作坊只定了一個目標(biāo)梳理“從用戶點擊購買到訂單關(guān)閉全生命周期”里究竟會發(fā)生哪些領(lǐng)域事件。便簽從“用戶提交訂單”開始依次貼出“訂單已創(chuàng)建”“支付已發(fā)起”“支付已完成”“庫存已預(yù)占”“訂單已發(fā)貨”“訂單已簽收”“訂單已取消”等等。這一步最大的價值在于把分散在不同人腦子里的業(yè)務(wù)知識顯性化。從領(lǐng)域事件時間線里誰發(fā)起、期望什么結(jié)果哪些操作是連貫的哪些需要跨領(lǐng)域協(xié)作都浮出水面。畫完時間線后再把命令和角色標(biāo)注上去一條清晰的業(yè)務(wù)流程就可靠得多。工作坊結(jié)束后的產(chǎn)出絕不是一面便利貼墻和幾張照片而是一份業(yè)務(wù)術(shù)語表。這個詞表必須精確定義每個業(yè)務(wù)動作和對象的含義比如“訂單取消”在下單后未支付和發(fā)貨后客戶退貨這兩種場景下是不同的概念術(shù)語表里要區(qū)分為“系統(tǒng)取消”和“退貨單創(chuàng)建”。統(tǒng)一術(shù)語以后領(lǐng)域模型設(shè)計、代碼命名、PR描述、測試用例全都以術(shù)語表為準(zhǔn)避免各說各話。3.2 識別限界上下文劃分服務(wù)與模塊的核心依據(jù)事件風(fēng)暴結(jié)束后下一步是把事件按業(yè)務(wù)歸屬分組形成限界上下文。分組時不要按技術(shù)模塊分比如不要出現(xiàn)“用戶模塊”“數(shù)據(jù)庫模塊”這種分組也不要按團隊組織分要站在業(yè)務(wù)能力角度劃分。我習(xí)慣用三個強制問題來篩每一個候選上下文這個上下文在解決誰的什么問題它使用哪些業(yè)務(wù)術(shù)語它產(chǎn)生的數(shù)據(jù)別的上下文需要用什么方式獲取在三問過程中一個高頻爭論點是“用戶”。用戶這個概念在登錄認(rèn)證、個人中心、訂單履約、客服工單里都存在但每個上下文關(guān)心的用戶信息完全不同。認(rèn)證上下文里用戶是憑證持有者履約上下文里用戶是收貨人客服上下文里用戶是工單發(fā)起者。如果你試圖建一個統(tǒng)一用戶模型讓所有系統(tǒng)共享大概率又要做成一個巨型通用模塊。成熟的方案是有獨立的身份上下文負(fù)責(zé)憑證、個人資料等基礎(chǔ)數(shù)據(jù)其他上下文各自維護自己業(yè)務(wù)視圖下的用戶數(shù)據(jù)比如收貨地址、默認(rèn)發(fā)票信息這些數(shù)據(jù)不是每次去身份上下文拉一整套用戶大對象而是在自己的數(shù)據(jù)庫里維護本地視圖。這個邊界畫清楚了服務(wù)拆分就順理成章。3.3 從戰(zhàn)略到代碼一種可快速落地的工程目錄與編碼范式很多團隊DDD落不了地的最后一公里是說不清楚代碼到底應(yīng)該怎么擺放。我給一個我一直在用的工程目錄結(jié)構(gòu)這個結(jié)構(gòu)兼容Spring Boot和普通Java工程PHP、Go等語言可以按同樣思想套用interfaces用戶接口層放Controller、MQ消費者、外部網(wǎng)關(guān)適配職責(zé)是協(xié)議轉(zhuǎn)換和參數(shù)校驗。application應(yīng)用層放應(yīng)用服務(wù)編排用例管理事務(wù)邊界發(fā)布領(lǐng)域事件不做任何業(yè)務(wù)規(guī)則判斷。domain領(lǐng)域?qū)臃艑嶓w、值對象、聚合根、領(lǐng)域服務(wù)、領(lǐng)域事件、倉儲接口、領(lǐng)域異常。infrastructure基礎(chǔ)設(shè)施層放倉儲實現(xiàn)、消息發(fā)送、外部HTTP調(diào)用、數(shù)據(jù)庫映射、緩存操作等。代碼層級約束要守住兩條鐵律依賴方向只能從外層指向內(nèi)層領(lǐng)域?qū)硬荒芤蕾嚾魏位A(chǔ)設(shè)施細(xì)節(jié)所以倉儲接口定義在領(lǐng)域?qū)訉崿F(xiàn)放在基礎(chǔ)設(shè)施層。第二應(yīng)用層不能直接操作實體內(nèi)部狀態(tài)只能調(diào)用聚合根暴露的業(yè)務(wù)方法。寫一個訂單取消的用例從Controller到基礎(chǔ)設(shè)施的完整鏈條應(yīng)該是這樣Controller接收取消請求轉(zhuǎn)成應(yīng)用服務(wù)的方法參數(shù)應(yīng)用服務(wù)先調(diào)用倉儲從數(shù)據(jù)庫里把訂單聚合加載出來然后判斷當(dāng)前用戶有沒有取消權(quán)限——權(quán)限判斷屬于應(yīng)用層事務(wù)腳本不放進領(lǐng)域?qū)印又{(diào)用訂單聚合的cancel方法修改狀態(tài)并發(fā)布區(qū)域事件最后調(diào)用倉儲保存。保存動作會在這個事務(wù)里同時寫入訂單狀態(tài)、事件發(fā)件箱記錄。實操中還有個細(xì)節(jié)值得注意加載聚合時一定要帶上做業(yè)務(wù)判斷所需的所有數(shù)據(jù)。我見過有人圖省事先load訂單頭信息判斷完狀態(tài)要改明細(xì)時發(fā)現(xiàn)明細(xì)沒加載又去調(diào)一次倉儲結(jié)果業(yè)務(wù)方法本身被拆得七零八落。正確的倉儲設(shè)計應(yīng)該提供清晰明確的加載接口比如findById(id)返回完整聚合如果因為開銷問題不需要全加載再設(shè)計專門的輕量查詢路徑那也應(yīng)該是顯示接口比如findSummaryById而不是讓調(diào)用方在同一個聚合對象上做流式懶加載。3.4 防腐層與開放主機服務(wù)系統(tǒng)間協(xié)作的護城河限界上下文之間協(xié)作不是無腦HTTP調(diào)用。每個下游上下文對上游數(shù)據(jù)都有自己的理解和加工方式所以上下文之間按邊界類型選協(xié)作模式。最常用的是防腐層Anticorruption Layer。這個詞聽著抽象其實就是一個翻譯層把上游上下文傳過來的模型翻譯成當(dāng)前上下文能理解的模型。比如外部供應(yīng)商系統(tǒng)管他們的數(shù)據(jù)叫“項目編號”我們內(nèi)部實際叫“任務(wù)ID”兩個系統(tǒng)對接時防腐層負(fù)責(zé)把“項目編號”翻譯成“任務(wù)ID”后續(xù)領(lǐng)域?qū)铀羞壿嬛徽J(rèn)識“任務(wù)ID”不認(rèn)識“項目編號”。這樣做最大的好處是外部系統(tǒng)的模型變化不會污染內(nèi)部模型。上游供應(yīng)商改了字段語義我們只需修改防腐層不需要改動領(lǐng)域模型和業(yè)務(wù)代碼。所有第三方API客戶端、消息消費者、讀模型轉(zhuǎn)換都應(yīng)該住在防腐層內(nèi)這個層很薄但它是一道關(guān)鍵性屏障。對外提供服務(wù)時推薦用開放主機服務(wù)Open Host Service模式為每種外部消費者提供專用接口。比如我們?yōu)锳pp端、小程序端、內(nèi)部運維后臺分別提供不同的DTO和接口而不是一個萬能DTO應(yīng)付所有端這樣任一端的展示需求變化不會影響本上下文的領(lǐng)域模型。4. 常見問題與排查技巧實錄4.1 聚合邊界畫錯的典型癥狀與修復(fù)策略聚合邊界是DDD項目里返工最多的地方。畫得太粗一個大聚合動不動幾千行并發(fā)沖突、性能瓶頸、修改范圍全部爆炸畫得太細(xì)一堆小聚合之間互相調(diào)倉儲拿數(shù)據(jù)一致性一塌糊涂業(yè)務(wù)規(guī)則被拆成片段到處飛。歸結(jié)起來畫得不對的典型癥狀有這么幾個領(lǐng)域方法里頻繁訪問其他聚合的數(shù)據(jù)一個用例要連續(xù)加載多個聚合做判斷表結(jié)構(gòu)設(shè)計先于聚合設(shè)計。診斷時我會重新逐個梳理業(yè)務(wù)不變量哪些業(yè)務(wù)規(guī)則一旦被打破系統(tǒng)就必須拒絕操作或立即補償這些規(guī)則覆蓋的對象就必須在一個聚合內(nèi)。分享一個具體調(diào)整案例。模擬項目X里有一個“優(yōu)惠券”模型最初設(shè)計是優(yōu)惠券聚合綁定用戶每次下單時應(yīng)用層同時加載訂單聚合和優(yōu)惠券聚合判斷優(yōu)惠券是否屬于該用戶、是否過期然后扣減優(yōu)惠券使用次數(shù)。問題是這種跨聚合校驗導(dǎo)致兩個聚合處于同一個強一致事務(wù)里但優(yōu)惠券CEO認(rèn)為“使用次數(shù)更新延遲一秒”完全沒問題。重構(gòu)后下單時只校驗優(yōu)惠券狀態(tài)快照訂單聚合自行判定總額與優(yōu)惠分?jǐn)們?yōu)惠券核銷通過領(lǐng)域事件異步處理。兩個聚合徹底分離事務(wù)范圍縮小并發(fā)提升業(yè)務(wù)上沒有任何抱怨。這件事給我留下很深的印象不少一致性約束其實是可以放寬的聚合設(shè)計時多追問一句“真的必須強一致嗎”往往能得到更好的邊界。4.2 分布式事務(wù)悖論別再用全局鎖換虛假的“一致”把DDD落地成微服務(wù)之后一個繞不開的問題是跨上下文的數(shù)據(jù)一致性。不少團隊在重構(gòu)前是一套單體、一個數(shù)據(jù)庫所有表可以隨意join現(xiàn)在拆成多個服務(wù)、多個庫自然會遇到分布式事務(wù)問題。很多人第一反應(yīng)是引入分布式事務(wù)框架做全局事務(wù)。但我要潑一盆冷水分布式事務(wù)會把系統(tǒng)性能拖垮還會把服務(wù)邊界重新耦合在一起違背了拆分服務(wù)的初衷。微服務(wù)架構(gòu)下跨上下文一致性的正確姿態(tài)是主動擁抱最終一致性。標(biāo)準(zhǔn)做法就是前面提到的可靠事件投遞領(lǐng)域?qū)影l(fā)出事件與業(yè)務(wù)數(shù)據(jù)寫進同一個本地事務(wù)發(fā)件箱表然后由發(fā)布器掃描發(fā)件箱推送到消息中間件下游消費端自己保證冪等。訂單服務(wù)和庫存服務(wù)各執(zhí)一份數(shù)據(jù)通過事件流對賬補償而不是靠一個全局事務(wù)鎖死所有庫。這套做法我第一次落地時業(yè)務(wù)方非常抵觸覺得數(shù)據(jù)短時間不一致無法接受。后來我寫了一個對賬腳本每分鐘檢測兩邊數(shù)據(jù)差異差異超過閾值自動告警并觸發(fā)補償。上線運行兩個月實際數(shù)據(jù)差異率極低僅有的差異也都自動消解。事實證明在人為設(shè)定的一致性和運維側(cè)的對賬補償機制配合下大多數(shù)業(yè)務(wù)場景都接受最終一致性。4.3 領(lǐng)域模型還是數(shù)據(jù)模型ORM映射的認(rèn)知誤區(qū)用ORM實現(xiàn)DDD持久化時最常見的坑是試圖讓數(shù)據(jù)庫表結(jié)構(gòu)直接對應(yīng)領(lǐng)域?qū)ο蠼Y(jié)構(gòu)。這種思路本質(zhì)上是拿數(shù)據(jù)模型冒充領(lǐng)域模型結(jié)果要么是領(lǐng)域模型為了迎合表結(jié)構(gòu)而失去業(yè)務(wù)含義要么是表結(jié)構(gòu)被領(lǐng)域模型的奇形怪狀搞得不倫不類。正確認(rèn)知是領(lǐng)域模型是運行時的內(nèi)存態(tài)表結(jié)構(gòu)是持久化形態(tài)兩者之間是一種規(guī)格化的存儲表示。ORM實現(xiàn)工具只是把內(nèi)存態(tài)翻譯成持久態(tài)的工具翻譯過程需要精心設(shè)計。值對象可以用一張單獨的從表存多條記錄或者用JSON列存儲甚至用逗號分隔字符串只要能可靠往返即可。這里要提醒一個很實際的坑不要為了追求“純粹領(lǐng)域模型”而把持久化映射搞得極其復(fù)雜比如大量使用ORM高級特性來做多態(tài)映射一旦升級版本或者遇到冷門數(shù)據(jù)庫方言可能會在詭異的地方爆炸。我的經(jīng)驗是領(lǐng)域模型保持充分表達力但持久化盡可能簡單常規(guī)一個實體一張主表值對象用JSON或單列存儲都行優(yōu)先考慮維護簡單。4.4 術(shù)語表是跨團隊協(xié)作的粘合劑不是文檔擺設(shè)DDD項目想要運轉(zhuǎn)順暢必須讓業(yè)務(wù)人員、產(chǎn)品經(jīng)理、開發(fā)、測試說的是同一種語言這就是通用語言Ubiquitous Language的作用。通用語言的核心不是高深的建模技巧而是一份被所有人共同遵守的業(yè)務(wù)術(shù)語表。術(shù)語表不是一次性產(chǎn)出它是一個持續(xù)演進的過程。每發(fā)現(xiàn)一個新的概念、每個命名分歧、每個“這個名字別處也在用”的苗頭都及時更新進表里同時指定一名業(yè)務(wù)代表當(dāng)術(shù)語表的最終仲裁者誰說了都不算業(yè)務(wù)代表點頭才算。術(shù)語表怎么影響代碼我在項目里定了一個機制所有類名、方法名、字段名都優(yōu)先從術(shù)語表里找詞術(shù)語表里沒有的詞不允許出現(xiàn)在代碼注釋和PR說明中。這么做看起來有點一刀切實際效果很直接代碼的可讀性、業(yè)務(wù)人員參與評審的意愿、新員工上手速度都明顯提升沒有術(shù)語表的時候大家討論問題能爭十分鐘原因只是雙方說的“訂單狀態(tài)”其實指的不是同一個東西。5. 落地的工程化支撐5.1 倉儲模式的價值在哪里以及什么時候不該用倉儲Repository模式是DDD的重要組成部分它存在的意義不是“把數(shù)據(jù)庫訪問封裝一下”這么淺層而是為領(lǐng)域模型提供一個基于集合概念的存儲接口。領(lǐng)域?qū)又恍枰f“把訂單聚合保存起來”或“根據(jù)訂單號取回訂單聚合”不需要知道底層是關(guān)系型數(shù)據(jù)庫、NoSQL、還是文件存儲。在實操中倉儲接口放在領(lǐng)域?qū)訉崿F(xiàn)放在基礎(chǔ)設(shè)施層這是依賴倒置的體現(xiàn)。很多人用倉儲模式時只是改了個名字實際還是把DAO那套findBy...、updateBy...接口原樣鋪了一遍這就是典型的披著DDD外衣的基于數(shù)據(jù)庫操作開發(fā)沒有為領(lǐng)域?qū)犹峁┤魏斡幸饬x的能力。怎樣的倉儲接口才算合格接口命名應(yīng)該面向業(yè)務(wù)語言比如findPendingOrders查找待支付訂單、save(OrderAggregate)、removeById方法參數(shù)和返回值應(yīng)該只出現(xiàn)領(lǐng)域類型而不是DTO或QueryObject。實現(xiàn)倉儲時內(nèi)部邏輯是把聚合拆成持久化對象、再做反向拼裝拼裝邏輯是ORM關(guān)注的重點。不過倉儲不是萬能解藥。對于純粹的報表查詢、搜索列表、統(tǒng)計面板不需要走倉儲去加載聚合直接使用查詢通道做讀模型投影會更合適。DDD的CQRS思想就是默認(rèn)寫操作走聚合與倉儲讀操作可以走輕量查詢、甚至直接寫SQL避免把所有獲取數(shù)據(jù)的路徑都壓給聚合裝載器。5.2 事件溯源要不要上DDD的進階選項聊DDD很難繞開事件溯源Event Sourcing——一種不存對象當(dāng)前狀態(tài)、只存狀態(tài)變更事件序列的持久化方式。這個概念很吸引人似乎只要用了事件溯源業(yè)務(wù)系統(tǒng)的追溯性、審計性、可擴展性全都解決了。但我對技術(shù)選型的建議一直很保守只有被明確需求逼到非上不可時才用事件溯源。比如對審計要求極其嚴(yán)格的資金流水系統(tǒng)、需要完整回放歷史的合規(guī)系統(tǒng)事件溯源的價值是壓倒性的而像普通的內(nèi)容管理、報表分析類系統(tǒng)引入事件溯源反而增加復(fù)雜度事件表膨脹、狀態(tài)重建耗時、事件結(jié)構(gòu)演進版本遷移這些都是實實在在的額外負(fù)擔(dān)。還有一種折中方案是只對核心高價值聚合啟用事件溯源其余聚合走傳統(tǒng)狀態(tài)持久化。這種局部事件溯源策略能在有限成本內(nèi)獲取最大好處我比較推薦有這個訴求的團隊考慮。5.3 工程上讓DDD可落地的幾個實踐機制DDD要在一個團隊里落地光有理論不行還要有配套的強制機制。我在項目里一般會建立三個機制缺一不可。第一是模型評審機制。每周固定時間業(yè)務(wù)代表、開發(fā)、測試坐下來對著最近更新的領(lǐng)域模型過一遍所有新增實體、方法、事件都從頭講一遍業(yè)務(wù)故事。這種評審在早期極其痛苦因為業(yè)務(wù)和開發(fā)存在“雞同鴨講”堅持幾個月后價值會越來越大模型偏差在實現(xiàn)前就被發(fā)現(xiàn)節(jié)省大量返工成本。第二是代碼邊界守護機制。這道防線可以靠架構(gòu)測試組件或代碼審查紀(jì)律來實現(xiàn)。比如在Java項目里用ArchUnit寫測試斷言領(lǐng)域?qū)硬荒躨mport基礎(chǔ)設(shè)施包、應(yīng)用層不能出現(xiàn)具體的HTTP客戶端和Mapper。這些測試加進流水線任何越界依賴在CI階段就掛掉比評審時人肉盯更可靠。第三是業(yè)務(wù)術(shù)語治理機制。術(shù)語表不是寫文檔要落到代碼里當(dāng)開發(fā)把一個新類名寫進代碼前先在術(shù)語表里檢索一下沒有就提請新增。對已經(jīng)建立良好的上下文還會用代碼生成或骨架模板固化通用語言到命名規(guī)范中。6. 關(guān)于DDD的一些經(jīng)驗沉淀老實說我見過不少團隊把DDD當(dāng)銀彈以為上了DDD微服務(wù)就順了、代碼就優(yōu)雅了、需求變更就簡單了。真正實踐過才發(fā)現(xiàn)DDD是放大器而不是修復(fù)器如果需求本來就一團亂麻、業(yè)務(wù)方無法清晰描述流程、團隊沒有基礎(chǔ)的分層和質(zhì)量意識DDD不會自動把這些變好只會把已有的混亂放大到更容易爆炸的規(guī)模。我個人的體感是DDD最值得投入的時間在“建模期”而不是“編碼期”。很多團隊在代碼寫到一半時才發(fā)現(xiàn)邊界定錯了然后就陷入“要不要重來”的痛苦抉擇。所以從一開始就值得用事件風(fēng)暴工作坊把核心流程徹底理清把術(shù)語表做扎實這些事糙但必須別跳。還有一點經(jīng)驗是DDD不要追求一次到位可以像切蛋糕一樣分階段落地。第一個迭代只選一個核心業(yè)務(wù)鏈路做DDD改造其他鏈路繼續(xù)走傳統(tǒng)分層先讓團隊感受到DDD帶來的模型清晰感再逐步擴大范圍。這種方式能降低風(fēng)險也能讓觀望者看到實實在在的增量收益比一開始就全量重構(gòu)要穩(wěn)妥得多。如果要給一條最有價值的建議我會說先找到一個懂業(yè)務(wù)的業(yè)務(wù)人員讓他愿意陪你做事件風(fēng)暴愿意和你逐詞逐句摳術(shù)語表。有了這個人在DDD已經(jīng)成功一半沒有這個人什么高超的戰(zhàn)術(shù)模式都是沙上建塔。技術(shù)永遠有解業(yè)務(wù)模型亂了解數(shù)學(xué)不出來。