基于ABP落地领域驱动设计-02.聚合和聚合根的最佳实践和原则
前言
上一篇?基于ABP落地領域驅動設計-01.全景圖?概述了DDD理論和對應的解決方案、項目組成、項目引用關系,以及基于ABP落地DDD的通用原則。從這本篇開始,會更加深入地介紹在基于 ABP Framework 落地DDD過程中的最佳實踐和原則。
圍繞DDD和ABP Framework兩個核心技術,后面還會陸續發布核心構件實現、綜合案例實現系列文章,敬請關注!?ABP Framework 研習社(QQ群:726299208)?ABP Framework 學習及實施DDD經驗分享;示例源碼、電子書共享,歡迎加入!
領域對象是DDD的核心,我們會依次分析聚合/聚合根、倉儲、規約、領域服務的最佳實踐和規則。內容較多,會拆分成多個章節單獨展開。
本文重點討論領域對象——聚合和聚合根的最佳實踐和原則
首先我們需要一個業務場景,例子中會用到 GitHub 的一些概念,如:Issue(建議)、Repository(代碼倉庫)、Label(標簽)和User(用戶)。
下圖顯示了業務場景對應的聚合、聚合根、實體、值對象以及它們之間的關系。
Issue 聚合是由?Issue(聚合根)、Comment(實體)和?IssuelLabel(值對象)組成的集合。因為其他聚合相對簡單,所以我們重點分析?Issue 聚合。
聚合
正如前面所講,一個聚合是一系列對象(實體和值對象)的集合,通過聚合根將所有關聯對象綁定在一起。本節將介紹與聚合相關的最佳實踐和原則。
我們對聚合根和子集合實體都使用實體這個術語,除非明確寫出聚合根或子集合實體。
聚合和聚合根原則
包含業務原則
?實體負責實現與其自身屬性相關的業務規則。?聚合根還負責其子集合實體狀態管理。?聚合應該通過實現領域規則和規約來保持自身的完整性和有效性。這意味著,與數據傳輸對象(DTO)不同,實體具有實現業務邏輯的方法。實際上,我們應該盡可能在實體中實現業務規則。
單個單元原則
聚合及其所有子集合,作為單個單元被檢索和保存。例如:如果向?Issue?添加?Comment,需要這樣做:
?從數據庫中獲取?Issue?包含所有子集合:Comments?(該問題的評論列表) 和?IssueLabels?(該問題的標簽集合)。?在?Issue?類中調用方法添加一個新的?Comment,比如:?Issue.AddCommnet(...)?作為一個單一的數據庫更新操作,將 Issue(包括所有子集合)保存到數據庫。
對于習慣使用 EF Core 和 關系數據的開發者來說,這看起來似乎有些奇怪。獲取 Issue 的所有數據是沒有必要且低效的。為什么我們不直接執行一個SQL插入命令到數據庫,而不查詢任何數據呢?
答案是,我們應該在代碼中實現業務規則并保持數據的一致性和完整性。如果我們有一個業務規則,如:用戶不能對鎖定的 Issue 進行評論,我們如何不通過檢索數據庫中數據的情況下,檢查 Issue 的鎖定狀態呢?所以,只有當應用程序代碼中的相關對象可用時,即獲取到聚合及其所有子集合數據時,我們才能執行該業務規則。
另一方面,MongoDB開發者會發現這個規則非常自然。因為在 MongoDB 中,一個聚合對象(包括子集合)被保存在數據庫中的一個集合中,而在關系型數據庫中,它被分布在數據庫中幾個表中。因此,當你得到一個聚合時,所有的子集合已經作為查詢的一部分被檢索出來了,不需要任何額外配置。
ABP框架有助于在您的應用程序中實現這一原則。
示例:添加 Comment 到 Issue
public class IssueAppService : ApplicationService ,IIssueAppService {private readonly IRepository<Issue,Guid> _issueRepository;public IssueAppService(IRepository<Issue,Guid> issueRepository){_issueRepository = issueRepository;}[Authorize]public async Task CreateCommentAsync(CreateCommentDto input){var issue = await _issueRepository.GetAsync(input.IssueId);issue.AddComment(CurrentUser.GetId(),input.Text);await _issueRepository.UpdateAsynce(issue);} }_issueRepository.GetAsync(...)方法默認作為單個單元檢索 Issue 對象并包含所有子集合。對于 MongoDB 來說這個操作開箱即用,但是使用 EF Core 需要配置聚合與數據庫映射,配置后?EF Core 倉儲實現?會自動處理。_issueRepository.GetAsync(...)方法提供一個可選參數includeDetails,可以傳遞值?false?禁用該行為,不包含子集合對象,只在需要時啟用它。
Issue.AddComment(...)傳遞參數?userId?和?text?,表示用戶ID和評論內容,添加到?Issue?的?Comments?集合中,并實現必要的業務邏輯驗證。
最后,使用?_issueRepository.UpdateAsync(...)?保存更改到數據庫。
EF Core 提供 變更跟蹤(Change Tracking)功能,實際上你不需要調用?_issueRepository.UpdateAsync(...)?方法,會自動進行保存。這個功能是由?ABP 工作單元系統?提供,應用服務的方法作為一個單獨的工作單元,在執行完之后會自動調用?DbContext.SaveChanges()。當然,如果使用 MongoDB 數據庫,則需要顯示地更新已經更改的實體。所以,如果你想要編寫獨立于數據庫提供程序的代碼,應該總是為要更改的實體調用UpdateAsync()方法。
事務邊界原則
一個聚合通常被認為是一個事務邊界。如果用例使用單個聚合,讀取并保存為單個單元,那么對聚合對象所做的所有更改,將作為原子操作保存,而不需要顯式地使用數據庫事務。
當然,我們可能需要處理將多個聚合實例作為單一用例更改的場景,此時需要使用數據庫事務確保更新操作的原子性和數據一致性。正因為如此,ABP框架為一個用例(即一個應用程序服務方法)顯式地使用數據庫事務,一個應用程序服務方法,就是一個工作單元。
可序列化原則
聚合(包含根實體和子集合)應該是可序列化的,并且可以作為單個單元在網絡上進行傳輸。舉個例子,MongoDB序列化聚合為Json文檔保存到數據庫,反序列化從數據庫中讀取的Json數據。
當您使用關系數據庫和ORM時,沒有必要這樣做。然而,它是領域驅動設計的一個重要實踐。
聚合和聚合根最佳實踐
以下最佳實踐確保實現上述原則。
只通過ID引用其他聚合
一個聚合應該只通過其他聚合的ID引用聚合,這意味著你不能添加導航屬性到其他聚合。
?這條規則使得實現可序列化原則得以實現。?可以防止不同聚合相互操作,以及將聚合的業務邏輯泄露給另一個聚合。
我們來看一個例子,兩個聚合根:GitRepository?和?Issue?:
public class GitRepository:AggregateRoot<Guid> {public string Name {get;set;}public int StarCount{get;set;}public Collection<Issue> Issues {get;set;} //錯誤代碼示例 }public class Issue:AggregateRoot<Guid> {public tring Text{get;set;}public GitRepository Repository{get;set;} //錯誤代碼示例public Guid RepositoryId{get;set;} //正確示例 }?GitRepository?不應該包含 Issue 集合,他們是不同聚合。?Issue?不應該設置導航屬性關聯?GitRepository?,因為他們是不同聚合。?Issue?使用?RepositoryId?關聯 Repository 聚合,正確。
當你有一個?Issue?需要關聯的?GitRepository?時,那么可以從數據庫通過?RepositoryId?直接查詢。
用于 EF Core 和 關系型數據庫
在 MongoDB 中,自然不適合有這樣的導航屬性/集合。如果這樣做,在源集合的數據庫集合中會保存目標集合對象的副本,因為它在保存時被序列化為JSON,這樣可能會導致持久化數據的不一致。
然而,EF Core 和關系型數據庫的開發者可能會發現這個限制性的規則是不必要的,因為 EF Core 可以在數據庫的讀寫中處理它。
但是我們認為這是一條重要的規則,有助于降低領域的復雜性防止潛在的問題,我們強烈建議實施這條規則。然而,如果你認為忽略這條規則是切實可行的,請參閱前面基于ABP落地領域驅動設計-01.全景圖[2]中關于數據庫獨立性原則的討論部分。
保持聚合根足夠小
一個好的做法是保持一個簡單而小的聚合。這是因為一個聚合體將作為一個單元被加載和保存,讀/寫一個大對象會導致性能問題。
請看下面的例子:
public class UserRole:ValueObject {public Guid UserId{get;set;}public Guid RoleId{get;set;} }public class Role:AggregateRoot<Guid> {public string Name{get;set;}public Collection<UserRole> Users{get;set;} //錯誤示例:角色對應的用戶是不斷增加的 } public class User:AggregateRoot<Guid> {public string Name{get;set;}public Collection<UserRole> Roles{get;set;}//正確示例:一個用戶擁有的角色數量是有限的 }Role聚合?包含?UserRole?值對象集合,用于跟蹤分配給此角色的用戶。注意,UserRole?不是另一個聚合,對于規則僅通過Id引用其他聚合沒有沖突。
然而,實際卻存在一個問題。在現實生活中,一個角色可能被分配給數以千計(甚至數以百萬計)的用戶,每當你從數據庫中查詢一個角色時,加載數以千計的數據項是一個重大的性能問題。記住:聚合是由它們的子集合作為一個單一單元加載的。
另一方面,用戶可能有角色集合,因為實際情況中用戶擁有的角色數量是有限的,不會太多。當您使用用戶聚合時,擁有一個角色列表可能會很有用,且不會影響性能。
如果你仔細想想,當使用非關系型數據庫(如MongoDB)時,當Role和User都有關系列表時還有一個問題:在這種情況下,相同的信息會在不同的集合中重復出現,將很難保持數據的一致性,每當你在User.Roles中添加一個項,你也需要將它添加到Role.Users中。
因此,根據以下因素來確定聚合邊界和大小:
?考慮對象關聯性,是否需要在一起使用。?考慮性能,查詢(加載/保存)性能和內存消耗。?考慮數據的完整性、有效性和一致性。
而實際:
?大多數聚合根沒有子集合。?一個子集合最多不應該包含超過100-150個條目。如果您認為集合可能有更多項時,請不要定義集合作為聚合的一部分,應該考慮為集合內的實體提取為另一個聚合根。
聚合根/實體中的主鍵
?一個聚合根通常有一個ID屬性作為其標識符(主鍵,Primark Key: PK)。推薦使用?Guid?作為聚合根實體的PK。?聚合中的實體(不是聚合根)可以使用復合主鍵。
示例:聚合根和實體
//聚合根:單個主鍵 public class Organization {public Guid Id{get;set;}public string Name{get;set;}//... } //實體:復合主鍵 public class OrganizationUser {public Guid OrganizationId{get;set;} //主鍵public Guid UserId{get;set;}//主鍵public bool IsOwner{get;set;}//... }?Organization?包含?Guid?類型主鍵?Id?OrganizationUser?是?Organization?中的子集合,有復合主鍵:OrganizationId?和?UserId?。
這并不意味著子集合實體應該總是有復合主鍵,只有當需要時設置;通常是單一的ID屬性。
復合主鍵實際上是關系型數據庫的一個概念,因為子集合實體有自己的表,需要一個主鍵。另一方面,例如:在MongoDB中,你根本不需要為子集合實體定義主鍵,因為它們是作為聚合根的一部分來存儲的。
聚合根/實體構造函數
構造函數是實體的生命周期開始的地方。一個設計良好的構造函數,擔負以下職責:
?獲取所需的實體屬性參數,來創建一個有效的實體。應該強制只傳遞必要的參數,并可以將非必要的屬性作為可選參數。?檢查參數的有效性。?初始化子集合。
示例:Issue(聚合根)構造函數
using System; using System.Collections.Generic; using System.Collections.ObjectModel; using Volo.Abp; using Volo.Abp.Domain.Entities;namespace IssueTracking.Issues {public class Issue:AggregateRoot<Guid>{public Guid RepositoryId{get;set;}public string Title{get;set;}public string Text{get;set;}public Guid? AssignedUserId{get;set;}public bool IsClosed{get;set;}pulic IssueCloseReason? CloseReason{get;set;} //枚舉public ICollection<IssueLabel> Labels {get;set;}public Issue(Guid id,Guid repositoryId,string title,string text=null,Guid? assignedUserId = null):base(id){//屬性賦值RepositoryId=repositoryId;//有效性檢測Title=Check.NotNullOrWhiteSpace(title,nameof(title));Text=text;AssignedUserId=assignedUserId;//子集合初始化Labels=new Collection<IssueLabel>();}private Issue(){/*反序列化或ORM 需要*/}} }?Issue類通過其構造函數參數,獲得屬性所需的值,以此創建一個正確有效的實體。?在構造函數中驗證輸入參數的有效性,比如:Check.NotNullOrWhiteSpace(...)?當傳遞的值為空時,拋出異常ArgumentException。?初始化子集合,當使用 Labels 集合時,不會獲取到空引用異常。?構造函數將參數id傳遞給base類,不在構造函數中生成 Guid,可以將其委托給另一個 Guid生成服務,作為參數傳遞進來。?無參構造函數對于ORM是必要的。我們將其設置為私有,以防止在代碼中意外地使用它。
實體屬性訪問器和方法
上面的示例代碼,看起來可能很奇怪。比如:在構造函數中,我們強制傳遞一個不為null的Title。但是,我們可以將?Title?屬性設置為?null,而對其沒有進行任何有效性控制。這是因為示例代碼關注點暫時只在構造函數。
如果我們用?public?設置器聲明所有的屬性,就像上面的Issue類中的屬性例子,我們就不能在實體的生命周期中強制保持其有效性和完整性。所以:
?當需要在設置屬性時,執行任何邏輯,請將屬性設置為私有private。?定義公共方法來操作這些屬性。
示例:通過方法修改屬性
namespace IssueTracking.Issues {public Guid RepositoryId {get; private set;} //不更改public string Title { get; private set; } //更改,需要非空驗證public string Text{get;set;} //無需驗證public Guid? AssignedUserId{get;set;} //無需驗證public bool IsClosed { get; private set; } //需要和 CloseReason 一起更改public IssueCloseReason? CloseReason { get;private set;} //需要和 IsClosed 一起更改public class Issue:AggregateRoot<Guid>{//...public void SetTitle(string title){Title=Check.NotNullOrWhiteSpace(title,nameof(title));}public void Close(IssueCloseReason reason){IsClosed = true;CloseReason =reason;}public void ReOpen(){IsClosed=false;CloseReason=null;}} }?RepositoryId?設置器設置為私有private,因為 Issue 不能將 Issue 移動到另一個 Repository 中,該屬性創建之后無需更改。?Title?設置器設置為私有,當需要更改時,可以使用?SetTitle?方法,這是一種可控的方式。?Text?和?AssignedUserId?都有公共設置器,因為這兩個字段并沒有約束,可以是null或任何值。我們認為沒有必要定義單獨的方法來設置它們。如果以后需要,可以添加更改方法并將其設置器設置為私有。領域層是內部項目,并不會暴露給客戶端使用,所以這種更改不會有問題。?IsClosed?和?IssueCloseReason?是成對修改的屬性,分別定義?Close?和?ReOpen?方法一起修改他們。通過這種方式,可以防止在沒有任何理由的情況下關閉一個問題。
業務邏輯和實體中的異常處理
當你在實體中進行驗證和實現業務邏輯,經常需要管理異常:
?創建特定領域異常。?必要時在實體方法中拋出這些異常。
示例:
public class Issue:AggregateRoot<Guid> {//..public bool IsLocked {get;private set;}public bool IsClosed{get;private set;}public IssueCloseReason? CloseReason {get;private set;}public void Close(IssueCloseReason reason){IsClose = true;CloseReason =reason;}public void ReOpen(){if(IsLocked){throw new IssueStateException("不能打開一個鎖定的問題!請先解鎖!");}IsClosed=false;CloseReason=null;}public void Lock(){if(!IsClosed){throw new IssueStateException("不能鎖定一個關閉的問題!請先打開!");}}public void Unlock(){IsLocked = false;} }這里有兩個業務規則:
?鎖定的Issue不能重新打開?不能鎖定一個關閉的Issue
Issue?類在這些業務規則中拋出異常?IssueStateException?。
namespace IssueTracking.Issues {public class IssueStateException : Exception{public IssueStateException(string message):base(message){}} }拋出此類異常有兩個潛在問題:
1.在這種異常情況下,終端用戶是否應該看到異常(錯誤)消息?如果是,如何實現本地化異常消息?因為不能在實體中注入和使用IStringLocalizer,導致不能使用本地化系統。2.對于 Web 應用程序或 HTTP API,應該給客戶端返回什么 HTTP Status Code?
ABP框架?Exception Handing 系統處理了這些問題。
示例:拋出業務異常
using Volo.Abp; namespace IssuTracking.Issues {public class IssueStateException : BuisinessException{public IssueStateExcetipn(string code): base(code){}} }?IssueStateException?類繼承?BusinessException?類。ABP框架在請求禁用時默認返回?403 HTTP?狀態碼;發生內部錯誤是返回?500 HTTP?狀態碼。?code?用作本地化資源文件中的一個鍵,用于查找本地化消息。
現在,我們可以修改?ReOpen?方法:
public void ReOpen() {if(IsLocked){throw new IssueStateException("IssueTracking:CanNotOpenLockedIssue");}IsClosed=false;CloseReason=null; }建議:使用常量代替魔術字符串"IssueTracking:CanNotOpenLockedIssue"。
然后在本地化資源中添加一個條目,如下所示:
"IssueTracking:CanNotOpenLockedIssue":"不能打開一個鎖定的問題!請先解鎖!"?當拋出異常時,ABP自動使用這個本地化消息(基于當前語言)向終端用戶顯示。?異常Code("IssueTracking:CanNotOpenLockedIssue")被發送到客戶端,因此它可以以編程方式處理錯誤情況。
實體中業務邏輯需要用到外部服務
當業務邏輯只使用該實體的屬性時,在實體方法中實現業務規則是很簡單的。如果業務邏輯需要查詢數據庫或使用任何應該從依賴注入系統中獲取的外部服務時,該怎么辦?請記住,實體不能注入服務。
有兩個方式實現:
?在實體方法上實現業務邏輯,并將外部依賴項作為方法的參數。?創建領域服務(Domain Service)
領域服務在后面介紹,現在讓我們看看如何在實體類中實現它。
示例:業務規則:一個用戶不能同時分配超過3個未解決的問題
public class Issue:AggregateRoot<Guid> {//..public Guid? AssignedUserId{get;private set;}//問題分配方法public async Task AssignToAsync(AppUser user,IUserIssueService userIssueService){var openIssueCount = await userIssueService.GetOpenIssueCountAsync(user.Id);if(openIssueCount >=3 ){throw new BusinessException("IssueTracking:CanNotOpenLockedIssue");}AssignedUserId=user.Id;}public void CleanAssignment(){AssignedUserId=null;} }?AssignedUserId?屬性設置器設置為私有,通過?AssignToAsync?和?CleanAssignment?方法進行修改。?AssignToAsync?獲取一個?AppUser?實體,實際上只用到?user.Id,傳遞實體是為了確保參數值是一個存在的用戶,而不是一個隨機值。?IUserIssueService?是一個任意的服務,用于獲取分配給用戶的問題數量。如果業務規則不滿足,則拋出異常。所有規則滿足,則設置?AssignedUserId?屬性值。
此方法完全實現了應用業務邏輯,然而,它有一些問題:
?實體變得復雜,因為實體類依賴外部服務。?實體變得難用,調用方法時需要注入依賴的外部服務?IUserIssueService?作為參數。
==聚合和聚合根的最佳實踐和原則部分完結!==
學習幫助
圍繞DDD和ABP Framework兩個核心技術,后面還會陸續發布核心構件實現、綜合案例實現系列文章,敬請關注!
ABP Framework 研習社(QQ群:726299208)?專注 ABP Framework 學習及DDD實施經驗分享;示例源碼、電子書共享,歡迎加入!
總結
以上是生活随笔為你收集整理的基于ABP落地领域驱动设计-02.聚合和聚合根的最佳实践和原则的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 基于ABP落地领域驱动设计-01.全景图
- 下一篇: 再见,REST,你好,gRPC