《領域驅動設計》:從領域視角深入倉儲(Repository)的設計和實現

来源:https://www.cnblogs.com/88223100/archive/2023/02/03/DDD_in-depth-design-and-implementation-of-repository-from-a-domain-perspective.html
-Advertisement-
Play Games

“ DDD設計的目標是關註領域模型而並非技術來創建更好的軟體,假設開發人員構建了一個SQL,並將它傳遞給基礎設施層中的某個查詢服務然後根據表數據的結構集取出所需信息,最後將這些信息提供給構造函數或者Factory,開發人員在做這一切的時候早已不把模型看做重點了,這個整個過程就變成了數據處理的風格 ”... ...


一、前言
“ DDD設計的目標是關註領域模型而並非技術來創建更好的軟體,假設開發人員構建了一個SQL,並將它傳遞給基礎設施層中的某個查詢服務然後根據表數據的結構集取出所需信息,最後將這些信息提供給構造函數或者Factory,開發人員在做這一切的時候早已不把模型看做重點了,這個整個過程就變成了數據處理的風格 ”——摘 Eric Evans《領域驅動設計》
《領域驅動設計》中的Repository(下麵將用倉儲表示)層實際上是極具有挑戰性的,對於它的理解,也十分重要。本文大部分內容都在眾多前輩理論基礎上,從一個嶄新的領域視覺開始探索,並結合自己的實踐感悟進行細緻的解析。同時本文不僅僅是DDD前輩的搬運工,也創新提出了倉儲實體轉移的概念,可以提供給讀者思考是否在自己場景中可以用到這種模式。即使讀者也對倉儲有很深的瞭解,我也覺得本文會對你有新的閱讀體驗。
導讀:
  • 本文首先從聚合根的生命周期和生存環境出發,引出了Repository概念,並說明其本質是管理中間過程的集合容器(2.1節);

  • 根據集合容器的概念,在領域角度去挖掘出Repository的職責,並提出了倉儲實體轉移模式用作對不同倉儲實現的對比標準(2.2節);

  • 然後從實現例子出發,介紹了一種純記憶體實現的倉儲,用作體現倉儲最佳實現(3.1節);

  • 繼續從實現例子出發,介紹了關係型資料庫下的倉儲特點,並描述面向持久化的倉儲的特點(3.4節);

 

二、概念剖析
DDD作者在介紹倉儲模式的時候,談到了大部分技術的過程會入侵領域模型,讓開發人員迷失,本文反其道行之,讀者可以先假設記憶體是無限大的,便於我們先關註模型再討論技術實現,然後我們先從DDD中的重要概念 聚合實體 的領域模型使用出發,挖掘出倉儲的本質特征和與之相關領域概念,然後再從本質特征,指導如何實現倉儲。

2.1 聚合實體

服務於實體的集合容器:說到倉儲我們必須要先討論聚合(聚合是由實體和值對象組成,其中有一個實體為聚合根,後面提到聚合實體即聚合根),倉儲必然是為聚合實體服務的,值對象則不必要。那我們的實體為何需要倉儲呢?這得從實體的整個生命周期說起,我們先總結一下DDD中聚合實體的特點:
  • 標識:實體具有唯一標識,這個唯一標識使得實體和值對象區分開來;

  • 狀態:實體是具有可以被改變的狀態,因此聚合實體無法被靜態描述;

  • 生命周期:實體擁有生命周期,從實體的創建,到實體的狀態的終態;

  • 生存環境:實體的活動存在於各個上下文中的領域服務或者應用服務中,其中分用例過程和中間過程;

  • 用例過程:只要在執行用例過程的時候才需要實體的存在,其他時候,實體生命周期並沒有結束,而是處於中間狀態;

  • 中間過程:當沒有任何用例在處理一個實體的時候,實體消失了嗎?沒有,它仍然存在生命周期內,這個時候我們認為實體正處在一種中間過程。

其中最重要的就是實體會存在於各個上下文中的用例運行過程中,之外的都會存在於一個中間過程中,我們用圖示來進行中間過程的描述。
圖片
檢索聚合根:在解決空間的運行態中,用例調度者(執行者、線程)要麼新建聚合實體,要麼獲取中間過程的聚合實體,創建新實體好說,但是中間過程的實體是如何獲取到的呢?其實中間過程的實體,只能是經過查找到得到的,這是一個檢索的過程。其中檢索包括全體遍歷(包括索引)和關聯遍歷,不管何種檢索渠道,我們都要讓Domain感覺到,檢索回來的實體還是原來那個實體。
統一語言:中間過程、用例過程,這些詞領域專家、業務人員是聽不懂的,中間過程也不在模型關註點上,但又是與模型有關聯。所以我們在領域角度、統一語言角度,封裝角度,這個中間過程都應該提出一個統一的領域概念抽象,屏蔽掉中間過程的細節,讓領域專家能明白我們的意思。倉儲(倉庫,貯藏室,Repository),這個詞就很適合,它類似一個幫你暫存物品的倉庫,然後你可以在倉庫中找回你要的物品。
但這個詞本身不重要,重要的是領域專家能聽懂倉儲這個詞的語義,並和技術人員統一,搭建一個溝通的橋梁。有關倉儲的統一語言應該有以下幾點:
  • 放置:建立一個新的聚合實體,這是一個聚合實體生命的開始,在用例過程結束後,把聚合實體放到倉儲中;

  • 查找:把已經存在的聚合實體找出來,這是一個聚合實體的中間過程到用例過程的行為;

  • 管理:它負責聚合實體的中間過程管理,並屏蔽掉中間過程的細節,向領域層提供統一的能力抽象,一些數據統計類的也可以在該範疇內;

