引言
繼上一篇BFF的文章後,我又去網上學習了一下DDD
(領域驅動設計),發現一篇不錯的文章,參考並寫了一些自己的理解分享在這裡。
DDD
是什麼
領域驅動設計(Domain Driven Design) 是一種從系統分析到軟體建模的一套方法論。以領域為核心驅動力的設計體系。
為什麼使用
- 物件導向設計,資料行為繫結,告別貧血模型
- 優先考慮領域模型,而不是切割資料和行為
- 準確傳達業務規則
- 程式碼即設計
以上是網上文章說的,但我在會議中理解的還是不變性,面向領域知識進行一系列的程式設計與設計,只要當前領域內的通用知識沒有發生變化,這些設計就不會進行變動。
戰略設計
首先戰略開始,不以戰略開始,戰術設計將無法有效實施。它強調的是業務戰略上的重點,如何按重要性分配工作,以及如何進行最佳整合。
首先用限界上下文的戰略設計模式來分離領域模型。然後在明確的限界上下文中發展一套領域模型的通用語言。進一步深入戰略設計中將會了解到用子域處理系統無邊界的複雜性。還會了解如何透過上下文對映來整合多個限界上下文。上下文對映圖同時定義了兩個進行整合的限界上下文之間的團隊間關係及技術實現方式。
限界上下文
限界上下文是一個顯式的語義和語境上的邊界,領域模型便存在於邊界之內。邊界內,通用語言中的所有術語和片語都有特定的含義。
子域
代表單一的,有邏輯的領域模型。最佳情況,限界上下文於子域一一對應。
子域有三種型別:
- 核心域:業務的核心,核心競爭力。
- 支撐域:輔助核心域
- 通用域:被整個業務系統使用
上下文對映圖
上下文對映圖就是表示兩個或多個限界上下文之間的對映關係。
上下文組織和整合模式:
-
合作關係(Partnership):要麼一起成功,要麼一起失敗,此時他們需要建立起一種合作關係。他們需要一起協調開發計劃和整合管理。
-
共享核心(SharedKernel):模型和程式碼的共享將產生一種緊密的依賴性。常見做法就是透過二進位制依賴(jar)的方式共享給所有上下文使用。
-
客戶方-供應方開發(Customer-Supplier Development):客戶方(D)提需求,供應方(U)配合做開發,現在用mq解耦的方式就非常類似這種。
-
尊奉者(Conformist):跟客戶方-供應方開發類似,只是供應方沒有開發功力。下游只能盲目的使用上游的模型。
-
防腐層(Anticorruption Layer):簡稱ACL,在整合兩個上下文,如果兩邊都狀態良好,可以引入防腐層來作為兩邊的翻譯,並且可以隔離兩邊的領域模型。
-
開放主機服務(Open Host Service):簡稱OHS,公開發布服務,公開的http服務,這是經常使用的
-
釋出語言(Published Language):簡稱PL,在兩個限界上下文之間翻譯模型需要一種公用的語言,釋出語言通常與開放主機服務一起使用。比如http服務,使用xml互動還是json做資料格式
-
另謀他路(SeparateWay):宣告兩個限界上下文不存在任何關係,這也是一種很重的關係,完全獨立各自開發
-
大泥球(Big Ball of Mud):對已有的一個大的混雜的系統,已經無法在內部梳理清楚了。你那怎麼辦呢?把這整個系統當成一個大泥球,對整個系統畫在一個邊界內,當成一個黑盒子,這樣只要介面可用就行,也防止了大泥球內部的混雜擴充套件到其它系統上。對歷史包袱的系統,可以採取這種做法。
示例
架構
分層
嚴格分層架構:某層只能與直接位於的下層發生耦合。
鬆散分層架構:允許上層與任意下層發生耦合
依賴倒置原則
高層模組不應該依賴於底層模組,兩者都應該依賴於抽象
抽象不應該依賴於實現細節,實現細節應該依賴於介面
簡單的說就是面向介面程式設計。
按照DIP的原則,領域層就可以不再依賴於基礎設施層,基礎設施層透過注入持久化的實現就完成了對領域層的解耦,採用依賴注入原則的新分層架構模型就變成如下所示:
六邊形架構(埠與介面卡)
對於每一種外界型別,都有一個介面卡與之對應。外界介面透過應用層api與內部進行互動。
對於右側的埠與介面卡,我們可以把資源庫看成持久化的介面卡。
架構中,我們平等的看待Web、RPC、DB、MQ等外部服務,基礎實施依賴圓圈內部的抽象。
當一個命令Command請求過來時,會透過應用層的CommandService去協調領域層工作,而一個查詢Query請求過來時,則直接透過基礎實施的實現與資料庫或者外部服務互動。再次強調,我們所有的抽象都定義在圓圈內部,實現都在基礎設施。
命令和查詢職責分離--CQRS
-
一個物件的一個方法修改了物件的狀態,該方法便是一個命令(Command),它不應該返回資料,宣告為void。
-
一個物件的一個方法如果返回了資料,該方法便是一個查詢(Query),不應該透過直接或者間接的手段修改物件狀態。
-
聚合只有Command方法,沒有Query方法。
-
資源庫只有add/save/fromId方法。
-
領域模型一分為二,命令模型(寫模型)和查詢模型(讀模型)。
-
客戶端和查詢處理器
客戶端:web瀏覽器、桌面應用等
查詢處理器:一個只知道如何向資料庫執行基本查詢的簡單元件,查詢處理器不復雜,可以返回DTO或其它序列化的結果集,根據系統狀態自定
-
查詢模型:一種非規範化的資料模型,並不反映領域行為,只用於資料顯示
-
客戶端和命令處理器
聚合就是命令模型
命令模型擁有設計良好的契約和行為,將命令匹配到相應的契約是很直接的事情
-
事件訂閱器更新查詢模型
-
處理具有最終一致性的查詢模型
事件驅動架構
- 事件驅動架構可以融入六邊型架構,融合的比較好,也可以融入傳統分層架構
- 管道和過濾器
- 長時處理過程
- 主動拉取狀態檢查:定時器和完成事件之間存在競態條件可能造成失敗
- 被動檢查,收到事件後檢查狀態記錄是否超時。問題:如果因為某種原因,一直收不到事件就一直不過期
- 事件源
- 對於聚合的每次命令操作,都至少一個領域事件釋出出去,表示操作的執行結果
- 每一個領域事件都將被儲存到事件儲存中
- 從資源庫獲取聚合時,將根據發生在聚合上的事件來重建聚合,事件的重放順序與其產生順序相同
- 聚合快照:將聚合的某一事件發生時的狀態快照序列化儲存下來。以減少重放事件時的耗時
戰術設計
實體
DDD中要求實體是唯一的且可持續變化的。意思是說在實體的生命週期內,無論其如何變化,其仍舊是同一個實體。唯一性由唯一的身份標識來決定的。可變性也正反映了實體本身的狀態和行為。
實體 = 唯一身份標識 + 可變性【狀態(屬性) + 行為(方法或領域事件或領域服務)
為什麼使用實體
在使用DDD時,將資料模型轉換為實體模型
唯一標識
在設計實體時。我們首先考慮實體的本質特徵,特別是實體的唯一標識和對實體的查詢,而不是一開始便關注實體的屬性和行為。
值物件可以儲存實體的唯一標識,與身份相關的行為可以封裝在值物件中,避免洩漏到模型的其他部份或客戶端中。
建立策略
- 使用者提供唯一標識
- 應用程式生成唯一標識
- 持久化機制生成唯一標識
- 另一個限界上下文提供唯一標識
標識生成時間
延遲生成方式
及早生成方式
委派標識
兩個標識,一個為領域所用,一個為ORM所用。委派標識沒有業務意義,迎合ORM而建。對外要隱藏,不是領域模型的一部分。
模式,層超型別,protected型別的委派標識欄位。
標識穩定性,不應該修改實體的唯一標識。
發現實體及其本質特性
挖掘實體的行為:set方法不是完全要禁止,在其符合通用語言(有語義)的時候,或者完成客戶端單個請求不用呼叫多個set時才有理由使用set方法。多個set方法使語義充潢歧義,使領域事件的傳送也無法應對到單個命令上
建立實體:實體維護了一個或多個不變條件(整個生命週期中都必須保持事務一致性的狀態),聚合關注不變條件
跟蹤變化:使用領域事件跟蹤領域實體的狀態變化,將領域專家所關心的狀態改變建模成事件。
值物件
當你只關心某個物件的屬性時,該物件便可作為一個值物件。為其新增有意義的屬性,並賦予它相應的行為。我們需要將值物件看成不變物件,不要給它任何身份標識,還應該儘量避免像實體物件一樣的複雜性。
值物件=值+物件=將一個值用物件的方式進行表述,來表達一個具體的固定不變的概念。
為什麼使用值物件
使用不變的值物件使得我們做更少的職責假設
值物件的特性
- 它度量或者描述了領城中的一件東西
- 它可以作為不變數
- 它將不同的相關的屬性組合成一個概念整體
- 當度量和描述改變時,可以用另一個值物件予以替換
- 它可以和其他值物件進行相等性比較
- 它不會對協作物件造成副作用
實現
值物件有兩個構造
第一個:包含所有屬性的建構函式,對基本屬性的賦值呼叫私有的setter方法(自委派性)
第二個:複製作用的建構函式,用於將一個值物件複製為另一個新的值物件(淺複製即可,深複製太複雜,對於不變的值物件共享屬性不會出現什麼問題)
無副作用方法的名字很重要,不推薦使用java bean規範,除非其有通用語言的意義。推薦:String.endWith(),startWith(), indexOf()等。值物件的設計,方法不要遵循JavaBean規範。其setter更違背了值物件的不變性原則
持久化值物件
持久化機制不應該影響到值物件的建模;根據領域模型來設計資料模型,而不是根據資料模型來設計領域模型
ORM與單個值物件
實體和值物件一對一對映,值物件的屬性作為欄位存在和實體同一張表中
多個值物件序列化到單個列中
實體引用了List和Set屬性的值物件集合
使用資料庫實體儲存多個值物件
值物件單獨一個資料庫實體表儲存,並且帶有一個委派主鍵標識,這個標識不對客戶端展示。領域模型依然是一個值物件。持久化相關的邏輯沒有洩漏到模型或客戶端上去。
領域服務
當領域中的某個操作過程或轉換過程不是實體或值物件的職責時,我們便應該將該操作放在一個單獨的介面中,即領域服務。請確保該服務和通用語言時一致的。並且保證它是無狀態的。
概述
- 用於實現某個領域的任務,不適合放在聚合或值物件上時,就放在領域服務上
- 放在聚合根的靜態方法上有悖DDD
- 避免在聚合中呼叫資源庫
可以用領域服務的情況
- 執行一個顯著的業務操作
- 對領域物件進行轉換
- 以多個領域物件作為輸入引數進行計算,結果產生一個值物件
領域事件
領域事件是一個領域模型中極其重要的部分,用來表示領域中發生的事件。忽略不相關的領域活動,同時明確領域專家要跟蹤或希望被通知的事情,或與其他模型物件中的狀態更改相關聯
領域事件 = 事件釋出 + 事件儲存 + 事件分發 + 事件處理。
建模領域事件
- 根據限界上下文的通用語言來命名事件及其屬性
- 如果事件由聚合上的命令操作產生,則應該根據操作方法的名字來命名領域事件
- 事件的名字應該反映過去發生的事情
- 領域事件應該都有一個發生時間屬性,同時要包括另外的屬性:比哪些聚合跟此事件相關,繼承統一的DomainEvent介面
- 事件所帶的屬效能夠反映出該事件的來源。事件物件提供getter方法。事件屬性應該是隻讀的,沒有setter方法
- 是否有必要消除事件的重複提交
- 一個業務用例對應一個事務,一個事務對應一個聚合根,也即在一次事務中,只能對一個聚合根進行操作。當一個聚合依賴另一個聚合時,可以透過事件實現它們狀態的最終一致性
模組
模組的設計是基於領域模型的,要符合通用語言的表述。其次,模組的設計要符合高內聚低耦合的設計思想。
領域模型命名規範
- 頂級模組下一層模組名定位了一個限界上下文(就是一個應用子域),如com.smudge.atum
- 示例:com.smudge.atum.domain.aggregate,該層定義模型中的聚合。
- 上述命名規範與傳統的分層架構和六邊形架構相容
聚合
聚合是領域物件的顯式分組,旨在支援領域模型的行為和不變性,同時充當一致性和事務性邊界。一個聚合包含聚合根、實體和值物件。
聚合設計原則
-
在一致性邊界之內建模真正的不變條件
- 一致性。事務一致性、最終一致性。一個事務中只修改一個聚合,反之:不能在一個事務中同時修改多個聚合例項,真要這麼做的話要考慮最終一致性
- 不變條件。指的是一個業務規則,該規則應該總是保持一致的
-
設計小聚合。根實體(Root Entity)表示聚合,絕大多數根實體可以設計為聚合
-
透過唯一標識引用其它聚合
-
在邊界之外使用最終一致性
打破原則的理由
- 方便使用者介面
- 一組聚合只有一個使用者在處理它們,保證使用者-聚合親和度使我們有理由在單個事務中修改多個聚合例項,因為這不會違背聚合不變條件
- 全域性事務
- 查詢效能
實現
- 建立具有唯一標識的根實體(將實體建成聚合根)
- 優先使用值物件
- 使用迪米特法則
- “告訴而非詢問”原則
- Version實現樂觀併發
- 聚合中不應該注入資源庫或者領域服務
聚合根、實體、值物件
從標識角度:聚合根是實體,具有全域性的唯一標識。而實體只有在聚合內部有唯一的本地標識,值物件沒有唯一標識,透過屬性判斷相等性,實現Equals方法。
從是否只讀的角度:聚合根除了唯一標識外,其他所有狀態資訊都理論上可變。實體是可變的。值物件不可變,是隻讀的。
從生命週期角度:聚合根有獨立的生命週期,實體的生命週期從屬於其所屬的聚合,實體完全由其所屬的聚合根負責管理維護。值物件無生命週期可言,因為只是一個值。
聚合根、實體、值物件物件之間如何建立關聯
聚合根到聚合根:透過ID關聯;
聚合根到其內部的實體,直接物件引用;
聚合根到值物件,直接物件引用;
實體對其他物件的引用規則:
- 能引用其所屬聚合內的聚合根、實體、值物件。
- 能引用外部聚合根,但推薦以ID的方式關聯,另外也可以關聯某個外部聚合內的實體,但必須是ID關聯,否則就出現同一個實體的引用被兩個聚合根持有,這是不允許的,一個實體的引用只能被其所屬的聚合根持有。
值物件對其他物件的引用規則:只需確保值物件是隻讀的即可,推薦值物件的所有屬性都儘量是值物件。
工廠
領域模型中的工廠
- 將建立複雜物件和聚合的職責分配給一個單獨的物件,它並不承擔領域模型中的職責,但是領域設計的一部份
- 對於聚合來說,我們應該一次性的建立整個聚合,並且確保它的不變條件得到滿足
- 工廠只承擔建立模型的工作,不具有其它領域行為
- 一個含有工廠方法的聚合根的主要職責是完成它的聚合行為
- 在聚合上使用工廠方法能更好的表達通用語言,這是使用建構函式所不能表達的
聚合根中的工廠方法
- 聚合根中的工廠方法表現出了領域概念
- 工廠方法可以提供守衛措施
領域服務中的工廠
- 在整合限界上下文時,領域服務作為工廠
- 領域服務的介面放在領域模型內,實現放在基礎設施層
資源庫
是聚合的管理,倉儲介於領域模型和資料模型之間,主要用於聚合的持久化和檢索。它隔離了領域模型和資料模型,以便我們關注於領域模型而不需要考慮如何進行持久化。
只為聚合建立資源庫
聚合和資源庫存在一對一的關係
實現
- 第一步,定義資源庫介面,介面中有put或save類似的方法
- 與面向集合的資源庫的不同點:面向集合的資源庫只有在新增時呼叫add即可,面向持久化的無論是新增還是修改都要呼叫save
- 實現類放在基礎設施層,將領域的概念與持久化相關的概念相分離,依賴倒置原則。基礎設施層位與所有層之上,並且單向向下引用領域層
事務管理
- 事務的管理絕對不該放在領域模型和領域層中,事務放在應用層,然後為每個主要的用例建立一個門面,門面的方法是粗粒度的,每一個用例流對應一個業務方法。當使用者介面層呼叫門面中的一個業務方法時,該方法都將開始一個事務。
- 警告:不要過度的在領域模型上使用事務,我們必須慎重的設計聚合以保證事確的一致性邊界。
資源庫VS資料訪問物件(DAO)
- DAO主要從資料庫表的角度看待問題,並且提供CRUD操作。Martin Fowler將DAO相關的設施與領域模型分離開來對待。他指出諸如“表模組”,“表資料閘道器”和”活動記錄“這樣的模式應該用於事務指令碼中。這些與DAO相關的模式只是對資料庫表的一層封裝。
- 資源庫和"資料對映器"則更偏向於物件,通常被應用於領域模型。
- DAO模式中所執行的CRUD操作都是可以放在聚合中實現的,要避免在領域模型領域模型中使用DAO模式
- 在設計資源庫時我們應該採用面向集合的方式,而不是面向資料訪問的方式,這有助於你將自己的領域當作模型來看待,而不是CRUD操作。
整合限界上下文
領域服務介面位於領域模型層(六邊形內部),實現位為基礎設施層(六邊形外部,即埠和介面卡所在位置)。
應用服務
應用服務是用來表達用例和使用者故事(User Story)的主要手段。
應用層透過應用服務介面來暴露系統的全部功能。在應用服務的實現中,它負責編排和轉發,它將要實現的功能委託給一個或多個領域物件來實現,它本身只負責處理業務用例的執行順序以及結果的拼裝。透過這樣一種方式,它隱藏了領域層的複雜性及其內部實現機制。
應用層相對來說是較“薄”的一層,除了定義應用服務之外,在該層我們可以進行安全認證,許可權校驗,持久化事務控制,或者向其他系統發生基於事件的訊息通知,另外還可以用於建立郵件以傳送給客戶等。
應用層作為展現層與領域層的橋樑。展現層使用VO(檢視模型)進行介面展示,與應用層透過DTO(資料傳輸物件)進行資料互動,從而達到展現層與DO(領域物件)解耦的目的。
總結
談談我對 DDD 的理解,我覺得 DDD 不像一門技術,我理解的技術比如高併發、快取、訊息佇列等,DDD 更像是一項軟技能,一種方法論,包含了很多設計理念。
這篇文章寫於去年,所以當時對 DDD 理解的其實還不夠深入,今年做過一些 DDD 的專案,所以現在對 DDD 的理解又加深了幾分。
大家不要認為,掌握了一些概念,以及 DDD 的基本思想,就掌握了 DDD,然後做專案時,照葫蘆畫瓢,這樣你會死的很慘!
只掌握 DDD 表面的東西,其實是不夠的,我覺得 DDD 最複雜的地方,其實是在它的領域設計部分,專案啟動前,你一定要設計各個領域物件,以及它們直接的互動關係。
比如我們之前做過一個專案,因為這塊沒有做好,大家一邊寫程式碼,一邊還在思考,這個領域物件該如何構造,嚴重影響開發效率,最後又不得不回退到 MVC 的模式。
不要為了炫技,啥都要搞個 DDD,兩者如何選擇:
MVC:上來就可以開幹,短平快,前期用起來很香,整體開發效率也更高,所以對於緊急,或者不那麼重要的專案,我會直接用 MVC 懟,不好的地方就是,後面會越來越複雜,可能最後就是一坨屎山,但是很多時候,比如老闆進度催的緊,我哪想到那麼多以後呢?
DDD:前期需要花大量時間設計好領域模型,對於一些基礎元件,或者一些核心服務,如果物件模型非常複雜,建議採用 DDD,前期可能會稍微痛苦一些,但是後期維護起來會非常方便。