实现领域驱动设计 - 使用ABP框架 - 聚合

这是本指南的关键部分。我们将通过实例介绍和解释一些明确的规则。在实现领域驱动设计时,您可以遵循这些规则并将其应用到您的解决方案中

领域案例

这些例子将使用GitHub中使用的一些概念,比如Issue, Repository, Label和User,你已经很熟悉了。下图显示了一些聚合、聚合根、实体、值对象以及它们之间的关系

image

问题聚合由一个问题聚合根组成,其中包含 Comment 和 IssueLabel 集合。其他聚合显示为简单的,因为我们将重点关注问题聚合

聚合

如前所述,聚合 是一个对象集群,它通过聚合根对象把(实体和值对象)绑定在一起。本节将介绍与聚合相关的原则和规则

我们将聚合根和子集合实体都称为实体,除非我们显式地编写聚合根实体或子集合实体

聚合 / 聚合根 原则

业务规则

实体负责实现与其自身属性相关的业务规则。
聚合根实体也负责它们的子集合实体。

聚合应该通过实现领域规则和约束来保持自身的完整性和有效性。这意味着,与dto不同,实体有实现某些业务逻辑的方法。实际上,我们应该尽可能在实体中实现业务规则

单个单元

聚合被检索并保存为单个单元,包含所有子集合和属性。例如,如果您想对某个问题添加注释,您需要这样做

  • 从数据库中获取 Issue,包括所有的子集合( Comments 和 issuelabel )
  • 使用 Issue 类上的方法来添加一个新的注释,比如 Issue. AddComment();
  • 将Issue(包括所有子集合)作为单个数据库操作保存到数据库(更新)

对于曾经使用 EF Core & Relational Databases 的开发人员来说,这可能看起来很奇怪。获得问题的所有细节似乎是不必要的和低效的。为什么我们不直接对数据库执行 SQL Insert 命令?

答案是,我们应该实现业务规则,并在代码中保持数据的一致性和完整性。如果我们有像 “用户不能评论锁定的问题” 这样的业务规则,如果不从数据库中检索它,我们如何检查问题的锁状态? 因此,只有当相关对象在应用程序代码中可用时,我们才能执行业务规则

另一方面,MongoDB 开发人员会发现这个规则非常自然。在MongoDB中,一个聚合对象(带有子集合)保存在数据库中的单个集合中(而它分布在关系数据库中的几个表中)。因此,当您获得一个聚合时,所有子集合都已经作为查询的一部分被检索,而不需要任何额外的配置。

ABP框架有助于在应用程序中实现这一原则

示例:向问题添加评论

image

_issueRepository.GetAsync 方法在默认情况下将所有详细信息(子集合)作为 Issue 的单个单元检索。虽然这对于MongoDB来说是开箱即用的,但你需要为EF Core配置聚合细节。但是,一旦配置,Repository 就会自动处理它。_issueRepository.GetAsync 方法有一个可选参数 includeDetails,当你需要它时,你可以传递 false 来禁用此行为

关于配置和备选方案,请参阅 EF Core文档 的加载相关实体一节。

Issue.AddComment 方法需要两个参数, 用户ID 和 评论内容,实现必要的业务规则,并将评论添加到 Issue 的 Comments 集合

最终,我们使用 _issueRepository.UpdateAsync 把修改保存到数据库

EF Core 有变化跟踪功能。你实际上不需要调用 _issueRepository.UpdateAsync。由于ABP的工作单元系统在方法结束时自动调用 DbContext.SaveChanges(),它将被自动保存。但是,对于MongoDB,您需要显式地更新更改后的实体

因此,如果你想编写独立于数据库提供程序的代码,你应该总是为更改的实体调用 UpdateAsync 方法

事务边界

聚合通常被视为事务边界。如果用例处理单个聚合,读取并保存为单个单元,对聚合对象所做的所有更改将作为原子操作一起保存,您不需要显式的数据库事务

然而,在现实生活中,您可能需要在一个用例中更改多个聚合实例,并且需要使用数据库事务来确保原子更新和数据一致性。正因为如此,ABP框架为用例(应用服务方法边界)使用显式的数据库事务。有关更多信息,请参阅 工作单元 文档

聚合/聚合根规则和最佳实践

仅通过ID引用其他聚合

第一条规则说一个聚合只能通过Id引用其他聚合。这意味着不能向其他聚合添加导航属性

  • 该规则使实现可序列化原则成为可能
  • 它还可以防止不同的聚合相互操作,以及将聚合的业务逻辑泄露给另一个聚合

在下面的例子中,你可以看到两个聚合根,GitRepository 和 Issue

