基於ABP落地領域驅動設計-02.聚合和聚合根的最佳實踐和原則

前言

上一篇 基於ABP落地領域驅動設計-01.全景圖 概述了DDD理論和對應的解決方案、項目組成、項目引用關係,以及基於ABP落地DDD的通用原則。從這本篇開始,會更加深入地介紹在基於 ABP Framework 落地DDD過程中的最佳實踐和原則

圍繞DDDABP Framework兩個核心技術,後面還會陸續發布核心構件實現綜合案例實現系列文章,敬請關注!
ABP Framework 研習社(QQ群:726299208)
ABP Framework 學習及實施DDD經驗分享;示例源碼、電子書共享,歡迎加入!

領域對象是DDD的核心,我們會依次分析聚合/聚合根、倉儲、規約、領域服務的最佳實踐和規則。內容較多,會拆分成多個章節單獨展開。

本文重點討論領域對象——聚合和聚合根的最佳實踐和原則

首先我們需要一個業務場景,例子中會用到 GitHub 的一些概念,如:Issue(建議)、Repository(程式碼倉庫)、Label(標籤)和User(用戶)。

下圖顯示了業務場景對應的聚合、聚合根、實體、值對象以及它們之間的關係。

image

Issue 聚合是由 Issue(聚合根)、Comment(實體)和 IssuelLabel(值對象)組成的集合。因為其他聚合相對簡單,所以我們重點分析 Issue 聚合

image

聚合

正如前面所講,一個聚合是一系列對象(實體和值對象)的集合,通過聚合根將所有關聯對象綁定在一起。本節將介紹與聚合相關的最佳實踐和原則。

我們對聚合根子集合實體都使用實體這個術語,除非明確寫出聚合根或子集合實體。

聚合和聚合根原則

包含業務原則

  • 實體負責實現與其自身屬性相關的業務規則。
  • 聚合根還負責其子集合實體狀態管理。
  • 聚合應該通過實現領域規則規約來保持自身的完整性有效性。這意味著,與數據傳輸對象(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(...)傳遞參數 userIdtext ,表示用戶ID評論內容,添加到 IssueComments 集合中,並實現必要的業務邏輯驗證。

最後,使用 _issueRepository.UpdateAsync(...) 保存更改到資料庫。

EF Core 提供 變更跟蹤(Change Tracking)功能,實際上你不需要調用 _issueRepository.UpdateAsync(...) 方法,會自動進行保存。這個功能是由 ABP 工作單元系統 提供,應用服務的方法作為一個單獨的工作單元,在執行完之後會自動調用 DbContext.SaveChanges()。當然,如果使用 MongoDB 資料庫,則需要顯示地更新已經更改的實體。
所以,如果你想要編寫獨立於資料庫提供程式的程式碼,應該總是為要更改的實體調用UpdateAsync()方法。

事務邊界原則

一個聚合通常被認為是一個事務邊界。如果用例使用單個聚合,讀取並保存為單個單元,那麼對聚合對象所做的所有更改,將作為原子操作保存,而不需要顯式地使用資料庫事務。

當然,我們可能需要處理將多個聚合實例作為單一用例更改的場景,此時需要使用資料庫事務確保更新操作的原子性數據一致性。正因為如此,ABP框架為一個用例(即一個應用程式服務方法)顯式地使用資料庫事務,一個應用程式服務方法,就是一個工作單元。

可序列化原則

聚合(包含根實體和子集合)應該是可序列化的,並且可以作為單個單元在網路上進行傳輸。舉個例子,MongoDB序列化聚合為Json文檔保存到資料庫,反序列化從資料庫中讀取的Json數據。

當您使用關係資料庫和ORM時,沒有必要這樣做。然而,它是領域驅動設計的一個重要實踐。

聚合和聚合根最佳實踐

以下最佳實踐確保實現上述原則。

只通過ID引用其他聚合

一個聚合應該只通過其他聚合的ID引用聚合,這意味著你不能添加導航屬性到其他聚合。

  • 這條規則使得實現可序列化原則得以實現。
  • 可以防止不同聚合相互操作,以及將聚合的業務邏輯泄露給另一個聚合。

我們來看一個例子,兩個聚合根:GitRepositoryIssue

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)時,當RoleUser都有關係列表時還有一個問題:在這種情況下,相同的資訊會在不同的集合中重複出現,將很難保持數據的一致性,每當你在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
  • OrganizationUserOrganization 中的子集合,有複合主鍵:OrganizationIdUserId

這並不意味著子集合實體應該總是有複合主鍵,只有當需要時設置;通常是單一的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是必要的。我們將其設置為私有,以防止在程式碼中意外地使用它。

實體屬性訪問器和方法

上面的示例程式碼,看起來可能很奇怪。比如:在構造函數中,我們強制傳遞一個不為nullTitle。但是,我們可以將 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 方法,這是一種可控的方式。
  • TextAssignedUserId 都有公共設置器,因為這兩個欄位並沒有約束,可以是null或任何值。我們認為沒有必要定義單獨的方法來設置它們。如果以後需要,可以添加更改方法並將其設置器設置為私有。領域層是內部項目,並不會暴露給客戶端使用,所以這種更改不會有問題
  • IsClosedIssueCloseReason 是成對修改的屬性,分別定義 CloseReOpen 方法一起修改他們。通過這種方式,可以防止在沒有任何理由的情況下關閉一個問題。

業務邏輯和實體中的異常處理

當你在實體中進行驗證和實現業務邏輯,經常需要管理異常:

  • 創建特定領域異常。
  • 必要時在實體方法中拋出這些異常。

示例:

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 屬性設置器設置為私有,通過 AssignToAsyncCleanAssignment 方法進行修改。
  • AssignToAsync 獲取一個 AppUser 實體,實際上只用到 user.Id,傳遞實體是為了確保參數值是一個存在的用戶,而不是一個隨機值。
  • IUserIssueService 是一個任意的服務,用於獲取分配給用戶的問題數量。如果業務規則不滿足,則拋出異常。所有規則滿足,則設置 AssignedUserId 屬性值。

此方法完全實現了應用業務邏輯,然而,它有一些問題:

  • 實體變得複雜,因為實體類依賴外部服務。
  • 實體變得難用,調用方法時需要注入依賴的外部服務 IUserIssueService 作為參數。

聚合和聚合根的最佳實踐和原則部分完結!

學習幫助

圍繞DDDABP Framework兩個核心技術,後面還會陸續發布核心構件實現綜合案例實現系列文章,敬請關注!

ABP Framework 研習社(QQ群:726299208)
專註 ABP Framework 學習及DDD實施經驗分享;示例源碼、電子書共享,歡迎加入!
image