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

iEricLee發表於2021-06-24

前言

上一篇 基於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

相關文章