集合容器:為了方便的把處於中間過程的實體找出來,我們的倉儲需要解決兩個問題,第一個是如何放置實體,第二個問題是如何檢索實體。
  • 如何放置實體:為了方便管理,我們通常會採用分治把同一種類型的實體放在一起成為一個集合。相同類型和集合給了我們一個指導就是:倉儲的設計應該是一個聚合實體類型對應一個倉儲實體,具有一一對應關係,所以倉儲實體應該是一個保存相同類型元素的集合容器;

  • 如何查找實體:我們知道實體具有唯一標識別,也具有其他特征屬性,所以為了查找實體,我們應該通過實體的唯一標識或者特征屬性去遍歷查找,倉儲應當提供這種功能,所以倉儲應該針對聚合實體欄位具有索引查找功能;

  • 如何查找倉儲:既然我們提到了需要用倉儲來查找實體,那麼我們又是如何查到倉儲的呢?其實這個很簡單,如果一個聚合實體類型只具有一個倉儲類型,那麼我們把倉儲設計為單例的就可以了。

圖片

我們從領域模型的生存環境角度,引申出了倉儲的必要性,併在統一語言的原則上,從它的必要性行為中挖掘出了倉儲的特征,關註領域模型的倉儲,應當讓客戶感覺模型就一直在記憶體中一樣,最後我們總結一下倉儲的本質:
  • 一個聚合類型(也就是一個聚合根),最好對應一個倉儲(這個不是絕對的);

  • 一個倉儲應該是單例的,便於先查到到倉儲,再查找到聚合實體(當然也不是絕對的);

  • 倉儲應該是一個集合的抽象概念,並且負責屏蔽中間過程,包括其中的實現細節,如持久化和重建,它最好能讓客戶感覺它似乎就一直在記憶體中一樣

  • 倉儲作為聚合實體的集合,應該具有檢索實體的功能,如果從技術角度看,那麼將一直持有聚合實體引用;

 

2.2 倉儲職責

倉儲與統計:在我們關註領域服務的時候,會有部分統計的領域邏輯可以歸納到中間過程管理中,例如我要根據某個聚合根的個數進行更新另一個聚合,倉儲也應當封裝這部分邏輯,主要是考慮到以下幾點:
  • 我們的一個用例服務中很可能不需要使用聚合實體本身,而僅使用到符合某種條件的聚合的數量,因此我們沒必要查出聚合實體進行統計;

  • 具體的基礎設施資料庫實現,對統計性能有著顯著的性能優化,為了使用這些中間技術的優點,把統計這種細節的操作委托給倉儲是一個很好的選擇。

  • 統計和查詢有很多時候的應用場景是不修改聚合根狀態的,所以這種情況你可能沒必要使用倉儲完成這件事,CQRS的思想要求我們去分離查詢,建立查詢模型,所以建立一套查詢模型去做這件事是一個好的解耦實踐。

倉儲與規格:上面提到倉儲應當具有檢索功能,檢索必然需要一些聚合實體的狀態欄位作為入參,最好的直接檢索是通過實體的唯一標識別進行,但如果我們有大量不同的欄位檢索需求,為每一個需求在倉儲建立一個這樣的方法介面,必然讓倉儲變得臃腫。規格這個概念可以消除這種臃腫變得可能。我們抽象一個規格實體,然後把規格作為一個參數傳給倉儲,讓倉儲根據規格獲取聚合實體,便可統一檢索功能。對該模式敢興趣的可以參考Eric Evans的《領域驅動設計》第9章:
  • 規格是一個謂詞,封裝了業務規則,可以明確表達一個特定實體是否滿足該規格標準;

  • 規則是值對象,可以組合使用,其組合實現與SQL的拼湊非常契合,使得其十分適合應用在倉儲;

  • 規格的概念引入,使得我們對實體多種檢索的需求過程做到了通用化;

  • 好的規格實現,鏈式 API 調用,可以使得編程變得靈活,表達能力強流暢;

倉儲與唯一標識:上面提到,聚合實體具有唯一標識,其中唯一標識的生產方法也有很多種(如用戶輸入生成、分散式ID生成、資料庫持久化時候生成),生成時機也可以在執行用例步驟之初,也可以在事務持久化的時候。在用例執行之初的情況下,我們其實可以讓倉儲封裝這種生成唯一標識,或者直接讓倉儲提供新聚合的工廠方法,這種表達會更自然。
  • 倉儲生成唯一標識別:在利用資料庫能力生成唯一ID的時候(例如TDDL的Sequence),因為倉儲本身封裝資料庫細節,所以倉儲可以單獨提供這種功能,例如 DomainRepository.getInstance().newEntityId() 方法,返回一個由資料庫管理的唯一ID。

  • 倉儲提供工廠方法:聚合實體的創建,不一定是由領域服務完成的,如果我們的聚合實體具有創建模板,那麼我們可以假設倉儲本身具有大量的新對象池待使用。所以可以這樣創建實體:DomainRepository.getInstance().newXXEntity() 返回聚合實體(該方式Evric不推薦);

