DDD領域驅動設計-設計規范-Ⅵ


不以規矩,不能成方圓。
                                                                    -戰國·鄒·孟軻《孟子·離婁章句上》

1. 前言

為什么要使用DDD領域設計?請參考以下博客:
DDD領域驅動設計,對比(dao+service)的腳本式編程,主要還是將以前的腳本代碼拆散,以實體為載體,協調各個模塊實現業務功能。DDD領域設計有如下好處:
  1. 強調實體的概念,將現實世界與軟件系統關聯起來,便於不同崗位的人達成統一的認知。有助於業務理解和需求討論。
  2. 明確業務規則和業務流程,將系統中的隱形業務邏輯在領域層顯性展示出來。
  3. 分模塊編寫代碼,有助於明確和細化代碼功能,編寫的代碼質量更好,可以最大化滿足SOLID原則。后續系統升級改造時,可精確定位到需要調整的模塊,便於高效的維護業務代碼。
SOLID原則:指單一職責原則(SRP),開閉原則(OCP),里氏替換原則(LSP),接口隔離原則(ISP)和依賴倒置原則(DIP)。

2. DDD規范說明

2.1. 實體建模

實體的定義在原書《領域驅動設計》中的描述如下:
一些對象主要不是由它們的屬性定義的。它們實際上表示了一條“標識線”,這條線跨越時間,而且常常經歷多種不同的表示。
在 DDD 中有這樣一類對象,它們擁有唯一標識符,且標識符在歷經各種狀態變更后仍能保持一致。對這些對象而言,重要的不是其屬性,而是其延續性和標識,對象的延續性和標識會跨越甚至超出軟件的生命周期。我們把這樣的對象稱為實體,實體有如下特征:
  1. 在同一類模型實中需要區別開來,一個實體是唯一的東西;
  2. 每個實體有唯一標識來區別彼此;
  3. 實體有生命周期,我們可以對它多次修改,但它仍然還是同一個實體。
一個實體包括: 唯一身份標識 , 可變性狀態(屬性),行為(方法或領域事件或領域服務)。
實體建模應根據業務場景來建立,不同的人對概念的理解不同,業務場景不同,設計出來的模型也會不同。初版本的DDD建模也不能完全匹配復雜業務,只能說盡可能的滿足業務,在項目實踐中去完善。檢驗實體設計的是否合理,還得看落地到編碼層的實現難度,后續業務迭代多個版本后,實體模型是否還能快速響應,在迭代中完善實體模型。

2.2. 聚合根

聚合根本身也是一個實體,外部不可直接操作聚合根內部的實體或對象,聚合根內部的對象必須通過聚合根統一調用。
設置聚合根存在一個取舍問題,如售后補償業務,整體就是圍繞補償單的創建,審批,處理流程。若完全設置為一個補償單聚合根,該聚合根就會偏大,聚合根盡可能的細化拆小,但又需避免過度設計,所以對於簡單很短的補償單操作,就當作是補償單聚合的一種行為。對於復雜的,應該單獨出來。創建和處理具有不同的生命周期,也是不同的場景,就應設置不同的聚合根。現在審批偏簡單,若其他的一些業務需要很復雜的審批環節,那么審批也可以設置專門的審批聚合根。具體還是以業務為主,靈活應用。

2.2.1. 聚合根配置

當實體為一個聚合根時,一個聚合根通常配置以下三個模塊:
  1. 工廠(Factory):只是負責創建聚合根,聚合根內部的子實體,與實體的行為無關。創建與使用分開,保證類的單一權責規范。
  2. 領域服務(DomainService):完成聚合根內實體的相關行為,處理所有的業務邏輯,如業務判斷,業務數據生成等。對於跨多個實體的應用,單獨編寫第三方領域服務處理。第三方領域服務的功能不屬於單獨某一個實體的行為。
  3. 倉庫(Repository):倉庫提供聚合根與底層數據的存儲功能。倉庫僅保存數據,查詢數據,不做實體行為的邏輯處理。

2.2.2. 配置原則

  1. 對於簡單的實體創建,可基於構造函數,或直接設置值生成實體,不一定非要使用工廠創建。
  2. 工廠,領域服務等都不能直接與底層的數據存儲系統交互,他們都要通過倉庫層來獲取數據,存儲數據。
  3. 聚合根統一配置倉庫等模塊,內部的子實體不用再單獨配置倉庫和工廠了。

2.3. 實體應用規范

每一個實體可設置一個驗證行為,編寫一個init()方法。 在實體對象初始化后,在調用驗證方法做數據的基礎驗證,如數據的合規性,必填,大小區間等驗證。此處的驗證不應該包括數據的業務邏輯驗證。

2.3.1. 實體必須干凈

