“ DDD設計的目標是關註領域模型而並非技術來創建更好的軟體,假設開發人員構建了一個SQL,並將它傳遞給基礎設施層中的某個查詢服務然後根據表數據的結構集取出所需信息,最後將這些信息提供給構造函數或者Factory,開發人員在做這一切的時候早已不把模型看做重點了,這個整個過程就變成了數據處理的風格 ”... ...
-
本文首先從聚合根的生命周期和生存環境出發,引出了Repository概念,並說明其本質是管理中間過程的集合容器(2.1節);
-
根據集合容器的概念,在領域角度去挖掘出Repository的職責,並提出了倉儲實體轉移模式用作對不同倉儲實現的對比標準(2.2節);
-
然後從實現例子出發,介紹了一種純記憶體實現的倉儲,用作體現倉儲最佳實現(3.1節);
-
繼續從實現例子出發,介紹了關係型資料庫下的倉儲特點,並描述面向持久化的倉儲的特點(3.4節);
2.1 聚合實體
-
標識:實體具有唯一標識,這個唯一標識使得實體和值對象區分開來;
-
狀態:實體是具有可以被改變的狀態,因此聚合實體無法被靜態描述;
-
生命周期:實體擁有生命周期,從實體的創建,到實體的狀態的終態;
-
生存環境:實體的活動存在於各個上下文中的領域服務或者應用服務中,其中分用例過程和中間過程;
-
用例過程:只要在執行用例過程的時候才需要實體的存在,其他時候,實體生命周期並沒有結束,而是處於中間狀態;
-
中間過程:當沒有任何用例在處理一個實體的時候,實體消失了嗎?沒有,它仍然存在生命周期內,這個時候我們認為實體正處在一種中間過程。
-
放置:建立一個新的聚合實體,這是一個聚合實體生命的開始,在用例過程結束後,把聚合實體放到倉儲中;
-
查找:把已經存在的聚合實體找出來,這是一個聚合實體的中間過程到用例過程的行為;
-
管理:它負責聚合實體的中間過程管理,並屏蔽掉中間過程的細節,向領域層提供統一的能力抽象,一些數據統計類的也可以在該範疇內;
-
如何放置實體:為了方便管理,我們通常會採用分治把同一種類型的實體放在一起成為一個集合。相同類型和集合給了我們一個指導就是:倉儲的設計應該是一個聚合實體類型對應一個倉儲實體,具有一一對應關係,所以倉儲實體應該是一個保存相同類型元素的集合容器;
-
如何查找實體:我們知道實體具有唯一標識別,也具有其他特征屬性,所以為了查找實體,我們應該通過實體的唯一標識或者特征屬性去遍歷查找,倉儲應當提供這種功能,所以倉儲應該針對聚合實體欄位具有索引查找功能;
-
如何查找倉儲:既然我們提到了需要用倉儲來查找實體,那麼我們又是如何查到倉儲的呢?其實這個很簡單,如果一個聚合實體類型只具有一個倉儲類型,那麼我們把倉儲設計為單例的就可以了。
-
一個聚合類型(也就是一個聚合根),最好對應一個倉儲(這個不是絕對的);
-
一個倉儲應該是單例的,便於先查到到倉儲,再查找到聚合實體(當然也不是絕對的);
-
倉儲應該是一個集合的抽象概念,並且負責屏蔽中間過程,包括其中的實現細節,如持久化和重建,它最好能讓客戶感覺它似乎就一直在記憶體中一樣;
-
倉儲作為聚合實體的集合,應該具有檢索實體的功能,如果從技術角度看,那麼將一直持有聚合實體引用;
2.2 倉儲職責
-
我們的一個用例服務中很可能不需要使用聚合實體本身,而僅使用到符合某種條件的聚合的數量,因此我們沒必要查出聚合實體進行統計;
-
具體的基礎設施資料庫實現,對統計性能有著顯著的性能優化,為了使用這些中間技術的優點,把統計這種細節的操作委托給倉儲是一個很好的選擇。
-
統計和查詢有很多時候的應用場景是不修改聚合根狀態的,所以這種情況你可能沒必要使用倉儲完成這件事,CQRS的思想要求我們去分離查詢,建立查詢模型,所以建立一套查詢模型去做這件事是一個好的解耦實踐。
-
規格是一個謂詞,封裝了業務規則,可以明確表達一個特定實體是否滿足該規格標準;
-
規則是值對象,可以組合使用,其組合實現與SQL的拼湊非常契合,使得其十分適合應用在倉儲;
-
規格的概念引入,使得我們對實體多種檢索的需求過程做到了通用化;
-
好的規格實現,鏈式 API 調用,可以使得編程變得靈活,表達能力強流暢;
-
倉儲生成唯一標識別:在利用資料庫能力生成唯一ID的時候(例如TDDL的Sequence),因為倉儲本身封裝資料庫細節,所以倉儲可以單獨提供這種功能,例如 DomainRepository.getInstance().newEntityId() 方法,返回一個由資料庫管理的唯一ID。
-
倉儲提供工廠方法:聚合實體的創建,不一定是由領域服務完成的,如果我們的聚合實體具有創建模板,那麼我們可以假設倉儲本身具有大量的新對象池待使用。所以可以這樣創建實體:DomainRepository.getInstance().newXXEntity() 返回聚合實體(該方式Evric不推薦);
-
作為Resource,我們通常會給它定一個URI(統一資源識別),用作全網唯一識別,但很少資源庫會定義URI,因為實體唯一標識已經足夠;
-
作為Resource,倉儲一但持有了資源,那麼就一直持有並跟蹤資源,直到資源被刪除;
-
作為Resource,倉儲有時會被當作是對遠程服務進程封裝的機制,這個時候倉儲有點像防腐層,但我不建議這樣做(國內部分書籍有這種介紹);
-
聚合實體一個時刻只能存在於一個用例過程或者一個倉儲實例中;
-
聚合實體無法同時存在在倉儲中和用例過程中;
-
聚合實體也無法同時存在於兩個用例過程中;
-
放置(put或save):把聚合實體從用例過程,放置到倉儲中,狀態變為中間過程,用例過程中不再擁有實體;
-
獲取(Take):用例過程運行中,需要把實體從中間過程,轉移到用例過程,完成這個操作後,倉儲將不再擁有實體,我特別用take而不是find表達了這種思想。
-
面向集合的資源庫:面向集合的倉儲提出的是完全按照集合的理念去設計倉儲,就似乎它就是Set數據結構一樣。所以他能自動去跟蹤聚合實體的變化
-
面向持久化的資源庫:面向持久化的倉儲,核心點是合併了插入和更新這兩種操作,統一用 save() 操作完全取代倉儲舊實體使得倉儲的功能更統一。這種數據存儲(如MongoDB等文檔資料庫)通常稱之為:面向聚合的資料庫(Aggregation-Oriented DataBase)或聚合存儲(Aggregation Store)。
3.1 記憶體倉儲
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;
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;
}
}
-
悲觀鎖:在一個調度者(線程)使用該聚合實體前,先對聚合實體進行加鎖,其他調度者則無法獲取實體進行操作
-
阻塞悲觀鎖:如果調度者發現聚合實體被鎖了之後,則停止調度直到等待得到實體鎖後繼續;
-
非阻塞悲觀鎖:如果調度者發現聚合實體被鎖了之後,不等待鎖,立即返回做其他用例;
-
樂觀鎖:一個調度者認為衝突可能性不大,所以可以先獲取聚合實體進行事務操作,但是當它想把聚合持久化的時候,發現有人操作過這個聚合,則回滾自己所有的操作。
3.2 關係型資料庫倉儲
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;
}
}
}
-
聚合內部一致性:聚合根的存在,最主要是的封裝和管理聚合內部各種實體的關聯和耦合,包括代碼耦合和數據耦合,所以上面的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的某個介面只對一個屬性更新,那麼無論代碼有何種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);
//其他/統計/集合操作等
//......
}
-
領域服務視覺:在獲取(take)到聚合實體後,領域服務可以認為倉儲中的聚合實體是不存在的(即使倉儲沒有刪除聚合實體);
-
合併插入和更新(全覆蓋):倉儲沒有所謂的更新操作,只有直接放置聚合實體到倉儲中,可以讓倉儲判斷該插入還是全量更新(其實和用隱式跟蹤實現部分更新差別不大,隱式跟蹤更安全但多一個複製操作),或者我們直接一點,完全刪除實體後再次插入或者全覆蓋實體;
-
刪除:不管是否改進模型,當聚合實體生命周期結束都需要去真正的刪除實體,這一點確實不好統一;
-
樂觀鎖:我們可以在實現的時候在關係型倉儲中採用樂觀鎖保證一個聚合實體不會存在於不同的領域事務中。因為樂觀鎖只會讓其中一個成功;
-
優點:所以它最大的優點就是無需跟蹤實體,而是以轉移的聚合實體為主;
-
缺點:因為倉儲實現要全量覆蓋整個聚合狀態,所以只適合用在類文檔資料庫,對於關係型資料庫則需要複雜的隱式讀/寫跟蹤了;
-
訪問對象DAO:可以封裝一層Mapper,或者其他ORM框架,提供DO以及其他統計數據;
-
Convertor:維護拆解規則和重建規則,同時複製聚合根監聽器的一些組裝;
-
DO:數據對象,一般和關係型數據表一一對應;
-
隱式狀態跟蹤:實現一套隱式讀時複製和隱式寫時複製狀態跟蹤的邏輯;
3.3 倉儲的架構
參考書籍:
《領域驅動設計》Eric Evans [著].趙俐[譯]2016.. 人民郵電出版社
本文來自博客園,作者:古道輕風,轉載請註明原文鏈接:https://www.cnblogs.com/88223100/p/DDD_in-depth-design-and-implementation-of-repository-from-a-domain-perspective.html