倉儲與Resource:Repository通常被翻譯為資源庫,個人認為對比倉儲,資源庫的描述可能會讓我們更多的把聚合實體看作為一種網路中可以唯一定位的資源(Resource)抽象。我們通常在網路術語中看到資源的概念,如URL中的R即資源,如REST架構風格(表現層狀態轉移)也會把對象當初是資源。如果從資源角度看倉儲,就是實實在在的資源庫:
  • 作為Resource,我們通常會給它定一個URI(統一資源識別),用作全網唯一識別,但很少資源庫會定義URI,因為實體唯一標識已經足夠;

  • 作為Resource,倉儲一但持有了資源,那麼就一直持有並跟蹤資源,直到資源被刪除;

  • 作為Resource,倉儲有時會被當作是對遠程服務進程封裝的機制,這個時候倉儲有點像防腐層,但我不建議這樣做(國內部分書籍有這種介紹);

介紹這種角度,只是想讓讀者瞭解各種一些方案背後的設計理念。後面介紹面向集合的倉儲的時候,或者需要結合DDD和REST架構風格的時候,讀者可以自行體會聚合實體作為Resource的意義。
倉儲實體轉移(創新):現在我們討論一個問題,當我們從倉儲中獲取到聚合實體之後,倉儲是否還應該擁有該聚合實體?如果我們拋開電腦和技術概念,完全從問題空間出發,那麼倉儲是不再擁有聚合實體的:想象一下,一個倉庫管理人員需要處理一個商品,當他從倉庫獲取到該商品後後,另一個人在倉庫中還能找到這個商品嗎?按照這種思維對倉儲進行建模,倉儲和聚合的關係可以明確為:
  • 聚合實體一個時刻只能存在於一個用例過程或者一個倉儲實例中;

  • 聚合實體無法同時存在在倉儲中和用例過程中;

  • 聚合實體也無法同時存在於兩個用例過程中;

如果我們在解空間中對這個過程進行建模,可以描述為下圖:
圖片
有人或者會覺得我對這個倉儲的建模太較真了,因為我完全從問題空間角度看這個問題,但我提出這個的目的,只是想為後面的實踐方案提供一個以問題空間為主的參考標準,突出在倉儲選擇不同實現的時候不得不屈服於技術的特性從而使得倉儲的特性產生的差異。我會在每個實現中提出如果要抹平差異要怎麼做,並給出可以應用的場景,讀者理解這些差異後會對倉儲有更深的瞭解,其中《實現領域驅動設計》中Vaughn Vernon提出的一種實現為面向持久化的資源庫和這種問題空間角度其實是相通的,而Vaughn Vernon提出的另一種實現為面向集合的資源庫和解空間看的角度是想通的。我暫且將倉儲實體轉移描述為一種模式(後面統一為倉儲實體轉移模式),在該模式下,倉儲領域本質上,應該只有兩種操作:
  • 放置(put或save):把聚合實體從用例過程,放置到倉儲中,狀態變為中間過程,用例過程中不再擁有實體;

  • 獲取(Take):用例過程運行中,需要把實體從中間過程,轉移到用例過程,完成這個操作後,倉儲將不再擁有實體,我特別用take而不是find表達了這種思想。

大家可以對比資料庫的操作更新刪除。這兩個操作是帶著數據建模的思想,我將會在下麵關係型數據倉儲中提及,讓大家衡量要不要倉儲增加這兩種行為。同時也會介紹在關係型數據倉儲實現和記憶體倉儲實現如何改進為倉儲實體轉移模式,達到對比的目的。
作為開發人員,我們在應用DDD,關註模型的時候隔離了中間過程,確實得到了以模型為關註點的概念設計,但我們還需要兼顧技術的實現難度以及可行性,其實整個倉儲的解決方案在細節中並沒有那麼簡單,下麵我們開始沿著領域模型分析的結論,開始看技術實現的鴻溝。
三、實現剖析
如果有無限大的記憶體,或者無需持久化的業務,DAO層必然不存在,但倉儲(集合容器+檢索的數據結構)是仍然存在的。這就是為什麼我認為,理解倉儲的本質,不應該從技術角度思考,而是從領域角度思。即使我們對倉儲在領域上有幾乎固定的職責和功能,具體實現的倉儲都很難滿足其領域模型角度的功能。在《實現領域驅動設計》一書中,Vaughn Vernon提出2種倉儲的實現模式:
  1. 面向集合的資源庫:面向集合的倉儲提出的是完全按照集合的理念去設計倉儲,就似乎它就是Set數據結構一樣。所以他能自動去跟蹤聚合實體的變化

  2. 面向持久化的資源庫:面向持久化的倉儲,核心點是合併了插入和更新這兩種操作,統一用 save() 操作完全取代倉儲舊實體使得倉儲的功能更統一。這種數據存儲(如MongoDB等文檔資料庫)通常稱之為:面向聚合的資料庫(Aggregation-Oriented DataBase)或聚合存儲(Aggregation Store)。

以上兩種模式對倉儲來說都沒有統一,他們各有不同特點,面向集合模式強調倉儲一直保持跟蹤(引用),而面向持久化則強調採用 save()或者 put() 操作全量覆蓋。本文的實現介紹角度不同,但效果差異不大,本文只對記憶體實現和關係型資料庫實現做區分,並希望在統一的角度做了一些解讀給讀者參考。但我認為讀者可以根據自己理解去側重選擇自己的實現。

3.1 記憶體倉儲

在《實現領域驅動設計》一書中,作者Vaughn Vernon提出一種面向集合的倉儲,我認為這其實就是一種完全面向記憶體實現的倉儲方式,在這種方式中,我們利用倉儲管理聚合實體的生命周期中間過程其實和使用框架集合(Collection)是一樣的。我把書中的例子稍改動展現如下:

public class CalendarRepository extends HashMap{
  
  private Map<CalendarId,Calendar> calendars;
  
  public CalendarRepository(){
    this.calendars = new HashMap<CalendarId,Calendars>();
  }
  