image

  • GitRepository 不应该包含 Issue 集合,因为它们是不同的聚合
  • Issue 不应该有 GitRepository 的导航属性,因为它是一个不同的聚合
  • Issue 可以有 RepositoryId (作为一个 Guid).

所以,当你有一个 Issue,并且需要有与这个 Issue 相关的GitRepository 时,你需要通过 RepositoryId 显式地从数据库中查询它

对于 EF Core 和 关系型数据库

在MongoDB中,自然不适合有这样的导航属性/集合。如果这样做,您将在源聚合的数据库集合中找到目标聚合对象的副本,因为它在保存时被序列化为JSON

但是,EF Core和关系数据库开发人员可能会发现这个限制规则是不必要的,因为EF Core可以在数据库读写时处理它。我们认为这是一个重要的规则,它有助于降低域的复杂性,防止潜在的问题,我们强烈建议实现该规则。但是,如果您认为忽略此规则是可行的,请参阅上面关于数据库无关性原则的讨论一节。

保持聚合小巧

一个好的做法是保持聚合的简单和小巧。这是因为聚合将被加载并保存为单个单元,而读取/写入大对象有性能问题。请看下面的例子:

image

角色聚合具有一组 UserRole 值对象,用于跟踪为该角色分配的用户。请注意,UserRole 不是另一个聚合,对于“仅按Id引用其他聚合”规则也没有问题。然而,这是一个现实问题。在现实场景中,一个角色可能被分配给数千(甚至数百万)个用户,当您从数据库查询 role 时,加载数千个条目是一个显著的性能问题(记住:聚合是由它们的子集合作为单个单元加载的)

另一方面,User 可能有这样的角色集合,因为用户在实际中没有太多角色,当您使用用户聚合时,拥有一个角色列表可能很有用

如果仔细考虑,在使用非关系型数据库(如MongoDB)时,Role 和 User 都有关系列表,这还有一个问题。在这种情况下,相同的信息在不同的集合中重复,很难维护数据的一致性(每当向 User.Roles 添加项时, 你也需要把它添加到 Role.Users 中)

因此,请根据以下考虑因素确定聚合边界和大小

  • 对象被一起使用
  • 查询(加载/保存)性能和内存消耗
  • 数据的完整性、有效性和一致性

实际上:

  • 大多数聚合根不会有子集合
  • 一个子集合中最多不应该有超过100-150个条目。如果您认为集合可能有更多项,那么不要将集合定义为聚合的一部分,而要考虑为集合内的实体提取另一个聚合根

聚合根/实体上的主键

  • 聚合根的标识符通常只有一个Id属性(Primark Key: PK)。我们更喜欢Guid作为聚合根实体的PK(参见 Guid生成文档 了解原因)。
  • 聚合中的实体(不是聚合根)可以使用复合主键

例如,请参阅下面的聚合根和实体:

image

  • Organization 有一个 Guid 标识符 (Id)
  • OrganizationUser 是 Organization 的子集合,并且具有由OrganizationId 和 UserId 组成的复合主键

这并不意味着子集合实体应该总是具有复合主键。它们可能在需要时具有单个Id属性

复合主键实际上是一个关系数据库的概念,因为子集合实体有自己的表,需要主键。另一方面,例如,在MongoDB中,你根本不需要为子集合实体定义主键,因为它们存储为聚合根的一部分

聚合根/实体的构造函数

构造函数位于实体生命周期开始的位置。一个设计良好的构造函数有以下几点职责:

  • 把必需的实体属性作为参数,以创建有效实体。应该强制只传递必需的参数,把非必需的属性作为可选参数
  • 检查参数的有效性
  • 初始化子集合

示例: Issue(聚合根) 的构造函数

image

  • Issue 类正确地通过在其构造函数参数中获取必填属性,来强制创建有效的实体。
  • 构造函数验证了输入, 如果标题为空, Check.NotNullOrWhiteSpace 抛出了异常
  • 它初始化了子集合,所以在创建 Issue 完成后, 你使用 Labels 集合时,不会导致空引用异常
  • 构造函数也接受id并传递给基类。我们不会在构造函数中生成Guid,应该将这个职责委托给另一个服务(参见 Guid生成 )。
  • 私有空构造函数是 ORM 必需的。我们将它设置为私有,以防止在我们自己的代码中意外地使用它

查看 实体 文档了解更多关于使用ABP框架创建实体的信息

实体中的属性访问器和方法

上面的例子可能对你来说很奇怪! 例如,我们强制在构造函数中传递一个非空Title。但是,开发人员可以将Title属性设置为空,不需要任何控制。这是因为上面的示例代码只关注构造函数

