目錄
- 系列文章
- 前言
- 聚合
- 聚合和聚合根原則
- 包含業務原則
- 單個單元原則
- 事務邊界原則
- 可序列化原則
- 聚合和聚合根最佳實踐
- 只通過ID參考其他聚合
- 用于 EF Core 和 關系型資料庫
- 保持聚合根足夠小
- 聚合根/物體中的主鍵
- 聚合根/物體建構式
- 業務邏輯和物體中的例外處理
- 物體中業務邏輯需要用到外部服務
- 學習幫助
系列文章
- 基于ABP落地領域驅動設計-00.目錄和前言
- 基于ABP落地領域驅動設計-01.全景圖
- 基于ABP落地領域驅動設計-02.聚合和聚合根的最佳實踐和原則
- 基于ABP落地領域驅動設計-03.倉儲和規約最佳實踐和原則
- 基于ABP落地領域驅動設計-04.領域服務和應用服務的最佳實踐和原則
- 基于ABP落地領域驅動設計-05.物體創建和更新最佳實踐
- 基于ABP落地領域驅動設計-06.正確區分領域邏輯和應用邏輯
圍繞DDD和ABP Framework兩個核心技術,后面還會陸續發布核心構件實作、綜合案例實作系列文章,敬請關注!
ABP Framework 研習社(QQ群:726299208)
ABP Framework 學習及實施DDD經驗分享;示例原始碼、電子書共享,歡迎加入!
前言
上一篇 基于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.全景圖中關于資料庫獨立性原則的討論部分,
保持聚合根足夠小
一個好的做法是保持一個簡單而小的聚合,這是因為一個聚合體將作為一個單元被加載和保存,讀/寫一個大物件會導致性能問題,
請看下面的例子:
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型別主鍵IdOrganizationUser是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)
{
}
}
}
拋出此類例外有兩個潛在問題:
- 在這種例外情況下,終端用戶是否應該看到例外(錯誤)訊息?如果是,如何實作本地化例外訊息?因為不能在物體中注入和使用
IStringLocalizer,導致不能使用本地化系統, - 對于 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實施經驗分享;示例原始碼、電子書共享,歡迎加入!

轉載請註明出處,本文鏈接:https://www.uj5u.com/ruanti/296419.html
標籤:領域驅動設計
上一篇:物聯網的應用模式