  public void add(Calendar aCalendar){
    this.calendars.put(aCalendar.getId,aCalendar);
  }
  
  public Calendar findCalendars(CalendarId calendarId){
    return this.calendars.get(calendarId);
  }
  
}
熟悉編程的人員很簡單就知道這是怎麼一回事了。這個實現也很能表達從領域模型的角度看倉儲應該是怎麼樣子的。我總結了該實現特點如下:
  • 倉儲應該是一個集合實例,而且無法對倉儲進行重覆的放置;

  • 從倉儲獲取的聚合實例,應當和放置倉儲的實例具有完全一樣的狀態,在這裡是原對象;

  • 如果在倉儲之外對聚合實例進行了修改,無需“重新保存”聚合實例;

  • 這種倉儲下的聚合實體,看起來更加像資源Resource;

抹去引用的創新改進:Vaughn Vernon的這個例子完全解析了倉儲應有的樣子,但即使純記憶體實現也不得不融入了實現的特性——倉儲完全持有集合。這種持有引用特性幾乎對領域無影響,但我還想試圖把這種實現特性抹掉。對比 2.2中間過程的倉儲實體轉移一小節中,當取出資源後,集合不應該再擁有聚合實體。所以按照這種思路進行,findCalendars方法還應該加上邏輯移除Calendar聚合的實現,如下麵代碼所示。但這樣完全模擬有什麼好處呢?這是一個好問題,因為我們的選擇必須要權衡其中得失。繼續往下看一下不這樣做引起的併發衝突問題......
public class CalendarRepository extends HashMap{
  //存聚合實體
  private Map<CalendarId,Calendar> calendars;
  //標記實體被邏輯移除
  private Map<CalendarId,Thread> calendarsTakenAway;
  
  public CalendarRepository(){
    this.calendars = new HashMap<CalendarId,Calendars>();
  }
  
  public synchronized void add(Calendar aCalendar){
    this.calendars.put(aCalendar.getId,aCalendar);
    //移除邏輯刪除
    calendarsTakenAway.remove(aCalendar.getId)
  }
  
  //註意我們改了命名方法,變為了take,獲取,體現倉儲不再擁有實體
  public synchronized Calendar takeCalendars(CalendarId calendarId){
    //如果已經被取過,無法再取
    if(calendarsTakenAway.containsKey(calendarId)){
      return null;
    }
    Calendar calendar = this.calendars.get(calendarId);
    //邏輯刪除
    calendarsTakenAway.put(calendarId,Thread.currentThread());
    return calendar;
  }
  
}
考慮併發:在領域角度,在同一個時刻沒有有兩個人可以同時在一個倉庫中獲取到同一件商品,但在電腦解空間中可以,所以電腦解空間會出現併發問題。為瞭解決併發問題,我們可以使用以下方式
  • 悲觀鎖:在一個調度者(線程)使用該聚合實體前,先對聚合實體進行加鎖,其他調度者則無法獲取實體進行操作

    • 阻塞悲觀鎖:如果調度者發現聚合實體被鎖了之後,則停止調度直到等待得到實體鎖後繼續;

    • 非阻塞悲觀鎖:如果調度者發現聚合實體被鎖了之後,不等待鎖,立即返回做其他用例;

  • 樂觀鎖:一個調度者認為衝突可能性不大,所以可以先獲取聚合實體進行事務操作,但是當它想把聚合持久化的時候,發現有人操作過這個聚合,則回滾自己所有的操作。

採用哪一種操作完全取決於軟體開發人員,這個時候要求我們對程式架構設計和運作方式有著充分的瞭解,但是我們可以看到,其實用到了倉儲實體轉移這種完全模擬真實的領域問題空間的實現,剛剛好就是非阻塞悲觀鎖。只要是findCalendars方法刪除找到的Calendar實體是原子性的操作,其他線程則無法獲取到實體,那麼我們便不需要考慮重新設計一個新的鎖方案。如果你不是為了性能等其他因素非要領域模型妥協或者你剛好選擇的就是非阻塞性悲觀鎖,那麼這種實現將會大大簡化你的程式代碼重量,也能讓客戶瞭解你的模型運作機制,使得該過程也做到了統一語言。
即使是我們常用的樂觀鎖,在資料庫倉儲下倉儲實體轉移也非常適用。最後明確一下,做到統一語言,回歸領域本質的意義非常大。它是領域驅動設計應付軟體複雜之道的核心理論基礎。它要求我們抓住問題的本質複雜度,儘量排除因電腦技術方案引入的偶然複雜度,從而實現軟體的架構價值,獲取長遠的軟體效益。

 

3.2 關係型資料庫倉儲

DAO和倉儲思維差異:正如本文開篇中的第一段話所引用,我們程式員通常會在實現技術的過程中,把關註模型的想法早早拋之腦後,這是可以理解的,我們在入門該科學所接受的基礎學習讓我們的思維很大程度上固化為面向電腦技術的開發,卻往往沒註意到,軟體工程的設計建模更應該關註的是模型,DAO和倉儲正是這兩種差異的產物。本文不會解析DAO和DO之類的概念,因為讀到這裡的讀者,對他們的瞭解應當是非常專業的。
DAO和倉儲實現差異:先引出一個例子:我們有一個主任務TaskA和兩個子任務subTaskB,subTaskC,這三個實體都有一個叫state的狀態欄位,我們有一個業務規則是:所有子任務實體的狀態都是FINISHED,那麼就把TaskA實體的state設置為FINISHED。但是外部事件是一個一個子任務回傳回來的,我們接下來看不同思維的實現。
面向數據的開發思維,使用關係型資料庫實現倉儲的時候,我們對數據表有插入、更新、刪除、查詢四種主要操作,而且在面向數據模型開發的時候,服務類本身明確知道自己是在做哪一步操作。所以面向數據模型的開發經常會寫這樣的代碼:
public class BusinessService {
  