實體本身是一種充血模型,對比普通的POJO對象,只是多了行為。不可在實體中依賴注入外部的服務,實體只能保留自有的狀態。不能因為實體要實現一些功能,需要依賴外部的服務,就通過注入的方式引入外部服務,這樣會污染實體且使實體單測變得復雜。正確的引用方式是通過方法參數引入(Double Dispatch)。

2.3.2. 不可以強依賴其他聚合根實體或領域服務

一個實體的原則是高內聚、低耦合,即一個實體類不能直接在內部直接依賴一個外部的實體或服務。這個原則和絕大多數ORM框架都有比較嚴重的沖突,所以是一個在開發過程中需要特別注意的。這個原則的必要原因包括:對外部對象的依賴性會直接導致實體無法被單測;以及一個實體無法保證外部實體變更后不會影響本實體的一致性和正確性。

2.3.3. 任何實體的行為只能直接影響到本實體

任何實體的行為只能直接影響到本實體(和其子實體),這個原則更多是一個確保代碼可讀性、可理解的原則,即任何實體的行為不能有“直接”的”副作用“,即直接修改其他的實體類。這么做的好處是代碼讀下來不會產生意外。另一個遵守的原因是可以降低未知的變更的風險。在一個系統里一個實體對象的所有變更操作應該都是預期內的,如果一個實體能隨意被外部直接修改的話,會增加代碼bug的風險。

2.4. 領域服務

實體行為的具體業務規則實現,單獨編寫一個實現類,這種類在DDD里被叫做領域服務(Domain Service)。

2.4.1. 單實體-領域服務

一個實體的相關行為,編寫在實現類中,且這些行為只影響單個實體。

2.4.2. 跨對象事務型(多實體)-第三方領域服務

當一個行為會直接修改多個實體時,不能再通過單一實體的方法作處理,而必須直接使用領域服務的方法來做操作。在這里,領域服務更多的起到了跨對象事務的作用,確保多個實體的變更之間是有一致性的。該領域服務是一個多實體的綜合服務實現類,不是任何一個單獨實體的領域實現類。
如賬戶轉賬模塊:有兩個賬戶實體,一個支出,一個收入,兩個實體的行為同時完成支出和收入才算轉賬完成。

2.4.3.領域層操作實體是一種內存操作行為

從原則上講,Domain層(包括DomainService)只負責業務規則,不負責業務流程。應用層(Application)只負責業務流程。保存數據這個屬於業務流程,所以不應該在Domain層操作,應該在
應用層中處理。
DomainService的職責很簡單,就是根據入參做計算。可以認為DomainService的所有方法都是純內存操作,無外部的副作用。(有點類似於攔截器的前置操作)
原則上,應該在應用層(Application)里調用Repo或第三方服務獲取所有需要的實體和DTO,然后通過入參的方式傳入到DomainService里做跨實體的計算,最后在應用層(Application)里調用Repo保存狀態和調用第三方服務/發消息等。
注意:
  1. 在領域層中直接調用倉庫層接口保存數據,是否可行。從代碼上來說是可以的,單從職能上來說,領域層是一種內存模式的業務規則操作,不應該直接去保存數據。
  2. 若在領域層處理過程中需調用外部服務接口,包括外部服務的創建數據接口(一種特殊的非內存操作),這種還是放在領域服務中實現。因為實際場景中,經常存在調用外部服務接口,基於返回內容再次做業務邏輯處理的情況。

2.5. 事件通知

領域事件是一個在領域里發生了某些事后,希望領域里其他對象能夠感知到的通知機制。領域事件的好處就是將這種隱性的副作用“顯性化”,通過一個顯性的事件,將事件觸發和事件處理解耦,最終起到代碼更清晰、擴展性更好的目的。
事件通知主要是基於觀察者模式,當一個實體行為結束后發出事件通知,一個或多個其他的觀察者監聽到了事件后,可做后續的衍生業務處理。

2.5.1. 副作用

實體完成一個行為完成后,可能會產生副作用。一般的副作用發生在核心領域模型狀態變更后,同步或者異步對另一個對象的影響或行為。
如:打死怪物了,可以獲得經驗值,經驗值達到一定的數量,可以升級。那么獲得經驗值是該領域的一個業務,但升級就是這個業務衍生出來的副作用了。

2.5.2. 事件通知與第三方領域服務的區別

注意區分領域事件與第三方領域服務的應用場景。第三方的領域服務主要是保證不同實體的強一致性,完成一個業務必須是多個實體同時完成。領域事件則不同,當前領域的核心行為完成后,后續副作用就算失敗了,也不關心。判斷的唯一標准就是,一個業務完成需多個實體協調處理,是否需要多個實體強一致性的處理。不需要,就可用事件模式了。

2.6. 工廠使用注意事項

