基于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.全景图中关于数据库独立性原则的讨论部分。
保持聚合根足够小
一个好的做法是保持一个简单而小的聚合。这是因为一个聚合体将作为一个单元被加载和保存,读/写一个大对象会导致性能问题。
请看下面的例子:
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)
{
}
}
}
抛出此类异常有两个潜在问题:
- 在这种异常情况下,终端用户是否应该看到异常(错误)消息?如果是,如何实现本地化异常消息?因为不能在实体中注入和使用
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实施经验分享;示例源码、电子书共享,欢迎加入!