  @Resource
  private TaskDao taskDao;
  
  @Resource
  private SubTaskDao subTaskDao;
  
  @Transactional
  public void onFinished(String subTaskId,String taskId){
    //查出所有子任務
    List<SubTask> subTasks = subTaskDao.getAllSubTask(taskId);
    //找出回傳的子任務
    SubTask callBackTask = subTasks.stream()
      .filter(e->subTaskId.equals(e.getSubTaskId)).findAny();
    //更新子任務狀態
    callBackTask.setFinished(true);
    //如果所有子任務完成,更新主任務狀態
    if(allFinished(subTasks)){
       taskDao.updateStateById(taskId,TaskStatusEnum.FINISHED);
    }
    //更新一個欄位
    subTaskDao.updateStateById(subTaskId,TaskStatusEnum.FINISHED);
  }
  
}
上面的代碼,用例服務知道自己要更新什麼欄位,並自行去做了更新,但當我們關註模型思維用到倉儲的之後,針對以上的功能實現用例服務就不應該關註到更新哪一個欄位這個和持久化相關的操作,而是讓倉儲需要自行去對比,哪些欄位變化了,然後更新到資料庫中去,用例服務會是下麵所示的樣子:
public class BusinessDomainService {
  
  public void onFinished(String subTaskId,String taskId){
    //獲取實體的時候記錄快照
    Task task = DomainRepository.getInstance().taskOf(taskId);
    //聚合實體負責業務邏輯
    task.subTaskFinished(subTaskId);
    //倉儲自己識別到底哪個欄位變化了,然後更新該欄位(簡稱diff)
    DomainRepository.getInstance().put(task);
  }
  
}

public class Task {
  
    private List<SubTask> subTasks;
  
    private TaskStatusEnum status;
    
    public void subTaskFinished(subTaskId){
      //找出回傳的子任務
      SubTask callBackTask = subTasks.stream()
        .filter(e->subTaskId.equals(e.getSubTaskId)).findAny();
      //更新子任務狀態
      callBackTask.setFinished(true);
      //如果所有子任務完成,更新主任務狀態
      if(allFinished(subTasks)){
         status = TaskStatusEnum.FINISHED;
      }
    }
}
以上就是面向數據開發和麵向領域模型的倉儲開發的差別。那麼這樣的例子應該選擇哪一種實現最好呢?這個問題不好回答,既然是DDD那隻能選擇倉儲,這基本涉及的是系統如何設計的問題。簡單的系統選擇面向數據開發是簡單直接的。你應該在什麼時候使用「領域驅動設計」這種倉儲設計思想,別忘記了它的作用:複雜性軟體應對之道。
複雜的聚合根實體:如果你的數據欄位是有限的,但是實體變化的規則是多種多樣的,那麼實現自動更新模式將得到好處。假設我們一個實體有20個欄位,那麼我們 diff 20個欄位的代碼必然比寫不知道多少個由這20個欄位組成的組合介面要強。另一方面,比較可怕的是,有可能用例過程本身根本不知道一個要更新的實體哪些欄位發生了變化,為了說明這些情況,我們不得不提一下聚合根的另外一些特點。
  • 聚合內部一致性:聚合根的存在,最主要是的封裝和管理聚合內部各種實體的關聯和耦合,包括代碼耦合和數據耦合,所以上面的task本身持有所有subTask的引用,而且負責subTask和task的state狀態業務規則一致。此時,這個事務處理過程,就無法感知Task封裝的一致性邏輯是否由subTask引起了Task實體自身的狀態變化成為FINISHED,所以diff的實現就很有必要。
圖片
  • 領域服務的純粹性:如上圖所示,因為設置Task的狀態規則是由聚合根負責,所以領域服務是不感知的,必須要靠diff,但是如果把diff這個邏輯寫在領域服務中,不如把邏輯寫在倉儲中,因為我們也不應該讓領域服務去關註一些技術上的邏輯,增加領域服務邏輯的複雜性。其實這樣做,剛好就是倉儲本身的職責,封裝diff後的倉儲讓領域服務感覺到聚合實體一直在記憶體中一樣。

  • 聚合根的重建工序:在DAO中,我們可以直接方便從ORM框架中返回數據對象,但是聚合根卻不能,因為聚合根是由多個DO組成的,我們的持久化中間件(不管是MySQL關係型還是MongoDB文檔型)無法給我們返回一個聚合根實體。所以倉儲還得老老實實的把ORM中獲取到的DO組裝為Entity和Value Object,且要保證查找到的實體是要和原來的實體一摸一樣的。這意味著需要“重建”實體的操作;

    • 拆建規則(Convertor):倉儲應當知道怎麼拆,就應該怎麼複原,所以它應該有一套拆解和重建規則,並根據此規則進行複原,Convertor是維護這種規則的一種工具,我建議採用這種命名類封裝拆建規則

    • 事件溯源(Event Sourcing):還有一種重建工廠的實現是利用實體的快照+實體的領域事件集合回放來恢復聚合實體,有興趣的同學可以瞭解一下事件溯源;

    • 聚合根與關聯單例:關聯單例是一種特殊的重建工序。我用一個領域事件監聽器來說明,例如我們的聚合根實體實現了觀察者模式,聚合根為主題,內部持有一些單例監聽器對象列表,其中一個監聽器用作監聽聚合根的狀態變化發送領域事件,那麼這個監聽器也應該讓倉儲負責拆解和恢復。

以上的幾種特性,都意味著關係型資料庫倉儲的實現都會比較複雜。但這種複雜換來的是我們領域模型的乾凈,當軟體系統的複雜度提升,面向數據的開發所帶來的偶然複雜度是指數級別的,所以這個時候我們就能感受到倉儲的複雜性付出是值得的。最後我們列舉一下對比DAO,倉儲的缺點:
  • 實現複雜:因為聚合的複雜性所以我們其實現起來也非常困難,其中最好模型能配合實現這種複雜性。

  • 犯錯成本:正如DAO的某個介面只對一個屬性更新,那麼無論代碼有何種bug,最多只會寫錯一個欄位,但倉儲全量化更新後,我們在未知情況下手一抖,那麼將可能覆蓋其他本應安全欄位,所以這也提高了我們的犯錯成本。斷言是解決的一種較好方案

關係型倉儲實現方案:倉儲必須要讓客戶感覺它似乎就一直在記憶體中一樣;但上面提到的 Diff 邏輯讓倉儲的使用和實現變得困難,設計者需要在整個上下文角度瞭解倉儲的原理細節,因為要追求性能和安全的實現,還要只針對已經變化的欄位更新,忽略無變化欄位。其中Vaughn Vernon在《實現領域驅動設計》裡面提到了兩個方法,來解決這個問題:

  • 隱式讀時複製:在查找聚合實體的時候,記錄下聚合實體的所有狀態,然後在更新的時候,用新狀態diff舊的狀態,只對特定欄位進行更新;

  • 隱式寫時複製:在查找到集合實體的時候,倉儲把聚合實體的更新操作隱式委派給倉儲的某種機制進行,所以每次更新狀態實體狀態倉儲都能跟蹤到,併在這個時候對該值標記為臟數據,最後倉儲在事務結束的時候把臟數據給刷盤。

public interface TaskRepository{
  
    //相當於findTask,獲取到的Task會被隱式追蹤複製
    public Task taskOf(String taskId);
    
    public void addTask(Task task);
  
    public void removeTask(String taskId);
    //其他/統計/集合操作等
    //......
}
看上面代碼,在獲取方法 taskOf() 中,倉儲負責開始對實體進行跟蹤,因為外界調用方不感知倉儲在跟蹤實體,所以稱之為隱式,我們可以根據聚合的不同構成自行實現以上提供的兩種隱式跟蹤的方案的一種,如果是追求性能那麼寫時複製比較好,如果是採用讀時複製,那麼Javers開源框架會是一個比較好的選擇,但記得一定要做好單測。
以上兩種方案其實都是對實體進行狀態跟蹤,但要註意的是在介紹這兩種方案的時候,Vaughn是打算讓倉儲往面向集合倉儲的思路走的(該方法被他歸到面向集合一章)。雖然以上兩種隱式方案是非常好的實踐,但我認為還是可以像在面向記憶體倉儲一節提到的一樣,繼續引入創新改進為倉儲實體轉移模型,現在我們看一下關係型資料庫倉儲該如何應對這種模型。
抹去跟蹤的創新改進:我們上面提到了,倉儲實體轉移模式下,倉儲實則只有兩種主要操作,一個是放置聚合實體,一個是獲取(Take)聚合實體。獲取到實體後,倉儲將不再擁有實體管理許可權。在面向記憶體的倉儲實現中,我們只需在take方法中remove掉實體即可。但是持久化下的這種倉儲模式該如何實現、又有什麼特點呢?
很簡單,只要我們在原來的基礎上,讓倉儲把插入更新(即上面的跟蹤)操作封裝為一個操作put(也可以用save),然後讓find操作不變,直接命名為take,讓領域服務認為倉儲實際上已經沒有實體即可完成倉儲實體轉移模式,解析如下:
  • 領域服務視覺:在獲取(take)到聚合實體後,領域服務可以認為倉儲中的聚合實體是不存在的(即使倉儲沒有刪除聚合實體);

  • 合併插入和更新(全覆蓋):倉儲沒有所謂的更新操作,只有直接放置聚合實體到倉儲中,可以讓倉儲判斷該插入還是全量更新(其實和用隱式跟蹤實現部分更新差別不大,隱式跟蹤更安全但多一個複製操作),或者我們直接一點,完全刪除實體後再次插入或者全覆蓋實體;

  • 刪除:不管是否改進模型,當聚合實體生命周期結束都需要去真正的刪除實體,這一點確實不好統一;

  • 樂觀鎖:我們可以在實現的時候在關係型倉儲中採用樂觀鎖保證一個聚合實體不會存在於不同的領域事務中。因為樂觀鎖只會讓其中一個成功;
圖片
在Vaughn的書中介紹,隱式讀時/寫時跟蹤是做成面向集合的Repository,而另外用面向聚合的資料庫(Aggregation-Oriented DataBase)來表達他的面向持久化Repository,不知道讀者是否能Get到其實關係型資料庫實現的倉儲實體轉移模式,正是關係型資料庫下的面向持久化的Repository。
  • 優點:所以它最大的優點就是無需跟蹤實體,而是以轉移的聚合實體為主;

  • 缺點:因為倉儲實現要全量覆蓋整個聚合狀態,所以只適合用在類文檔資料庫,對於關係型資料庫則需要複雜的隱式讀/寫跟蹤了;

關係型倉儲總結:但確實不同的實現倉儲表現出了不同的特點,所以不管用何種實現,我們都需要瞭解倉儲的使用方法,不然是無法正確使用倉儲的。下麵給一個圖大概描述一下關係型資料庫持久化倉儲的功能和內部結構:
  • 訪問對象DAO:可以封裝一層Mapper,或者其他ORM框架,提供DO以及其他統計數據;

  • Convertor:維護拆解規則和重建規則,同時複製聚合根監聽器的一些組裝;

  • DO:數據對象,一般和關係型數據表一一對應;

  • 隱式狀態跟蹤:實現一套隱式讀時複製和隱式寫時複製狀態跟蹤的邏輯;
圖片
當性能不是很重要而且代碼比較重視質量的時候,我比較推薦推薦在領域服務結束之前,都要把聚合實體回歸倉儲,然後用樂觀鎖把整個聚合實體替換掉倉儲實現中的聚合實體。在開發規範約束、統一語言閉環的情況下,我們有了這條默契的規則,就不用擔心這種漏掉持久化實現的問題,也無需考慮我們到底是插入還是更新。

3.3 倉儲的架構

倉儲層(資源層):我們提到,中間過程是不歸領域模型關註的,我們屏蔽了中間過程提供了倉儲的領域概念,那麼顯然倉儲是領域模型關註的,這就涉及一個耦合以及依賴的問題。其中最自然的依賴就是我們的領域服務,要依賴倉儲,而倉儲要依賴資料庫、記憶體等具體的實現工具去做真正的中間過程狀態維護(持久化),如下圖所示(圖中連線代表依賴關係):
圖片
如此,在代碼實現上,必然很容易讓領域模型對資料庫、記憶體等這裡基礎設施的代碼產生依賴,從而讓基礎設施的概念入侵到領域模型變得容易。我們習慣於面向數據和過程的開發,當這類代碼和領域模型的代碼界限變得沒那麼明顯的時候,聚焦於模型也容易被破壞,倒置依賴整潔架構分層給了我們解決這個問題很好的實踐。我們可以把倉儲的行為抽象為基本的介面,然後利用控制反轉,把實現該節點的倉儲註入領域模型的運行態中。實現了倒置依賴的依賴圖如下:
圖片
應用了依賴倒置,把所有的倉儲都在一個命名空間(模塊)中管理,就形成了我們熟知的倉儲層(也叫資源層)。
四、結束語
對Repository的認知其實和對DDD思維的認識是統一的,他們都是從領域專家角度去對解決方案進行建模。倉儲為聚合根在領域知識和工程知識之間做了隔離,併為技術實現提供了統一的概念抽象。這樣的模式和例子在DDD中是經常有的,例如:防腐層也是其中的一種,他們都是為了保持領域模型的純粹性作出了自己的努力。最後由於篇幅問題簡要提一下倉儲的一些我還能想到的關註點:
倉儲與事務:聚合根是事務修改的基本單元;所以倉儲其實也是隱藏著一個事務原子化的能力。我們通常資料庫事務的實現要控制在應用層,但有時候會遇到大事務問題或者兩階段提交的問題,所以有極端情況下把事務用一個領域概念進入領域層,從而讓倉儲層的實現來反轉控制事務也不失為一個好選擇。這種打破原則的事情也要求我們理解原則。
倉儲與值對象:值對象可以很簡單,就一個數字,也可以很複雜,如一個完備的Domain Primitive概念。我們的實體擁有值對象,所以Repository也是要負責值對象的持久化,這點的處理也是非常值得大家去註意的點。讀者在實戰中處理的值對象的時候更需要豐富的經驗去取捨設計方案。
倉儲的設計和實現十分的複雜,我們很難在節奏比較快的開發迭代中去完成業務不關註的這種設計方式,這或許要求我們在每一次不同的迭代中去慢慢完成一個倉儲。這個時候代碼實現的倉儲有多醜陋不重要,或者重要的是你心中有一個成型的倉儲,它始終會跟著你的每一次改進被沉澱、演進。這就是為什麼我們要去理解倉儲存在的意義和本質,開發者如何去看待一個系統的各個構件,最終系統就會被開發成什麼樣子。

參考書籍:

《領域驅動設計》Eric Evans [著].趙俐[譯]2016.. 人民郵電出版社

《實現領域驅動設計》Vaughn Vernon[著].滕雲[譯].2014.中國工信出版集團
作者|範燦華(少嵐)

本文來自博客園,作者:古道輕風,轉載請註明原文鏈接:https://www.cnblogs.com/88223100/p/DDD_in-depth-design-and-implementation-of-repository-from-a-domain-perspective.html


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 數據類型 整數數據類型 特殊說明: ​ 對於整數類型,MySQL還支持在類型名稱後面加小括弧(M),而小括弧中的M表示顯示寬度,M的取值範圍是(0, 255)**。int(M)這個M在欄位的屬性中指定了unsigned(無符號)和zerofill(零填充)的情況下才有意義。**表示當整數值不夠M位時 ...
  • 一、前言 很多企業管理系統,在單據及報表的使用時,都會提供小數點有效數字後0是否顯示的功能。在金蝶雲星空的BOS平臺,這個功能叫"隱藏尾0"。現在假如自己開發一個系統,提供可配置的"隱藏尾0"的功能,該如何實現呢?以下是資料庫開發方面的解決方案,僅供參考。 二、方案 方法一、將數據轉換為REAL類型 ...
  • 閱識風雲是華為雲信息大咖,擅長將複雜信息多元化呈現,其出品的一張圖(雲圖說)、深入淺出的博文(雲小課)或短視頻(雲視廳)總有一款能讓您快速上手華為雲。更多精彩內容請單擊此處。 摘要:GaussDB性能調優過程需要綜合考慮多方面因素,因此,調優人員應對系統軟體架構、軟硬體配置、資料庫配置參數、併發控制 ...
  • 華為 HMS Core 運動健康服務(HUAWEI Health Kit)提供原子化數據開放。應用在獲取用戶數據授權後,可通過介面訪問運動健康數據,對用戶數據進行讀寫等操作,為用戶提供運動健康類數據服務。 開發者應用在開發和測試階段訪問用戶運動或健康數據時,會有100個用戶的數量限制,需要通過“申請 ...
  • 這裡給大家分享我在網上總結出來的一些知識,希望對大家有所幫助 本文用一個簡單的 demo 講解 App端 半屏連續掃碼 的實現方式,包括(條形碼、二維碼等各種各樣的碼)。 我會從實現思路講起,如果你比較急可以直接跳到 動手實現 章節獲取代碼。 開發和運行環境 開發工具:HBuilderX 前端框架: ...
  • Web 頁面可以使用多種方式實現動畫效果,其中最常用的有兩種: CSS 動畫:通過 CSS 中的 transition 和 animation 屬性來實現動畫效果。CSS 動畫實現起來簡單,性能消耗小,支持廣泛。 JavaScript 動畫:通過 JavaScript 代碼來實現動畫效果。JavaS ...
  • At-rules規則是目前CSS中一種常見的語法規則,它使用一個"@"符號加一個關鍵詞定義,後面跟上語法區塊,如果沒有則以分號結束即可。 這種規則一般用於標識文檔、引入外部樣式、條件判斷等等,本文是對該規則的使用總結。 常用規則 @import @import 主要用於從其他樣式表導入新的樣式規則, ...
  • jwt擴展欄位介紹 為了實現業務的定製化需求和開發人員使用的便利,對kc的JWT進行了擴展,這個擴展欄位在解析JWT之後都可以看到。 jwt的payload { "exp": 1675329802, "iat": 1675329622, "jti": "2a80e925-b9ce-464f-822d ...
一周排行
    -Advertisement-
    Play Games
  • 前言 在我們開發過程中基本上不可或缺的用到一些敏感機密數據,比如SQL伺服器的連接串或者是OAuth2的Secret等,這些敏感數據在代碼中是不太安全的,我們不應該在源代碼中存儲密碼和其他的敏感數據,一種推薦的方式是通過Asp.Net Core的機密管理器。 機密管理器 在 ASP.NET Core ...
  • 新改進提供的Taurus Rpc 功能,可以簡化微服務間的調用,同時可以不用再手動輸出模塊名稱,或調用路徑,包括負載均衡,這一切,由框架實現並提供了。新的Taurus Rpc 功能,將使得服務間的調用,更加輕鬆、簡約、高效。 ...
  • 順序棧的介面程式 目錄順序棧的介面程式頭文件創建順序棧入棧出棧利用棧將10進位轉16進位數驗證 頭文件 #include <stdio.h> #include <stdbool.h> #include <stdlib.h> 創建順序棧 // 指的是順序棧中的元素的數據類型,用戶可以根據需要進行修改 ...
  • 前言 整理這個官方翻譯的系列,原因是網上大部分的 tomcat 版本比較舊,此版本為 v11 最新的版本。 開源項目 從零手寫實現 tomcat minicat 別稱【嗅虎】心有猛虎,輕嗅薔薇。 系列文章 web server apache tomcat11-01-官方文檔入門介紹 web serv ...
  • C總結與剖析:關鍵字篇 -- <<C語言深度解剖>> 目錄C總結與剖析:關鍵字篇 -- <<C語言深度解剖>>程式的本質:二進位文件變數1.變數:記憶體上的某個位置開闢的空間2.變數的初始化3.為什麼要有變數4.局部變數與全局變數5.變數的大小由類型決定6.任何一個變數,記憶體賦值都是從低地址開始往高地 ...
  • 如果讓你來做一個有狀態流式應用的故障恢復,你會如何來做呢? 單機和多機會遇到什麼不同的問題? Flink Checkpoint 是做什麼用的?原理是什麼? ...
  • C++ 多級繼承 多級繼承是一種面向對象編程(OOP)特性,允許一個類從多個基類繼承屬性和方法。它使代碼更易於組織和維護,並促進代碼重用。 多級繼承的語法 在 C++ 中,使用 : 符號來指定繼承關係。多級繼承的語法如下: class DerivedClass : public BaseClass1 ...
  • 前言 什麼是SpringCloud? Spring Cloud 是一系列框架的有序集合,它利用 Spring Boot 的開發便利性簡化了分散式系統的開發,比如服務註冊、服務發現、網關、路由、鏈路追蹤等。Spring Cloud 並不是重覆造輪子,而是將市面上開發得比較好的模塊集成進去,進行封裝,從 ...
  • class_template 類模板和函數模板的定義和使用類似,我們已經進行了介紹。有時,有兩個或多個類,其功能是相同的,僅僅是數據類型不同。類模板用於實現類所需數據的類型參數化 template<class NameType, class AgeType> class Person { publi ...
  • 目錄system v IPC簡介共用記憶體需要用到的函數介面shmget函數--獲取對象IDshmat函數--獲得映射空間shmctl函數--釋放資源共用記憶體實現思路註意 system v IPC簡介 消息隊列、共用記憶體和信號量統稱為system v IPC(進程間通信機制),V是羅馬數字5,是UNI ...