在基於工廠創建實體時,若工廠僅做傳入數據的初始化組裝工作,功能顯得雞肋。
工廠創建出的實體應具備如下條件:
  1. 實體必要數據完整,如傳入訂單號,訂單號有效,且能獲取到訂單的信息,訂單的信息能完善實體的必要數據;
  2. 實體合法,如業務審批實體,它一定是一個滿足審批條件的實體;
創建出來的實體是一個可靠的實體,就不需要在使用實體時,還去驗證實體的合法性。但基於此就會存在在工廠內部,調用外部Repo或外部服務去驗證數據或獲取數據的需求。
可參考案例: DDD工廠責任
在工廠內部調用外部的Repo或外部服務:
  1. 好處:可以保證工廠創建出來的實體是一個合規可用的實體,對於傳入的命令,先做數據層面的合規性驗證,同時工廠可從數據層拿到數據,便於做數據加工和驗證。
  2. 弊端:在工廠的內部去調用外部Repo或外部服務,會導致工廠類不夠純,單元測試需要mock所有的外部服務,測試覆蓋度會有影響。
首先明確目標,肯定是要生成一個可靠的實體的,若能接收工廠內部去調用外部服務,就直接使用。若為了保證工廠便於測試,同時保證工廠類足夠的干凈,可以通過參數的方式傳入外部Repo,或者一些基礎數據,可以在應用層拿到數據后,將拿到的數據傳入到工廠進行處理。
關於是應用層調用工廠還是領域層調用工廠,網上也有不同的觀點。個人認為不同的場景應該分開分析:
  1. 當系統中已經存在一個實體時,一般來說不需要工廠在去創建實體了,就不存在調用工廠的說法。
  2. 當系統中不存在實體時,需要初始化實體數據,此刻由應用層調用工廠創建。這個理論的依據是實體的創建與實體的使用要分離,領域層中的實體是實體的使用。本例不認可那種在實體中加一個創建實體的行為,創建行為調用工廠創建實體的模式,這種模式還是把實體的創建和實體的其他行為都放在了一起,這樣就不需要引入工廠了,也達不到使用與創建分離的目的。
  3. 工廠是否可以直接接收CE(命令,事件消息)數據,本例設計的是可以,工廠接收到傳入的指令,生成對應的實體。

2.7. 防腐層使用場景

防腐層相關知識: DDD防腐層應用
簡單來說,需要明白那些代碼邏輯寫到領域層,哪一些應該寫入到防腐層模塊。確保外部系統變化后,或本系統其他領域模塊變化后,盡可能的不影響當前領域的領域層代碼。常用場景如:
  1. 發送短信,消息;
  2. 跨系統查詢數據,調用外部系統業務接口;
  3. 調用支付寶,網銀,微信等轉賬;

2.8. 倉庫層注意事項

  1. 倉庫層入參不應該使用底層數據格式,Repository操作的是Entity對象(實際上應該是Aggregate Root),而不應該直接操作底層的數據對象(數據表映射的貧血對象)。更近一步,Repository接口實際上應該存在於Domain層,根本看不到數據層的實現。這個也是為了避免底層實現邏輯滲透到業務代碼中的強保障。
  2. 實體狀態變更,行為處理,倉庫層入參可以接收處理命令(如XxxUpdateCommand)。
  3. 倉庫層接口放在領域層模塊,但倉庫層的實現放在基礎層模塊。
  4. 當發現數據存儲要求更多的字段,實體缺乏某些數據項時(如一些加工生成的中間數據),不要將缺少的數據,通過參數的方式傳遞到倉庫層。應反思實體是否設計的完善合理,盡可能的完善實體后,再存儲數據。
  5. 倉庫層的業務接口入參一般為實體,實體的唯一身份標識,部分基礎數據類型。
  6. 倉庫層的查詢接口入參可以為Query對象,單個主鍵編碼。
  7. 倉庫層的數據操作接口,原則上由應用層調用,不要在領域層中調用,領域層一般調用查詢接口。

2.9. 業務處理如何依賴實體行為

一個業務的完成,往往會關聯多個實體,需要多個實體的不同行為協調運行。
如在售后補償中,補償單整體流程結束:包括履約單完成和補償單狀態完成。兩個實體都完成了,才算整個補償業務完成。不同實體之間不能直接調用,參考實體的行為規范,針對多個實體的情況,一般存在以下三個常用場景:
  1. 多實體強一致性:完成一個業務必須保證相關的實體同時完成,具有事務性質。可采用第三方領域服務處理,處理完成后,在應用層加上事務保證數據一致性的存儲到系統。
  2. 實體副作用:完成一個實體后,其他實體監聽處理,實體不依賴於其他實體的處理結果。如履約單完成后,發出一個事件。
  3. 多實體先后處理:在應用層,應用服務調用領域層實體相關的功能,做業務編排,先執行一個實體的行為,在執行其他實體的行為。如補償單審批通過后,調用履約單的處理功能。

2.10. 領域驅動數據傳輸