如果我们用 public set 声明所有属性(就像上面的示例Issue类),我们就不能强制实体在其生命周期中保持有效性和完整性。所以

  • 当需要在设置属性时执行任何逻辑时,请为该属性使用私有setter
  • 定义公共方法来操作这些属性

示例:以受控方式更改属性的方法

image

  • RepositoryId 的 setter 被设置为私有,并且在创建 Issue 后没有办法更改它,因为这是我们在这个领域中想要的:一个 Issue 不能被移动到另一个仓库
  • Title 的 setter 是私有的,如果你想在以后以一种可控的方式改变它, 所以 SetTitle方法被创建了
  • TextAssignedUserId 是共有的 setter, 因为对他们没有限制。它们可以是null或任何其他值。我们认为没有必要定义单独的方法来设置它们。如果以后需要,我们可以添加方法并将setter设为私有。在域层中,中断更改不是问题,因为域层是一个内部项目,它不向客户端公开
  • IsClosedIssueCloseReason 是一对属性,定义了关闭和重新打开方法以同时更改它们。这样,我们就可以确保在关闭问题时必须填写理由

实体中的业务逻辑和异常

在实体中实现验证和业务逻辑时,经常需要处理异常情况。在这些情况下

  • 创建领域特定的异常
  • 当需要时在实体的方法中抛出这些异常

示例:

image

这里有2个业务规则:

  • 无法重新打开锁定的问题。
  • 您无法锁定未关闭的问题

在这些情况下,Issue 类抛出一个 IssueStateException 来强制执行业务规则, 抛出此类异常有两个潜在问题:

  • 在出现这种异常的情况下,最终用户是否应该看到异常(错误)消息?如果是,如何本地化异常消息?你不能使用本地化系统,因为你不能在实体中注入和使用 IStringLocalizer
  • 对于一个web应用程序或HTTP API, HTTP状态代码应该返回给客户端什么?

ABP的 异常处理 系统解决了这些以及类似的问题

示例:用代码抛出业务异常

image

  • IssueStateException类继承了BusinessException类。对于继承自BusinessException的异常,ABP默认返回403(禁止的)HTTP状态码(而不是500 -内部服务器错误)
  • 该 code 在本地化资源文件中用作查找本地化消息的键

现在,我们可以更改 ReOpen 方法,如下所示:

image

使用常量而不是魔法字符串。
然后添加一个本地化资源条目,如下所示:

"IssueTracking:CanNotOpenLockedIssue" : "Can not open a locked issue! Unlock it first."

  • 当你抛出异常时,ABP会自动使用这个本地化的消息(基于当前的语言)显示给最终用户
  • 异常代码 (IssueTracking:CanNotOpenLockedIssue), 也发送给客户端,因此它可以通过编程方式处理错误情况。

对于本例,您可以直接抛出BusinessException,而不是定义专门化
IssueStateException。结果是一样的。详细信息请参见 异常处理文档

实体中需要外部服务的业务逻辑

当业务逻辑只使用该实体的属性时,在实体方法中实现业务规则很简单。如果业务逻辑需要查询数据库或使用任何应该从依赖项注入系统解析的外部服务,该怎么办? 记住,实体不能注入服务!

有两种常见的实现这种业务逻辑的方法:

  • 在实体的方法里实现业务逻辑,把外部依赖作为方法的参数传递进来
  • 创建领域服务

稍后将解释领域服务。但是,现在让我们看看如何在实体类中实现它

示例:业务规则:不能同时为一个用户分配超过3个开放问题

image

  • AssignedUserId 的属性设置器是私有的,改变它的唯一方法是使用 AssignToAsyncCleanAssignment 方法
  • AssignToAsync 需要 AppUser 实体, 实际上,它只使用 User.Id ,你可以传递一个Guid值,比如userId。但是,这种方法可以确保Guid值是一个现有用户的Id,而不是一个随机的Guid值
  • IUserIssueService 是一个服务,用来获取已经分配给用户的问题数量, 调用 AssignToAsync 的代码负责解析 IUserIssueService 并传递到这里
  • 如果业务规则不满足的话, AssignToAsync 会抛出异常
  • 最好,如果所有事情都顺利的话, AssignedUserId 属性会被赋值

当您希望将问题分配给用户时,此方法可以完美地保证应用业务逻辑。然而,它有一些问题

  • 它使实体类依赖于外部服务,这使实体变得复杂
  • 这使得使用实体变得困难。使用实体的代码现在需要注入IUserIssueService 并传递给 AssignToAsync 方法

实现此业务逻辑的另一种方法是引入领域服务,稍后将对此进行解释。

posted @ 2022-06-23 14:10  Broadm  阅读(644)  评论(0编辑  收藏  举报