2021年6月24日星期四

基于ABP落地领域驱动设计-02.聚合和聚合根的最佳实践和原则

基于ABP落地领域驱动设计第二篇,领域对象是DDD的核心,我们会依次分析聚合/聚合根、仓储、规约、领域服务的最佳实践和规则。本文重点讨论领域对象——聚合和聚合根的最佳实践和原则。

目录
  • 前言
  • 聚合
  • 聚合和聚合根原则
    • 包含业务原则
    • 单个单元原则
    • 事务边界原则
    • 可序列化原则
  • 聚合和聚合根最佳实践
    • 只通过ID引用其他聚合
    • 用于 EF Core 和 关系型数据库
    • 保持聚合根足够小
    • 聚合根/实体中的主键
    • 聚合根/实体构造函数
    • 业务逻辑和实体中的异常处理
    • 实体中业务逻辑需要用到外部服务
  • 学习帮助

前言

上一篇 基于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 方法一起修改他们。通过这种方式,可以防止在没有任何理由的情况下关闭一个问题。

业务逻辑和实体中的异常处理

当你在实体中进行验证和实现业务逻辑,经常需要管理异常:

<......

原文转载:http://www.shaoqun.com/a/826741.html

跨境电商:https://www.ikjzd.com/

败欧洲网站:https://www.ikjzd.com/w/1555

史泰博办公用品:https://www.ikjzd.com/w/2112

粉丝通:https://www.ikjzd.com/w/743


基于ABP落地领域驱动设计第二篇,领域对象是DDD的核心,我们会依次分析聚合/聚合根、仓储、规约、领域服务的最佳实践和规则。本文重点讨论领域对象——聚合和聚合根的最佳实践和原则。目录前言聚合聚合和聚合根原则包含业务原则单个单元原则事务边界原则可序列化原则聚合和聚合根最佳实践只通过ID引用其他聚合用于EFCore和关系型数据库保持聚合根足够小聚合根/实体中的主键聚合根/实体构造函数业务逻辑和实体中的
dmm.adult:https://www.ikjzd.com/w/2026
7种女人最容易遭遇婚姻破裂:http://lady.shaoqun.com/a/72561.html
我偷偷的摸了老师下面 趁老师睡着扒了她的内裤:http://lady.shaoqun.com/m/a/247944.html
谷歌竞价广告怎么投放:https://www.ikjzd.com/articles/146023
Amazon Appliances类四大榜单整合 ------ 每小时自动更新 --- 附件为入口:https://www.ikjzd.com/tl/3011
Amazon Electronics类四大榜单整合 ------ 每小时自动更新 --- 附件为入口:https://www.ikjzd.com/tl/3022
Amazon CPC广告基础PPT:https://www.ikjzd.com/tl/3037
邂逅2020神农架冰雪节:世界那么大、还是神农架的冬季醉美!:http://www.30bags.com/a/224989.html
口述实录:"干妹妹"的杀伤力,到底有多大?:http://lady.shaoqun.com/m/a/255497.html
女人坐在头上被㖭 他的舌头弄得我爽水好多:http://www.30bags.com/m/a/249879.html
如何运营阿里国际站:https://www.ikjzd.com/articles/146027
2021亚马逊Prime会员日:全球Prime会员共计购买产品逾2.5亿件:https://www.ikjzd.com/articles/146025

没有评论:

发表评论