DTO Assembler:在Application層,Entity到DTO的轉化器有一個標准的名稱叫DTO Assembler。Martin Fowler在P of EAA一書里對於DTO 和 Assembler的描述: Data Transfer Object。DTO Assembler的核心作用就是將1個或多個相關聯的Entity轉化為1個或多個DTO。
Data Mapper:在Infrastructure層,實體到數據表映射對象的轉化器沒有一個標准名稱,暫定稱這種轉化器為Data Mapper。
雖然Assembler/Mapper是非常好用的對象,但是當業務復雜時,手寫Assembler/Mapper是一件耗時且容易出bug的事情,所以業界會有多種Bean Mapping的解決方案,從本質上分為動態和靜態映射。動態映射方案包括比較原始的BeanUtils.copyProperties、能通過xml配置的Dozer等,其核心是在運行時根據反射動態賦值。動態方案的缺陷在於大量的反射調用,性能比較差,內存占用多,不適合特別高並發的應用場景。不采用動態映射,推薦使用MapStruct( MapStruct官網)。基於MapStruct在基礎層寫一個統一的對象轉換工具類既可,專門處理不同類型對象之間的數據轉換工作。

2.11. 領域驅動設計常規流程

領域驅動設計的代碼通常有類似的結構:應用層通常不做任何決策(Precondition除外),僅僅是把所有決策交給DomainService或Entity,把跟外部交互的交給Infrastructure接口,如Repository或防腐層。

2.11.1. 產生實體模式

產生實體指系統中還不存在實體,基於外部服務傳入的數據,生成一個新的實體並保存實體的過程。如:創建訂單,創建一個售后補償單。這種模式主要是處理實體從無到有的過程。一般操作流程如下:
  1. 應用層准備數據,包括從網關接收傳入的數據,調用外部服務得到的數據。
  2. 應用層准備好數據后,調用工廠創建實體。對於比較簡單的實體,可不基於工廠創建,直接在應用層設置實體的值即可。
  3. 工廠生成實體,根據獲取到的各個數據對象,組合生成領域實體。
  4. 領域層執行操作:基於傳入的實體調用領域對象的方法對其進行操作。需要注意的是這個時候通常都是純內存操作,非持久化。
  5. 應用層持久化:將操作結果調用倉庫層數據保存接口,持久化實體數據,或操作外部系統產生相應的影響,包括發消息等異步操作。
  6. 倉庫層接收到實體后,轉換實體為數據表映射對象,最終保存數據;

2.11.2. 應用實體模式

應用實體指系統中已經存在實體了,對存在的實體做其他的行為操作,如修改實體中的部分信息。一般操作流程如下:
  1. 客戶端在不同的場景(Command,Event)下,傳入數據到應用層(application),此刻是普通的DTO數據;
  2. 應用層基於傳入的數據調用倉庫層接口,倉庫層基於實體唯一編碼返回一個已經存在的實體信息;
  3. 實體在領域層實現核心業務規則后,返回對應的實體對象,實體的行為或DomainService的所有方法都是純內存操作,無外部的副作用。(若實體信息修改無業務規則,應用層獲取實體后,直接調用倉庫層保存數據);
  4. 應用層得到領域層返回的實體對象后,調用倉庫層接口,傳入實體對象(或命令對象)保存數據;
  5. 倉庫層接收到傳入數據后,轉換數據為數據表映射對象,最終保存數據;

2.12. 領域驅動注意事項

  1. 實體保存時,倉庫層的入參不能為基礎的數據表對象,入參對象應設置為實體;
  2. 其他命令操作時,應用層調用倉庫層保存數據,入參可根據實際的情況傳入對應的實體或入參命令對象(比如只是修改數據的個別字段可傳入修改命令,修改大量字段信息,傳入實體)。
  3. 領域層只做純內存的業務規則操作,原則上不能在領域層中直接調用倉庫層存儲數據;
  4. 應用層的出參設置為DTO或基礎數據對象(如主鍵編碼),不能直接返回實體;
  5. 當一個業務涉及到多個實體時,不能在一個實體中直接調用另外一個實體(聚合根可調用內部的子實體),應該基於應用層或第三方領域服務協調處理;
  6. 明確各個層的職能,不要混用;
  7. 領域層做業務規則處理,針對不同的規則場景,建議采用策略設計模式,多用設計模式實現領域服務便於系統后續的擴展和調整;
  8. 涉及到主子記錄的情況(如訂單,子訂單),一般建立聚合根,基於聚合根統一的保存數據;
  9. 當業務需要多個實體同時處理時,可在應用層統一加上事務管理;
  10. 針對一些基礎配置信息,或比較簡單的業務(CRUD),采用DDD模式也很費力,此刻不一定非要使用DDD模式,生搬硬套DDD模式感覺把簡單事情復雜化了。 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM