-NET-Core-测试驱动开发实用指南-全-
.NET Core 测试驱动开发实用指南(全)
原文:
zh.annas-archive.org/md5/090175b8503a2fd03da8637f3e71ad3e译者:飞龙
前言
这本书将帮助你通过更好地理解用户、找到正确的问题来解决,并构建精益、事件驱动的系统来满足客户真正需求,从而解决复杂的企业问题。你将学习领域驱动设计(DDD)的基本原则以及如何使用现代工具如 EventStorming、Event Sourcing 和 CQRS 来应用它。通过这本书,你将了解 DDD 如何直接应用于各种架构风格,如 REST、反应式系统和微服务。你将赋予团队以灵活的方式与改进的服务和解耦的交互工作。
本书面向对象
这本书是为那些对 C#有中级水平理解的.NET 开发者和那些寻求提供价值而非仅仅编写代码的人所写。在 UI 章节中,具备中级水平的 JavaScript 技能将有所帮助。
本书涵盖内容
第一章,为何选择领域驱动设计?,涵盖了问题和解决方案空间、需求规范、复杂性、知识和无知的概念。这些话题对我们如何以及提供什么内容有着重要影响。
第二章,语言与上下文,深入探讨了语言的重要性,并解释了通用语言。
第三章,EventStorming,探讨了领域建模中最受欢迎的技术之一,并介绍了一些关于如何组织领域专家和开发者之间有用工作坊的实用技巧。
第四章,设计模型,深入建模过程,更多地关注可以帮助我们尽快开始编写代码并交付初始原型的工件。
第五章,实现模型,构成了我们用代码实现的领域模型的基础。我们将探讨在领域实体中执行行为的不同风格,并编写一些测试。
第六章,使用命令行动,展示了如何实现命令,以及命令是如何成为我们的领域模型和外部世界的粘合剂的。我们将学习如何通过让人们与之交互来使我们的模型变得有用。
第七章,一致性边界,更仔细地观察实体持久化,其范围将是我们的重点。我们将学习我们需要处理哪些类型的致性以及理解一致性边界的重要性。
第八章,聚合持久化,深入探讨了聚合持久化的主题。我们将找到一种方法将我们的领域对象存储在数据库中,并看到我们的应用程序首次运行。
第九章,CQRS - 读取侧,涵盖了 CQRS 的读取侧,并解释了读取模型是什么。您将学习如何使用通用语言进行查询,并了解如何使用一个数据库实现 CQRS。
第十章,事件源,展示了如何使用事件来持久化对象的状态,而不是使用传统的持久化机制。我们将介绍事件流的概念,并了解流与聚合之间的关系。我们将使用事件存储在流中持久化我们的聚合,并将它们加载回来。
第十一章,投影和查询,将带您了解查询事件源系统的挑战,并通过使用单独的读取模型和投影来解决这些挑战。
第十二章,边界上下文,使您熟悉边界上下文的概念。我们将识别项目中的上下文,并将系统分割成部分。我们还将了解上下文图,它显示了整个系统的边界上下文及其关系。
第十三章,系统拆分,提供了关于识别边界上下文和在示例应用程序中实现多个上下文的实用建议。本章作为在线章节可在以下链接找到:www.packtpub.com/sites/default/files/downloads/Splitting_the_System.pdf。
为了充分利用本书
为了遵循本书中的说明,您需要具备中级水平的 C#理解。其他要求在各自的章节的相关实例中提及。
本书中的图表使用在线协作工具 Miro 和免费在线服务draw.io创建,故意使用线条样式和字体,以体现所有模型和图表的时间性质和实用性。
下载示例代码文件
您可以从www.packtpub.com的账户下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。
您可以通过以下步骤下载代码文件:
-
在www.packtpub.com登录或注册。
-
选择“支持”选项卡。
-
点击“代码下载与勘误表”。
-
在搜索框中输入书名,并遵循屏幕上的说明。
文件下载后,请确保使用最新版本的以下软件解压缩或提取文件夹:
-
Windows 系统使用 WinRAR/7-Zip
-
Mac 系统使用 Zipeg/iZip/UnRarX
-
Linux 系统使用 7-Zip/PeaZip
本书代码包也托管在 GitHub 上,地址为github.com/PacktPublishing/Hands-On-Domain-Driven-Design-with-.NET-Core。如果代码有更新,它将在现有的 GitHub 仓库中更新。
我们还有其他来自我们丰富图书和视频目录的代码包可供选择,请访问github.com/PacktPublishing/。查看它们吧!
下载彩色图像
我们还提供包含本书中使用的截图/图表的彩色图像的 PDF 文件。您可以从这里下载:www.packtpub.com/sites/default/files/downloads/9781788834094_ColorImages.pdf
使用的约定
本书使用了多种文本约定。
CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“将下载的WebStorm-10*.dmg磁盘映像文件作为系统中的另一个磁盘挂载。”
代码块设置如下:
html, body, #map {
height: 100%;
margin: 0;
padding: 0
}
当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:
[default]
exten => s,1,Dial(Zap/1|30)
exten => s,2,Voicemail(u100)
exten => s,102,Voicemail(b100)
exten => i,1,Voicemail(s0)
任何命令行输入或输出都按以下方式编写:
$ mkdir css
$ cd css
粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示如下。以下是一个示例:“从管理面板中选择系统信息。”
警告或重要注意事项显示如下。
技巧和窍门显示如下。
联系我们
我们欢迎读者的反馈。
一般反馈:请发送电子邮件至feedback@packtpub.com,并在邮件主题中提及书籍标题。如果您对本书的任何方面有疑问,请发送电子邮件至questions@packtpub.com。
勘误:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,如果您能向我们报告,我们将不胜感激。请访问www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。
盗版:如果您在互联网上以任何形式遇到我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过链接至材料的方式与我们联系至copyright@packtpub.com。
如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com。
评论
请留下您的评价。一旦您阅读并使用了这本书,为何不在购买它的网站上留下评价呢?潜在读者可以查看并使用您的客观意见来做出购买决定,我们 Packt 公司可以了解您对我们产品的看法,并且我们的作者可以查看他们对书籍的反馈。谢谢!
如需了解更多关于 Packt 的信息,请访问 packtpub.com。
第一章:为什么是领域驱动设计?
软件行业早在 20 世纪 60 年代初就出现了,并且一直以来的增长。有人预测,总有一天所有软件都将被编写,软件开发者将不再需要,但这种预言从未成为现实,不断增长的软件工程师大军正在努力满足不断增长的需求。
然而,从行业的早期开始,大量项目交付延迟且严重超预算,以及大量失败的项目,令人难以承受。Standish Group 的 2015 年 CHAOS 报告(www.projectsmart.co.uk/white-papers/chaos-report.pdf)表明,从 2011 年到 2015 年,成功 IT 项目的百分比保持不变,仅为 22%。超过 19%的项目失败,其余项目面临挑战。尽管报告可能对项目成功的预期有些争议,但它仍然描绘了一幅许多人都熟悉的画面。这些数字令人震惊。在四十多年的时间里,许多方法被开发并宣传为软件项目管理中的银弹,但成功项目的数量几乎没有变化。
定义任何 IT 项目成功的关键因素之一是理解系统旨在解决的问题。我们都非常熟悉那些无法解决它们声称要解决的问题或效率非常低下的系统。敏捷开发方法中的 SCRUM 和 XP 都强调与用户互动和理解他们的问题。
术语领域驱动设计(DDD)由埃里克·埃文斯在他的现在已成为经典的书籍《领域驱动设计:软件核心的复杂性处理》(由 Addison-Wesley 于 2004 年出版)中提出。在本书出版十多年后,书中描述的实践和原则的兴趣开始呈指数级增长。许多因素影响了这种流行度的增长,但最重要的一个因素是 DDD 解释了软件行业的人如何理解用户的需求,并创建解决问题和产生影响的软件系统。
在本章中,我们将讨论如何通过理解业务领域、构建领域知识以及区分本质复杂性和偶然复杂性,有助于创建有意义的软件。
本章的目标是探讨以下主题:
-
问题空间与解决方案空间
-
需求出了什么问题
-
理解复杂性
-
知识在软件开发中的作用
理解问题
我们很少仅仅为了编写代码而编写软件。当然,我们可以为了乐趣和学习新技术而创建一个宠物项目,但在专业上,我们构建软件是为了帮助其他人更好地、更快地、更高效地完成他们的工作。否则,最初编写任何软件都没有意义。这意味着我们需要有一个我们打算解决的问题。认知心理学将这个问题定义为当前状态和期望状态之间的限制。
问题空间和解决方案空间
在他们的书《人类问题解决》中,艾伦·纽厄尔和赫伯特·西蒙概述了问题空间理论。该理论表明,人类通过在问题空间中寻找解决方案来解决问题。问题空间描述了初始状态和期望状态,以及可能的中间状态。它还可以包含定义问题背景的具体约束和规则。在软件行业中,在问题空间中操作的人通常是客户和用户。
每个实际问题都需要一个解决方案,如果我们正确地在问题空间中搜索,我们就可以概述出从初始状态到期望状态需要采取的步骤。这个概述以及关于解决方案的所有细节构成了一个解决方案空间。
问题空间和解决方案空间在实施过程中完全脱离的经典故事,是关于在太空中写作的故事。这个故事是这样的:在 20 世纪 60 年代,太空探索国家意识到,由于缺乏重力,普通的圆珠笔在太空中无法使用。因此,NASA 花费了 100 万美元来开发一种在太空中可以使用的笔,而苏联人则决定使用成本几乎为零的旧铅笔。
这个故事如此引人入胜,以至于它仍在流传,甚至被用于电视剧《白宫风云》,由马丁·辛扮演美国总统。我们很容易相信这个故事,不仅因为我们习惯了政府资助机构浪费资金,而且主要是因为我们看到了许多效率低下和现实问题误解释的例子,给他们的解决方案增加了巨大的不必要的复杂性,并解决了不存在的问题。
这个故事是一个神话。NASA 也尝试使用铅笔,但由于微尘的产生、笔尖断裂和木制铅笔的潜在易燃性,最终决定放弃它们。一家名为 Fisher 的私营公司开发了现在被称为太空笔的产品。后来,NASA 对该笔进行了测试,并决定使用它。该公司还从苏联获得了订单,笔在全球范围内销售。每个人的价格都是一样的,每支笔 2.39 美元。
在这里,你可以看到问题空间/解决方案空间问题的另一部分。尽管问题本身看起来很简单,但额外的限制,我们也可以称之为非功能性需求,或者更准确地说,操作需求,使得它比第一眼看起来更复杂。
直接跳到解决方案是非常容易的,因为我们大多数人都有解决日常问题的丰富经验,我们可以几乎立即找到许多问题的解决方案。然而,正如 Bart Barthelemy 和 Candace Dalmagne-Rouge 在他们 2013 年发表在《哈佛商业评论》上的文章《When You're Innovating, Resist Looking for Solutions》中建议的那样(hbr.org/2013/09/when-youre-innovating-resist-l),思考解决方案会阻止我们的大脑去思考问题。相反,我们开始深入思考最初出现在脑海中的解决方案,添加更多细节,使其成为给定问题的最理想解决方案。
在寻找特定问题的解决方案时,还有一个方面需要考虑。有危险的是,你可能会把所有的注意力都集中在一种特定的解决方案上,而这可能根本不是最好的解决方案,只是基于你以前的经验、对问题的当前理解和其他因素而首先出现在脑海中的。

精细化与探索
寻找和选择解决方案的探索性方法涉及大量工作来尝试几种不同的事情,而不是专注于原始“好主意”的迭代改进。然而,在这种探索过程中找到的答案可能会更加精确和有价值。我们将在本章后面讨论对第一个可能解决方案的执着。
需求出了什么问题
我们都熟悉软件需求的概念。开发者很少直接与想要解决问题的人接触。通常,一些专门的人员,如需求分析师、业务分析师或产品经理,会与客户交谈,并将这些对话的结果概括为功能需求。
需求可以有不同的形式,从称为“需求规范”的大型文档到更敏捷的方法,如用户故事。让我们看看这个例子:
"每天,系统应为每家酒店生成一份预计当天入住和退房的客人名单。"
如您所见,这个陈述只描述了解决方案。我们不可能知道用户在做什么,我们的系统将解决什么问题。可能需要指定额外的需求,进一步细化解决方案,但问题描述永远不会包含在功能需求中。
相比之下,使用用户故事,我们对我们用户的期望有了更深入的了解。让我们回顾一下这个真实的用户故事:"作为仓库经理,我需要能够打印库存水平报告,以便在库存耗尽时订购物品。"
这样我就可以在库存耗尽时订购物品。
在这种情况下,我们了解到了用户的需求。然而,这个用户故事已经规定了开发者需要做什么。它描述的是解决方案。真正的可能问题是客户需要一个更高效的采购流程,这样他们就不会出现库存耗尽的情况。或者,他们需要一个先进的采购预测系统,这样他们可以在不额外囤积库存的情况下提高吞吐量。
我们不应该认为需求是浪费时间。有许多优秀的分析师能够生产出高质量的规范。然而,理解这些需求几乎总是代表撰写者从其角度对实际问题的理解是至关重要的。在业界,普遍存在一种误解,即投入更多的时间和金钱来编写更高质量的需求规范。
然而,精益和敏捷方法论强调开发者和最终用户之间更直接的沟通。从最终用户到开发者和测试者,所有利益相关者共同理解问题,一起寻找解决方案,消除假设,为最终用户构建原型以供评估——所有这些做法都被成功的团队采纳,正如我们在本书后面将要看到的,它们也与领域驱动设计(DDD)密切相关。
处理复杂性
在撰写关于复杂性的内容之前,我试图找到一些关于这个词本身的花哨、引人注目的定义,但这似乎是一项复杂的任务。Merriam-Webster 将“复杂性”定义为“复杂性的质量或状态”,这个定义相当明显,甚至可能听起来有些愚蠢。因此,我们需要对这个主题进行更深入的探讨,并更多地了解复杂性。
在软件中,复杂性的概念与现实世界并没有太大的不同。大多数软件都是为了处理现实世界的问题而编写的。这些问题可能听起来很简单,但本质上很复杂,甚至很棘手。毫无疑问,问题空间复杂性将在试图解决这类问题的软件中体现出来。因此,在创建软件时意识到我们正在处理什么样的复杂性变得非常重要。
复杂性的类型
在 1986 年,图灵奖获得者弗雷德·布鲁克斯(Fred Brooks)撰写了一篇名为《没有银弹 – 软件工程中的本质与偶然》的论文,在其中他区分了两种类型的复杂性——本质复杂性和偶然复杂性。本质复杂性源于领域,源于问题本身,如果不减少问题的范围,就无法将其移除。相比之下,偶然复杂性是由解决方案本身带来的——这可能是框架、数据库或其他基础设施,以及不同类型的优化和集成。
布鲁克斯认为,当软件行业变得更加成熟时,偶然复杂性的水平会显著降低。高级编程语言和高效的工具为程序员提供了更多时间来处理业务问题。然而,正如我们今天所看到的,30 多年后,该行业仍在努力应对偶然复杂性。确实,我们手中握有强大的工具,但大多数这些工具都需要花费时间来学习使用这些工具本身。每年都会出现新的 JavaScript 框架,每个框架都有所不同,所以在写出任何有用的东西之前,我们需要学习如何高效地使用我们选择的框架。我多年前编写了一些 JavaScript 代码,当时我把 Angular 视为一种祝福,直到我意识到我花在与之斗争上的时间比写任何有意义的东西还要多。或者以容器为例,它承诺给我们带来一种简单的方式来隔离地托管我们的应用程序,无需处理物理或虚拟机器的所有麻烦。但随后我们需要一个协调器,我们得到了很多,花费时间学习如何与它们一起工作,直到我们得到了 Kubernetes 来统治它们所有,现在我们花在编写 YAML 文件上的时间比实际编写代码的时间还要多。我们将在下一节讨论这种现象的一些可能原因。
你可能已经注意到,本质复杂性与问题空间有很强的关联,而偶然复杂性则倾向于解决方案空间。然而,我们似乎经常遇到比问题本身更复杂的问题陈述。通常,这种情况是由于问题与解决方案混合在一起,正如我们之前讨论的那样,或者由于缺乏理解。
Gojko Adžić,一位软件交付顾问和几本有影响力的书籍的作者,如《示例规格说明》(Specification by Example)和《影响映射》(Impact Mapping),在他的研讨会中给出了这个例子:
“一家软件即服务公司收到了一个功能请求,要求提供特定报告的实时功能,而之前该报告是按计划每月执行一次。经过几个月的开发,销售人员试图获取一个估计的交付日期。然后,开发部门报告称,该功能至少还需要六个月才能交付,总成本约为 100 万英镑。这是因为该报告的数据源位于事务型数据库中,实时运行它将意味着性能显著下降,因此需要采取额外的措施,如数据复制、地理分布和分片。
公司随后决定分析请求此功能的客户实际需求。结果发现,客户希望执行与之前相同的操作,但他们希望改为每周执行,而不是每月执行。当被问及整个功能的预期结果时,客户表示,每周批量运行相同的报告可以解决问题。重新安排数据库作业远比重新设计整个系统容易,而对最终客户的影响是相同的。
这个例子清楚地表明,不理解问题可能会导致严重后果。我们作为开发者喜欢像 DRY(Don't Repeat Yourself)这样的原则。我们寻求抽象,使我们的代码更加优雅和简洁。然而,这往往可能是完全不必要的。有时,我们可能会陷入使用某些承诺解决世界上所有问题的工具或框架的陷阱。再次强调,这从来都不是没有代价的。作为一名.NET 开发者,当我看到社区对依赖注入的当前痴迷时,我清楚地看到了这一点。
足够真实,微软最终推出了一种有意义的 DI 容器,但当我看到它被用于一个小型控制台应用程序中,仅仅是为了初始化记录器时,我感到不安。有时,编写更多的代码只是为了满足工具、框架或环境,而不是提供实际价值的代码。在这个例子中看似必要的复杂性最终证明是浪费的:

随着时间的推移,复杂性增长
上述图表显示,随着系统复杂性的不断增长,本质的复杂性正在被推向下方,而偶然的复杂性正在接管。您可能会对这样一个事实表示怀疑,即当所需的功能几乎平坦时,偶然的复杂性会随着时间的推移而不断增长。这是怎么发生的,我们肯定不能只花费时间创造更多的偶然复杂性?当系统变得更加突出时,需要大量的努力来使整个系统工作,并管理大型数据模型,大型系统往往具有这样的数据模型。支持性代码增长,并且花费了大量努力来保持系统运行。我们引入缓存,优化查询,拆分和合并数据库,等等。最终,我们可能实际上决定减少所需功能的范围,只是为了使系统在没有太多故障的情况下运行。
DDD 帮助您专注于解决复杂的领域问题,并专注于本质的复杂性。当然,处理一个新潮的前端工具或使用云文档数据库是很有趣的。但如果没有理解我们试图解决的问题是什么,所有这些都可能只是浪费。对于任何企业来说,首先得到一些有用的东西并尝试它,比得到一个完美无缺的、完全偏离主题的尖端软件更有价值。为此,DDD 通过将系统拆分成更小的部分并提供一些有用的技术来管理复杂性,这些技术将使这些部分专注于解决一系列相关的问题。这些技术将在本书的后续章节中描述。
处理复杂性时的经验法则是——拥抱本质的复杂性,或者我们可能称之为领域复杂性,并消除或减少偶然的复杂性。作为开发者的您的目标不是创建过多的偶然复杂性。因此,非常常见的是,偶然的复杂性是由过度设计引起的。
复杂性的分类
当处理问题时,我们并不总是知道这些问题是否复杂。如果它们是复杂的,那么有多复杂?有没有一个工具可以测量复杂性?如果有,那么在开始解决问题之前对其进行测量或至少对其进行分类将是有益的。这种测量将有助于调节解决方案的复杂性,因为复杂问题也要求有复杂的解决方案,尽管这个规则很少例外。如果您不同意,我们将在下一节中更深入地探讨这个话题。
在 2007 年,Dave Snowden 和 Mary Boone 在《哈佛商业评论》2007 年发表了一篇名为《领导者决策框架》的论文。这篇论文获得了管理学会组织行为分部颁发的杰出实践者导向出版物奖。它有什么独特之处,它描述了哪个框架?
该框架是Cynefin。这个词在威尔士语中类似于栖息地,习惯,熟悉。斯诺登在 1999 年开始研究这个框架,当时他在 IBM 工作。这项工作非常有价值,以至于 IBM 成立了组织复杂性 Cynefin 中心,Dave Snowden 是其创始人兼总监。
Cynefin 将所有问题划分为五个类别或复杂性领域。通过描述每个领域内问题的属性,它为任何给定问题提供了一个定位感。在问题被归类到某个领域之后,Cynefin 还提供了一些处理这类问题的实用方法:

Cynefin 框架,图片由 Dave Snowden 提供
这五个领域具有特定的特征,该框架为这两个领域提供了属性,用于确定你的问题属于哪个领域,以及如何处理该问题。
第一个领域是简单或明显。在这里,你面临的问题可以描述为已知已知,这里有最佳实践和一套既定的规则可用,并且原因和结果之间存在直接联系。这个领域的行动顺序是感知-分类-反应。建立事实(感知),识别过程和规则(分类),并执行它们(反应)。
然而,斯诺登警告人们将问题错误地归类为简单的倾向。他为此识别了三种情况:
-
过度简化:这与下文所述的一些认知偏差相关。
-
固有思维:当人们盲目地使用他们在过去获得的技能和经验,因此对新思维方式视而不见。
-
自满:当事情顺利时,人们往往会放松并高估自己应对不断变化世界的能力。这种情况下存在的危险是,当问题被归类为简单时,由于未能充分评估风险,它可能会迅速升级到混乱领域。注意图中从简单领域到混乱领域的捷径,这通常会被研究框架的人忽略。
对于这本书,重要的是要记住两件事:
-
如果你将问题识别为明显,你可能不想设置一个复杂的解决方案,甚至可能会考虑购买一些现成的软件来解决问题,如果确实需要软件的话。
-
然而,请注意,不要错误地将这个领域中的更复杂问题归类,以避免应用错误的最佳实践,而不是进行更彻底的探索和研究。
第二个领域是复杂。在这里,你发现的问题是需要专家和技能来找到因果关系,因为这些问题的答案不是单一的。这些都是已知未知。这个领域行动的顺序是感知-分析-响应。正如我们所看到的,分析取代了categorize,因为在这个领域没有事实的明确分类。需要进行适当的分析来识别应该应用哪种良好实践。在这里也可以进行分类,但你需要经过更多的选择,并分析后果。这就是以往经验所必需的地方。工程问题通常属于这一类别,其中明确理解的问题需要复杂的技术解决方案。
在这个领域,指派合格的人事先进行一些设计,然后执行实施,这是完全合理的。当进行彻底的分析时,实施失败的风险很低。在这里,应用 DDD 模式进行战略和战术设计,以及实施,是有意义的,但你可能避免使用更高级的探索性技术,如 EventStorming。此外,如果问题被彻底理解,你可能花费在知识压缩上的时间会更少。
复杂是 Cynefin 的第三个复杂性领域。在这里,我们遇到了没有人做过的事情。即使是粗略的估计也是不可能的。预测我们对行动的反应是困难的或不可能的,我们只能在事后了解我们所产生的影响。这个领域行动的顺序是探索-感知-响应。这里没有正确答案,也没有可以依赖的实践。以往的经验也不会有帮助。这些都是未知未知,这也是所有创新发生的地方。在这里,我们找到了我们的核心领域,即概念,我们将在本书的后面部分讨论。
复杂领域的行动方案由实验和原型驱动。在事先创建一个大的设计几乎没有什么意义,因为我们不知道它将如何运作,以及世界将如何对我们所做的事情做出反应。在这里的工作需要以小迭代的方式进行,并持续进行密集的反馈。
在这个背景下,适合快速响应变化的精益建模和实施技术是完美的。特别是,使用 EventStorming 进行建模和使用事件溯源进行实施在复杂领域非常得心应手。彻底的战略设计是必要的,但在进行 spikes 和原型时,可以安全地忽略 DDD 的一些战术模式,以节省时间。然而,再次强调,事件溯源可能是你的最佳朋友。EventStorming 和事件溯源将在本书的后面部分进行描述。
第四个领域是混沌。这里烈火燃烧,地球旋转得比应有的速度还要快。没有人愿意在这里。这里合适的行动是行动-感知-反应,因为这里没有时间进行峰值操作。由于在这个阶段没有时间或预算进行任何设计,所以这里可能不是应用领域驱动设计(DDD)的最佳地点。
混乱是第五个也是最后一个领域,正位于中间。之所以如此,是因为在这个阶段,不清楚哪个复杂性情境适用于这种情况。摆脱混乱的唯一方法就是尝试将问题分解成更小的部分,然后可以将这些部分分类到那四个复杂性情境中,并相应地处理它们。
这只是对复杂性分类的简要概述。其中还有很多内容,我希望你的好奇心能够驱使你去看看关于这个主题的例子、视频和文章。这正是我引入这个话题的原因,所以请随时停止阅读,进一步探索复杂性这个主题。对于这本书来说,最重要的成果是领域驱动设计(DDD)几乎可以在任何地方应用,但在明显和混乱的领域中几乎毫无用处。在复杂系统中使用事件风暴法(EventStorming)作为设计技术对于复杂和复杂领域都是有用的,同时结合事件溯源(event-sourcing),这对于复杂领域最为合适。
决策和偏见
人类大脑每秒钟都在处理大量的信息。我们做很多事情都是自动化的,由本能和习惯驱动。我们的大部分日常惯例都是这样的。大脑活动的其他区域包括思考、学习和决策。这些行动执行得显著更慢,并且需要比自动操作更多的能量。
心理学中的双重过程理论表明,这些类型的脑活动确实是完全不同的,并且有两种不同的过程用于两种类型的思考。一种是隐含的、自动的、无意识的过程,另一种是显式的、有意识的过程。无意识的过程是在长时间内形成的,并且也很难改变,因为改变这样的过程需要培养新的习惯,而这并不是一件容易的事情。然而,有意识的过程可以通过逻辑推理和教育来改变。
这些过程或系统在一个大脑中愉快地共存,但在它们运作的方式上却相当不同。Keith Stanovich 和 Richard West 提出了隐含系统或系统 1和显式系统或系统 2(Individual difference in reasoning: implications for the rationality debate? Behavioral and Brain Sciences 2000)。Daniel Kahneman 在他获奖的书籍《思考,快与慢》(Thinking Fast and Slow,纽约:Farrar, Straus and Giroux,2011)中,为每个系统分配了几个属性:

系统 1 和系统 2
这一切与领域驱动设计(DDD)有什么关系呢?嗯,这里的重点更多的是关于我们如何做决定。科学研究表明,所有人类都有偏见,无论以何种方式。作为开发者,我们有自己的方式来解决技术问题,当然,当业务挑战我们编写软件解决问题的方法时,我们随时准备应战。另一方面,我们的客户也有自己的偏见,他们可能在没有我们的软件的情况下就已经赚钱了,或者他们可能有一些其他系统,这是二十年前由古老的 Cobol 程序员创建的,并且某种方式上它还能工作,所以他们只是想要一个现代的,甚至基于云的相同版本。我试图在这里说明的是,我们应该努力减轻我们的偏见,更加开放地倾听他人的意见,同时避免陷入自己偏见的陷阱。谷歌人力资源团队创建“工作中的无意识偏见”研讨会并非没有原因,这个研讨会旨在帮助他们的同事意识到自己的偏见,并展示一些处理它们的方法。***
Cynefin 复杂性模型要求我们至少将我们在问题空间(有时在解决方案空间)中处理到的复杂性进行分类。但为了分配正确的类别,我们需要做出很多决定,而在这里我们经常让系统 1 做出反应,基于我们的偏见和过去的经验做出假设,而不是让系统 2 开始推理和思考。当然,我们每个人都熟悉一个同事在你甚至还没描述完问题之前就大喊“是的,那很简单!”的情况。我们也经常看到人们组织无休止的会议和电话会议来讨论我们认为是一目了然的决策。***
认知偏差在这里起着至关重要的作用。一些偏见可以深刻地影响决策,这无疑是系统 1 在说话。以下是一些可能影响你对系统设计思考的偏见和启发式方法:***
-
选择支持偏差:如果你已经做出了选择,即使这个选择可能已经被证明存在重大缺陷,你也会对这个选择持积极态度。通常,这种情况发生在我们提出了第一个模型并试图不惜一切代价坚持它的时候,即使很明显这个模型不是最优的并且需要改变。此外,当你选择使用某种技术,如数据库或框架时,也可以观察到这种偏差。
-
确认偏差:与上一个类似,确认偏差会让你只听到支持你选择或立场的论点,而忽略那些与你选择支持的论点相矛盾的论点,尽管这些论点可能表明你的观点是错误的。***
-
跟风效应:当房间里的大多数人同意某事时,这“某事”开始对之前不同意的大多数人来说更有意义。在不调动系统 2 的情况下,多数人的观点在没有任何客观理由的情况下获得了更多的认可。记住,多数人决定的不一定是最佳选择!
-
过度自信:人们往往过于乐观地看待自己的能力。这种偏见可能导致他们承担更大的风险,做出没有客观依据但完全基于他们个人意见的错误决策。最明显的例子是估算过程。人们不可避免地低估而不是高估他们将花费在问题上的时间和精力。
-
可得性启发式:我们拥有的信息并不总是关于特定问题的所有信息。人们倾向于仅基于手头的信息做出决策,甚至不尝试获取更多细节。这通常会导致领域问题的过度简化和对基本复杂性的低估。这种启发式方法在我们在做技术决策时也可能欺骗我们,当我们选择总是“有效”的东西而不分析操作需求时,这些需求可能远远高于我们的技术能够处理的范围。
了解我们的决策过程是如何工作的,其重要性难以过高估计。本节中引用的书籍包含关于人类行为和可能对我们认知能力产生负面影响的不同因素的大量信息。我们需要记住,为了做出不基于情绪和偏见的更好决策,我们需要开启系统 2。
知识
许多初级开发者倾向于认为软件开发只是打代码,当他们打字更有经验、了解更多的 IDE 快捷键、对框架和库了如指掌时,他们将成为忍者开发者,能够在几天内写出类似 Instagram 的东西。
好吧,现实情况截然不同。事实上,在获得一些经验,并在故意花费数月甚至数年的时间在死亡行军中向不可能的截止日期和不切实际的目标迈进之后,人们通常会放慢脚步。他们开始理解,在收到规格说明后立即编写代码可能不是最好的主意。如果你已经阅读了所有前面的部分,这个原因可能已经很明显了。迷恋解决方案而不是理解问题,忽视基本复杂性,以及迎合偏见——所有这些因素在我们开发软件时都会影响我们。一旦我们获得更多经验,并从自己的错误以及最好是从他人的错误中学习,我们就会意识到,编写有用、有价值的软件的最关键部分是对问题空间的了解,这是我们正在为构建解决方案而了解的。
领域知识
在构建软件系统时,并非所有知识都具有同等的价值。在金融领域了解编写 Java 代码可能在你开始创建房地产管理的 iOS 应用时并不非常有用。当然,像清洁代码、DRY 等原则无论使用什么编程语言都是有帮助的。但一个领域的知识可能与另一个领域所需的完全不同。
正是在这里,我们遇到了领域知识的概念。领域知识是你将要使用软件操作的领域中的知识。如果你正在构建一个交易系统,你的领域就是金融交易,你需要获得一些关于交易的知识,以便理解你的用户在谈论什么,以及他们想要什么。
所有这些都归结于进入问题空间。如果你连问题空间的术语都无法至少理解,那么与未来的用户交流将会变得非常困难(如果不是不可能的话)。如果你缺乏领域知识,对你来说唯一的信息来源就是规范。当你至少拥有一些领域知识时,与用户的对话就会变得更加富有成效,因为你能够理解他们在谈论什么。这种结果之一就是建立客户和开发者之间的信任。这种信任很难被高估。一个值得信赖的人会获得更多的洞察力,而且错误也会更容易被原谅。通过用领域语言与领域专家(你的用户和客户)交流,你也会获得信誉,他们会把你和你的同事看作更胜任的人。
获得领域知识并不是一项容易的任务。人们在他们各自的领域里专研多年,成为他们领域的专家,并以这种方式谋生。软件开发人员和业务分析师做的是其他事情,而他们需要获得领域知识时,那个特定的领域可能知之甚少或完全未知。
获得领域知识的艺术在于有效的协作。领域专家是终极真理的来源(至少,我们希望这样对待他们)。然而,他们可能并不是。一些组织拥有碎片化的知识;一些可能只是错误的。在这样的环境中进行知识压缩甚至更困难,但可能有一些信息碎片正等待着在某个低级职员的书桌上被发现,而你的任务就是去发现它们。
这里的普遍建议是,与领域内部的不同人交谈,从整个组织的管理层到相邻的领域。获得领域知识有几种方法,以下是一些:
-
对话是最流行的方法,形式化为会议。然而,如果没有明显的成果,对话往往会变得一团糟。尽管如此,其中仍有一些价值,但你需要仔细倾听并问很多问题来获取有价值的信息。
-
观察是一种非常强大的技术。软件开发人员需要克服他们的内向,离开象牙塔,去到交易大厅、仓库、酒店,或者任何商业运行的地方,然后与人交谈,看看他们是如何工作的。杰夫·帕顿在 2017 年 DDD 交流会上提供了许多很好的例子(
skillsmatter.com/skillscasts/10127-empathy-driven-design)。 -
领域叙事,由汉堡大学的海因茨·霍弗和他的同事提出的一种技术(
domainstorytelling.org/),提倡使用图符、箭头和一点文字,以及按顺序编号行动,来描述领域内的不同交互。这种技术易于使用,通常在人们开始使用它来传递知识之前,不需要向他们解释太多。 -
EventStorming 是由阿尔贝托·布兰多利尼发明的。他在自己的书《介绍 EventStorming》(2017 年,Leanpub)中解释了这种方法,我们将在本书后面开始分析我们的示例领域时进一步详细介绍。EventStorming 使用便签和纸卷以直接的方式模拟各种活动。研讨会参与者将过去的事实(事件)写在便签上,并将它们贴在墙上,试图制作一个时间线。它允许发现活动、工作流程、业务流程等。通常,它还会揭示模糊性、假设、隐含术语、混淆,有时甚至冲突和愤怒。简而言之——构成领域知识的一切。
避免无知
回到 2000 年,菲利普·阿姆斯特朗发表了一篇名为《五级无知》(Communications of the ACM, 第 43 卷第 10 期,2000 年 10 月)的文章,副标题为《将软件开发视为知识获取和无知减少》。这条信息与上一节中阿尔贝托的引言非常相关,尽管它可能没有那么吸引人,但绝对不逊色。文章认为,增加领域知识和减少无知是创造能够带来价值的软件的两个关键因素。
文章专注于无知,并确定了五个层次:
-
零无知水平,作者称之为“无无知”,是最低的。在这个水平上,你没有无知,因为你拥有大部分知识,知道该做什么以及如何去做。
-
第一级是“缺乏知识”。这是当你不知道某事,但意识到并接受这个事实的时候。你想要获取更多的知识,减少你的无知到零水平,因此你有获取知识的渠道。
-
第二级也称为缺乏意识,是你不知道你不知道的情况。最常见的情况是你得到一份描述解决方案的规范,但没有指定这个解决方案试图解决哪个问题。当人们假装拥有他们并不具备的能力,同时对此一无所知时,这一级也可以观察到。这样的人可能既缺乏业务知识,也缺乏技术知识。在无知到这种程度时,会做出很多错误的决定。
-
第三级是缺乏流程。在这一级,你甚至不知道如何发现你的缺乏意识。字面上,你没有方法来确定你不知道你不知道,这听起来像是《盗梦空间》,但那正是它的本质。在这一级上做任何事情都很困难,因为显然没有方法可以接触到最终用户,甚至询问你是否理解他们的问题,以便降至第二级。本质上,缺乏流程几乎不可能确定你试图解决的问题是否存在。在这种情况下,构建一个系统可能是唯一的选择,因为这是获得任何反馈的唯一方式。
-
第四级也是最后一级的无知是元无知。这是当你不知道有五个无知级别的时候。
正如你所见,无知是知识的对立面。减少无知唯一的方法是增加理解。高水平的无知,无论是意识到的还是无意识的,都会导致知识缺乏和对问题的误解,因此增加了构建错误解决方案的机会:

无知在最初阶段是最高的。
DDD 之父埃里克·埃文斯将前期设计描述为锁定我们的无知。前期设计的问题在于我们在项目开始时进行它,那时我们知识最少,无知最多。在项目开始时,几乎没有任何东西可以依据来做出关于软件设计和架构的大部分重要决策,这已经成为了一种规范。这种做法显然不是最优的。
在文章《介绍刻意发现》(dannorth.net/2010/08/30/introducing-deliberate-discovery/)中,丹·诺斯建议,当我们开始任何项目时,我们应该意识到我们至少处于第二级无知的位置。特别是,以下三个风险需要考虑:
-
在项目过程中会发生一些不可预测的坏事。
-
不可预测性,这些事物事先是未知的。
-
不良,这些事物将对项目产生负面影响。
为了减轻这些风险,Dan 建议使用引入有意识的发现,即从一开始就寻求知识。由于并非所有知识都同等重要,我们需要尝试识别那些无知造成最大障碍的敏感区域。通过提高这些领域的知识水平,我们能够实现进步。同时,我们还需要关注新的麻烦区域,并解决它们;这个过程是持续和迭代的。
摘要
在本章中,我们简要介绍了问题和解决方案空间的概念、需求规范、复杂性、知识和无知。虽然最初,这些主题似乎与软件开发没有直接关系,但它们对我们如何以及交付什么有着重要影响。
不要陷入这样的陷阱,认为您只需编写代码就能向客户交付有价值的解决方案,而且您可以通过每秒输入更多字符和编写更干净的代码来更快、更好地交付。客户不关心您的代码或您打字的速度。他们只关心您的软件以他们以前从未做过的方式解决他们的问题。正如 Gojko Adžić在他关于影响映射的小书中所写(*《影响映射:软件产品和项目的重大影响》,2012,由 Provoking Thoughts 出版),您不能仅仅这样制定用户故事:
-
作为某人
-
要做某事
-
我需要使用一些功能
您的用户,某人,可能已经在执行某些功能的情况下进行某些操作,即使没有您的软件:使用笔和纸,使用 Excel,或者使用您竞争对手的系统。您需要确保的是,您要有所区别,有所影响。您的系统将使人们工作更快,更有效率,使他们能够节省金钱,甚至完全自动化这项工作,从而不必进行这项工作。
要构建这样的软件,您必须了解您用户的问题。您需要压缩领域知识,降低无知水平,准确分类问题的复杂性,并试图在实现目标的过程中避免认知偏差。这是领域驱动设计(DDD)的一个基本部分,尽管并非所有这些主题都在埃里克·埃文斯的原始著作《领域驱动设计:软件核心的复杂性处理》中有所涉及,尽管在 DDD 社区中被称为蓝皮书。
在下一章中,我们将深入探讨语言的重要性,并发现通用语言的定义。
进一步阅读
这里有一份您可以参考的信息列表:
-
*《决策者的框架》,Snowden D J,Boone M E. (2007),哈佛商业评论 2007 年 11 月号
-
《思考,快与慢》(第一版),Kahneman, Daniel (2011),纽约:Farrar, Straus, and Giroux
-
《影响映射:软件产品和项目的重大影响》,Adžić, G. (2012),启发思考。
第二章:语言与语境
在上一章中,我们简要讨论了语言的重要性。在软件行业中,我们形成了这样的天真观念:只有编程语言才是重要的。这就是为什么我们经常说一些完全不知所云的话,而来自其他部门的同事或我们的客户很难理解我们试图表达的意思。这个问题是相互的,因为许多业务部门都发展了自己的行话,其他人可能不完全理解。
在本章中,我们将深入探讨语言的重要性,并分析几个原型和代码的示例。我们还将探讨语言在特定环境中的概念,并介绍领域驱动设计(DDD)中最关键的部分之一:通用语言。
在本章中,我们将深入探讨以下主题:
-
通用语言
-
为什么语言很重要
-
将隐含的变为显性的
-
上下文中的语言
-
表达行为
通用语言
埃里克·埃文斯的网站(他是原始 DDD 书籍的作者)位于domainlanguage.com。DDD 的基本概念,如通用语言和边界上下文,都是基于语言的理念。对于那些没有多年软件开发经验的人来说,这听起来可能有些奇怪,因为对于经验不足的开发者来说,唯一重要的语言是编程语言。我们通常通过学习一些概念并将它们应用于使用一种编程语言的实践中来学习编程。我们认为我们可以将人类语言翻译成编程语言,这是我们工作的本质。确实,这里有一些真理。然而,这远非开发者日常工作的本质部分。
只有当两个人能够使用同一种语言进行交流时,他们才能相互理解。这不一定需要是口头语言;它也可能是手语或音乐语言。但双方交流者必须共享对这种通用语言的相同理解。否则,就会出现问题。他们不仅需要说同一种语言,而且这种语言必须处于一个特定的语境中。有一本名为《美国和英国英语:被共同语言分隔?》的书,作者是保罗·贝克,书中描述了同一语言在经过足够长时间的海洋分隔后变得多么不同。
在本节中,我们将更深入地探讨语言的重要性以及这些概念在成功软件项目中至关重要的语境。
领域语言
几乎每个行业都发展了一种只有该行业的人才能完全理解的语言。其中一些词汇已经传播到世界各地,比如汽车行业,它用术语如变速箱、点火、内燃机和甚至车身修理店丰富了我们的语言。最后一个术语在领域之外看时是模糊的。但一旦指定了领域,它就变得清晰了,我们不是指美容产品精品店,也不是指软件外包公司,而是一个在事故后对车身进行修理的地方。
当然,汽车行业在这个意义上并不是独一无二的。其他行业也发展了自己的术语,并且它们的语言可能对外行人来说更为陌生和晦涩。
这样的行业例子之一当然是软件行业。当两个程序员讨论一些相对复杂的系统的实现细节时,周围的非程序员对这次对话的理解很少,通常都会感到无聊。缺乏理解总是导致缺乏兴趣:

程序员眼中的世界(基于 Manu Cornet 的原始作品)
在某种程度上,软件行业是独特的,因为它倾向于为其他行业中的各种商业问题提供服务。今天几乎每件事都需要或希望有一定程度的自动化,这意味着软件。这也意味着来自商业领域的人会找到他们的开发者或外部软件公司,并试图用他们的语言表达他们的问题。当这种语言没有被正确理解时,问题就会出现。
由既不是商业人士也不是软件开发者专门撰写的功能需求,曾经被视为成功软件的圣杯。每次软件交付给不满意的客户后,我们都会归咎于需求。我们说——下次,我们会写出更好的需求,更详细的规范,并解释开发者需要做到的每一个细节。当每个人都互相指责,没有人愿意承担责任时,这很快就会变成一场指责游戏。
除了我们在第一章中讨论的点,为什么是领域驱动设计?(在需求出了什么问题?部分),还有一个额外的方面值得在此提及。那就是语言。需求不仅关注解决方案并隐藏问题,而且需求还倾向于将商业语言翻译成更技术性的语言,这被视为开发者友好。实际上,这更像是一个断线电话。在传输线上添加的翻译层级越多,到达接收者的相关信息就越少,而且信息在传递过程中被严重干扰,以至于无法辨认。
这种“翻译”的另一个方面是它减缓了沟通。如果开发者需要从业务中获得更多信息,但无法理解业务人士说话时的含义,那么翻译者的介入变得不可避免。通常,这些人就是编写需求的人,但并不总是如此。我听到过足够多的例子,只有像企业架构师这样的人才被允许与客户交谈;然后,他们将他们的理解翻译成业务分析,然后把这些需求扔给可怜的开发者,而这些需求在翻译过程中实际上已经丢失了。
正因如此,对于致力于为真实商业问题创造良好解决方案的人来说,理解业务非常重要。能够理解业务并且无需翻译和翻译者进行沟通不仅缩短了沟通时间,而且极大地提高了沟通质量。
同时,我们都知道,作为我们创建的系统客户的商业人士,他们通常比我们希望的要少得多可用。你可能会只有几次与真正掌握你必须获取以使系统按预期工作的重要信息的会议。有时他们甚至不愿意与开发者进行讨论。这可能与某些个人问题、过去的负面经历或无意识地害怕在众多技术宅面前显得愚蠢有关。
在这种情况下,有一个受业务信任并且能说他们语言的人是非常有帮助的。我们的目标是要确保这个人也受到开发团队的信任。你可以称这个角色为业务分析师或产品所有者,这并不重要。我所知道在这个角色中做得最好的人能够用他们的语言与所有人交谈,就像那些在幕后与世界领导人一起工作的顶级翻译者一样,能够将一种语言翻译成另一种语言而不丢失事物的含义。同时,最好的方法仍然是完全避免这种翻译。
例如,伦敦市银行家因雇佣那些已经接触过银行业并且理想情况下在伦敦市工作过的发展者而闻名。他们重视时间,希望缩短沟通和讨论所花费的时间。因此,那些了解他们的语言并且对他们的业务和语言有相当理解的人比那些可能更优秀但需要在开始实际工作前接受培训和了解语言的人更受重视。
行业术语通常对外行人来说很难理解,因为所使用的词汇通常是日常用语,但它们有着完全不同的含义。以下是一些来自上述金融领域的例子:
-
看涨期权:简称看涨期权,这是对借出或未偿还资本支付的要求
-
安全:为了证明信用、财产权或与可交易衍生品相关的股票或债券的所有权,此证书被使用
-
掉期:在两个借款人之间,如果他们中的每一个都能获得所需的资金,或者固定利率被改为浮动利率,这被视为掉期
学习领域语言对于在领域专家和开发者之间建立有效的沟通至关重要。
样例应用领域
在整本书中,我们将开发一个样例应用来练习我们获得的知识和技能。在本节中,你将了解业务领域,更多细节将在本书的后续部分添加。
我们将要工作的领域是为个人在线销售物品。我们将构建一个发布分类广告的应用程序以及可能支持此类活动所需的一些东西。
如果你对术语不熟悉,想想你储藏室或地下室里的一堆东西,你可能会很高兴把它们清理掉。你可以在网上发布一个小广告,其他人可能会购买你不再需要的东西。你也可以免费赠送物品。这类服务的例子包括 eBay、Craigslist、Gumtree、Marktplaats(荷兰)和 FINN.no(挪威)等网站。
将隐含的显式化
当我们开始开发一个新系统时,我们需要学习很多东西。我们在第一章,“为什么是领域驱动设计?”中讨论了无知悖论,你可能还记得,无知程度最高、知识水平最低的时候是我们对未来的系统做出很多决策的时候。
我们不仅缺乏对我们试图解决问题的业务领域的知识,我们还被迫在一个高度不确定的环境中工作。在我们学习领域语言之前,我们使用我们的理解,这通常基于假设。
想象一下,在项目开始时,你与领域专家开会的情况。他们试图解释他们的问题,而你开始慢慢地学习他们的语言,在某个时刻,你认为你理解了这个想法,大致知道该怎么做。在这里,重要的是要记住我们在上一章讨论认知偏差及其对决策影响时所经历的情况。第一个也是最明显的风险是你所看到的即是全部(WYSIATI),或者说是可用性启发式。你将你有限的知识应用于过去的经验,然后产生理解的感觉。在这个时候,我们通常会被要求做出估计,从逻辑上讲,我们会失败,因为偏见在我们的脑海中玩弄了我们,并给了我们理解的错觉。
在这样的会议上,我们经常达成一致。然后,每个人都离开房间再次开会,可能几周后讨论一些规范或甚至原型。时间过去了,我们还在同一个房间里,没有人满意,因为我们共同的不满,我们发现我们达成了一致,但完全不同的事情。每个人都有一幅图,而这些图都是不同的。

如果我们不可视化,我们就会就不同的事情达成一致
人们花费数小时争论他们认为不同但实际上相同的事情。人们也会就他们没有共同理解的事情达成一致,这从来都不会顺利。
为了解决这个问题,我们需要去除假设。我们需要将隐含的东西明确出来。
看一下这个来自现实生活中的 HR 管理系统的样本表格。在这里,员工可以申请病假:

病假登记表
在这里,我们可以看到由程序员创建的典型结构。我们甚至可以想象一个 SQL 表,其中存储了输入到这个表格中的数据。它很可能有StartDate、EndDate和HalfDay列以及员工的id。注意,这里也有一个保存按钮,这在类似这样的表格中非常常见。
尽管这个表格看起来可能没问题,但让我们再稍微思考一下我们在这里看到的东西。在花了一些时间分析这个表格之后,我们可以看到以下问题:
-
开始日期是不明确的。它可能是登记病假的日子,也可能是员工因为生病而没有上班的那天。
-
结束日期甚至更加不明确,因为它可能代表病假的最后一天,也可能是员工回来上班的那天。
-
半天可能适用于这两个领域,但没有明确的说明它意味着什么。
-
最后,保存按钮没有给我们任何关于接下来会发生什么的线索。可能只是在表中记录了一条记录,我们需要告诉某人查看它,或者可能有一个自动启动的审批流程。填写完这个表格后,员工需要打电话或发送电子邮件给直线经理吗?
正如你所见,即使在这样一个只有两个字段、一个复选框和两个按钮的小表格中,也有很多隐含的东西。如果我们想象一下这个表格背后的代码,所有那些隐含和模糊的概念也都会在那里被发现。我已经提到了一个表格,它有代表表格中那些字段的列。在领域模型类、数据模型对象和其他代码工件中的所有属性都是同样隐含的。那里的一切都需要解释,比如 这个日期意味着员工回来上班的那一天,如果没有这样的解释,像报告这样的东西可能会完全错误。
将它与另一个例子进行比较,这个例子也来自现实生活中的 HR 管理系统,由一个竞争对手制作:

那个有意义的病假登记表
在这个表格中,对于不需要解决谜题或阅读帮助来理解这些字段应输入什么内容的普通人来说,字段的意义更加明确。在第一个示例中是隐含的,在这里则是显式的。从命名单个字段到行动呼吁,每一项都有更好的意义。我们也可以想象,在这个表格背后,我们可以找到如下代码:
SickLeaveApplication.Handle(new SendSickLeaveForApproval
{
EmployeeId = context.User.EmployeeId,
DateRegistered = request.DateRegistered,
FirstDayNotAtWork = request.FirstDayNotAtWork,
LeftDuringWorkday = request.LeftDuringWorkday,
CameBackToWork = request.CameBackToWork,
CameBackAfterLunch = request.CameBackAfterLunch
});
这段代码表达了与用户界面相同的含义和术语。因此,不仅最终用户填写这个表格会很容易,而且其他开发者也会很高兴阅读这段代码,因为意图表达得非常清楚,所有概念都是显式的。
将隐含的变为显性的另一个方面是创建在代码中可见的领域概念。在前面的代码中,SendSickLeaveForApproval命令在代码中展示了精确的领域概念。
分类广告的领域语言
我们的开发者在讨论发布分类广告的流程。他们经历了创建过程,并到达用户点击“发布”按钮的点。与领域专家一起,他们发现广告不能立即发布,因为广告可能包含恶意内容。他们决定添加一个审批流程,这个流程应该在用户点击“发布”并且广告在网站上可见之后进行。
开发者迅速决定为他们的领域类ClassifiedAd创建一个名为Status的属性。它应该是一个枚举类型,表示审查和发布过程的各个阶段。它也可以在以后用于尚未知的其他状态。由于他们希望在领域模型中拥有行为,所以他们向类中添加了UpdateStatus方法,其代码如下:
public class ClassifiedAd
{
private ClassifiedAdStatus _status;
public void UpdateStatus(ClassifiedAdStatus newStatus)
{
_status = newStatus;
DomainEvents.Publish(
new ClassifiedAdStatusUpdated(_id, newStatus));
}
}
现在这个方法也发布了一个领域事件,系统的其他部分可以订阅这个事件并执行一些其他重要的操作。
我们将在本书的后面部分花更多的时间讨论领域事件和命令。目前,示例代码使用领域事件在代码中紧密地模拟我们在事件风暴章节中使用的事件和命令。
因此,在用户点击“发布”后,以下情况会发生:
ad.UpdateStatus(ClassifiedAdStatus.Published);
审查完成后,广告将被激活,如下所示:
ad.UpdateStatus(ClassifiedAdStatus.Activated);
这可能看起来是可以接受的。我们的ClassifiedAd类是一个状态机,这个类的实例在广告的生命周期中从一个状态移动到另一个状态。然而,我们已经失去了意图。我们的语言变得奇怪——我们不是说要发布公告,而是更新状态。我们不是激活广告,而是再次更新状态!
即使在系统添加更多行为之后一切似乎都正常工作,这样的代码也将开始出现:
public void UpdateStatus(ClassifiedAdStatus newStatus)
{
if (newStatus == ClassifiedAdStatus.Published
&& (string.IsNullOrEmpty(_title)
|| _price == 0 || string.IsNullOrEmpty(_text))
throw new DomainException(
"Ad can't be activated because some mandatory fields are empty");
if (newStatus == ClassifiedAdStatus.Activated
&& _status == ClassifiedAdStatus.ViolationReported)
throw new DomainException("Reported ads can't be activated");
if (newStatus == ClassifiedAdStatus.Deactivated
&& _status != ClassifiedAdStatus.ViolationReported)
throw new DomainException("Only a reported ad can be deactivated");
_status = newStatus;
DomainEvents.Publish(new AdStatusUpdated(newStatus));
}
这并不是我们期望在这样一个简单方法中看到的代码。它承担了太多的责任,而这个方法中的逻辑块几乎相互之间没有关联。但当涉及到领域事件处理时,情况变得更糟:
public void Handle(ClassifiedAdStatusUpdated @event)
{
// controlling the ad visibility based on it's reported status
if (_status == ClassifiedAdStatus.ViolationReported
&& @event.Status == ClassifiedAdStatus.MaliciousContentDetected)
CommandDispatcher.Send(
new UpdateAdVisibility(@event.Id, false));
}
流程控制操作符的数量正在增加,现在大部分行为都是由状态更新驱动的,最初这被认为是对领域对象单个属性进行的小而简洁的操作。这个更新操作的意图已经消失,每次调用都需要仔细控制副作用。在添加新功能时,损害现有行为的风险现在是真实的。
与领域专家的讨论也失去了一些意义。不再是使用诸如“如果检测到恶意内容,我们隐藏广告并通知我们的审核小组”这样的短语,而是变成了“然后我们查询所有状态等于MaliciousContentDetected的广告,并使用通知服务向所有拥有审核权限的用户发送消息”。语言的含义在技术术语的混乱中丢失,混合了诸如状态和消息这样的通用词汇。
团队决定重构代码,并使用适当的领域语言。因此,这是他们提出的方案:
public class ClassifiedAd
{
private ClassifiedAdStatus _status;
public void Publish()
{
_status = ClassifiedAdStatus.Published;
DomainEvents.Publish(new ClassifiedAdPublished(_id));
}
}
现在,我们也可以将领域事件处理重构为如下所示:
public void Handle(ClassifiedAdPublished @event) =>
CommandDispatcher.Send(new ShowClassifiedAd(@event.Id));
然后,为了处理包含恶意内容的情况,我们可以编写新的事件处理器:
public void ReportViolation(User reportedBy, string reason)
{
_violationReports.Add(reportedBy, reason);
DomainEvents.Publish(new ViolationReported(reportedBy, reason));
}
public void Handle(ViolationReported @even) =>
CommandDispatcher.Send(new InformModerators(@event.Id, @event.Reason));
public void Handle(MalicionsAdDetected @event) =>
CommandDispatcher.Send(new InformModerators(@event.Id, @event.Reason));
我们的小例子也表明,领域语言不能仅仅通过制作一个名词列表的词汇表来构建。关于收集大量名词并称之为领域语言的误解确实存在。但这并不是一条幸福的路径,通常它会导致所谓的贫血模型,这被认为是一种反模式。贫血模型中的类只有属性,属性总是用名词命名。但每个领域的一个重要部分是行为。名词表达领域操作的内容,但动词描述正在执行的动作。没有动词,我们的领域在属性值改变时往往变成了一组没有特定原因的魔法动作。但我们的前述代码通过引入动词作为领域语言的一部分,清楚地表达了领域行为。这些动词是精确的,显示了意图,并描述了动作。它们既用于命令式风格的活动中,也用于描述我们发布领域事件时的历史,使用过去时。
在前面的例子中,我们不仅改进了代码,并更好地理解了它的功能和其中存在哪些概念,而且还发现了我们的领域模型将从中受益的一些新术语和概念。我们可以开始在与领域专家交谈时使用这些术语,看看他们是否理解。有时,他们可能会对开发者投来奇怪的目光,试图理解他们的兴奋,因为他们已经知道这个新概念了——它是他们语言的一部分,只是从未在业务和开发人员之间的对话中表达过。这样的突破不仅使代码更好,更接近实际的商业模型,而且也改善了开发者和领域专家之间的沟通。
通过将隐含的事物明确化,我们不仅发现了代码中缺失的概念,而且还将它们纳入我们的领域模型中。这部分是至关重要的,因为这种语言被用于整个模型范围——商业和心智模型、概念和视觉模型,以及图表和代码中的领域模型。在系统的多个模型级别上使用相同的概念和,一般来说,相同的语言的模式被称为通用语言。
语言与语境
在本章的引言中,我们已经提到了同一语言中的语言差异问题。如果一种语言被某种边界所分割,无论是地理的、国家的还是专业的,它就开始分裂。我们之前以英国英语和美国英语为例,但当然还有更多类似的例子。在比利时使用的荷兰语甚至经常被称作一种单独的语言,弗拉芒语,因为它听起来不同,但主要是因为在日常物体和动作中,使用了不同的词汇。同样的情况也适用于在专业群体中发展的语言,其中人们形成了行话,我们也已经看到了一些这样的例子。
这些例子被提出是为了说明定义词语的精确含义是多么重要。避免混淆确实是找到和识别通用语言的目标之一。
重要的是要认识到通用语言仅在特定语境内有效。不同的语境由不同的语言定义。有一种误解认为通用语言被称为通用是因为它是整个商业、组织或领域的单一语言。事实并非如此。它不是在水平方向上通用,而是在垂直方向上通用。每个语境可能都有自己的语言,但在这个语境中的所有层级都共享一种通用语言——与业务、模型、代码、测试、UI 概念、数据结构等的会议。
让我们来看一个经典的例子,即术语产品在电子商务领域的不同语境中的应用:

不同语境中的产品
尽管我们在同一领域运营,但很明显,产品这个术语在每个已识别的上下文中的含义都有所不同:
-
销售:对于销售人员来说,产品意味着销售价格和可能的利润。这是公司赚钱的地方,产品的其他属性并不那么重要。
-
采购:如果我们购买产品进行转售,我们主要关注的是购买价格,供应商有多少库存的特定产品,以及他们能多快交付。
-
库存:我们主要关注的是我们有多少库存商品。如果某个特定商品缺货,这个上下文可以保持估计的补货日期。在这里,我们可能还会定义一些产品的内部属性,例如商品编号。
-
仓库:它需要管理存储产品所需的空间,因此在这个上下文中的人需要知道产品批次何时到达,客户发货何时进行,产品如何包装,以及它们存储在哪里。
如您所见,尽管我们有一个流行的术语,比如产品,但同一领域或组织中的不同部门实际上很少有共同兴趣,并且对某物(否则可能被视为同一对象)的属性子集有更深入的了解。
另一个很好的例子是保险领域的术语政策。对于不在保险行业工作的人来说,它可能不太为人所知,但一般来说,我们理解卖给我们保险的人主要对金钱和获取新客户感兴趣。因此,对他们来说,政策意味着新的销售和金钱。当涉及到审批时,如果我们以车辆保险为例,政策意味着风险。即使政策已经售出,风险评估部门仍有可能要求额外的文件,并在一些内部检查后拒绝该政策。最后,当涉及到处理现有政策的索赔时,对于索赔处理部门来说,它是一个成本,因为保险公司会损失金钱。
这两个例子都表明,即使在同一领域,语言也会在不同的上下文中发生变化,有时变化还相当显著。如果我们继续在上下文中使用相同的词义,会发生什么?嗯,事情会变得不那么明确。随着我们未能识别和区分的新上下文的增加,歧义程度也在增加。这导致模型不明确,结果就是代码晦涩难懂,我们需要在用这个词或那个词时明确我们到底想表达什么。
在一个工作环境中混合不同的上下文也会导致所谓的上下文切换。在《质量软件管理:系统思维》一书中,Gerald Weinberg 指出,一个人负责的项目数量越来越多会导致由于上下文切换而造成的显著生产力损失:

由于上下文切换造成的生产力损失
在当前任务中添加一个新项目意味着 20%的生产力损失。因此,当上下文数量达到五个时,实际工作花费的时间变得极其低。大多数时间都花在试图弄清楚当前任务属于哪个上下文中。
这不仅适用于项目。你可能从经验中知道,对于泛化胜过精确性和明确性的大型项目,上下文切换现象对性能的影响同样巨大。在我们的产品示例中,如果我们把产品不同逻辑视图的所有属性都放在一个地方,那么处理这样一个对象将涉及一些额外的努力,试图理解我们目前正在处理产品的哪个部分。因此,尽管这仍然是一件事,但隐藏的上下文切换和生产率会受到影响。
假设集中化和泛化是好事,许多软件系统创建了所谓的神级类,例如Customer或Product,这些类包含了所有可能的属性,以适应物理对象的所有可能视图。除了上下文切换之外,这种方法的缺点还有很多。
其中一个明显的问题是,在系统中的这样一个对象的生命周期中,并非所有属性都需要有值。例如,已经淘汰的产品没有任何与销售相关的特性。但由于我们有一个包含所有内容的类,我们必须为所有这些属性分配空值。这种做法导致高度混乱,因为我们几乎不明白为什么这些属性是空的——要么是系统中的错误,要么这只是由于对象状态而出现的典型情况。
另一个问题是不容忽视的,这样的类会吸引大量的依赖。你可能见过数据模型,有时它们模仿领域模型,整个复杂系统有一个大型的 SQL 数据库,其中表有很多交叉引用。我们可以想象,像Product这样的东西可以被Order、ShoppingCart、Catalogue、Invoice、PurchaseInvoice、Return、CreditNote等引用。模型变得错综复杂,难以维护。有时,情况变得更糟,因为引用依赖有时是明显错误的。例如,在某个过去的订单上显示更新的产品描述是不正确的。订单应该包含购买产品时的快照。
我们已经找到了足够的原因,让我们在寻求语言时对忘记上下文保持谨慎。通用语言总是明确、具体和上下文相关的。一旦你感觉到或观察到单词在不同系统部分之间的含义开始变化,这应该在你脑海中触发一个警报,表明你可能正在跨越上下文边界。
在讨论用户时,上下文出现。开发者喜欢将人们视为用户。这个术语如此含糊,几乎可以保证我们在谈论用户时会切换到不同的上下文。
回到我们的示例领域,团队成员讨论了他们的用户会如何评价他们的交易。他们认为,如果人们可以互相给出评价,这将有助于建立社区信任。在对话中,他们中的一些人注意到他们交替使用 用户、卖家、销售者、买家 和 购买者 这些词。当使用通用术语 用户 时,几乎总是需要澄清:这个用户在那个特定时刻扮演什么角色。同时,当他们将用户命名为 买家 和 卖家 时,没有模糊性,也不需要进一步的澄清。
注意到这一点后,该小组决定他们发现了普遍语言的新元素,并开始使用这些术语。这是一个很好的洞察力,在讨论模型和消除代码中的模糊性时,为他们节省了大量时间。
同时,在系统的授权部分将人们分为 卖家 和 买家 完全没有意义。这些人只是 用户,他们可以登录系统并执行一些操作,例如更新他们的个人资料,而无需明确区分他们是否会在网站上买卖。这是另一个当 用户 这个词没有歧义且明确的情况下。
后来,当他们为后台系统建模时,发现了用户之间的另一个区别。在那里,用户开始承担角色,再次出现了模糊性,直到他们确定了这些角色并开始使用诸如 管理员、支持助理 和 审阅者 等术语。发现了一个新的上下文,并为该上下文出现了一个新的模型,这个模型通过词语的意义与其他上下文区分开来。
摘要
在本章中,我们讨论了语言在系统设计中的重要性以及精确和无歧义术语如何带来清晰性。我们还就业务人员和开发人员对领域有共同的理解进行了探讨。我们还研究了不同行业如何使用相同的词语来表达不同的概念,以及这对领域建模有什么影响。
本章中的代码示例展示了不清晰的语言如何使实现变得更加复杂和难以理解。通过将更好的语言引入代码,我们使其更加清晰、简短和简洁。我们将一些隐含的概念变得更加明确,这有助于更好地理解业务并提高代码质量。我们还发现了许多成为语言一部分的动词,这对于名词的词汇表是一个重要的补充,而名词通常被视为领域模型唯一重要的部分。
本章介绍了由埃里克·埃文斯提出的术语通用语言。我们强调,语言的通用性不在于其广泛性,而在于其精确性,以及它被用于软件开发过程的全部工件——从最初的讨论,经过建模和设计,到代码和测试。
我们探讨了词语在不同语境中如何改变其含义,以及语境切换如何对生产力产生负面影响。使用我们的样本领域,我们回顾了在建模过程中以及开发者和领域专家之间的对话中,如何发现语境的几个例子。
在下一章中,我们将探讨领域建模中最受欢迎的技术之一,并介绍一些在领域专家和开发者之间组织有用研讨会时的实用技巧。
第三章:EventStorming
发现领域术语是至关重要的,这些术语成为通用语言的一部分。然而,发现过程可能相当漫长,并不总是成功的。当我们讨论业务运作方式和我们将通过编写软件解决的问题时,谈话往往归结为讨论业务热衷于实施的功能。当然,一组功能可以称为软件,但它并不一定构成一个系统。此外,为了构建针对特定问题的综合解决方案,还需要更多的系统级思考。
在这本书中,系统思维只被简要地涉及。为了更好地了解这个主题,请参考这个主题的经典书籍,如 Gerald Weinberg 的《通用系统思维导论》和 Donella H. Meadows 等人合著的《系统思维》。
但谁会告诉我们业务作为一个系统是如何运作的?我们应该和谁交谈,这种对话应该采取什么形式?我们将揭示这些问题,并在本章中尝试找到一些答案。
在本章中,我们将涵盖以下主题:
-
什么是 EventStorming?
-
EventStorming 的实践方面
-
如何自己主持工作坊
-
工作坊结束后决定做什么
EventStorming
在前面的章节中,我们学习了理解实际问题的有多么重要。我们还深入探讨了通用语言的概念,并解释了它不仅是一个术语表,而且是用语言描述的系统行为。
如何开始知识压缩以及如何加强与领域专家的沟通以更好地理解问题空间并正确概述我们将要构建的内容,这一点仍然不清楚。
非常常见的是,开发者以需求的形式了解领域。我们已经讨论过这个话题,到现在你应该意识到需求有其缺陷。因此,你希望通过直接与领域专家交谈以及与他们组织工作坊或会议来提高你的知识。有些人来了,你们进行两三个小时的对话;讨论了很多事情,涌现出很多新的见解,但最终形成的任何建模成果却很少。当然,你可以开始绘制 UML 图,但哪个业务人员能理解它们?你可以做笔记,发现你需要进行一到两轮澄清工作坊,因为存在太多模糊和隐含的概念,这些概念构成了你未来系统的基石,这使得理解变得非常困难。
在这里,我们需要解决几个基本问题:
-
在讨论中提供可见性。这应该消除当许多人用不同的术语讨论同一件事时的假设。它还消除了一些模糊性,并将其带到表面以供进一步探索。
-
拥有一个人们能够理解的模式语言。UML 不是选择,而常见的方框和箭头并没有真正的符号,因此人们可能会感到困惑,开始花费时间试图澄清事物的含义。
-
同时涉及许多人。在传统的会议中,只有一个人可以有效地传达信息,而其他人则需要闭嘴并倾听。一旦许多人同时开始说话,就不再有对话了。但是,假设不同兴趣和背景的人在同一会议中,他们可能会表现出缺乏兴趣并感到无聊。
-
找到一种表达术语、行为、模型过程和决策的方法,而不是特性和数据。
回到 2013 年,Alberto Brandolini 提出了一种他称之为事件风暴法的方法,试图解决这些问题。我们将在本章中了解这种方法。
模式语言
事件风暴法背后的基本思想是它提供了一个直接的建模符号,用于以每个人都能理解的方式可视化系统的行为。这种方法创造了可见性,增加了参与度,并涉及那些在其他情况下可能会对参与建模会议或是在白板上写下任何内容感到焦虑的人。
将行为视为领域知识的核心方面,整个事件风暴法练习就是找出业务是如何运作的。一般来说,我们可以假设在任何给定时刻,每个系统都处于特定的状态。当与系统交互的演员采取行动时,这种状态可以改变。这些演员的行为导致系统状态改变,因此我们可以看到发生了某些事情,现在我们需要处理一个新的情况。
让我们看看一个人使用网上银行支付账单的简单例子:

付款处理的事件序列(简化版)
正如你所看到的,从个人的角度来看,他们的账户中的金额减少了,付款已完成,账单被认为是已支付,可以扔掉了。然而,从收款人的角度来看,账单在他们收到钱时被认为是已支付,并且可以通过使用发票号码或账单上提到的某些魔法付款参考来将这笔付款与一张未支付的账单相匹配。
这些系统中演员所执行的动作导致了一些状态转换。付款订单被创建并签署。金额从付款人的账户中扣除。然后金额被添加到收款人的账户中。账单被标记为已支付。所有这些操作都变成了生活的现实,除非我们有时光机,否则我们无法逆转它们。如果收款人发现账单已经被支付,他们不能只是简单地逆转一切。他们需要通过启动新的付款来退还这笔钱。
这些事实被称为领域事件。这是 EventStorming 处理的最基本也是最核心的概念。这也是为什么它被称为 EventStorming。领域事件的观念对任何人来说都不陌生。生活中的事实是人们可以迅速理解的东西。它们是已经发生的事情;不是某人想要做的事情;不是功能;不是表单或按钮。每个领域事件代表一个事实,是我们试图模拟的系统中的变化。
因此,我们建模语言的第一部分是创建领域事件的概念。EventStorming 中的每个概念都表示为特定颜色的便利贴。颜色是至关重要的,因为随着我们继续进行并带来更多的想法到模型中,我们需要颜色来一致地表示模型中的相同想法,以避免混淆。
阿尔贝托最初的建议是使用橙色便利贴来表示领域事件。最简单的模型可能看起来像这样:

从小做起,逐步推进
这些是按顺序发生的两个领域事件——首先,客户使用信用卡或借记卡付款;然后他们的订单被确认。我们可以将其识别为电子商务领域。写在便利贴上的句子没有什么特别之处,除了一个关键规则——事件必须有一个主语(名词)和一个谓语(动词)。动词必须是过去时态,表示已经发生并且成为事实。
如果我们回到账单支付示例,我们可以尝试弄清楚我们会在那里找到哪些事件:

事件被放置在时间轴上
有几件事情你可能立刻就会注意到。第一点是领域事件遵循时间轴。这是非常合乎逻辑的,因为事实代表了系统中随后的变化,因此按照特定顺序发生。例如,在签署之前,支付不会被批准。有些事情可以并行发生,比如在支付订单批准后立即同时借记和贷记账户,这可能意味着银行相信付款人有足够的资金来完成支付。
第二点是,我们这里不仅仅只有一个系统。实际上,我们正在模拟整个流程,但至少有三个部分我们可以清楚地区分——面向用户的互联网银行,它创建并签署支付订单;银行的后台办公室,它完成交易;以及收款人的自己的支付对账系统,顺便说一下,这可能完全是手工的。
可视化
如我们所见,我们的简单模型已经为参与研讨会的人们提供了相当多的价值。我们不仅试图识别支付过程中的发生情况,而且将整个流程放在时间轴上,并能够大致识别出在不同物理系统中可能发生的流程部分。
可视化是任何建模技术中最强大的方面之一,事件风暴也不例外。一旦我们把某件事放到我们的模型上,我们就可以对其进行分析,而不仅仅是说出词语和挥手。
当人们看到被认为是整体的情况时,有些人可能会开始提出如果问题。如果账户里没有足够的钱?如果账单参考号码是错误的?如果收款人账户不正确?如果,如果,如果?最终,我们简单的流程似乎并不那么简单。记住可用性启发式,WYSIATI(代表What You See Is All There Is)?我们基于对世界的简化视图来形成最初的理解。一切按预期工作;没有例外和边缘情况,人们的行为没有计划犯错,无论是否有意图这么做。这可能会让人惊讶,但现实世界要复杂得多。大多数时候,边缘情况的数量超过了被认为是常规事件流程的数量。当这些边缘情况和潜在例外被可视化并公之于众时,它们就变得更加明显。
这里有一个问题,可能会对那些尽力创建适当事件模型的人造成不利。你可以想象这样的研讨会是在会议室里举行的。通常,人们围坐在桌子旁交谈。正如我们已经建议的,这不是事件风暴的工作方式。我们希望人们能在房间里四处走动,并积极参与对话,这些对话可能同时在房间的不同侧面发生。因此,我们需要一些空间。但这还不是我们需要的所有空间。仔细看看前面的简单流程模型。虽然我们都可以同意我们只是模拟了快乐路径,没有覆盖边缘情况和例外,但现实生活中的流程要复杂得多;这个图表已经占用了一些水平空间。现在,想象一下以这种方式模拟现实世界场景。确实,一个传统的两三米白板对你来说是不够的。
想象一下你的模型是这样的:

对于一个合理复杂的系统,你需要更大的空间
这里中间是你的白板。但模型并不小。正如阿尔贝托所说,我的问题更大!
当白板上空间不足时会发生什么?人们将剩余的空间视为神圣的资源。它变得珍贵,人们开始节省空间。一些事件变得不重要,因此没有放在白板上。一些想法变得次要,不值得一看。总的来说,为了节省一些白板空间,建模讨论受到了影响。
这是正常的,这也是我们大脑的工作方式。如果我们看到一些限制,无论在事后看来多么愚蠢或人为,我们都会感觉到它的物理存在,并据此安排我们的活动。如果您有一个有限的建模空间,准备得到一个有限的模型。因此,请注意这个问题,并为任何建模会议的参与者,特别是 EventStorming 会议的参与者提供尽可能多的建模空间。我们将在下一节中获得一些更具体的建议。
引导 EventStorming 工作坊
本节为您作为 EventStorming 工作坊的引导者提供实用建议并分享一些真实经验。请记住,在进行 EventStorming 之前,您不需要在您的组织中推销DDD。这项简单但非常有效的技术即使在您没有计划在项目中实施 DDD 的情况下也能有所帮助。它将帮助您建立对您计划创建软件的领域的理解。同时,它也有助于改善开发者和他们潜在用户之间的关系,因为他们将公开讨论用户遇到的问题,并对这些问题表示同情,同时寻求解决方案。
邀请谁
对于工作坊,您始终需要有两种类型的人——有问题的人和有答案的人。
有问题的人是开发者和架构师。令人惊讶的是,开发者很少参加任何直接涉及他们计划开发的软件潜在用户的会议。我们在讨论第一章中提到的“为什么是领域驱动设计?”时提到了这个话题,即问题空间和解决方案空间的分离。因此,再次强调,在这些工作坊中有开发者是至关重要的。另一组人可能是人们期望有答案的人,但实际上他们有更多问题,那就是业务分析师或需求分析师,或者他们是在您的组织中被称为什么。诚然,他们与潜在用户和客户在一起度过相当多的时间,但在 EventStorming 工作坊期间,他们通常会获得新的见解,因为这项练习不是一对一的,而是针对一个群体的。
这个小组需要研究关于领域已经可用的信息(一般理解,也许是有可能已经制定的某些需求或规范)并准备问题。
通常,我们称之为领域专家的人会有答案。但请记住,他们并不了解所有细节,他们也可能有一种知识错觉。这就是为什么你需要尽可能多地在研讨会上聚集这些人,因为这些人的忙碌日程通常使他们难以聚集在同一个房间里。这间接表明,这些人通常在组织结构中处于较高的位置,但并非总是如此。你需要目标是每个部门都能有最多的人参与。你应该寻找那些知道如何做事的人,而不是那些只知道根据一些虚构的描述和标准应该怎么做的人。
无论是有问题的人还是有答案的人的群体,都需要通过更好地制定问题来为研讨会做准备。思考需要解决的问题,并对这些问题进行更具体的描述,可能在研讨会上有所帮助。准备部分不仅适用于开发者。最终,是业务方需要解决问题,因此,他们了解自己的需求,并拥有足够的材料将这些需求传达给那些将通过编写代码来解决这些需求的技术人员群体,这对他们来说是有帮助的。
准备场地
很常见的情况是,人们来到一个在常规会议室举行的研讨会,发现没有白板,投影仪不工作,演讲者有一个与房间内的视频设备不兼容的视频输出端口。所有这些都非常令人沮丧,因为它们需要花费大量时间来解决,从而减少了实际工作的时长。更糟糕的是,浪费时间的不仅仅是一个人。房间里每个人实际上都被阻止做任何事情,只能等待。有时,取消会议以避免浪费更多时间是最佳选择。
这是你要避免的情况,EventStorming 研讨会有一些特定的要求需要在事先解决,以避免这种混淆。
材料清单
很早之前,当你已经商定了有问题的与会者和有答案的与会者最终会面的时间和日期时,立即开始准备文件。
最重要的材料是你的未来建模空间。记住,你的问题比任何会议室里可以找到的白板都要大得多(除非是专门为 EventStorming 会议设计的,但我怀疑你是否有这样一个房间)。这意味着你将不得不使用墙壁作为建模空间。除了玻璃墙,便利贴在墙上粘得不牢固。你还会希望在使用后从墙上移除你的模型,以免让在你之后使用房间的人感到沮丧。
因此,你需要一卷纸。对于游击风格的 EventStorming,你可能可以使用宜家(IKEA)的简单纸卷,它的原始目的是为孩子们提供无限的绘画空间。但它的宽度太窄了,最好的选择是购买绘图纸卷。这些卷纸更宽(通常大约一米宽),更长,并且由更高品质的纸张制成。
下一步是找到可以用来将纸张固定在墙上的东西。你可能需要检查你计划举办研讨会房间的墙壁表面,并尝试不同的固定方法。确保没有或几乎没有障碍物阻碍纸卷,所以墙壁上不应该有画作、孔洞、门或窗户。
当然,你需要很多便利贴。我的意思是很多。你永远不知道人们会写多少领域事件,你不想经历的是当人们开始重新考虑事件时,因为已经没有更多的便利贴了。便利贴很便宜,而想法很贵,所以务必带上足够的便利贴来捕捉所有想法。你需要不同的大小和颜色;我们将在本章后面讨论颜色标记。

这是你需要的最少东西
最后的部分是文具。这往往被忽视,被视为显而易见的事情,然后你房间里只剩下一些高薪、非常忙碌的人,只有一支正在工作的笔。这可能会非常令人沮丧。所以,买足够的永久性记号笔,最好是黑色的,不要太细也不要太粗。理想情况下,你需要每人一支记号笔,还有一些备用。
房间
现在,说到地点。EventStorming 不能在人们坐着的时候进行。恰恰相反——他们需要站立并自由地在整个空间内走动。这就是为什么传统的会议室设置,中央有一张大桌子,周围有很多椅子,是不适用的。所以,你需要确保的是,你计划放置纸卷的墙壁之间有足够的空间供人们自由移动。理想情况下,所有椅子都需要移除,或者至少移到远离建模空间的一个地方。
应该有一个地方来放置所有文具。因此,你至少需要在角落里某个地方放一张小桌子。此外,让标记可见也很有帮助,这样人们就可以用特定的颜色来表示特定的概念。对一个概念使用不同的颜色会非常混乱,应该避免。
EventStorming 会议通常非常紧张,涉及大量的移动、思考和交谈,有时还会争论。这通常很有趣,但可能会很累。所以,作为引导者,你需要保持血糖水平高,喉咙舒适。准备一些小吃、饮料和水果——这有助于。人们也尊重这种待遇,并有一种被邀请参加特殊活动的感受。
研讨会
因此,现在空间已经准备好了,你需要把人们叫进房间。当你让他们来到你的会议时,使用本节中的提示来关注时间,使会议更有效率。我们还将讨论一些观察和解释人类行为以及如何使会议富有建设性的技巧。
时间和安排
为一次会议至少计划两个小时。这可能感觉不够(这通常是事实),但在这种环境中,人们很难在一个更长的时间内保持高效。更长时间的研讨会会耗尽讨论,并产生原地打转的感觉。这种情况可能仅仅是因为几乎所有可以讨论的内容都已经考虑过并贴在了墙上。因此,抵制住计划更长时间研讨会的冲动,比如全天会议。
第一小时通常非常紧张,但之后,你会看到能量水平下降。让人们休息十分钟,喝杯咖啡,吃他们得到的果品。通常,参与者会以小团体的形式继续讨论已经讨论过的事情,这样他们的头脑仍在处理信息,但方式更为轻松。休息后,通常会揭示新的见解。此外,你还需要逐步应用不同的技术,第一小时后的休息将为你提供一个极好的机会去做在研讨会第一部分所做的事情之外的事情。我们将在本章后面讨论这些技术。
开始
对于以前从未做过的人来说,开始一个 EventStorming 会议可能会相当尴尬。作为引导者,你解释规则,给人们分发笔和便利贴,然后挂起记号法的第一个元素:

记号法的第一个元素
在完成这一切之后,人群中会出现一段沉默和不确定的动作。没有人知道该做什么,人们通常对做不熟悉的事情感到不舒服。如果这种活动需要在众人面前进行,这一点就会特别明显:

中间的随机事件——破冰活动
这个时刻需要引导者打破僵局。这并不难做。当你组织这样的会议时,你已经对组织和领域有所了解。这让你能够想象一个领域事件,或者两个,或者更多。将这些内容贴在墙上正是人们通过例子学习所需的内容。整个过程并不难做,但没有例子,人们不会感到安全和放心。因此,作为引导者,你需要将第一张便利贴贴在墙上,或者贴几张。尝试让它与主题相关,或者你可以故意让它非常荒谬,期待人们用笑声和讽刺来回应你的错误。当然,你首先得到的反应是人们大声说出他们认为需要贴在墙上的内容,而不采取任何行动,并期待你记录下来。这就是传统会议的做法——人们谈论,希望有人能记笔记。抵制这种做法。一旦你看到有人解释你需要在下一张便利贴上写什么,或者告诉你墙上的内容是错误的,就给他们一支笔和一叠便利贴。他们会开始写。他们会产出。他们会讨论。然后,你的工作就是观察和引导,以防人们陷入困境。
至少有两种我知道的技术,关于如何通过在墙上贴第一张便利贴来开始研讨会。第一种来自阿尔贝托·布兰多利尼。他说,你可以把任何你想放的东西放在任何你想放的地方,但不是开始的地方。从开始开始是你想避免的。作为人类,我们寻求结构是非常自然的,在我们看来,每个过程都有开始和结束。所以,从逻辑上讲,我们需要从开始的地方开始。唯一的问题是这里没有开始。首先,我们总是花费大量的时间和精力讨论过程从哪里开始,而没有产生任何东西。其次,在确定开始之前,肯定会有一些事情发生。因此,将第一张便利贴贴在中间某个地方,并从这里开始工作。在那个事件之前发生的事情将放在左边,之后发生的事情将放在右边:

现在填充所有中间的空白
丹·诺思在 2016 年 DDD 交流会上提到了另一种技巧。他在一张便利贴上写着从前有,并将其放在纸张卷轴的左侧附近,但不是边缘。在第二张便利贴上,他写着从此幸福快乐,并将其放在纸张卷轴的右侧附近,但同样不是边缘。你需要在两侧留出这些间隙,因为,如前所述,肯定会有一些比从前有更早和比从此幸福快乐更晚的事情,你需要空间来放置它们。正如你所看到的,你不需要对领域有确切的知识就能制作这两张便利贴,而且效果相当高效。人们能够感受到时间的流逝,而且当墙上已经有了一些东西,中间留有空间供他们填写时,这会激发他们的想象力。
在研讨会期间
作为引导者,你的角色不是统治,而是观察和引导。你设定的规则越少,执行的越少,你的研讨会就会越成功。在破冰之后,一些人会开始在墙上贴东西,而其他人会开始提问。在这个阶段,有几个要点需要记住:
-
人们倾向于向引导者提问,因为他们把他们看作是会议组织者,因此是一个拥有更多信息和权威的人。作为引导者,将他们和他们的提问引导到房间里其他人那里,特别是那些有答案的人,就是你邀请来的人。
-
对于领域事件可能会有一些混淆,尤其是如果你的听众不是母语为英语的人,而“领域事件”这个术语可能被视为技术性的(其实不是)。因此,可能会有一些人不断贴上他们希望拥有的功能(如支付处理或购物车)或命令性动作(如处理支付或注册客户)的便利贴。引导者的工作是防止这种情况发生,并再次解释,在这个阶段,目标是描述领域事件的流程,这些是生活的事实,不能被撤销、删除或更改。
-
除了前面提到的内容之外,人们不会犯真正的错误或错误,至少在符号层面是这样。不要试图去重事件或进行泛化,不要通过说他们正在做错事来阻止人们做事情。
通常,这三个小贴士有助于从组织角度推动研讨会。但由于我们是在与人打交道,总有一些行为和个人方面。通常会有一些对引导者来说至关重要的事情需要观察,有时甚至需要干预。
首先,要做好准备进行复杂讨论。如果没有讨论,那么可能是因为领域过于简单,或者你找错了人,或者有其他因素阻止人们发言。由于房间里每个人都有自己对领域的观点,所以争议是不可避免的。即使是开发者,在理解了初步想法或阅读了规范后,也会很快形成自己的观点。这里的关键是开发者需要提问。不应该对发生的事情及其发生方式有任何假设。鼓励开发者参与是促进者的工作,因为其中一些人是内向的,并不真的喜欢公开讨论。但既然我们的目标是让开发者更好地理解领域,他们就需要参与进来。
尽量注意边缘情况。人们总是更喜欢模拟没有异常和错误发生的快乐路径。我们始终需要记住,在特定条件下,一个事件是另一个事件的后果。是的,可能会有一些直接的流程,但它们并不常见,尤其是如果我们谈论的是商业。例如,“支付处理”可以逻辑上导致“订单已支付”,然后是“订单已发货”,然后是“订单已送达”。但如果是支付失败呢?如果支付金额没有覆盖订单总额(部分支付)呢?如果商品库存不足,尽管我们认为它们有呢?如果包裹在运输途中丢失呢?所有这些事情都可能发生。对于开发者来说,他们可能会觉得这些情况很复杂,通常他们不知道如何处理这些情况,除了抛出异常。但业务通常有程序来处理这些情况中的大多数,并且这些修复可以也应该被建模。
其次,关于边缘情况的讨论几乎肯定会创造一些模糊性和不确定性,因为并非所有异常情况都被业务流程所涵盖,或者你可用于研讨会的人员并不处理这种情况。如果房间里有多位领域专家,他们可能会意见不合,互相争论。对于你的短期研讨会来说,这种状况是事与愿违的。因此,如果你观察到正在进行激烈的讨论,或者在某些时候,有太多困惑的表情,没有人能对某事提供清晰的解释,你就可以确定一个热点。在这个时候,你需要向符号中引入另一个元素。热点通常用鲜艳的便利贴标记;例如,阿尔贝托提议使用鲜艳的粉色。所以,你可能会在墙上看到如下内容:

明亮的颜色有助于吸引注意力
识别和描述热点可以使你推迟决策和停止争论,有效地让团队前进而不停滞。你可能会在研讨会结束时发现你的墙上布满了热点,这是完全正常的。这表明你邀请的人可能需要就处理某些情况达成一致,或者你需要与其他人交谈并收集缺失的信息。热点应得到密切关注,但应在研讨会结束后进行。尝试通过挂上粉色的便利贴并要求人们继续前进来停止无生产力的讨论和陷入僵局。
第三点需要注意的事情是,当你有几个领域专家,他们各自专长于更大领域中的不同部分时,你会观察到他们根据功能专长区域聚集在一起,形成几乎不与其他群体产生的岛屿相连的事件岛屿或云。观察这一点非常有趣,并且至关重要,因为你可能正在见证你的上下文图的第一个草稿。我们将在本书的后面讨论上下文图。不要阻止人们这样做,顺其自然。注意这些岛屿是如何相互连接的。通常,最小数量的事件属于多个领域事件组。
最后,你的业务可能与你无法控制的组织和系统合作。这样的实体可以被称为外部系统,并需要在模型中体现。有些领域事件可以进入这样的系统,你也可能从外部系统接收到一些事件。这引入了你的符号中的新元素,在阿尔贝托的色彩方案中,外部系统使用大粉色的便利贴来可视化:

支付提供者是外部系统
记住无限建模空间,并确保人们不要试图节省空间,因为空间已经不多了。重新组织事件以创造更多空间,或者最好是在墙上贴更多纸张。记住纸张很便宜,知识很宝贵。你不想因为有人节省纸张空间而失去理解。
当人们用尽想法时,可能会有一些尴尬的沉默时刻。你可能需要通过提供从不同角度审视模型的方式来重新点燃激情。至少有两种相对简单的方法可以通过添加缺失的内容来丰富模型。首先,要求人们倒着穿越时间线。很多时候,被认为不重要的东西没有被贴在墙上,但这个事情对于下一件事情的发生是至关重要的。例如,有人可能忘记在发货之前需要制作装箱单。另一种技术是确定业务创造价值的地方。简单地说,试着追踪资金流向。很多时候,开发者会陷入讨论一些花哨的、令人愉快的需求,而忘记了业务需要赚取一些东西来支付他们的薪水。
最后,正如之前提到的,要记住时间,并且每小时至少休息一次。遵守你的承诺,不要过度劳累;不要让人们待得比他们计划的时间更长。如果你遵循了我的建议,买了些水果和饮料,有些人甚至可能想要继续,但这取决于他们是否决定你的研讨会应该比预期更长。
研讨会之后
当时间结束,你很可能会有一大卷纸,上面贴满了不同颜色的便签。为了制作它,你已经投入了大量的时间和精力,这张纸卷通常被视为一件珍贵的文物。然而,情况并非完全如此。你可能想要保留你所讨论和建模的证据,尤其是热点列表。但主要的收获是开发者和其他参与者刚刚获得并将带走的知识。更多的领域知识和更少的无知是 EventStorming 会议中最重要的、尽管无形的人工制品。
这并不意味着你会扔掉纸卷。第一次这样做的人可能会把这种行为视为不尊重的标志。保留纸卷,拍全景照片并发送给每个人。是的,你很可能永远不会再次打开纸卷。如果你需要再次这样做,从头开始创建所有这些事件会更有生产力。人们已经知道了,而且以这种方式完善模型对他们来说将更有益。但是,为了安全起见,将纸卷放在一个安全的地方保存几周:

保留它们,但你可能永远不会再看它们一眼
记得计划后续会议来讨论热点。通常,较小的群体可能需要参与。有时你可能需要邀请其他人,因为你有问题。你可能不需要重复已经做过的事情,而应该专注于讨论你所发现的问题。实际上,在这样讨论中使用 EventStorming 是有益的,并且会丰富模型。
在下一章中,我们还将讨论如何进行设计级别的 EventStorming,这需要一些技术知识,并且只能在领域的小功能区域内进行。此类会议的结果可以直接用于您的缺陷跟踪器。
我们的第一模型
现在我们来尝试练习并为我们示例应用领域进行一次想象中的 EventStorming 会议。想象它会如何进行并不容易,因为任何 EventStorming 研讨会最关键的因素是人和他们的行为方式。我们当然无法在这里用文字重现它,但我们可以想象一些可能发生的讨论和产生的事件流程。
我们将进行一次虚构的 EventStorming 会议,讨论一个分类广告应用程序。一位协调员,我们称她为安,邀请了以下人员参加研讨会:
-
约翰,公司老板。他相信由于系统的简单性和独特功能,该系统将成为市场领导者。
-
玛丽是用户体验设计师,她已经对现有系统进行了一些研究,并与一些潜在用户进行了交谈。
-
尼克和伊夫是全栈开发者。
-
爱恩负责后台办公室,处理财务并确保公司运营良好。
计划中的会议时间到了,人们陆续进入会议室,试图寻找椅子,但一个也没有。房间里有两张小桌子——在一桌上他们找到了一些水果和饮料;在另一桌上则堆放着一摞不同颜色的便利贴和许多记号笔,足够容纳两倍人数的人群。两面墙上装饰着一张长约七米的纸。还有一个记事板和白板。人们看起来有些困惑,现在是时候提供一些清晰的信息了:
“欢迎来到我们的第一次,但希望不是最后一次,EventStorming 研讨会,”安说,“我们将探讨我们的公司希望业务如何运行,到这次会议结束时,这个房间里每个人都应该对我们想要做什么有一个共同的理解。”
“为了做到这一点,我们将描述当客户使用我们的服务时在我们这边发生的事情,”安继续说,“我们将使用便利贴来完成这个任务。首先,我们将在墙上贴上事实陈述,也称为领域事件。想象一下东西是如何在业务中流动的,当发生某事时,在便利贴上写上几个过去时态的词,并将其贴在墙上。”
安在记事板上写下“图例”这个词,并在中间贴上一张橙色的便利贴,上面写着“发生了某事——领域事件”。然后她在墙上的纸条上画了一条水平箭头,并在下面写上“时间”。“**由于一个事实紧随另一个事实,它们形成了序列,或过程,这些过程不是同时发生的,而是依次发生,一个接一个。因此,我们试图将这些事件按时间顺序排列,”她解释说。
房间里的人们似乎都明白了,安给每个人发了一堆便利贴和一支笔。然而,每个人似乎都不愿意采取行动,而是互相看着,感到有些不舒服和紧张,仿佛害怕做错什么。然后约翰说:“好吧,最好的开始就是从开始。第一件事会是什么?也许当客户在我们这里注册时**。”* 这种不确定性引发了一场无果而终的讨论。墙上还没有贴出任何便利贴。注意到这一点,安在一张便利贴上写下“分类广告发布”,并将其放在中间某个位置。
墙上的一张便利贴引发了一场关于系统中主要元素应该叫什么的讨论——这将是分类广告,还是仅仅广告,或者其他什么?人们开始贴出似乎在广告发布之前发生的事情,比如广告创建和广告更新。经过一番观察,玛丽对这些术语表示怀疑,因为广告是不会更新的。广告有多个独立的属性,会以不同的方式改变。例如,上传图片是单独进行的,然后广告标题才会更新。更改广告类别可能受到限制,而更新价格可能会触发一些有趣的行为,比如通知那些订阅了分类广告价格最近下降的订阅者。
同时,约翰开始讨论一些高级功能,例如卖家和买家评分,最终他们发现,在墙的另一边,没有买家或卖家,而是用户。这个术语在讨论身份验证和资料时似乎有含义,但在买卖过程中并没有帮助。
此时,墙上的情况如下:

第一个模型
一些工作已经完成,人们需要稍作休息,以享用安为他们准备的所有美味佳肴,并反思所讨论和发现的内容。
休息过后,他们继续讨论。
伊芙和约翰开始讨论审批流程,这在之前是完全缺失的。似乎有大量潜在的欺诈性和一般性的恶意分类广告被放置在竞争对手的网站上,所有这些都有一些预防机制。那些不进行任何审查的人很快就会失去信誉和信任,并被挤出市场。但我们的公司没有人员能够手动审查所有广告,所以讨论一直在原地打转。安注意到了这一点,并在墙上贴了一张明亮的粉色便利贴,上面写着“恶意广告检测”。她说:“我们似乎需要这个,但又不清楚如何操作。”她说:“我们先把它放在这里作为提醒,等我们讨论完其他事情后再回来处理。”关于这个话题的讨论随后停止,并建设性地继续讨论在检测发生之前和之后的流程演变。
在这个阶段,符号中添加了第三个元素:

大图工作坊的最终符号
伊恩在讨论中并不活跃,他四处走动,点头,有时还会做出表示不同意的苦笑。当安问他怎么了时,伊恩不耐烦地回答:“No one has even thought about how are we going to earn any money. Without earning anything we will not survive. We don't have that much investment, and we better get some revenue as soon as we can." 这让团队感到一阵小小的震动,人们开始思考他们是如何忘记将赚钱的因素纳入考虑的。然后,约翰解释了原始想法,即基本服务是免费的,但一些附加服务,如将广告放置在搜索结果顶部、展示更大的图片等,将收取少量费用。他还解释说,免费服务仅适用于私人个人,如果公司想通过网站销售产品,他们需要签订独家协议。此外,他继续说,销售汽车和房地产应该完全是另一回事,因为它需要高级集成和一些安全措施,这些服务永远不会免费。
这条新信息引发了讨论,不久之后,大家一致认为,对于第一个版本,他们需要针对最大的受众和最直接的服务。这意味着他们将只提供带有几个选项的免费广告,这应该会创造主要的收入来源。
我们现在已到达工作坊的尾声,墙上贴满了便利贴。以下是他们的成果:

最终的大图模型
如我们所见,这次会议揭露了一些未知或被他人假设但从未明确指出的问题。以下是一些例子:
-
没有所谓的“更新广告”,而是“上传图片”和“降价”等等,这些更加精确并触发不同的逻辑。
-
在不同的情境中,一个人被称为“用户”,同时又是“卖家”或“买家”。这些情境之间的联系并不稳定,这可能表明这些至少是不同的实体。
-
最小可行产品(MVP)缩减到仅包含少量付费广告的免费广告的最低限度。其他一切都将随后到来。
-
卖家和买家评分以及智能建议等特性虽然很好,但并不立即为用户提供价值。
-
然而,将需要一个强大的恶意内容检测系统,因为如果我们考虑到每天发布的广告数量,人工审查将不足以应对。
开发者对业务期望系统做什么以及他们的 UX 专家希望它看起来如何有了更深入的理解。他们做出了许多调整和原创想法,似乎大家都站在同一条战线上。无知水平比以前低得多,开发者忙于解决错误问题的风险显著降低。
摘要
在本章中,我们学习了什么是 EventStorming,以及为什么每个团队都会从组织这样的研讨会中受益,即通过获取领域知识来减少无知。我们还探讨了如何安排和促进 EventStorming 会议的一些实用技巧。
本章的最后一部分是关于我们示例业务的模型。我们讨论了业务流程,确定了多个事件和一些热点,对我们将在本书中构建的系统有了重大的洞察。
我们简要地提到了 EventStorming 的行为方面,但还有更多内容,由于这个主题的广泛性,我们无法在本书中涵盖。请查看下一节,以找到关于阿尔贝托·布兰多利尼工作的参考,并研究引用的材料,以了解更多关于人、他们的偏见和行为,以及为什么软件开发是一个学习过程。
在下一章中,我们将更深入地探讨建模过程,更多地关注可以帮助我们尽快开始编写代码和交付初始原型的工件。
EventStorming 社区有很多关于在分布式团队中使用该技术或当最终用户无法直接参与此类会议时的讨论。阿尔贝托指出,肢体语言对于了解会议的整体进展以及识别与会者在会议和业务中的角色至关重要。在我看来,这是真的,但我们经常遇到这种会议几乎无法组织的情况。对于分布式系统,我建议个人使用在线实时工具,如 Miro,我以前用它为这本书创建了许多图表。它允许人们无论身处何地都能参与建模会议。
对于 SaaS 业务,问题可能更加困难,因为没有单一的用户群体可以信任来代表整个用户基础。然而,即使在这样的场景中,通常也可以识别出一组最活跃的客户,并邀请他们成为你团队的一部分。这不仅让你能够深入了解他们如何使用你的系统以及他们希望如何改进它,而且通过口碑宣传,你还能获得很好的免费宣传。人们普遍尊重开放性,并且当他们的声音被开发者听到时,他们会感到非常感激。
进一步阅读
这里有一些你可以参考的信息:
-
EventStorming (EventStorming.com)—获取更多信息链接的地方
-
介绍事件风暴法,Brandolini A. (2017),Leanpub (
leanpub.com/introducing_eventstorming)
第四章:设计模型
许多人认为领域模型就是数据模型。你只需在 Google 上搜索领域模型就可以轻易看到这一点——你找到的所有东西都是数据图或类图。尽管类图有时包含一些有用的行为(方法),但这并不经常发生。然而,由于商业的复杂性很少在于其数据,我们需要意识到行为是领域模型的一个组成部分。
大图事件风暴帮助我们理解整个业务或其一部分,但我们需要更进一步以到达实施阶段。设计级事件风暴正是如此——我们关注对我们来说最有趣的系统部分,并深入其中,发现更多事件和新流程。
在本章中,我们将涵盖以下主题:
-
领域模型代表什么?
-
模式和反模式
-
设计级事件风暴
领域模型
正如我们在第一章,“为什么是领域驱动设计?”中讨论的那样,我们设计和实施的软件只有一个主要目的——解决领域问题。理解问题空间或业务领域对于找到适当的解决方案和使我们的系统满足用户至关重要。当我们使用如大图事件风暴等技术对领域有更多了解时,正如前一章所讨论的,我们需要进一步深入,并尝试使用其他人能够理解和推理的视觉工件来可视化我们的知识。简而言之,我们需要一个模型。
模型代表什么?
词语模型有许多不同的含义。当我们提到模型时,我们可以想到汽车、船只或甚至房屋的缩小模型。这样的模型以不同的规模代表现实生活中的物体,并且展示了实质上不同的细节水平。一些模型可能相当抽象,例如建筑群的模型。然而,其他模型可以提供它们所代表内容的更详细视图,例如汽车的比例模型,这些模型通常非常精确。但大多数时候,这样的模型也缺少了真实汽车的一些重要特征,比如引擎、变速箱和复杂的电子设备。
因此,模型代表了现实世界的一些产物,但具有狭窄的目的。例如,建筑将占据多少空间,整个综合体将有多高,在建筑项目的初步审查阶段,这些通常只是粗略模型所必需的。模型并不旨在复制现实生活。相反,它们以一定细节水平代表现实生活的某些特定方面,这取决于模型的目的。
城市交通线路图是一个很好的例子。在任何通用地图上,你都可以看到它显示了交通线路、所有车站和变化。它还指出了某些基本的地理方面,例如相对距离到海边或车站位于河流的哪一侧。
同时,这样的地图并不显示车站之间的距离,也没有直接与真实地理位置相似。要确切地找到车站的位置,或者确定从一个车站到另一个车站需要花费多少时间,你需要另一张地图。
这个例子表明,一个特定的模型可以代表现实生活的一些有用方面,但可能忽略其他元素,因为它们不是必要的。这并不意味着被忽视的方面根本不重要,只是它们不是那个特定问题空间的关键。因此,交通线路图解决了公共交通用户的定位问题,并且做得很好。但它并不解决街道导航的问题,因为它不需要,因为它服务于不同的目的。
因此,软件中的领域模型也需要表示与解决特定问题相关的业务领域的本质方面。有时,将我们所知道的一切,以及更进一步,我们可能假设的业务领域的所有内容,都放入我们的模型中,是非常诱人的。但这会给模型增加不必要的复杂性,并且不会帮助解决问题。更糟糕的是,在模型中加入太多无关的细节可能会扩大实施范围,并模糊业务人员在指定他们想要解决的问题时心中的意图。
回到第一章,“为什么是领域驱动设计?(Why Domain-Driven Design?)”,如果业务领域和我们必须解决的特定问题都在我们的问题空间中,那么领域模型就纯粹属于我们的解决方案空间。我们将建模我们的解决方案,而这些模型将成为我们的领域模型。
弱领域模型
术语领域模型,尽管它已经存在,但在马丁·福勒(Martin Fowler)的著作《企业应用架构模式》(Patterns of Enterprise Application Architecture)中被提及后,才被广泛认可。以下是福勒在书中对这一术语的定义(martinfowler.com/eaaCatalog/domainModel.html):
“领域模型:领域对象模型,它结合了行为和数据。”
这个定义相当简短和简洁。然而,如果你在 Google 上搜索“领域模型”并查看各种链接,你会发现它仍然存在很大的错误。如果你像我一样做了同样的事情并搜索了这个短语,你会发现大多数找到的图片和大多数链接都指向我们可以认为是数据模型或实体模型的东西。这些模型可视化实体、具有类型的字段以及实体之间的关系。在最好的情况下,链接通过诸如attends或consist of这样的领域术语进行属性化,而在一些罕见的情况下,我们可以找到显示某些方法的类图。
在实体或数据模型的案例中,我们看到的所谓贫血模型。正如术语所暗示的,这样的模型只表达系统状态,而对这种状态如何变化以及系统中执行的操作一无所知。通常,如果你查看这些系统的实现,你会发现系统所做的一切都是对数据的某种操作。新的实体被创建,新的关系被建立,实体中的字段被更改。仅此而已。
贫血模型之所以流行,有几个原因。首先,UML 中可视化领域模型的指南建议,所设想的是概念类。这些类代表现实世界中的实体及其属性。在 UML 中,这些模型包括具有属性(字段)的项目(实体)、它们的关联(关系)和参与者。因此,在概念类中没有行为的地方。下一个原因是,概念类的想法似乎已经丢失,这些 UML 模型成为了唯一的领域模型,其中领域行为被认为是不重要的。
回到 Fowler,在关于贫血模型的 bliki 文章(martinfowler.com/bliki/AnemicDomainModel.html)中,他明确地将这种建模系统的方式定义为一种反模式。除了之前的描述外,贫血模型往往完全由数据库操作实现。由具有关系的对象组成的模型与关系模型非常相似,因此关系数据库最常用于持久化此类对象。贫血领域模型与其在数据库中的状态之间的关联如此紧密,以至于它们变成了同胞,无法相互区分。
非常常见,如果你发现一个以贫血模型作为领域模型的系统,你将很难理解这个系统做什么,因为你在代码中看到的所有东西都是 SQL 和运行它的调用。如果你向使用该系统的人询问他们模型实现的所在地,他们很可能会直接指向数据库。也有人认为,在应用程序行为很少或没有行为的情况下,贫血领域模型是有用的,这样的模型作为持久化模型非常完美。我争辩说,在这种情况下,没有必要称之为领域模型。数据模型完全可以,因为它们服务于持久化的目的。然而,将数据模型呈现为领域模型并没有真正的理由,因为这两者是完全不同的东西。
函数式语言和贫血模型
这里还有一点值得提及。在函数式编程社区中,有人讨论他们设计的模型是否也是贫血的。这是因为,在函数式编程中,使用类不是强制的,有时甚至不可能。即使可能,使用类也不自然,因为函数和函数组合可以更容易地解决许多问题。我争辩说,如果行为仍然以函数的形式建模和实现,这样的模型就不是贫血的。它可能不完全符合原始定义,但 2003 年是面向对象编程语言的统治时期,所以使用对象模型这个术语是自然的。然而,关键在于数据和行为的组合,并且肯定的是,当使用丰富的类型系统以及明确表达意图的函数时,这样的模型确实不是贫血的。
领域模型应包含什么
正如我们之前提到的,领域模型的对象代表领域的数据和行为。通过写对象,我并不是指与面向对象语言相关的东西,而是它们所代表的本质。领域模型的实现也受到所使用的编程语言的影响,因此这些对象可以是记录、结构体,或者实际上就是对象。因为这本书是关于使用 C#来实现领域驱动设计(DDD),我们将使用类和对象来实现我们的领域模型。
即使当我们拥有多态的概念时,我们也可以在我们的类中组合数据和行为。这并不意味着我们的领域模型将包括这样的类。领域实现的所有部分也都是领域模型的一部分。没有比实现该模型的代码更好的领域模型文档了。
模型中的行为和数据是相互关联的。模型的行为除了操纵模型的数据外,没有其他意义,并且由于数据代表的是模型所关注和操作的内容,这种数据也被称为状态。状态是描述我们的系统在特定时间点看起来如何的数据。模型的所有行为都会改变状态。状态是我们持久化到数据库并可以在应用新行为之前随时恢复的东西。
这可以通过一个简单的例子来说明:

状态转换由一个动作触发,引起一个反应
在这里,你可以将账户余额视为状态的一部分。当我们应用一个行为时,状态会发生变化。这被称为状态转换。每个领域模型行为都会导致领域模型状态的变化。所有记录领域状态变化的方式都应该成为领域模型的一部分。
设计考虑因素
正如我们之前所见,在面向对象的语言中,我们经常看到使用多态能力的类来保持行为与状态的紧密关联。然而,在函数式语言中,状态通常被独立维护,因为行为可以表示为操作代表状态的记录类型实例的函数。
从逻辑上讲,诸如通信协议、用户输入验证和持久化实现等问题并不被视为领域模型的一部分。这些都是技术和基础设施问题。这里的一个好的经验法则是,整个领域模型应该能够在不涉及任何基础设施的情况下进行测试。主要来说,在你的领域模型测试中,你不应该使用测试框架和模拟。
如果你观察洋葱架构、六边形架构和清洁架构原则,你会发现它们有一个共同点。任何应用的中心是领域:

洋葱架构
应用服务和基础设施被保持在系统核心之外,并围绕这个核心形成层。与从 UI 层到数据层的依赖关系相反的分层架构不同,我们可以看到领域是所有事物的中心,一切皆依赖于它。这种变化,尽管可能被视为一个小调整,但具有非常重大的影响。不再是所有东西都依赖于数据层,这使得数据库成为一切的主宰,焦点转向领域,使领域模型成为系统最重要的部分。
CQRS
除了之前讨论的将领域模型设计为多态类的方法,使用面向对象以及使用操作记录类型实例的函数来设计某些函数式语言之外,还有另一种在领域内部表达状态转换的方法。这里指的是 CQRS 模式,这是由格雷格·杨十年前提出的。
这个术语起源于命令查询分离(CQS),由伯特兰·梅耶提出,该理论指出对象方法分为两类。这些类别如下:
-
命令,这些命令会改变系统(通常是对象)的状态并返回
void。 -
查询,这些查询返回系统状态的一部分,但不会改变系统的状态。这使得查询无副作用(除了像日志记录这样的东西)并且幂等,因此可以多次执行并得到相同的结果。
命令查询责任分离(CQRS)将这个原则扩展到对象之外。这是同样的原则,但应用于系统级别。这个模式的发展历时数年,从 2007 年格雷格在 InfoQ 会议上提出这个模式的早期愿景,到 2010 年发表了总结论文(cqrs.files.wordpress.com/2010/11/cqrs_documents.pdf)。谷歌也花了几年时间才认识到这个缩写的含义。几年前,那些急于寻找 CQRS 的人从谷歌得到了建议说你是指 CARS 吗?但如今,这个模式已被广泛认知和赞誉。
在系统级别上分离命令和查询意味着任何系统的状态转换都可以通过一个命令来表达,并且这样的命令应该被高效地处理,优化以执行状态转换。另一方面,查询返回来自系统状态的数据,这意味着查询可以以不同的方式执行,并且可以针对读取状态或任何存在的状态衍生进行优化。
在数据库写入和读取之间存在明显不平衡的情况下,这种分离是有益的。典型的商业或面向消费者的应用程序在读取方面严重不平衡。然而,典型的实现是针对写入优化的,使用规范化的关系数据库,其中写入可以相当高效地执行,但读取需要大量的连接和广泛的过滤:

单数据库的 CQRS
在最简单的场景中,CQRS 可以通过仅使用数据库映射的领域对象来执行操作,以改变系统状态(通常,这是通过 ORM 工具完成的)和使用直接 SQL 查询以及跨多个表的连接来检索系统,完全忽略领域模型类层次结构。这导致了读取的极大优化,同时合理地增加了对状态持久化机制的认识。虽然这种方法完全合法,但你应该意识到,在这种情况下,查询需要要么从数据持久化层中适当抽象出来,要么在设计领域模型之外进行设计。
在更复杂的场景中,我们不仅可能有两个不同的客户端来操作相同的领域实体,还可以将这些实体分开。我们将在本书后面的章节中详细讨论这些技术,当我们讨论事件溯源时。
你可能想知道为什么 CQRS 被纳入了本书的领域模型设计部分,而不是在属于实现的章节中解释。原因在于 CQRS 将命令和查询视为一等领域对象。领域事件始终应被视为一等领域对象,但在更高级的 CQRS 实现模型中,领域事件在保持整个系统一致性的关键作用中扮演着至关重要的角色,因此领域事件的作用变得更加关键。
正因如此,CQRS 在这里被提及,让我们意识到我们不仅应该包括具有属性和方法用于我们的领域模型的类,命令、查询和领域事件也属于模型的一部分,我们将在下一节中探讨如何对这些所有元素进行建模。
我的最后一点是,无论是否使用 CQRS 进行实现,事件风暴都是有价值的。
设计级事件风暴
在上一章中,我们通过大图事件风暴的过程对整个业务进行了建模。我们主要讨论了领域事件,后来又添加了热点和外部系统。
在本节中,我们将以更详细的程度进行建模,使用更丰富的符号,以更接近模型在代码中的实际实现。
深入了解知识
让我们回到完成大图事件风暴工作坊的那一刻。团队花了几小时讨论关键主题:
-
业务运行哪些流程?
-
在这些过程中,哪些类型的对象参与其中?
-
我们可以记录关于系统行为的哪些事实?
-
谁负责什么?
-
我们需要学习和使用哪些基本术语?
关于这些点的讨论产生了一张包含许多代表生活事实的橙色便利贴的图表,我们称之为领域事件。还有一些粉色的便利贴散落在各处,表示热点——需要关注、进一步阐明或引起担忧的事情。通常,这意味着缺乏知识。
所有这些事情都使团队在实现方面更加接近,但他们还没有开始编码的感觉。团队成员需要深入设计,并获取更多关于系统中可以执行哪些操作以及由谁执行的具体知识。
这是一个另一种类型的 EventStorming 讲座的主题——设计级 EventStorming。让我们更详细地看看如何组织这样的讲座。
讲座准备
对于更详细的 EventStorming 讲座,你需要的与 Big Picture 讲座大致相同:
-
纸卷或任何其他类型的无限建模空间
-
不同颜色的便利贴纸;我们稍后会讨论符号
-
足够的永久性记号笔
当然,关键因素是拥有正确的人。但现在,我们正在深入细节,因此选择一个探索区域至关重要,而找到这样的空间通常是一项非平凡的任务,我们将在本章后面讨论。因此,正确的人将是那些将忙于编写代码的人,负责该系统部分的产品负责人,以及所选领域的领域专家。正如你所看到的,我们可以将我们的小组限制在比 Big Picture 会话更少的人。
这两种选择——限制范围和限制人数——使我们能够以更高的详细程度讨论设计,有一个单一的讨论线索,让每个人都表达自己的想法并提出问题。
扩展符号
由于 EventStorming 是语言和技术无关的,我们无法对类、字段、方法或函数等事物进行建模。相反,我们需要使用更普遍的概念。我们已经在 CQRS 部分讨论了这样的想法,并且我们看到我们可以在领域模型中不仅将行为表达为方法列表,还可以表达命令的执行。命令表达了用户的意图。领域模型随后进行状态转换,并产生新事件,记录目的和状态转换。查询代表了用户想要在屏幕上看到的内容,以便做出决策和执行其他命令。因此,这为设计会议提供了一些与任何特定编程语言或技术无关的元素。
命令
命令和事件不受任何语言或技术的限制。它们还非常详细地描述了系统的行为,使用通用语言并表达用户的意图。
因此,我们在设计级 EventStorming 的符号中包含命令。命令表达了与系统交互的用户意图,因此将命令应用于我们的系统将自然产生状态转换,并在我们的领域模型内部产生事件。如果我们用蓝色便利贴纸表示命令,命令处理的常规流程将如下所示:

命令触发状态改变
注意,我们没有连接便签的箭头。流程完全由将它们按时间顺序放置在一起来决定。首先,我们请求系统做某事,当操作被接受并执行时,系统转换其状态并发出新事件。
通常情况下,避免在建模空间中使用箭头,因为它们会为你的便签创建空间锁定,你将停止移动它们,因为箭头是画在纸上或白板上的,不能移动。这种锁定会降低建模的动态性,并阻止实验。
读取模型
我们将引入模型的新概念是读取模型。读取模型是我们用户在请求系统做某事之前查看的内容。它可以是我们的应用程序中的任何屏幕,例如表单、仪表板或报告。任何这样的屏幕都包含一组具有有限元素类型的元素。通常,我们可以将元素分类如下:
-
以文本和图像形式显示的信息
-
表单元素,如输入框、复选框和单选按钮
-
动作按钮
-
导航
当导航元素自然地将用户从一屏引导到另一屏时,动作按钮用于向系统发送命令。信息元素和表单元素是用户在决定做什么之前会查看的内容。这些元素内部显示的内容由读取模型定义。为了建模的目的,我们可以假设我们的读取模型是系统中的屏幕,这样我们就可以确定需要组合并展示给用户的信息。
为了演示这一点,让我们看看以下示例:

读取模型、命令和事件
因此,这里的绿色便签代表分类广告的读取模型。从那里,用户可以执行特定操作:发布广告或删除它。执行这些命令之一将导致领域模型发布一个事件。
用户
在我们的系统中,大多数命令都是由使用系统的人——用户执行的。在设计模型时,我们经常需要了解谁在运行哪个命令,仅仅是因为并非所有命令都允许每个人执行。我们可能定义不同的用户角色,例如管理员、经理、审阅者等,并设法可视化它们,以及它们执行特定命令的能力。你可能会发现识别角色并使用它们,或者代替角色是有用的。如果你预期一个人在系统中扮演不同的角色,或者当你正在建模一个你了解特定人员和他们的特定职责的现有系统时,直接在模型中使用他们的名字将使所有相关人员更加清晰和易于理解。
从视觉上看,我们可以使用带有人物形状的小便签,绘制成 UML 的参与者符号。你也许会称你的用户为参与者,但在 UML 中,参与者并不一定是用户。在 EventStorming 中,我们希望用户以不同的方式可视化,因此我们使用更大的浅紫色便签来表示外部系统,正如我们在上一章所讨论的。
让我们把一些用户放入我们的模型中:

用户是触发命令的人
我使用了一个不同的读取模型,它可以被两种不同类型的用户使用。在这里,分类广告的所有者可以将其标记为已售出。但所有者和担任审阅者角色的人都可以停用广告。
政策
在设计级别的会话中,我们将使用最后的元素是政策。正如我们之前所学的,系统中的动作由命令表示。用户可以通过发送命令来执行动作。当命令被处理时,系统会改变其状态并发出事件。这是系统对用户动作的初始反应。但是,当我们发布事件时,我们也让之前不知道正在执行命令的领域模型的其他元素知道发生了某些事情。这对于一次不执行与某个动作相关联的所有工作非常有用。理想情况下,我们应该将处理命令所需的工作量限制在绝对最小。技术上,这样的原子操作可以表示为一个事务。可能还有一些其他操作也需要作为领域模型状态转换的结果来执行,但我们不需要将这些动作包裹在一个事务中并强制用户等待所有这些工作完成。这正是我们需要政策的地方。政策订阅领域事件,当政策接收到它感兴趣的某些领域事件时,它会检查事件内容,并可能向系统发送另一个命令以补充工作。可能有多个政策对同一事件类型做出反应,以异步方式执行各种后处理,而用户在原始命令执行后重新获得控制权。
我们可以在我们的建模空间中这样表达一个政策:

政策可能会根据事件触发命令
看这个模型,我们可以这样转录——当分类广告的所有者将其标记为已售出时,系统也应停用此广告。
如你所见,一个政策可以响应领域事件并发布命令,基于某些条件。这种行为被称为反应行为,而积极使用这种模式的系统可以被称为反应系统。
请注意,术语“反应性”在近年来变得模糊不清。反应宣言提出了“反应系统”的定义,这与我在本书中的意思不同。
一同来做
总结一下,我们可以绘制出本节中引入的所有元素的概念图,如下所示:

解释(几乎)一切的画面——阿尔贝托·布兰多利尼
此图的转录将是——用户,使用来自系统的信息,表示为读取模型,以及来自外部世界的信息、用户的感受和思考,向系统发送操作请求,称为命令,这可能导致系统状态发生变化,从而产生领域事件。领域事件可以触发策略,这些策略可能会根据在事件中接收到的信息和系统状态发出新的命令。外部系统也可以产生领域事件。系统状态的变化还会导致读取模型被更新,因此用户可以从系统中接收新的信息,循环重复。
此图可以描述大多数系统,你可能想象它不仅适用于软件系统。该图也与 CQRS 非常吻合,我相信这使 CQRS 模式非常有用。有些人可能会争论,CQRS 由于实施工作而给系统增加了意外的复杂性。然而,当正确实施时,它为模型增加了更多的清晰度,因为它直接实现了关注点分离(SoC)原则(埃德加·W·迪杰斯特拉,《科学思维的作用》,1974 年),并且通常使系统更容易构建和维护。
建模参考域
在本节中,我们将使用本章中介绍的工具来设计参考域的一部分。
我们团队再次聚在一起,更详细地讨论系统的一部分。他们决定,在第一阶段,他们的核心领域是分类广告的生命周期,而与额外服务和支付相关的部分将由于投资者决定在货币化应用程序之前获取用户而稍后实施。
重要的是要认识到,这样的决策不能仅由开发者做出,并且涉及所有利益相关者在决策过程中的参与至关重要。
首先,我们需要快速回顾一下被确定为分类广告生命周期一部分的事件。我们的团队开始工作,但很快达到了他们在墙上看到这一点:

会话的第一轮——仅限领域事件
如你所见,此模型与上一章的最终模型略有不同。每次团队讨论模型时,都会发生一些变化,因为团队成员对领域有了更好的理解。
接下来,他们添加了一些命令,这些命令导致模型上已经存在的事件。在许多情况下,命令展示了用户直接意图做某事,从而直接导致事件。这些命令是最明显的一些,首先出现在模型中。
在一段时间之后,模型看起来是这样的:

事件和命令
团队随后同意大多数命令都是由用户执行的,但随后就引发了关于谁是用户的讨论。从技术上讲,使用该系统的每个人都是用户。但不同的人可以执行不同的操作,某些管理员允许执行的操作普通用户则不能执行。这是显而易见的,但由此,团队认识到需要根据人们的行为来区分他们。当然,一个人可以扮演不同的角色,但在这个具体的例子中,大多数操作都是由一种类型的用户执行的——那些想要 sell 东西的人。自然地,这个角色被认定为 seller。在做出这一发现后,模型开始变得更加详细:

事件、命令和参与者
如您所见,在过程中,更多的角色被识别并分配给命令。例如,广告不能由卖家批准或拒绝;这显然没有意义。服务内部的人需要完成这项工作,或者允许广告发布,或者因为某种原因拒绝发布。
趣味的是,由于讨论的上下文仅涵盖广告生命周期,没有买家参与。因此,团队继续交替使用 owner 和 seller 这两个词。为了保持一致性,他们倾向于使用 seller 这个词,但记住,Ubiquitous Language 中的所有词汇都是上下文特定的。我们没有在这里提到 buyer 的原因是,就团队目前所关心的,他们只会处理系统其他部分的买家,这意味着另一个上下文。初步来看,他们认为买家将参与关于购买协议和条款的讨论,以及在托管情境下,如果需要的话。此外,团队还在思考相互审查的问题,自然地,卖家和买家都将参与这项活动。
然后,思绪开始飘向因不同原因出现的事件。其中之一是 Ad deactivated。卖家在查看广告时可能会点击 Deactivate 按钮,或者广告可能会被审阅者拒绝发布。发现是,当触发一个使广告失效的策略时,Deactivate Ad 命令可以由卖家和系统本身执行。将策略添加到模型中导致了一些更详细的细节:

模型中包含策略的部分
最后,团队审查了一些需要显示特定信息以允许用户做出决策并作为命令执行的命令。并非在第一次迭代中都能达到这样的清晰度,在这种情况下,团队需要将做出任何决策推迟到以后的阶段。与过于细致的模型相比,取得进展并继续前进更为重要,因为无论如何,这样的模型永远不会完美。
例如,当向广告添加类别时,卖家必须能够使用一些高级技术,如自动完成搜索,从现有类别的列表中进行选择。对于审阅者来说,不仅看到广告的内容有帮助,还能获取更多关于撰写广告的卖家的详细信息。一系列因素,如卖家在平台上的经验、之前发布的广告数量、当前发布的广告数量以及最终的当前广告内容,可以为审阅者提供清晰度,帮助他们区分恶意广告和合法广告。
当与读取模型一起工作时,开发者有很多机会与 UX 专家、UI 设计师以及其他人员合作,因为真实模型是 UI 的自然组成部分。但也不应忘记命令,因为它们是使系统执行有用操作的处理器。没有命令,整个系统将只是一个静态页面的集合,因为没有方法可以改变系统的状态和执行任何行为。任何系统的整个 UI 都是由读取模型组成的,这些模型通过按钮和其他触发动作的元素附加了命令执行器。
你还可能考虑基于任务的 UI,这是一种设计 UI 元素的有用方法,可以引导用户进行简单、原子和精确的操作。在我们的例子中,我们也使用了基于任务的 UI,因为我们的卖家将广告价格与广告标题分开更改,仅仅因为这些操作实质上不同。基于任务的 UI 的理念与 CQRS 和命令处理非常一致。毫不奇怪,关于这种技术的最多信息可以在 Greg Young 与 CQRS 相关的文章中找到,例如这篇:cqrs.wordpress.com/documents/task-based-ui/。
摘要
在本章中,我们确定了领域模型,并同意模型代表现实生活的一部分,旨在解决某些特定问题。我们还讨论了行为的重要性,以及它是模型的一个基本部分,通常被忽视甚至被忽略。
在这个过程中,我们介绍了 CQRS 模式。它将命令作为模型内部要执行的行为与仅用于检索状态的查询区分开来。
然后,我们为 EventStorming 建模技术增加了更多元素,以便更深入地建模细节,朝着我们可以开始编码实现的方向发展。我们认识到这些新元素与 CQRS 范式非常匹配。
最后,我们进行了样本领域的建模会议,并对系统核心部分应该如何工作有了更深入的了解,因此我们现在准备将这一知识转化为代码。这就是我们在下一章将要做的事情。
进一步阅读
更多信息,请参考以下资源列表:
-
介绍 EventStorming,Brandolini A. (2017),Leanpub (
leanpub.com/introducing_eventstorming) -
微软归纳用户界面指南,微软公司,2001 (
msdn.microsoft.com/en-us/library/ms997506.aspx) -
基于任务的 UI (
cqrs.wordpress.com/documents/task-based-ui/)
第五章:实现模型
在前面的章节中,我们经历了一个不同层次的知识压缩和领域分析。我们使用 EventStorming 作为我们的主要工具,因此,作为我们努力的结果,我们得到了大量的纸卷,上面贴满了五颜六色的便利贴。但如何从这些纸卷中生成一些可工作的代码呢?这是一个好问题,这正是我们在本章继续前进时将开始做的事情。
到本章结束时,我们将有一个基于代码实现的领域模型的基础。我们将探讨在领域实体中执行行为的不同风格,并编写一些测试。
将涵盖以下主题:
-
为领域模型创建项目
-
将领域对象添加到新项目中
-
实体和值对象是什么
-
如何确保领域模型始终处于有效状态
技术要求
本章将提供一些实际操作的指南。为了跟进,你需要以下工具:
-
.NET Core 2.2.203 或更高版本 (
www.asp.net/) -
Visual Studio 2017 或更高版本 (
www.visualstudio.com/vs/),或 JetBrains Rider (www.jetbrains.com/rider/)
对于平台没有特别的要求,因为.NET Core 和工具几乎无处不在。在整个书中,我将使用 macOS 上的 Rider。初始截图将来自 Windows 的 Visual Studio 2017,因为大多数读者都会使用这个 IDE。一些对话框在 Windows Visual Studio、Visual Studio for Mac 和 Rider 之间差异很大。
我将在代码中使用 C# 8.0 的一些功能,因此需要使用 .NET Core SDK 2.2.203 或更高版本。
我假设你熟悉你正在使用的工具以及.NET Stack,因此你知道如何创建项目、构建它们并在不同的环境中执行应用程序。
开始实施
在本节中,我们将创建一个新的项目并向其中添加一个领域项目。
由于我们计划实现一个 Web 应用程序,我们将从一开始就考虑这一点,并使用 Web 应用程序模板。我们还将添加一些项目来托管我们系统的不同部分和测试。
如果你不太熟悉.NET 的工具,你总是可以检查 Mapt,Packt 书籍和视频课程的广泛图书馆,并使用那里的材料来提高你的技能。在这本书中,我们假设读者对 C#和用于开发.NET 应用程序的工具有足够的了解。
创建项目
我们将从一个空项目开始。你需要创建一个.NET Core Web 应用程序,并确保启用创建 Git 仓库,以便它可以记录你的更改历史。
我们预期我们的系统会变得更加复杂,但我们从简单开始。让我们将解决方案命名为Marketplace,我们的第一个项目也将这样命名。要创建的项目类型是 ASP.NET Core Web 应用程序。这个项目是我们的启动项目,将由.NET 运行时执行。您需要为新 Web API 项目选择Empty项目类型,因为我们不会使用 Razor 或 SPA(单页应用程序)模板。
我们已经讨论过,领域模型不应该依赖于基础设施。一般来说,它不应该引用任何东西,除了标准语言类型、自身以及如果需要的话,一些基类和接口集合。为了强制执行这一点,让我们创建一个单独的项目,我们将把所有的领域对象放在这个项目中。向解决方案中添加一个更多项目,并将其命名为Marketplace.Domain。这个项目将不会独立执行,因此项目类型应该是类库 (.NET Standard),并且不要忘记将其框架更改为 netstandard2.0(或更高版本)。.NET Standard 是类库的默认值,但如果您不打算在旧版.NET Framework 应用程序中使用您的库,也可以自由使用 ASP.NET Core 目标框架。
然后,我们需要添加一个用于单元测试的更多项目。您可以通过向解决方案中添加一个项目来实现,该项目的名称将是Marketplace.Tests。项目类型是单元测试项目,类型是 xUnit,因为我们将在本书中使用 xUnit.net 测试框架进行测试。xUnit.NET 测试项目是 ASP.NET Core SDK 的默认测试项目模板之一。请记住将Marketplace.Domain项目添加到测试项目中,因为我们主要将测试我们的领域代码。
解决方案现在应该看起来像以下截图:

解决方案结构的初步了解
有道理移除wwwroot和Properties文件夹,Class1.cs和UnitTest1.cs文件,因为我们将从零开始创建新类,我们不需要空文件夹悬挂在那里。
框架
我们需要找到一个地方来放置上一节中提到的这组基类和接口。我们可能会争论这些是否真的需要。对于启动一个简单的Hello World风格的项目来说,它们不是必需的,但随着我们的深入,我们将需要创建更多的抽象。
此外,我们还需要一些组件,这些组件将允许我们的领域模型与数据库、消息总线、Web 服务器等事物一起工作。根据洋葱架构原则,这些是适配器。最终,我们的项目将需要有一组适配器,用于所有正在使用的基础设施。
你可能会合理地问——我们是要构建一个框架吗?框架不是被认为很糟糕,不应该避免吗?嗯,我们应该对一切持保留态度,并且拥有一套对我们领域对象和基础设施有用的抽象将大大帮助我们前进。此外,我们肯定需要构建一些适配器,尽管这些适配器可以分离到它们自己的库中,这些库将连接到我们的抽象(因此是端口和适配器),出于简单起见,我们将把这些东西的大部分放在一个项目中,这个项目将被称为Marketplace.Framework。如果你不喜欢Framework这个名字,你可以为这个项目选择任何其他名字,但在这本书中,我们将大量引用它,所以我希望你不要感到困惑。
到这本书结束时,这个框架中的大多数抽象和实现都将准备好投入生产。与第三方框架不同,你完全控制着里面的内容及其工作方式,所以即使你根本不喜欢框架,这也应该使其成为一种较温和的“邪恶”。
许多 DDD 实践者一直在重复“你不需要 DDD 框架”的咒语,这在某种程度上是正确的,但人们总是需要为他们的应用程序设定一些基准以加快开发速度。我们使用由微软构建的.NET Framework,而不是为每个项目从头开始创建所有这些类。当我们拥有框架中一套有用的抽象和组件时,我们可以在其他项目中使用它或类似的东西,并完全控制。
因此,为了完成本节,向同一解决方案添加一个类库项目。它的框架应该是netstandard2.0(或更高版本),就像Marketplace.Domain项目一样。将这个新项目命名为Marketplace.Framework
将模型转换为代码
由于我们不是在进行Hello World练习,所以我们暂时不会使用可执行项目。相反,我们将专注于在领域项目中编写内容,向框架项目添加一些实用的类和接口,并编写测试。
首先,我们需要确定我们的实现将基于哪些构建块。这些构建块通常被称为领域驱动设计(DDD)战术模式,而不是 DDD 战略模式。有些人甚至说可以忽略战术模式,而优先考虑战略模式。虽然我同意通用语言、边界上下文和上下文图是 DDD 的必要部分,但我仍然相信一些战术模式是有用的,并为实现提供了清晰性和共同语言。这本书不是战术 DDD 模式的集合,相关概念只有在必要时才会用于实现模型。
实体
让我们回到我们的 EventStorming 会话,并查看我们的模型的一部分,如下所示:

使用便利贴建模的核心领域
在所有这些命令中都有重复。你能看到吗?所有这些命令都是在被称为分类广告的东西上执行的。此外,如果你还记得与领域专家的所有那些对话,我们的团队成员在谈论一般业务以及模型时,经常提到这个术语。
主要地,这里有一个实体。实体代表同一类型的独特对象。除了分类广告外,我们可能还期望我们的系统保存有关卖家和买家的信息,这些信息也可能是实体,而不仅仅是系统中的角色。这是因为我们需要识别这些人,所以我们需要有一些独特的东西,比如用户名或电子邮件地址,来了解谁是谁。对于分类广告也是如此。想象一下以下广告:

用户界面草图
如果我们忽略卖家和价格,这两个广告是相同的。但,很可能是两个不同的对象。由于 IKEA 的大规模生产,同时出售多个类似使用过的对象的可能性很大,但对我们来说,这些对象是不同的。这是因为我们不是使用对象属性,如型号和尺寸,来确定两个对象是否相同。在我们的系统中,这些对象将以两个不同的分类广告形式表示,并将拥有独立的身份。
身份
我们提到用户名或电子邮件作为系统中用户的身份,但什么可以作为对象的身份?在现实生活中,许多对象已经得到了识别。最常见的是物品序列号。像智能手机、电视、电脑和汽车这样的复杂对象具有独特的标识符,这有助于制造商了解这些对象是以何种配置生产的,因此他们可以提供更好的支持。此外,由于这些对象的显著价格,它们通常被单独追踪。
然而,当我们谈论我们的系统时,大多数时候我们需要使用我们自己的身份。真正重要的规则是,所有实体都需要有唯一的标识。有几种方法可以获得这样的身份,你可能已经熟悉其中的一些。目前最常用的获取唯一身份的方法(稍后称为ID)是使用唯一的数据库键。这是因为大多数系统都是面向数据的,并且以持久性为首要考虑。这样的系统如果没有将数据持久化到特定的数据库中是无法工作的。这种方法至少有一个明显的优势——这样的 ID 通常是数字和递增的,因此通过电话直接指定这样的 ID 非常直接。但最显著的缺点来自 ID 的来源——必须存在数据库才能获得这样的身份,即使后来在流程中,系统决定不接受该对象并丢弃它,因此它永远不会被持久化。大多数经验丰富的开发者都见过代码中一些奇怪的构造,其中在某个表中插入一个空或虚拟行以获取对象 ID,稍后这样的行要么需要填充真实值,要么需要删除。这种方法会引发一系列问题,我们不会使用它。
相反,我们将使用生成的唯一 ID。因为我们更愿意不使用任何基础设施来创建我们的 ID,我们将使用一种可靠的方法和身份类型——一个全局唯一标识符(GUID),更常见的是称为通用唯一标识符(UUID)。这样的 ID 可以使用当前时间和一些关于计算机的信息生成,其中它被产生。这样的 ID 具有很高的全球唯一性概率。当使用 GUID 时,我们可以在接触任何基础设施之前为对象生成身份,例如,创建对仅存在于内存中的对象的引用。
分类广告实体
如我们从 EventStorming 模型中理解的那样,我们很可能需要一个实体来表示分类广告。这似乎是我们系统中的一个核心概念。我们花了很多时间与领域专家讨论我们的模型,这个术语在谈话中不断出现。这是一个完美的指标,表明我们也识别了一些重要的领域概念,因为我们总是在一个命令导致一个事件时得到一个重复的模式,我们总是有一个分类广告作为对象。
实体在代码中以对象的形式表示,因此我们需要一个类,这样我们就可以创建此类实例。这是我们第一次尝试创建一个类来表示分类广告:
namespace Marketplace.Domain
{
public class ClassifiedAd
{
public Guid Id { get; private set; }
private Guid _ownerId;
private string _title;
private string _text;
private decimal _price;
}
}
你可能会对这个类感到困惑,你质疑这种实体实现方式是正确的。它看起来像是一个属性包,与这里的 DTO(数据传输对象)的唯一区别是,这个类只有一个属性,所有其他细节都由私有字段表示。这个类可以编译,但实际上是不可用的,因为即使是单个公共属性也只能在类内部设置,但我们没有公开任何这样做的方法。
然而,尽管这个实现没有用,但它展示了我们在进一步推进时需要牢记的两个基本原则。首先,所有实体都需要有一个 ID,并且它必须可以从实体外部访问。其次,由于我们正在使用面向对象的语言,我们应该尽可能地封装,并保持我们的内部安全,最好是对外部世界不可见。
为了能够正确地实例化这个类,让我们创建一个构造函数,至少允许我们设置实体的 id:
using System;
namespace Marketplace.Domain
{
public class ClassifiedAd
{
public Guid Id { get; }
public ClassifiedAd(Guid id)
{
if (id == default)
throw new ArgumentException(
"Identity must be specified", nameof(id));
Id = id;
}
private Guid _ownerId;
private string _title;
private string _text;
private decimal _price;
}
}
在这里添加了以下内容:
-
由于我们只在构造函数中设置了
Id属性值,我们可以将其设为只读属性。 -
在创建
ClassifiedAd的实例时,我们必须提供id,因为没有无参数构造函数。 -
提供的
id必须是有效的。否则,构造函数将抛出参数异常。
就在这里,我们强制执行了这样一个规则:我们的实体只能通过提供一个有效的参数集(目前只有一个)来创建,并且根据定义,任何给定类型的创建实体都是合法的。你可能担心,如果没有一些人类可读的属性,如标题和价格,分类广告实际上是不正确的,但这种担忧不是技术性的。企业可能会决定这确实是一个有效的实体。
添加行为
我们接下来要做的事情是弄清楚我们可以告诉我们的实体做什么。记住,我们需要(设计和实现)首先设计(和实现)行为。我们添加那些 private 字段到实体的唯一原因实际上是为了支持行为。正如我们之前讨论的,系统中执行的每个操作都会改变系统状态,而这些 private 字段正是代表这种状态。但是,再次强调,由于封装被强制执行,我们不应允许通过从实体外部更改属性值来操作实体状态;这将把我们引向 CRUD 的尘土飞扬之地。让我们看看我们如何给实体注入生命:
namespace Marketplace.Domain
{
public class ClassifiedAd
{
public Guid Id { get; }
public ClassifiedAd(Guid id)
{
if (id == default)
throw new ArgumentException(
"Identity must be specified", nameof(id));
Id = id;
}
public void SetTitle(string title) => _title = title;
public void UpdateText(string text) => _text = text;
public void UpdatePrice(decimal price) => _price = price;
private Guid _ownerId;
private string _title;
private string _text;
private decimal _price;
}
}
我们添加了三种简单的方法,你可能会感到有些失望,因为这些是属性设置器(甚至不是被夸大的)。但是,我们在这里所做的是在代码中表达使用通用语言的想法,并将从粘性笔记(在这种情况下是命令)到方法的词汇转换。
当然,这只是一个开始。在下一节中,我们将更深入地探讨我们的实体实现,并找出表达行为的那些方法如何变得更加有用。
确保正确性
在上一节中,我们检查了实体构造函数的参数是否有效,以确保新创建的实体对象也是正确的。我们应用了一个约束,不允许在不指定有效参数值的情况下创建新的实体。通过这样做,我们保护了领域模型免受无效对象的影响。这是领域模型的基本功能之一,并且由于我们采用了行为优先的方法,这种类型的代码需要成为领域模型实现的一部分,而不是外包给外部层,如 UI 或应用服务层。当然,由于我们的领域模型是系统核心,数据从用户界面移动到领域对象需要几个步骤。在数据尝试进入领域模型之前对其进行初步质量检查是有效的方法。这由于反馈更快而改善了用户体验。然而,最终的控制始终在领域模型内部执行,因为它绝不应该进入无效状态。
输入值的约束
你可能已经在上一节中注意到了实体实现中的一些缺陷。目前有很多,但让我们看看最明显的一个。这里严重缺失的是所有者 ID。很难相信我们可以允许没有所有者就拥有广告。在这种情况下,我们将如何理解谁可以修改这些广告的内容?此外,我们已经在类中有了_ownerId字段。所以,让我们给构造函数添加一个额外的参数来强制执行这个约束:
public ClassifiedAd(Guid id, Guid ownerId)
{
if (id == default)
throw new ArgumentException(
"Identity must be specified", nameof(id));
if (ownerId == default)
throw new ArgumentException(
"Owner id must be specified", nameof(ownerId));
Id = id;
_ownerId = ownerId;
}
从现在开始,我们不会列出整个类,而只是列出正在更改的部分。
我们不仅添加了一个额外的参数,还添加了一个额外的检查。因此,现在我们的实体在创建后保证是有效的,因为客户端必须提供广告 ID 和所有者 ID。
创建分类广告实体的代码看起来如下:
public void CreateClassifiedAd(Guid id, Guid ownerId)
{
var classifiedAd = new ClassifiedAd(id, ownerId);
// store the entity somehow
}
注意,我们正在向实体构造函数添加更多参数,构造函数本身也在增长,因为我们为这些参数添加了更多的检查。最终,由于许多规则混合在一个大块代码中,所以很难理解正在发生什么。此外,很明显,我们没有检查涉及实体多个属性的核心理解规则。在我们的案例中,我们控制的是每个参数都有值。这种方法并不错误,但也不是理想的。相反,我们可以在到达实体构造函数之前,使用值对象来检查这些值的有效性。
值对象
值对象模式并不仅限于领域驱动设计(DDD),但它可能在 DDD 社区中变得最为流行。这可能是由于值对象的特点,如表达性和强封装性。从根本上讲,值对象允许使用显式类型声明实体属性,这些类型使用通用语言。此外,这样的对象可以显式定义它们如何被创建,以及它们之间可以执行哪些操作。这是一个将隐式操作显式化的完美例子。
通过在我们的代码中创建一个值对象,让我们更深入地了解什么是值对象。之前,我们在实体构造函数中接受ownerId参数,并检查它是否具有非默认的 GUID。在这里,我们想要的是一个用户 ID,因为我们知道广告所有者是我们用户之一,因为人们在创建分类广告之前需要在系统中注册。这意味着我们可以通过使用一个新的类型UserId来拥抱类型系统,并通过使用UserId而不是Guid来使隐式操作更明确。
让我们在Marketplace.Domain项目中创建一个新的类,命名为UserId。这个类的初始代码如下所示:
using System;
namespace Marketplace.Domain
{
public class UserId
{
private readonly Guid _value;
public UserId(Guid value)
{
if (value == default)
throw new ArgumentNullException(
nameof(value), "User id cannot be empty");
_value = value;
}
}
}
正如你所见,我们将断言身份值不是空 GUID 的逻辑移动到了UserId构造函数中。这意味着我们可以将实体构造函数更改为以下形式:
public class ClassifiedAd
{
public Guid Id { get; }
private UserId _ownerId;
public ClassifiedAd(Guid id, UserId ownerId)
{
if (id == default)
throw new ArgumentException(
"Identity must be specified", nameof(id));
Id = id;
_ownerId = ownerId;
}
// rest of the code skipped
}
我们的实体没有对ownerId进行检查,因为我们通过接收类型为UserId的参数,保证了值的有效性。当然,我们在这里并没有检查提供的 GUID 是否指向一个有效的用户,但这至少不是我们的初衷。
然而,我们仍然在实体构造函数中对参数的有效性进行了一次检查。让我们通过添加一个名为ClassifiedAdId的类,将实体id的类型也改为值对象,代码如下:
using System;
namespace Marketplace.Domain
{
public class ClassifiedAdId
{
private readonly Guid _value;
public ClassifiedAdId(Guid value)
{
if (value == default)
throw new ArgumentNullException(
nameof(value),
"Classified Ad id cannot be empty");
_value = value;
}
}
}
现在我们的构造函数没有任何检查,但它仍然可以创建一个有效的实体:
public class ClassifiedAd
{
public ClassifiedAdId Id { get; }
private UserId _ownerId;
public ClassifiedAd(ClassifiedAdId id, UserId ownerId)
{
Id = id;
_ownerId = ownerId;
}
// rest of the code skipped
}
当我们移动到应用层,我们的实体将被构建时,我们可以想象构造函数的调用将如下所示(假设id和ownerId的类型为Guid):
var classifiedAd = new ClassifiedAd(new ClassifiedAdId(id), new UserId(ownerId));
上述代码清楚地表明,我们首先向实体构造函数发送分类广告 ID,然后是所有者 ID。当我们使用Guid作为两个参数的类型时,如果我们不小心改变了参数的顺序,我们的应用程序仍然可以编译,但当然,我们的实体将被错误地构建,整个系统可能在执行管道的某个深层位置崩溃。值对象类型的强类型参数迫使编译器参与类型检查,如果我们搞错了参数,代码将无法编译。
但是值对象不仅仅是原始类型的包装类型。正如我们之前所学的,实体被认为是相等的,如果它们的身份相同。值对象是不同的,因为它们的相等性是通过值来建立的,这就是模式名称的由来。一个经典的值对象例子是货币。如果我们拿两张€5 的银行纸币,它们代表两个不同的实体,因为它们实际上是两个截然不同的对象,甚至上面印有独特的编号。但在支付时,它们是完全相同的,因为它们都有相同的€5 价值。
但我们如何在代码中表示它呢?让我们创建Money类并尝试一下:
namespace Marketplace.Domain
{
public class Money
{
public decimal Amount { get; }
public Money(decimal amount) Amount = amount;
}
}
现在,让我们编写一个简单的测试来检查两个Money类型的对象是否相等,如果金额相等:
using Marketplace.Domain;
using Xunit;
namespace Marketplace.Tests
{
public class MoneyTest
{
[Fact]
public void
Money_objects_with_the_same_amount_should_be_equal()
{
var firstAmount = new Money(5);
var secondAmount = new Money(5);
Assert.Equal(firstAmount, secondAmount);
}
}
}
当然,这个测试失败了,因为类实例是一个引用对象,同一类的两个实例是不同的对象,无论它们的属性和字段包含什么。我们可以得出结论,Money类以及我们的UserId和ClassifiedAdId类都不能表示值对象。
为了使Money类更接近于正确的值对象类型,我们需要它实现IEquatable接口。类的实例需要与同一类型的实例进行比较,因此我们需要Money实现IEquatable<Money>。如果你将此接口添加到类中,在 Rider 中,以及在 Visual Studio 的 Resharper 中,将会有一个选项自动生成必要的代码,使用“生成相等性成员”重构建议:

在 Rider 中生成相等性成员
因此,如果启用了重载相等性运算符选项,也会创建隐式相等性运算符的代码。所以,我们的Money类的代码将看起来像下面这样:
using System;
namespace Marketplace.Domain
{
public class Money : IEquatable<Money>
{
public decimal Amount { get; }
public Money(decimal amount) => Amount = amount;
public bool Equals(Money other)
{
if (ReferenceEquals(null, other)) return false;
if (ReferenceEquals(this, other)) return true;
return Amount.Equals(other.Amount);
}
public override bool Equals(object obj)
{
if (ReferenceEquals(null, obj)) return false;
if (ReferenceEquals(this, obj)) return true;
if (obj.GetType() != this.GetType()) return false;
return Equals((Money) obj);
}
public override int GetHashCode() => Amount.GetHashCode();
public static bool operator ==(Money left, Money right) =>
Equals(left, right);
public static bool operator !=(Money left, Money right) =>
!Equals(left, right);
}
}
如果我们现在运行相同的测试,它将通过,因为当我们调用Assert.Equals(firstAmount, secondAmount)时,前面的代码将比较两个实例的_value字段的值,当这些值相同时。因为我们还创建了隐式相等性运算符的代码,我们可以在代码中使用比较if (firstAmount == secondAmount)。
现在,假设我们需要为每个创建的值对象类型编写所有这些代码。是的,通过 Resharper 的一些很好的自动魔法,我们可以非常快速地生成此代码,然后将其隐藏在一个区域中,该区域始终是折叠的。但是,如果我们决定向值对象添加一个更多属性,我们需要重新打开这个区域,并在几个地方添加这个新属性。
我们可以通过使用基类来减少样板代码的数量,并使等价比较方法变得动态。创建这样的基类至少有两种方法。一种方法包括使用反射来发现实现类型中的所有字段,并使用它们进行等价比较。另一种方法涉及创建一个抽象方法,每个实现都必须重写它以提供用于等价比较的特定值。虽然第一种方法由于所有字段都是自动发现并使用的,因此可以编写更少的代码,但第二种方法允许我们选择哪些属性将用于等价比较。
在 C#的下一个版本中,这个新特性可能会在您阅读这本书的时候已经可用,它被称为记录类型。从高层次来看,记录类型将与 F#记录类似。使用记录类型,值对象的声明将变得非常简短,所有与等价(以及更多)相关的样板代码将由编译器生成。
例如,提前声明Money类型可以这样一行完成:
public class Money(double amount);
在整本书中,我使用的是类,它们是引用类型,与结构体不同,结构体是值类型。这意味着这些值对象并不完全遵循不可变原则。然而,我们将尽可能确保这些对象不能被自由更改,但使用对象实例的赋值运算符只会分配对原始对象的引用,这与值类型不同。
在Marketplace.Framework项目中使用抽象基类,我们现在可以将Money类重构为以下形式:
using Marketplace.Framework;
namespace Marketplace.Domain
{
public class Money : Value<Money>
{
public decimal Amount { get; }
public Money(decimal amount) => Amount = amount;
}
}
如您所见,所有样板代码现在都已移动到基类,我们回到了本质。然而,测试仍然通过,因为基类中实现了适当的等价比较。
到目前为止,我们只有直接规则在值对象中,但当我们处理金钱时,我们应该添加一个有用的检查。很少,如果我们谈论金钱,我们指的是负数。是的,这样的金额在会计中存在,但我们不是在构建会计系统。在我们的领域内,分类广告需要有一个价格,价格不能为负,正如我们的领域专家解释的那样。因此,我们可以在以下代码中用一个新的值对象表示这个规则:
using System;
namespace Marketplace.Domain
{
public class Price : Money
{
public Price(decimal amount) : base(amount)
{
if (amount < 0)
throw new ArgumentException(
"Price cannot be negative",
nameof(amount));
}
}
}
因此,尽管我们有基类,Money类仍然允许其金额为负数或零;价格始终为正,因此在我们的领域内始终有效。
谈到不可变,我们必须确保我们的值对象没有暴露任何方法,这些方法允许在这些对象内部更改字段值。如果我们想在值对象实例上执行某些操作,它需要产生一个相同类型的新实例,但具有新的值。通过这样做,我们确保原始对象将保留其值。
让我们看看Money示例,并给它添加一些有用的操作,同时考虑到不可变性:
using Marketplace.Framework;
namespace Marketplace.Domain
{
public class Money : Value<Money>
{
public decimal Amount { get; }
public Money(decimal amount) => Amount = amount;
public Money Add(Money summand) =>
new Money(Amount + summand.Amount);
public Money Subtract(Money subtrahend) =>
new Money(Amount - subtrahend.Amount);
public static Money operator +(
Money summand1, Money summand2) => summand1.Add(summand2);
public static Money operator -(
Money minuend, Money subtrahend) =>
minuend.Subtract(subtrahend);
}
}
如果我们有一枚 1 欧元硬币和两枚 2 欧元硬币,总价值是 5 欧元。如果我们与一张 5 欧元的纸币相比,其价值相同。由于我们不对这些货币工具的形状、大小和重量感兴趣,我们只对价值感兴趣,我们可以得出结论,这两个的价值是相等的。我们之前的新Money类让我们可以在测试代码中表达这个陈述,当我们运行它时,它会变成绿色:
[Fact]
public void Sum_of_money_gives_full_amount()
{
var coin1 = new Money(1);
var coin2 = new Money(2);
var coin3 = new Money(2);
var banknote = new Money(5);
Assert.Equal(banknote, coin1 + coin2 + coin3);
}
现在,我们最终可以重写我们的身份类,以适当的值对象实现:
public class ClassifiedAdId : Value<ClassifiedAdId>
{
private readonly Guid _value;
public ClassifiedAdId(Guid value) => _value = value;
}
public class UserId : Value<UserId>
{
private readonly Guid _value;
public UserId(Guid value) => _value = value;
}
现在,让我们更深入地探讨更多高级的方式来实例化值对象和实体。
工厂
现在,我们可以实现更多值对象,这些值对象将用于我们实体的其他字段。记住,我们在实体中有三个方法表达了其基本行为——SetTitle(string)、UpdateText(string)和UpdatePrice(double)。最容易处理的一个是最后一个,因为我们已经有了它的值对象类型——Price。让我们专注于其他两个方法,看看我们可以使用值对象而不是普通字符串来为广告标题和文本实现哪些约束。
对于分类广告标题的完整值对象类可能看起来像这样:
using System;
using Marketplace.Framework;
namespace Marketplace.Domain
{
public class ClassifiedAdTitle : Value<ClassifiedAdTitle>
{
public static ClassifiedAdTitle FromString(string title) =>
new ClassifiedAdTitle(title);
private readonly string _value;
private ClassifiedAdTitle(string value)
{
if (value.Length > 100)
throw new ArgumentOutOfRangeException(
"Title cannot be longer that 100 characters",
nameof(value));
_value = value;
}
}
}
让我们一步步来理解它是如何工作的。
首先,我们使用我们的抽象Value<T>基类来移除样板代码,就像我们在身份和price值对象中之前所做的那样。然后,跳过static方法,你可以看到private值字段,就像我们在之前创建的其他值对象中一样。然而,然后我们有一个私有的构造函数,它接受一个常规字符串参数。在构造函数内部,我们强制执行约束,即广告标题不能超过100个字符。它不会允许我们将此类检查扩展到应用程序的其他部分。你可能会问这样的问题——为什么在这个情况下构造函数是private的?这是因为我们可能有不同的数据源用于标题字符串,并且在调用构造函数之前,我们可能需要执行一些额外的操作。这在前面的代码片段中还没有完成,但我们将稍后添加这样的功能。下一个问题将是——如果构造函数是private的,我们如何构造这个类的新实例?这就是`工厂模式变得有用的地方。
工厂是用于创建领域对象实例的函数,根据定义,这些实例是有效的。工厂函数可以执行一些逻辑来构建有效的实例,并且这种逻辑可能因工厂而异。这就是为什么我们期望在一个值对象类中拥有多个工厂方法,尽管这不是一个要求。工厂还有助于通过使用适当的命名来使隐含的事物更加明确。在我们的ClassifiedAdTitle类中,我们只有一个工厂,它将字符串转换为值对象实例。它所执行的操作以及它接受的参数类型非常清晰。
让我们看看我们如何使用工厂来处理不同的用例。想象一下,我们得到一个要求广告标题部分支持Markdown的要求。实际上,我们只需要支持斜体和粗体。我们确实需要验证现有的工厂参数,因为任何字符串都是一个有效的Markdown字符串。但是,如果我们能从只能生成纯 HTML 的在线编辑器那里获取输入,我们可以在一个新的工厂函数中进行转换:
public static ClassifiedAdTitle FromHtml(string htmlTitle)
{
var supportedTagsReplaced = htmlTitle
.Replace("<i>", "*")
.Replace("</i>", "*")
.Replace("<b>", "**")
.Replace("</b>", "**");
return new ClassifiedAdTitle(Regex.Replace(
supportedTagsReplaced, "<.*?>", string.Empty));
}
我必须承认,这个函数并不完美,因为它在处理标签的数量上不足。它也无法正确处理使用大写字母编写的 HTML 标签。但是,对于演示目的来说,它足够好,可以给你一个关于可以在工厂函数中包含哪种逻辑的印象。
现在,让我们转到Price类,看看它是否可以创建一些工厂并对其应用更多规则。由于Price类继承自Amount类,我们可以考虑使Amount类更加严格:
using System;
using Marketplace.Framework;
namespace Marketplace.Domain
{
public class Money : Value<Money>
{
public static Money FromDecimal(decimal amount) =>
new Money(amount);
public static Money FromString(string amount) =>
new Money(decimal.Parse(amount));
protected Money(decimal amount)
{
if (decimal.Round(amount, 2) != amount)
throw new ArgumentOutOfRangeException(
nameof(amount),
"Amount cannot have more than two decimals");
Amount = amount;
}
public decimal Amount { get; }
// Public methods go here as before
}
}
正如你所看到的,Money类现在有一个protected构造函数,它不能从外部调用,除了像Price这样的继承类。构造函数现在检查金额参数是否有超过两位小数,如果是这样,就会抛出一个异常。最后,我们有两个工厂函数,可以从十进制或字符串参数创建Money实例。我们很可能会从 API 接收字符串,因此我们可以在工厂内部尝试解析它们。当然,如果给定的字符串不表示一个有效的数字,它将抛出十进制解析异常。
我们正在检查一个金额是否有两位小数,这通常是我们需要做的。然而,请记住,并非所有货币都支持两位小数。例如,日元必须没有小数点。日元金额总是四舍五入。你可能不知道,阿曼里亚尔支持三位小数,所以如果你计划在阿曼交付你的应用程序,你不应该使用这本书中的Money类,或者至少改变规则。
总是检查你应用的规则是否适用于你计划支持的所有市场。像货币、日期和时间格式、人名、银行账户和地址这样的东西在全球范围内可能有非常大的差异,因此检查你应用的规则是否合理总是值得的。
现在,让我们假设我们的应用程序需要支持不同的货币。我的意思是,货币信息也需要包含在这个值对象中。添加之后,我们得到如下代码:
using System;
using Marketplace.Framework;
namespace Marketplace.Domain
{
public class Money : Value<Money>
{
private const string DefaultCurrency = "EUR";
public static Money FromDecimal(
decimal amount, string currency = DefaultCurrency) =>
new Money(amount, currency);
public static Money FromString(
string amount, string currency = DefaultCurrency) =>
new Money(decimal.Parse(amount), currency);
protected Money(decimal amount, string currencyCode = "EUR")
{
if (decimal.Round(amount, 2) != amount)
throw new ArgumentOutOfRangeException(
nameof(amount),
"Amount cannot have more than two decimals");
Amount = amount;
CurrencyCode = currencyCode;
}
public decimal Amount { get; }
public string CurrencyCode { get; }
public Money Add(Money summand)
{
if (CurrencyCode != summand.CurrencyCode)
throw new CurrencyMismatchException(
"Cannot sum amounts with different currencies");
return new Money(Amount + summand.Amount);
}
public Money Subtract(Money subtrahend)
{
if (CurrencyCode != subtrahend.CurrencyCode)
throw new CurrencyMismatchException(
"Cannot subtract amounts with different currencies");
return new Money(Amount - subtrahend.Amount);
}
public static Money operator +(
Money summand1, Money summand2) =>
summand1.Add(summand2);
public static Money operator -(
Money minuend, Money subtrahend) =>
minuend.Subtract(subtrahend);
}
public class CurrencyMismatchException : Exception
{
public CurrencyMismatchException(string message) :
base(message)
{
}
}
}
首先,我们将货币信息传递给了构造函数和两个工厂方法。默认情况下,如果没有指定货币,工厂将使用EUR。我们还在类中保留了货币信息。其次,Add和Subtract方法开始检查两个操作数是否具有相同的货币。如果操作数的货币不匹配,这些方法将抛出异常。
我们还添加了一个特定于领域的异常,它明确地告诉我们,由于两个Money实例具有不同的货币,它们之间的操作无法完成。
想象一下,这种简单的技术可以防止在多货币系统中出现多少错误,因为在多货币系统中,开发者往往忘记,对于相同的十进制金额,货币价值可能会因货币的不同而截然不同。例如,一美元大约等于 110 日元,在这种情况下,将1加到110上不会得到正确的结果。
我们Money对象的一个未被覆盖的问题是,我们可以提供任何字符串作为货币代码,它都会被接受。正如你可能想象的那样,我们可以非常容易地出现这种失败:
var firstAmount = Money.FromDecimal(10, "USD");
var secondAmount = Money.FromDecimal(20, "Usd");
var thirdAmount = Money.FromDecimal(30, "$");
观察一下Money类的代码,我们可以很快得出结论,不能在这些对象的组合上执行任何操作。firstAmount + secondAmount将会崩溃,因为我们的类会决定它们具有不同的货币。thirdAmount完全无效,因为美元符号不是一个有效的货币代码,但我们的类仍然接受它。让我们看看我们能做些什么来修复它。
要检查货币代码的有效性,我们或者需要在我们的值对象类代码中保留所有有效的国家代码,或者使用一些外部服务来进行检查。第一种方法是完全自包含的,因此我们不会为值对象类有任何依赖。然而,这样做会在值对象代码中引入一个相对陌生的概念,每次金融世界发生变化时,我们都需要对其进行修改。有人可能会说,新货币并不是每天都出现,但与此同时,欧元区在过去几年中已经扩大,每次有新国家开始使用欧元时,他们的旧货币就会消失,这一点需要考虑。这些因素完全超出了我们的系统范围,在我们的代码中创建这样一个容易忘记的时间炸弹是不明智的。
领域服务
我们可以选择依赖某个外部服务,但我们知道领域模型不应该有外部依赖,那么我们如何解决这个问题呢?我们可以使用一个称为领域服务的模式。在 DDD 中,领域服务可以执行不同种类的任务,在这里,我们将探讨其中一种类型。
我们的领域服务需要检查给定的国家代码是否有效。Money类将作为依赖项获取它,因此我们需要在我们的领域模型内部声明领域服务。因为我们不希望依赖于领域模型外部的任何东西,所以我们不应该在领域模型内部放置任何实现细节。这意味着我们将在领域项目中拥有的唯一东西是领域服务接口,如下面的代码所示:
namespace Marketplace.Domain
{
public interface ICurrencyLookup
{
CurrencyDetails FindCurrency(string currencyCode);
}
public class CurrencyDetails : Value<CurrencyDetails>
{
public string CurrencyCode { get; set; }
public bool InUse { get; set; }
public int DecimalPlaces { get; set; }
public static CurrencyDetails None = new CurrencyDetails {
InUse = false};
}
}
新的接口不仅会检查给定的货币代码是否可以与某种货币匹配。因为我们已经讨论过不同货币可能有不同的小数位数,所以服务将返回包含此信息的CurrencyDetails类实例。如果未找到给定代码的货币,服务将返回CurrencyDetails.None常量。
在 C#中,如果预期函数返回引用类型的一个实例,它也可以返回 null 来表示该函数无法产生有效结果。尽管这种做法一开始可能看起来很简单,但它会引发大量问题。我们的代码充满了空检查,因为我们怀疑每个函数都可能返回 null,因此我们必须信任没有人来避免NullReferenceException。空引用有一个特定的空类型,将其分配给不应该为空的东西太容易了。
查尔斯·安东尼·理查德·霍尔爵士,更广为人知的是托尼·霍尔,他在 1965 年将空引用引入了 ALGOL 编程语言。他记得这样做是因为它很容易实现。后来,在 2009 年伦敦的 QCon 会议上,他为空引用道歉,说“我称之为我的十亿美元错误”。
视频:www.infoq.com/presentations/Null-References-The-Billion-Dollar-Mistake-Tony-Hoare。
在大多数函数式语言中,不存在空引用,因为它很容易破坏函数式组合。相反,正在使用可选类型。在先前的代码片段中,我们使用类似的技术来返回一个预定义的值,表示没有找到给定代码的货币。这个常量具有正确的类型和正确的名称,我们永远不应该检查函数输出是否为空。
为了减轻空引用问题,微软决定允许显式声明可空引用类型。默认情况下,引用类型将被假定为不可为空。这个特性将保留到下一个版本的 C#,你可以在以下链接中获取更多关于这个提议的详细信息:github.com/dotnet/csharplang/blob/master/proposals/nullable-reference-types.md。
当接口存在时,我们可以将我们的值对象修改如下:
using System;
using Marketplace.Framework;
namespace Marketplace.Domain
{
public class Money : Value<Money>
{
public static string DefaultCurrency = "EUR";
public static Money FromDecimal(
decimal amount, string currency,
ICurrencyLookup currencyLookup) =>
new Money(amount, currency, currencyLookup);
public static Money FromString(string amount, string currency,
ICurrencyLookup currencyLookup) =>
new Money(decimal.Parse(amount), currency, currencyLookup);
protected Money(decimal amount, string currencyCode,
ICurrencyLookup currencyLookup)
{
if (string.IsNullOrEmpty(currencyCode))
throw new ArgumentNullException(
nameof(currencyCode),
"Currency code must be specified");
var currency = currencyLookup.FindCurrency(currencyCode);
if (!currency.InUse)
throw new ArgumentException(
$"Currency {currencyCode} is not valid");
if (decimal.Round(
amount, currency.DecimalPlaces) != amount)
throw new ArgumentOutOfRangeException(
nameof(amount),
$"Amount in {
currencyCode} cannot have more than {
currency.DecimalPlaces} decimals");
Amount = amount;
Currency = currency;
}
private Money(decimal amount, CurrencyDetails currency)
{
Amount = amount;
Currency = currency;
}
public decimal Amount { get; }
public CurrencyDetails Currency { get; }
public Money Add(Money summand)
{
if (Currency != summand.Currency)
throw new CurrencyMismatchException(
"Cannot sum amounts with different currencies");
return new Money(Amount + summand.Amount, Currency);
}
public Money Subtract(Money subtrahend)
{
if (Currency != subtrahend.Currency)
throw new CurrencyMismatchException(
"Cannot subtract amounts with different currencies");
return new Money(Amount - subtrahend.Amount, Currency);
}
public static Money operator +(Money summand1, Money summand2)
=> summand1.Add(summand2);
public static Money operator -(Money minuend, Money subtrahend)
=> minuend.Subtract(subtrahend);
public override string ToString() => $"{
Currency.CurrencyCode} {Amount}";
}
public class CurrencyMismatchException : Exception
{
public CurrencyMismatchException(string message) :
base(message)
{
}
}
}
这里有一些新的变化,如下列所示:
-
我们给值对象添加了对货币查找域服务的依赖。由于我们使用接口,我们的领域模型仍然没有外部依赖。
-
由于我们不使用空引用来指示未找到指定代码的货币,我们不使用空检查。相反,我们检查返回的货币是否有效。由于
CurrencyDetails.NotFound常量的InUse属性设置为false,我们将抛出异常,就像我们会对任何存在但未使用的货币做的那样。 -
我们不使用两位作为最大小数位数。相反,我们从货币查找中获取这个数字,因此我们的值对象变得更加灵活。
-
对于我们的公共方法,我们需要一个简化的构造函数,因为这些方法控制两个操作数具有相同的(有效)货币。因为我们只信任我们的内部使用此构造函数,所以它需要是私有的。
Add和Subtract方法都使用此构造函数。 -
添加了
ToString重写,以便能够看到值对象的人类可读值,例如,在测试结果中。
由于我们可以提供假的货币查找,我们的Money值对象仍然非常易于测试:
using System.Collections.Generic;
using System.Linq;
using Marketplace.Domain;
namespace Marketplace.Tests
{
public class FakeCurrencyLookup : ICurrencyLookup
{
private static readonly IEnumerable<CurrencyDetails>
_currencies =
new[]
{
new CurrencyDetails
{
CurrencyCode = "EUR",
DecimalPlaces = 2,
InUse = true
},
new CurrencyDetails
{
CurrencyCode = "USD",
DecimalPlaces = 2,
InUse = true
},
new CurrencyDetails
{
CurrencyCode = "JPY",
DecimalPlaces = 0,
InUse = true
},
new CurrencyDetails
{
CurrencyCode = "DEM",
DecimalPlaces = 2,
InUse = false
}
};
public CurrencyDetails FindCurrency(string currencyCode)
{
var currency = _currencies.FirstOrDefault(x =>
x.CurrencyCode == currencyCode);
return currency ?? CurrencyDetails.None;
}
}
}
在此实现到位后,我们可以按照以下方式重构Money的测试:
using System;
using Marketplace.Domain;
using Xunit;
namespace Marketplace.Tests
{
public class Money_Spec
{
private static readonly ICurrencyLookup CurrencyLookup =
new FakeCurrencyLookup();
[Fact]
public void Two_of_same_amount_should_be_equal()
{
var firstAmount = Money.FromDecimal(5, "EUR",
CurrencyLookup);
var secondAmount = Money.FromDecimal(5, "EUR",
CurrencyLookup);
Assert.Equal(firstAmount, secondAmount);
}
[Fact]
public void Two_of_same_amount_but_different*Currencies* should_not_be_equal()
{
var firstAmount = Money.FromDecimal(5, "EUR",
CurrencyLookup);
var secondAmount = Money.FromDecimal(5, "USD",
CurrencyLookup);
Assert.NotEqual(firstAmount, secondAmount);
}
[Fact]
public void FromString_and_FromDecimal_should_be_equal()
{
var firstAmount = Money.FromDecimal(5, "EUR",
CurrencyLookup);
var secondAmount = Money.FromString("5.00", "EUR",
CurrencyLookup);
Assert.Equal(firstAmount, secondAmount);
}
[Fact]
public void Sum_of_money_gives_full_amount()
{
var coin1 = Money.FromDecimal(1, "EUR", CurrencyLookup);
var coin2 = Money.FromDecimal(2, "EUR", CurrencyLookup);
var coin3 = Money.FromDecimal(2, "EUR", CurrencyLookup);
var banknote = Money.FromDecimal(5, "EUR", CurrencyLookup);
Assert.Equal(banknote, coin1 + coin2 + coin3);
}
[Fact]
public void Unused_currency_should_not_be_allowed()
{
Assert.Throws<ArgumentException>(() =>
Money.FromDecimal(100, "DEM", CurrencyLookup)
);
}
[Fact]
public void Unknown_currency_should_not_be_allowed()
{
Assert.Throws<ArgumentException>(() =>
Money.FromDecimal(100, "WHAT?", CurrencyLookup)
);
}
[Fact]
public void Throw_when_too_many_decimal_places()
{
Assert.Throws<ArgumentOutOfRangeException>(() =>
Money.FromDecimal(100.123m, "EUR", CurrencyLookup)
);
}
[Fact]
public void Throws_on_adding_different_currencies()
{
var firstAmount = Money.FromDecimal(5, "USD",
CurrencyLookup);
var secondAmount = Money.FromDecimal(5, "EUR",
CurrencyLookup);
Assert.Throws<CurrencyMismatchException>(() =>
firstAmount + secondAmount
);
}
[Fact]
public void Throws_on_substracting_different_currencies()
{
var firstAmount = Money.FromDecimal(5, "USD",
CurrencyLookup);
var secondAmount = Money.FromDecimal(5, "EUR",
CurrencyLookup);
Assert.Throws<CurrencyMismatchException>(() =>
firstAmount - secondAmount
);
}
}
}
你可以看到,我们正在测试一些正面和负面场景,以确保那些有效操作被正确完成,并且那些无效操作不允许被执行。
实体不变性
我们已经通过使用值对象来保护无效值不被用作实体构造函数和方法参数。这种技术允许将许多检查移动到值对象中,提供良好的封装,并启用类型安全。然后,当我们创建一个新的实体或使用实体方法执行某些行为时,我们需要进行更多的检查。由于我们可以相当肯定所有参数已经包含有效的单个值,我们需要确保给定的参数组合、当前实体状态和执行行为不会使实体进入某些无效状态。
让我们看看我们为我们的分类广告实体定义了哪些复杂规则。为了找到这些规则,我们可以使用第三章中详细的事件风暴会议的一些便签,事件风暴,并将它们放在像这样的图表上:

分析命令的约束
我们将命令放在左边,事件放在右边,并试图找出什么可能阻止我们的命令以产生预期结果(事件)的方式执行。在我们的案例中,我们需要确保在广告可以放入审查队列之前,它必须有一个非空的标题、文本和价格。仅使用值对象,我们无法保证我们的实体状态作为一个整体是正确的。实体状态的有效性可能会根据实体在其生命周期中的特定时刻的状态而变化。只有在给定命令正在执行时,我们才需要检查这些约束是否得到满足。这就是我们可以称之为该实体的不变量——一个处于待审查状态的广告不能有一个空的标题、空的文本或零价格。
至少有两种方法可以确保我们的实体永远不会达到无效状态。第一种也是最明显的方法是在操作代码中添加检查。我们没有请求发布广告的方法,所以让我们添加它,并对使用值对象作为实体状态的事实进行一些更改:
namespace Marketplace.Domain
{
public class ClassifiedAd
{
public ClassifiedAdId Id { get; }
public ClassifiedAd(ClassifiedAdId id, UserId ownerId)
{
Id = id;
OwnerId = ownerId;
State = ClassifiedAdState.Inactive;
}
public void SetTitle(ClassifiedAdTitle title) => Title = title;
public void UpdateText(ClassifiedAdText text) => Text = text;
public void UpdatePrice(Price price) => Price = price;
public void RequestToPublish()
{
if (Title == null)
throw new InvalidEntityStateException(this, "title
cannot be empty");
if (Text == null)
throw new InvalidEntityStateException(this, "text
cannot be empty");
if (Price?.Amount == 0)
throw new InvalidEntityStateException(this, "price
cannot be zero");
State = ClassifiedAdState.PendingReview;
}
public UserId OwnerId { get; }
public ClassifiedAdTitle Title { get; private set; }
public ClassifiedAdText Text { get; private set; }
public Price Price { get; private set; }
public ClassifiedAdState State { get; private set; }
public UserId ApprovedBy { get; private set; }
public enum ClassifiedAdState
{
PendingReview,
Active,
Inactive,
MarkedAsSold
}
}
}
在新的实体代码中,我们将所有属性都指定为值对象,并且我们为分类广告的当前状态添加了一个属性。一开始,它被设置为Inactive,当广告被请求发布时,我们将其状态更改为PendingReview。然而,我们只有在所有检查都满足的情况下才这样做。
为了让调用者知道如果某些检查失败,我们的实体是否尚未准备好发布,我们使用我们自定义的异常,其实现方式如下:
using System;
namespace Marketplace.Domain
{
public class InvalidEntityStateException : Exception
{
public InvalidEntityStateException(object entity, string
message)
: base($"Entity {entity.GetType().Name} state change
rejected, {message}")
{
}
}
}
在操作方法本身中执行操作之前检查约束的方法有一个缺点。如果我们现在将价格更改为零,它将通过,因为UpdatePrice方法没有检查价格值。
当然,我们可以将价格检查复制到UpdatePrice方法中,但也可能有更多需要相同测试的方法,我们将继续复制控制块。这会导致一种情况,即如果我们需要更改这些规则中的任何一个,我们需要去许多地方替换所有的检查;这是非常容易出错的。
为了在一个地方合并规则,我们可以使用合同编程的技术。合同编程的一部分可以在值对象中看到,因为我们为操作方法的每个参数评估前置条件。当我们执行操作而不进行任何额外的检查时,我们需要进行一个组合测试(后置条件控制)。这个检查可以在整个实体的一个地方实现,并且每个操作都需要在方法的最后一行调用它。
对于我们的分类广告实体,它可能看起来像这样:
namespace Marketplace.Domain
{
public class ClassifiedAd
{
public ClassifiedAdId Id { get; }
public ClassifiedAd(ClassifiedAdId id, UserId ownerId)
{
Id = id;
OwnerId = ownerId;
State = ClassifiedAdState.Inactive;
EnsureValidState();
}
public void SetTitle(ClassifiedAdTitle title)
{
Title = title;
EnsureValidState();
}
public void UpdateText(ClassifiedAdText text)
{
Text = text;
EnsureValidState();
}
public void UpdatePrice(Price price)
{
Price = price;
EnsureValidState();
}
public void RequestToPublish()
{
State = ClassifiedAdState.PendingReview;
EnsureValidState();
}
protected override void EnsureValidState()
{
var valid =
Id != null &&
OwnerId != null &&
(State switch
{
ClassifiedAdState.PendingReview =>
Title != null
&& Text != null
&& Price?.Amount > 0,
ClassifiedAdState.Active =>
Title != null
&& Text != null
&& Price?.Amount > 0
&& ApprovedBy != null,
_ => true
});
if (!valid)
throw new InvalidEntityStateException(
this, $"Post-checks failed in state {State}");
}
public UserId OwnerId { get; }
public ClassifiedAdTitle Title { get; private set; }
public ClassifiedAdText Text { get; private set; }
public Price Price1 { get; private set; }
public ClassifiedAdState State { get; private set; }
public UserId ApprovedBy { get; private set; }
public enum ClassifiedAdState
{
PendingReview,
Active,
Inactive,
MarkedAsSold
}
}
}
如您所见,我们添加了一个名为EnsureValidState的方法,该方法检查在任何情况下,实体状态都是有效的,如果不是有效状态,则会抛出异常。当我们从任何操作方法调用此方法时,我们可以确信,无论我们试图做什么,我们的实体都将始终处于有效状态,或者调用者将获得异常。
此外,我们将所有private字段转换为公共只读属性。我们需要公共属性来编写测试,尽管我们不一定需要公开内部实体状态。为了防止在操作方法之外设置这些属性的值,所有属性都有私有设置器,或者对于在构造函数中设置的属性,没有设置器。
现在,让我们编写一些测试来确保我们的约束起作用:
using System;
using Marketplace.Domain;
using Xunit;
namespace Marketplace.Tests
{
public class ClassifiedAd_Publish_Spec
{
private readonly ClassifiedAd _classifiedAd;
public ClassifiedAd_Publish_Spec()
{
_classifiedAd = new ClassifiedAd(
new ClassifiedAdId(Guid.NewGuid()),
new UserId(Guid.NewGuid()));
}
[Fact]
public void Can_publish_a_valid_ad()
{
_classifiedAd.SetTitle(
ClassifiedAdTitle.FromString("Test ad"));
_classifiedAd.UpdateText(
ClassifiedAdText.FromString("Please buy my stuff"));
_classifiedAd.UpdatePrice(
Price.FromDecimal(100.10m, "EUR",
new FakeCurrencyLookup()));
_classifiedAd.RequestToPublish();
Assert.Equal(ClassifiedAd.ClassifiedAdState.PendingReview,
_classifiedAd.State);
}
[Fact]
public void Cannot_publish_without_title()
{
_classifiedAd.UpdateText(
ClassifiedAdText.FromString("Please buy my stuff"));
_classifiedAd.UpdatePrice(
Price.FromDecimal(100.10m, "EUR",
new FakeCurrencyLookup()));
Assert.Throws<InvalidEntityStateException>(() =>
_classifiedAd.RequestToPublish());
}
[Fact]
public void Cannot_publish_without_text()
{
_classifiedAd.SetTitle(
ClassifiedAdTitle.FromString("Test ad"));
_classifiedAd.UpdatePrice(
Price.FromDecimal(100.10m, "EUR",
new FakeCurrencyLookup()));
Assert.Throws<InvalidEntityStateException>(
() => _classifiedAd.RequestToPublish());
}
[Fact]
public void Cannot_publish_without_price()
{
_classifiedAd.SetTitle(
ClassifiedAdTitle.FromString("Test ad"));
_classifiedAd.UpdateText(
ClassifiedAdText.FromString("Please buy my stuff"));
Assert.Throws<InvalidEntityStateException>(
() => _classifiedAd.RequestToPublish());
}
[Fact]
public void Cannot_publish_with_zero_price()
{
_classifiedAd.SetTitle(
ClassifiedAdTitle.FromString("Test ad"));
_classifiedAd.UpdateText(
ClassifiedAdText.FromString("Please buy my stuff"));
_classifiedAd.UpdatePrice(
Price.FromDecimal(0.0m, "EUR",
new FakeCurrencyLookup()));
Assert.Throws<InvalidEntityStateException>(
() => _classifiedAd.RequestToPublish());
}
}
}
这个规范包含了对一个操作(发布或提交审查)的几个测试,这些测试有不同的前提条件。在这里,我们测试了一个愉快的路径,即在广告可以提交审查之前,所有必要的细节都正确设置;我们还测试了几个不允许发布的情况,因为这些信息是强制性的。也许测试负面场景甚至更为重要,因为当愉快的路径不起作用时,很容易发现——你的用户会立即抱怨。测试负面场景可以防止在控制实体不变性时出现错误,这反过来又防止实体变得无效。
到目前为止,你可能想知道为什么我们花了这么多时间讨论领域事件,但在代码中却一个也没有看到?我们将在下一节中讨论这个问题。
代码中的领域事件
EventStorming 使我们能够做出有用的领域发现。我们获得了一些关于领域的知识,并设法将其可视化以便共同理解。命令也出现在更详细的模式中。在这一章中,我们学习了如何创建能够保护自己免受执行无效操作并永远不进入无效状态的实体。对实体的操作是通过执行方法来进行的,这些方法与我们发现的详细模型中的命令非常相似。因此,这部分内容或多或少是清晰的,但事件到目前为止从未出现在我们的代码中。
事实上,你可以使用 DDD 原则和模式来实现一个系统,而无需任何领域事件。在花费这么多时间使用便利贴与他们一起工作之后,这听起来可能有些奇怪,但这确实是一个事实。当我们执行实体方法时,它会改变实体状态。这种状态变化是一个隐含的事件。例如,当我们的系统执行ClassifiedAd实体的RequestToPublish方法时,它将实体的State属性设置为ClassifiedAdState.PendingReview值。实际上,这可以翻译为分类广告已发送至审查,这是我们之前写在橙色便利贴上的内容。
然而,大多数情况下,将领域事件作为领域模型的一等公民具有极佳的好处。以下列出了两个主要用例,这些用例被明确地作为领域模型的一部分实现:
-
允许系统的一部分通知系统的其他部分其状态变化,使用通用语言和状态变化细节:我们之前讨论了将系统拆分成多个部分的想法,这些部分需要通过监听彼此的事件并执行必要的操作来良好地协同工作。如果一个系统以这种方式构建,不同的系统部分对彼此的变化做出反应,那么这样的系统被称为反应式系统。
-
将领域事件持久化以获取领域模型内部状态变化的完整历史:然后,任何实体的状态都可以通过读取这些事件并将它们重新应用于实体来重建。这种模式被称为事件源,我们将在本书中花费大量时间讨论它,特别是在第十章,事件源。
这两种技术可以结合使用,因此当我们持久化领域事件时,我们还可以监听系统其他部分的写入操作,并对这些事件执行反应。
在本章中,我们将探讨如何将领域事件引入代码,以及我们的实体方法如何引发它们,以便我们可以在以后使用这些事件。
领域事件作为对象
将领域事件引入代码很容易。每个事件都是一个对象。这意味着我们可以将事件类型表示为类或结构体。由于我们稍后需要序列化事件,而结构体与序列化器配合得不好,因此我们将领域事件实现为类。
我们在我们的ClassifiedAd实体上具有以下基本操作:
-
创建一个新的分类广告
-
设置广告标题
-
更新文本
-
更新价格
-
发布广告(发送进行审查)
每个这样的操作都会改变我们实体的状态,并通过这种方式引发一个想象中的领域事件。我们所有的这些事件都记录在我们的便利贴上,如下所示:

核心业务领域的完整图景
表示事件的类需要清楚地描述事件(发生了什么)并包含解释系统状态如何改变所必需的信息。通常,事件是对命令执行的响应。因此,事件中的数据通常表示命令中的数据,以及从引发事件的实体中的一些其他细节。
现在我们来创建一些领域事件类。请记住,这是我们第一次实现领域事件,如果你在阅读关于事件源等技术的内容,可能会觉得它过于简化,但这是故意的:
using System;
namespace Marketplace.Domain
{
public static class Events
{
public class ClassifiedAdCreated
{
public Guid Id { get; set; }
public Guid OwnerId { get; set; }
}
public class ClassifiedAdTitleChanged
{
public Guid Id { get; set; }
public string Title { get; set; }
}
public class ClassifiedAdTextUpdated
{
public Guid Id { get; set; }
public string AdText { get; set; }
}
public class ClassifiedAdPriceUpdated
{
public Guid Id { get; set; }
public decimal Price { get; set; }
public string CurrencyCode { get; set; }
}
public class ClassifiedAdSentForReview
{
public Guid Id { get; set; }
}
}
}
事件类被封装在Events静态类中,这为我们提供了一些命名空间。因此,这些类中的所有属性都是基本类型。我们不在事件中使用值对象。这是一个需要记住的重要事情。只在事件中使用基本类型的原因是,如前所述,域事件通常被跨系统使用。事件可以被视为我们系统发布的合约。如果我们使用事件溯源,并且事件正在被持久化,我们也无法容忍某些值对象中的规则发生变化的情况。此外,我们无法再加载我们的事件,因为值对象的数据现在被认为是无效的。当然,不在事件中使用值对象意味着需要将一些更复杂的价值对象简化。在我们的例子中,我们从Price属性提取值到ClassifiedAdPriceUpdated的两个属性中:Price,表示金额,和CurrencyCode。
你可以看到每个事件都有一个Id属性,因为没有知道它来自哪个实体就引发事件是没有意义的。因此,每个操作都需要注意在它引发的事件中填充实体id。
再次强调,关于域事件最关键的是要表示已经发生的事情,这些事情不能被改变,因为我们没有时间机器或 TARDIS 来擦除或修复过去。因此,事件应该尽可能简单,这样我们就可以始终加载过去的事件,并且这永远不会失败。
引发事件
现在,让我们看看域事件是如何在我们的实体中被使用的。首先,我们需要从我们的方法中引发事件。为了做到这一点,我们需要在实体内部有一个事件列表,以便我们可以保留正在创建的事件。否则,最初创建事件实例就几乎没有意义。
由于我们期望在实体内部以某种形式保持事件的功能,我们可以将其移动到实体的基类中,这是我们之前没有的。
让我们创建一个抽象类,并将其命名为Entity:
using System.Collections.Generic;
using System.Linq;
namespace Marketplace.Framework
{
public abstract class Entity
{
private readonly List<object> _events;
protected Entity() => _events = new List<object>();
protected void Raise(object @event) => _events.Add(@event);
public IEnumerable<object> GetChanges() =>
_events.AsEnumerable();
public void ClearChanges() => _events.Clear();
}
}
由于引发的事件将代表实体的变化,检索事件列表和清除此列表的方法被称为GetChanges和ClearChanges。
下一步是将这个基类添加到我们的实体中,并从方法中引发事件:
using Marketplace.Framework;
namespace Marketplace.Domain
{
public class ClassifiedAd : Entity
{
public ClassifiedAdId Id { get; }
public ClassifiedAd(ClassifiedAdId id, UserId ownerId)
{
Id = id;
OwnerId = ownerId;
State = ClassifiedAdState.Inactive;
EnsureValidState();
Raise(new Events.ClassifiedAdCreated
{
Id = id,
OwnerId = ownerId
});
}
public void SetTitle(ClassifiedAdTitle title)
{
Title = title;
EnsureValidState();
Raise(new Events.ClassifiedAdTitleChanged
{
Id = Id,
Title = title
});
}
public void UpdateText(ClassifiedAdText text)
{
Text = text;
EnsureValidState();
Raise(new Events.ClassifiedAdTextUpdated
{
Id = Id,
AdText = text
});
}
public void UpdatePrice(Price price)
{
Price = price;
EnsureValidState();
Raise(new Events.ClassifiedAdPriceUpdated
{
Id = Id,
Price = Price.Amount,
CurrencyCode = Price.Currency.CurrencyCode
});
}
public void RequestToPublish()
{
State = ClassifiedAdState.PendingReview;
EnsureValidState();
Raise(new Events.ClassidiedAdSentForReview{Id = Id});
}
// Rest of the entity code remains the same
}
}
因此,现在,如果我们想象我们的实体是如何在应用服务层(我们将在本书的后面部分详细讨论)中被使用的,它可能看起来是这样的:
public async Task Handle(RequestToPublish command)
{
var entity = await _repository.Load<ClassifiedAd>(command.Id);
entity.RequestToPublish();
await _repository.Save(entity);
foreach (var @event in entity.GetChanges())
{
await _bus.Publish(@event);
}
}
这段代码不是生产就绪的,正如你可以想象的那样,但它演示了如何使用领域事件在不同系统部分之间进行集成。如果我们向某个消息总线发布事件,并且我们系统中的其他组件订阅这些消息,它们可以执行反应性行为,并在它们的领域模型中做出一些更改,或者执行一些特定操作,比如发送电子邮件、短信或实时通知。随着现代单页应用框架拥抱客户端状态管理,你甚至可以更新用户当前在浏览器中拥有的信息,以实现 Web 应用程序中的实时更新。
关于实例化事件的代码,值得添加一个小备注。在那里,我们直接将值对象赋值给原始类型。这是通过使用 C#的隐式转换功能完成的,实现看起来如下:
using System;
namespace Marketplace.Domain
{
public class ClassifiedAdId
{
private readonly Guid _value;
public ClassifiedAdId(Guid value)
{
if (value == default)
throw new ArgumentNullException(nameof(value),
"Classified Ad id cannot be empty");
_value = value;
}
public static implicit operator Guid(ClassifiedAdId self) =>
self._value;
}
}
隐式转换使我们能够显著简化实体属性和事件属性之间的赋值,尽管它们是不兼容的类型。
事件改变状态
如果我们继续探讨事件源的概念,事件代表了状态变化的事实。这意味着没有与领域事件的交互,实体状态不能被改变。然而,在我们目前的代码中,改变系统状态和引发领域事件的事实是完全分离的。让我们看看我们如何可以改变它。
首先,我们需要在Entity基类中做一些更改:
using System.Collections.Generic;
using System.Linq;
namespace Marketplace.Framework
{
public abstract class Entity
{
private readonly List<object> _events;
protected Entity() => _events = new List<object>();
protected void Apply(object @event)
{
When(@event);
EnsureValidState();
_events.Add(@event);
}
protected abstract void When(object @event);
public IEnumerable<object> GetChanges()
=> _events.AsEnumerable();
public void ClearChanges() => _events.Clear();
protected abstract void EnsureValidState();
}
}
我们将Raise方法重命名为Apply,因为它不仅会将事件添加到更改列表中,而且还会将每个事件的内容物理应用到实体状态。我们通过使用每个实体都需要实现的When方法来完成。Apply方法还调用了我们之前在实体中但不在基类中拥有的EnsureValidState方法。通过这样做,我们消除了对每个实体操作调用此方法的必要性。
下一步将是应用领域事件并将所有状态更改移动到When方法:
using Marketplace.Framework;
namespace Marketplace.Domain
{
public class ClassifiedAd : Entity
{
public ClassifiedAdId Id { get; private set; }
public UserId OwnerId { get; private set; }
public ClassifiedAdTitle Title { get; private set; }
public ClassifiedAdText Text { get; private set; }
public Price Price { get; private set; }
public ClassifiedAdState State { get; private set; }
public UserId ApprovedBy { get; private set; }
public ClassifiedAd(ClassifiedAdId id, UserId ownerId) =>
Apply(new Events.ClassifiedAdCreated
{
Id = id,
OwnerId = ownerId
});
public void SetTitle(ClassifiedAdTitle title) =>
Apply(new Events.ClassifiedAdTitleChanged
{
Id = Id,
Title = title
});
public void UpdateText(ClassifiedAdText text) =>
Apply(new Events.ClassifiedAdTextUpdated
{
Id = Id,
AdText = text
});
public void UpdatePrice(Price price) =>
Apply(new Events.ClassifiedAdPriceUpdated
{
Id = Id,
Price = price.Amount,
CurrencyCode = price.Currency.CurrencyCode
});
public void RequestToPublish() =>
Apply(new Events.ClassidiedAdSentForReview {Id = Id});
protected override void When(object @event)
{
switch (@event)
{
case Events.ClassifiedAdCreated e:
Id = new ClassifiedAdId(e.Id);
OwnerId = new UserId(e.OwnerId);
State = ClassifiedAdState.Inactive;
break;
case Events.ClassifiedAdTitleChanged e:
Title = new ClassifiedAdTitle(e.Title);
break;
case Events.ClassifiedAdTextUpdated e:
Text = new ClassifiedAdText(e.AdText);
break;
case Events.ClassifiedAdPriceUpdated e:
Price = new Price(e.Price, e.CurrencyCode);
break;
case Events.ClassidiedAdSentForReview e:
State = ClassifiedAdState.PendingReview;
break;
}
}
protected override void EnsureValidState()
{
var valid =
Id != null &&
OwnerId != null &&
(State switch
{
ClassifiedAdState.PendingReview =>
Title != null
&& Text != null
&& Price?.Amount > 0,
ClassifiedAdState.Active =>
Title != null
&& Text != null
&& Price?.Amount > 0
&& ApprovedBy != null,
_ => true
});
if (!valid)
throw new InvalidEntityStateException(
this, $"Post-checks failed in state {State}");
}
public enum ClassifiedAdState
{
PendingReview,
Active,
Inactive,
MarkedAsSold
}
}
}
我们在实体类中改变了两个基本的东西,如下列所示:
-
所有用于修改实体状态(操作)的公共方法现在都应用于领域事件。那些方法中不再有状态变化或有效性检查。正如你记得的,有效性合约方法现在是从
Entity基类的Apply方法中调用的。 -
我们添加了一个
When方法重写,其中使用了 C# 7.1 的高级模式匹配功能来识别正在应用的事件类型,以及实体状态需要如何改变。
因此,测试中没有变化。如果我们执行迄今为止创建的解决方案中的所有测试,它们都将通过。这意味着触发领域事件并将它们应用于更改实体状态可以被视为实现细节。实际上,这是一种使用领域事件的工作方式,通常在应用事件源(Event Sourcing)的领域驱动设计(DDD)中使用,我们将在稍后讨论。
请记住,使用 DDD 以及在特定情况下使用领域事件,并不意味着使用事件源,反之亦然。本书更侧重于事件源;因此,通过应用事件来更改领域状态的技术在早期就提出了。
一些进一步的更改可能并不那么明显,但它们是使整个系统正常工作所必需的。如果您仔细查看When方法,仍然属于值对象类型的实体属性,使用值对象的构造函数而不是工厂函数。这是因为工厂函数在构建有效的值对象时应用约束并执行检查。然而,领域事件代表已经发生的事情,因此没有必要检查这些过去事实的有效性。如果它们在那时是有效的,它们应该被允许通过。即使值对象中的逻辑已经改变,这也永远不会对应用历史数据的事件产生影响。
为了解决这个问题,我们需要更改值对象,使它们具有内部构造函数而不是私有构造函数。此外,检查已从构造函数移动到工厂函数,因此构造函数现在可以接受任何值。对于更复杂的Price对象,我们需要添加一个不需要货币查找服务的构造函数。即使货币已经不再有效,当我们尝试加载某些过去的事件时,它也应该能够通过。然而,这并不改变工厂函数的使用。它们仍然需要查找服务,并且一旦我们在应用程序服务层中创建新的值对象实例,就会立即使用它。这将继续保护我们免受执行包含某些错误信息的命令的影响,这些命令可能会使我们的模型处于无效状态。
在以下内容中,您可以找到更改后的分类广告文本的值对象:
using Marketplace.Framework;
namespace Marketplace.Domain
{
public class ClassifiedAdText : Value<ClassifiedAdText>
{
public string Value { get; }
internal ClassifiedAdText(string text) => Value = text;
public static ClassifiedAdText FromString(string text)
=> new ClassifiedAdText(text);
public static implicit operator string(ClassifiedAdText text)
=> text.Value;
}
}
这是表示广告标题的值对象的完整代码:
using System;
using System.Text.RegularExpressions;
using Marketplace.Framework;
namespace Marketplace.Domain
{
public class ClassifiedAdTitle : Value<ClassifiedAdTitle>
{
public static ClassifiedAdTitle FromString(string title)
{
CheckValidity(title);
return new ClassifiedAdTitle(title);
}
public static ClassifiedAdTitle FromHtml(string htmlTitle)
{
var supportedTagsReplaced = htmlTitle
.Replace("<i>", "*")
.Replace("</i>", "*")
.Replace("<b>", "**")
.Replace("</b>", "**");
var value = Regex.Replace(supportedTagsReplaced,
"<.*?>", string.Empty);
CheckValidity(value);
return new ClassifiedAdTitle(value);
}
public string Value { get; }
internal ClassifiedAdTitle(string value) => Value = value;
public static implicit operator string(ClassifiedAdTitle title)
=> title.Value;
private static void CheckValidity(string value)
{
if (value.Length > 100)
throw new ArgumentOutOfRangeException(
"Title cannot be longer that 100 characters",
nameof(value));
}
}
}
最后,基于Money类但有一些额外规则的Price类:
using System;
namespace Marketplace.Domain
{
public class Price : Money
{
private Price(
decimal amount,
string currencyCode,
ICurrencyLookup currencyLookup
) : base(amount, currencyCode, currencyLookup)
{
if (amount < 0)
throw new ArgumentException(
"Price cannot be negative",
nameof(amount));
}
internal Price(decimal amount, string currencyCode)
: base(amount, new CurrencyDetails{CurrencyCode =
currencyCode}) { }
public static Price FromDecimal(decimal amount, string
currency,
ICurrencyLookup currencyLookup) =>
new Price(amount, currency, currencyLookup);
}
}
再次强调,尽管这些变化可能看起来很重要,但我们并没有改变任何领域逻辑和约束。我们所有的现有测试都完好无损,并且仍在通过,所以我们的重构是成功的,我们成功地在保持领域模型本质不变的情况下改变了实现细节。
摘要
在本章中,我们开始编写大量代码,并学习了在代码中实现领域模型的基础。我们研究了实体和价值对象,它们需要什么,以及它们的不同之处。解释价值对象的力量占据了本章相当大的篇幅,但这个主题至关重要,因为价值对象往往被忽视。
我们使用了工厂函数来创建构建价值对象的多种方式。类似的技巧也可以用来形成有效的实体,但我们还没有触及这个话题。我们还使用领域服务来利用价值对象内部的一些外部服务,同时保持领域模型本身不受任何外部依赖的影响。
在保持系统状态始终有效方面起着如此重要作用的约束和不变性也得到了讨论,我们使用了不同的技术来实现它们。
最后,我们转向领域事件,并在代码中实现了之前仅在橙色便利贴上看到的一些事件。展望未来,我们学习了如何使领域事件从辅助工具转变为模型状态变化的驱动因素,这为我们转向事件溯源奠定了坚实的基础。
在本章中,我们也编写了一些测试。编写测试并保持其有效性在任何编程工作中都是至关重要的,但在使用领域驱动设计(DDD)并在模型内部处于探索模式时,测试成为处理回归问题最重要的工具之一,甚至可以表达和记录业务规则,正如我们将在接下来的章节中看到的。
由于我们开始将一些便利贴移动到代码中,在下一章中,我们将探讨如何实现命令,以及命令是如何成为我们的领域模型与外部世界之间的粘合剂的。在这种状态下,我们将学习如何通过让人们与之交互来使我们的模型变得有用。
第六章:使用命令执行操作
在上一章中,我们讨论了一个简单领域模型的实现过程。该模型有一个实体,以及几个值对象和领域服务。该模型仅代表我们系统的一个领域,我们故意将其他所有内容排除在范围之外。我们讨论了领域模型项目需要如何与其他内容隔离,以及领域服务虽然可以在应用层实现,但也可以成为模型的一部分。现在,我们将学习如何将我们的领域模型付诸实践。到目前为止,我们还没有在任何地方引用领域模型,这使得它相当无用。为了开始在应用中使用模型,我们需要能够调用模型。此外,我们还需要能够持久化模型中发生的所有更改,以防止我们丢失系统状态。
本章将涵盖以下主题:
-
应用层——洋葱架构的外层边缘
-
从 Web API 调用领域模型
-
持久化领域模型更改
技术要求
除了第五章中关于实现模型的技术要求之外,您还需要安装 Docker,因为我们将使用容器来运行必要的基础设施组件,例如数据库。Docker 支持所有流行的平台,包括 Windows、macOS 和 Linux。如果您需要了解更多关于如何安装 Docker 的信息,请参阅 Docker 安装文档(docs.docker.com/install/)。
领域模型之外
我们努力保持领域模型不受与基础设施、持久化、执行和通信相关内容的干扰。这使得领域模型纯净,并使其专注于业务。然而,我们仍然需要创建围绕它的整个系统,保持领域模型作为系统核心。
运行时环境的整个世界围绕着我们的领域模型,在本节中,我们将剖析构建一个适当系统所需的所有必要组件,并查看这些组件需要如何相互绑定,以及如何与领域模型绑定在一起。
公开 Web API
我们的系统需要向用户提供一些可见性,因此,在某个时候,我们将需要构建一个用户界面。这个主题将在我们继续前进时进行讨论,但现在是时候能够以某种方式公开我们的应用,以便未来的 UI 可以建立在它之上。我们预计我们的系统将拥有多个 UI。考虑一个单页 Web 应用(SPA)和移动应用。作为这两种类型前端的后端,我们需要构建一个 Web API。我会避免使用 REST API 这个术语,因为它远不止是一个简单的 Web API,我们也不会深入探讨这个主题。相反,我们将专注于通过 HTTP 调用我们的领域模型,以执行一些有用的操作。
首先,让我们创建一个 ASP.NET Web API 控制器,我们将在这里放置一些 HTTP 端点处理器。为了做到这一点,我们需要在我们的 Marketplace 项目中添加一个类,因为现在我们不会在领域模型内部工作,而是在模型外部工作。
在我们添加控制器之前,让我们创建一个名为 Api 的项目文件夹,将所有控制器和相关服务放在一个地方。稍后我们将在应用程序项目中添加不同类型的组件,因此最好从一开始就保持整洁。
公共 API 契约
要服务 HTTP 请求,我们需要的不仅仅是控制器。控制器需要接受强类型请求,这些请求的集合将是我们公共 API 以及这些请求的模型。让我们准备一个放置这些模型的地方,并在应用程序项目中创建一个名为 Contracts 的文件夹。
契约是 数据传输对象(DTOs)和 普通的 C# 对象(POCOs)。这意味着它们没有逻辑,它们只包含原始类型,并且不需要任何技巧来进行序列化和反序列化。实际上,你可以在你的 DTO 中添加复杂类型,仅仅是因为某些复杂类型在许多契约中使用。这种类型的例子可以是 Address 类型。
假设我们有一个以下契约类的示例:
public class UpdateCustomerAddressDetails
{
public string BillingStreet { get; set; }
public string BillingCity { get; set; }
public string BillingPostalCode { get; set; }
public string BillingCountry { get; set; }
public string DeliveryStreet { get; set; }
public string DeliveryCity { get; set; }
public string DeliveryPostalCode { get; set; }
public string DeliveryCountry { get; set; }
}
使用复杂类型而不是分别列出两个地址的所有属性,非常方便。在我们添加一个名为 Address 的新类型之后,我们的契约将变得更加紧凑:
public class Address
{
public string Street { get; set; }
public string City { get; set; }
public string PostalCode { get; set; }
public string Country { get; set; }
}
public class UpdateCustomerAddressDetails
{
public Address BillingAddress { get; set; }
public Address DeliveryAddress { get; set; }
}
请注意,复杂类型会增加兼容性问题,因为当你更改类型时,所有使用该类型的契约也将更改,并且这种更改将是隐式的。由于你没有内部消费者使用此契约,你将无法看到使用此契约的客户端是否会受到影响。此类信息只能通过测试获得。
说到更改,请记住,你发布在开发机器之外的所有内容都被认为是公共的。公共 API 是任何具有适当权限的人都可以使用的东西。实际上,这意味着你不再控制谁在使用你的 API。因此,发布 API 的任何更改都可能潜在地破坏系统或其他系统的其他部分。公共契约的更改需要谨慎处理,因为既有非破坏性更改,也有破坏性更改。
POCO 类型的一些更改被认为是非破坏性的,例如以下更改:
-
改变属性类型,以便任何之前使用的类型值都可以序列化到新类型。例如,我们可以将属性从整数更改为字符串,并且它将是兼容的。
-
添加一个新属性也被视为一种非破坏性更改。这是因为当我们尝试反序列化一个没有这个新属性的 XML 或 JSON 对象时(因为发送者尚未更新他们的合约),大多数流行的序列化器都会接受它,并且如果未提供值,将使用默认值。
我们软件在不断发展,当然,我们并不总是能够做出非破坏性更改。这意味着我们应该准备好做出破坏性更改。我们已经讨论了公共 API 是一旦公开就默认共享的东西。因此,我们需要确保当我们做出破坏性更改时,所有使用旧 API 的人都不会遇到异常,并且能够像以前一样工作,至少在一段时间内。这是通过API 版本控制来实现的。你可能已经遇到了像 GitHub 或 Twitter 这样的流行服务的不同 API 版本。例如,当我写这篇文章的时候,Twitter API 文档告诉我使用这个调用来获取推文的时序:GET https://api.twitter.com/1.1/statuses/home_timeline.json。正如你所看到的,它们在 URI 中有1.1,这是它们当前的稳定 API 版本。我们可以假设它们还有其他版本,其中一些较旧的版本可能仍在运行和使用。因此,在我们的 API 中,我们也将使用版本化的合约,尽管我们最初并不期望有很多变化。
我们已经知道我们可以对我们的领域执行哪些操作,因此我们可以添加一些合约来从外部世界调用这些操作。让我们创建一个文件,我们将在这里放置我们的第一个合约。我们已经有了一个Contracts文件夹,所以我们可以在这个文件夹中创建一个新的 C# 类文件,命名为ClassifiedAds.cs。文件就位后,我们可以在那里添加我们的第一个合约:
using System;
namespace Marketplace.Contracts
{
public static class ClassifiedAds
{
public static class V1
{
public class Create
{
public Guid Id { get; set; }
public Guid OwnerId { get; set; }
}
}
}
}
在这里,我们使用嵌套的静态类ClassifiedAds和V1作为命名空间的替代,这样在必要时我们可以在一个文件中拥有更多版本。这种方法允许我们使用静态成员导入来使代码更加简洁。
我们在这里有一个命令。我第一次在第一章,“为什么是领域驱动设计?”,当我们讨论 CQRS 时提到了命令。命令允许用户和其他系统在我们的领域模型中执行操作。当一个命令成功处理时,领域模型状态会发生变化,并发出新的领域事件。现在,当我们有一个命令在代码中实现时,我们需要接受来自外部世界的这个命令,我们将使用 HTTP 端点来实现这一点。
HTTP 端点
由于现在 API 最明显的通信方法是使用同步 HTTP 调用,我们将从这里开始。我们将使用 ASP.NET Web API。因此,我们需要添加一个控制器来接受我们的命令。让我们在可执行项目的Api文件夹中添加一个名为ClassifiedAdsCommandsApi.cs的文件,让这个类继承自Controller,并添加一个Post方法来处理我们在上一节中添加的命令:
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
namespace Marketplace.Api
{
[Route("/ad")]
public class ClassifiedAdsCommandsApi : Controller
{
[HttpPost]
public async Task<IActionResult> Post(
Contracts.ClassifiedAds.V1.Create request)
{
// handle the request here
return Ok();
}
}
}
我们目前还没有做任何事情。相反,我们正在创建一个可以接受外部世界命令的 web API。我们稍后会添加处理这些命令的代码。记住,这是我们针对 HTTP 基础设施的适配器,它在洋葱架构的最外层找到其位置。这就是我们称这个层为边缘的原因,因为它的外面没有任何我们可以认为是应用一部分的东西。应用可以通过多种方式与外部世界通信,所以如果我们添加了其他边缘,比如消息传递,我们期望这个新的通信适配器能够处理相同的命令。
现在,我们需要向应用程序启动代码中添加更多代码以使 web API 工作。我们需要对Program类做一些事情:
-
-
构建配置。
-
配置 web 宿主。
-
执行 web 宿主。
-
要执行这些操作,我们需要让Program类看起来像这样:
using System.IO;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using static System.Environment;
using static System.Reflection.Assembly;
namespace Marketplace
{
public static class Program
{
static Program() =>
CurrentDirectory = Path.GetDirectoryName(GetEntryAssembly().Location);
public static void Main(string[] args)
{
var configuration = BuildConfiguration(args);
ConfigureWebHost(configuration).Build().Run();
}
private static IConfiguration BuildConfiguration(string[] args)
=> new ConfigurationBuilder()
.SetBasePath(CurrentDirectory)
.Build();
private static IWebHostBuilder ConfigureWebHost(
IConfiguration configuration)
=> new WebHostBuilder()
.UseStartup<Startup>()
.UseConfiguration(configuration)
.ConfigureServices(services =>
services.AddSingleton(configuration))
.UseContentRoot(CurrentDirectory)
.UseKestrel();
}
}
这里目前没有太多的事情发生。我们确保当前目录是可执行文件所在的位置,因为这也是我们可以找到配置文件的地方。然后我们读取配置,从配置中创建并启动 web 宿主。目前我们没有配置文件,因此没有配置,但稍后我们会添加一些。
现在,我们正在使用Startup类来配置服务,它也需要一些关注。在Startup类中,我们需要配置 web API 以便它可以使用我们的控制器。此外,我们需要一种简单的方法来与 API 交互,而无需任何用户界面。一种既好又简单的方法是使用与 Web API 集成的 Swagger (swagger.io/)。在我们开始使用它之前,我们需要添加一个 Swagger Web API 集成 NuGet 包,Swashbuckle.AspNetCore。使用新的.csproj文件格式,添加集成的最简单方法可能是直接将包引用添加到项目文件中。在这里,您可以看到Marketplace.csproj文件的新内容,并且更改被突出显示:
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>netcoreapp2.1</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="4.0.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Marketplace.Domain\Marketplace.Domain.csproj" />
</ItemGroup>
</Project>
Swashbuckle.AspNetCore 包在您阅读这本书的时候可能版本不同。请使用最新可用的版本。
当您保存项目文件时,您的 IDE 将安装包并将引用添加到您的项目中。
现在,我们可以更改Startup类,以便它注册 Web API 内部组件、我们的控制器以及所有必要的 Swagger 生成。我们还添加了一个嵌入式的 Swagger UI,这样我们就可以直接从浏览器测试我们的 API,而无需任何额外的软件:
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Swashbuckle.AspNetCore.Swagger;
using static System.Environment;
// ReSharper disable UnusedMember.Global
namespace Marketplace
{
public class Startup
{
public Startup(IHostingEnvironment environment,
IConfiguration configuration)
{
Environment = environment;
Configuration = configuration;
}
private IConfiguration Configuration { get; }
private IHostingEnvironment Environment { get; }
public void ConfigureServices(IServiceCollection services)
{
services.AddMvc();
services.AddSwaggerGen(c =>
c.SwaggerDoc("v1",
new Info
{
Title = "ClassifiedAds",
Version = "v1"
}));
}
public void Configure(IApplicationBuilder app,
IHostingEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
app.UseMvcWithDefaultRoute();
app.UseSwagger();
app.UseSwaggerUI(c =>
c.SwaggerEndpoint(
"/swagger/v1/swagger.json",
"ClassifiedAds v1"));
}
}
}
现在一切就绪,我们准备第一次启动应用程序。按下F5后,您应该在控制台看到以下内容:

应用程序最终运行
现在,让我们在浏览器中打开 Swagger UI,访问http://localhost:5000/swagger。我们应该看到一个操作(POST),当我们展开它时,以下内容应该出现:

Swagger 用户界面用于测试 API
您可以点击“尝试一下”按钮并向 API 发送一些请求,但它不会做任何事情,因为我们总是返回200 OK响应。
我们已经完成了所有必要的步骤来暴露一个原始的 Web API 端点,并提供了支持它的引导代码。我们还创建了一个 API 合约,它代表创建分类广告的命令。现在是时候让这个命令生效了。
应用层
边缘部分——在我们的案例中,是一个简单的 Web API——接受来自外部世界的请求。我们边缘组件的主要任务是接受一些请求,这些请求以 JSON 文档、XML 文档、通过 RabbitMQ 的消息或任何其他通信渠道和序列化类型发送;将其转换为命令;然后确保这个命令得到处理。
边缘部分当然可以直接与领域模型工作,但这意味着我们接受这样一个事实,我们总是只与一种边缘类型一起工作,使用一种通信协议。此外,边缘组件通常对通信基础设施有很强的依赖性——虽然这对于集成测试来说是可行的,但为这样的组件编写单元测试可能会具有挑战性。
为了将通信基础设施与实际请求处理隔离开来,我们可以引入应用层。在这个层中,我们需要一个组件来接受来自边缘的命令并使用我们的领域模型来处理这些命令。这样的组件被称为应用服务。
如果您参考第四章,设计模型,并查看洋葱架构的图片,您将在基础设施和领域模型之间找到应用服务。应用服务对用于从外部发送命令的传输没有依赖性。
然而,服务需要有一种方式来加载和存储实体,因为应用程序服务的典型操作将执行如下命令:

典型交互流程
在这个流程中存在一些异常。当应用程序服务接收到需要创建新实体的命令时,它不会从实体存储中加载任何内容,因为还没有可以加载的内容。它将创建实体并将其保存到实体存储中。同样,当处理命令需要删除实体时,应用程序服务将加载实体,但不一定将其保存回去。它可能只是从存储中删除这个实体,但这在很大程度上取决于模型。例如,如果业务要求保留所有数据,我们可能只是将实体标记为已删除,然后将更改持久化到实体存储中。
让我们在项目中添加一个新的应用程序服务类并编写一些代码。首先,我们需要在我们的可执行 Web API 项目的Api文件夹中创建一个新的文件。有些人可能会争论应用程序服务不是 API 的一部分,但到目前为止,我们只有一个边缘,并且没有真正的原因将它们分开。新文件的名称将是ClassifiedAdApplicationService.cs,它具有以下代码:
namespace Marketplace.Api
{
public class ClassifiedAdsApplicationService
{
public void Handle(Contracts.ClassifiedAds.V1.Create command)
{
// we need to create a new Classified Ad here
}
}
}
现在,我们需要从我们的 API 中调用应用程序服务。我们需要将应用程序服务作为依赖项添加到我们的控制器中,并在启动时,我们将依赖项注册到 ASP.NET Core 服务定位器中。首先,我们进行注册。由于我们的应用程序服务类还没有依赖项,我们可以使用单例,因此我们在Startup类的ConfigureServices方法中添加一行:
public void ConfigureServices(IServiceCollection services)
{
services.AddSingleton(new ClassifiedAdsApplicationService());
...
}
当这一切完成之后,我们可以将ClassifiedAdsApplicationService类作为依赖项添加到ClassifiedAdsCommandsApi控制器中,并从我们的Post方法中调用Handle方法:
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
namespace Marketplace.Api
{
[Route("/ad")]
public class ClassifiedAdsCommandsApi : Controller
{
private readonly ClassifiedAdsApplicationService _applicationService;
public ClassifiedAdsCommandsApi(
ClassifiedAdsApplicationService applicationService)
=> _applicationService = applicationService;
[HttpPost]
public async Task<IActionResult> Post(
Contracts.ClassifiedAds.V1.Create request)
{
_applicationService.Handle(request);
return Ok();
}
}
}
在下一节中,我们将深入探讨命令处理,包括将实体保存到实体存储和检索它们。我们还将为这些命令添加更多的命令和处理器。
处理命令
在上一节中,我们创建了一个简单的 Web API 并了解到 API 是应用程序的边缘。边缘与外部世界进行通信,并通过 HTTP 或其他通信协议接受请求。为了执行这些请求,我们需要一个作为边缘组件和领域模型之间中介的应用程序服务。应用程序服务还负责持久化实体。
今后,我们将学习更多关于处理命令和持久化的知识。我们还将讨论处理异常和检查传入请求是否有效。
命令处理器模式
在 CQRS 中处理命令有几种方法。一个已建立的模式是使用命令处理器。这并不特定于 CQRS,但它被广泛使用,因为它非常合适。命令处理器是一个类,它有一个方法来处理单个命令类型。例如,我们可能有一个这样的命令处理器:
public class CreateClassifiedAdHandler :
IHandleCommand<Contracts.ClassifiedAds.V1.Create>
{
private readonly IEntityStore _store;
public CreateClassifiedAdHandler(IEntityStore store)
=> _store = store;
public Task Handle(Contracts.ClassifiedAds.V1.Create command)
{
var classifiedAd = new ClassifiedAd(
new ClassifiedAdId(command.Id),
new UserId(command.OwnerId));
return _store.Save(classifiedAd);
}
}
命令处理器之前使用了两个接口。接口看起来像这样:
public interface IHandleCommand<in T>
{
Task Handle(T command);
}
public interface IEntityStore
{
Task<T> Load<T>(string id);
Task Save<T>(T entity);
}
请记住,IEntityStore接口是简化的,并不是所有持久化方法都可以通过这样的接口表示。
请不要误解,我并不是试图在你的脑海中播种通用仓库的想法。实际上,实体存储并不是仓库模式的精确数学。当仓库的目的是模拟一组对象并隐藏持久性时,实体存储是完全相反的。它不表示一个集合。它确实做了它告诉你的——持久化一个单一的对象并检索它。而且,尽管通用仓库通常被认为是一个反模式,但我不会对实体存储接口应用同样的规则。
然后,我们可以在 API 中使用这个命令处理程序:
using System.Threading.Tasks;
using Marketplace.Contracts;
using Microsoft.AspNetCore.Mvc;
using static Marketplace.Contracts.ClassifiedAds;
namespace Marketplace.Api
{
[Route("/ad")]
public class ClassifiedAdsCommandsApi : Controller
{
private readonly IHandleCommand<V1.Create>
_createAdCommandHandler;
public ClassifiedAdsCommandsApi(
IHandleCommand<V1.Create>
createAdCommandHandler
) =>
_createAdCommandHandler = createAdCommandHandler;
[HttpPost]
public Task Post(V1.Create request) =>
_createAdCommandHandler.Handle(request);
}
}
你可以看到这里我们通过IHandleCommand接口引用了命令处理程序。这给了我们一些选择我们想要使用的实现方式的自由。首先,我们可以注册我们已有的实现:
services.AddSingleton<IEntityStore, RavenDbEntityStore>();
services.AddScoped<
IHandleCommand<Contracts.ClassifiedAds.V1.Create>,
CreateClassifiedAdHandler>();
在这里,我们注册了RavenDbEntityStore类,它实现了IEntityStore。我们不会在这里查看实际的实现,但既然RavenDb是文档数据库,这样的类可能很容易实现。
我们到目前为止所做的是非常直接的,但由于我们在 API 中使用IHandleCommand<T>接口,我们可以做一些更有趣的事情。例如,我们可以创建一个通用的命令处理程序,它会重试失败:
public class RetryingCommandHandler<T> : IHandleCommand<T>
{
static RetryPolicy _policy = Policy
.Handle<InvalidOperationException>()
.Retry();
private IHandleCommand<T> _next;
public RetryingCommandHandler(IHandleCommand<T> next)
=> _next = next;
public Task Handle(T command)
=> _policy.ExecuteAsync(() => _next.Handle(command));
}
我们只需要将服务注册更改如下:
services.AddScoped<IHandleCommand<V1.Create>>(c =>
new RetryingCommandHandler<V1.Create>(
new CreateClassifiedAdHandler(c.GetService<RavenDbEntityStore>())));
在这里,我们将实际的命令处理程序包裹在通用的重试处理程序中。由于它们都实现了相同的接口,我们可以通过这些类的组合来构建一个管道。我们可以继续向链中添加更多元素,例如使用断路器或记录器。
我们可以向命令类添加更多属性(记住弱模式),但可能需要更改的唯一处理程序就是实际的命令处理程序。所有瞬态处理程序都将保持不变,因为我们使用的是命令类型,这是一个复杂类型,作为参数,所以接口定义本身并没有改变。
命令处理器模式很有吸引力,并且遵循 单一职责原则(SRP)。同时,我们 API 中的每个 HTTP 方法都需要一个单独的命令处理器作为依赖项。如果我们有两个或三个方法,这并不是什么大问题,但我们可能会有更多。仅通过查看我们的 EventStorming 会话的结果,我们可能预测我们的分类广告 Web API 将会有超过 10 个方法,以及足够的命令处理器。命令处理器需要实体存储作为依赖项,并且由于所有 Web API 控制器都是按范围实例化的,因此所有命令处理器也将被实例化和注入,包括它们的所有依赖项。通过使用工厂委托而不是每个请求的依赖项来减轻庞大依赖树的实例化,这样每个方法就能实例化其处理器:
using System;
using System.Threading.Tasks;
using Marketplace.Contracts;
using Microsoft.AspNetCore.Mvc;
using static Marketplace.Contracts.ClassifiedAds;
namespace Marketplace.Api
{
[Route("/ad")]
public class ClassifiedAdsCommandsApi : Controller
{
private readonly Func<IHandleCommand<V1.Create>>
_createAdCommandHandlerFactory;
public ClassifiedAdsCommandsApi(
Func<IHandleCommand<V1.Create>> createAdCommandHandlerFactory)
=> _createAdCommandHandlerFactory = createAdCommandHandlerFactory;
[HttpPost]
public Task Post(V1.Create request) =>
_createAdCommandHandlerFactory().Handle(request);
}
}
这种方法需要更高级的注册,因为我们不是使用实际类型,而是一个委托。另一个解决方案可能是使用 Lazy<IHandleCommand<T>> 作为依赖项。同样,这也需要更复杂的注册。注册挑战可能通过使用另一个依赖注入容器来解决,例如支持自动工厂委托和 Lazy<T> 的 Autofac。
在这本书中,我们不会使用命令处理器模式,而是将使用应用服务来实现命令处理。我们已经在本节中开始实现一个简单的服务,将在下一节中继续。命令处理器绕行存在是为了更好地概述有用的模式,因为没有任何模式适合所有用例。
应用服务
事实上,我们的应用服务将看起来和表现得很像一组命令处理器。一个 经典 的应用服务会公开一些带有多个参数的方法,如下所示:
public interface IPaymentApplicationService
{
Guid Authorize(
string creditCardNumber,
int expiryYear,
int expiryMonth,
int cvcCode,
intcamount);
void Capture(Guid authorizationId);
}
使用这种声明方式是完全可以接受的,但它与组合不太兼容。将此类应用服务添加到具有日志记录、重试策略等的管道中并不容易。要构建管道,我们需要所有处理器具有兼容的参数,但 IPaymentApplicationService 的这些方法根本不允许我们这样做。管道中的每个其他调用都必须具有相同的参数集,而且一旦我们想要向任何方法添加一个参数,我们就注定要在构成管道的多个类中进行大量更改。这不是我们愿意做的事情。
或者,我们可以有一个实现多个 IHandle<T> 接口的应用服务类。这可以工作,但每个命令将需要单独的引导代码,尽管我们在管道中添加的是相同的元素:
services.AddScoped<IHandleCommand<V1.Create>>(c =>
new RetryingCommandHandler<V1.Create>(
new CreateClassifiedAdHandler(c.GetService<RavenDbEntityStore>())));
services.AddScoped<IHandleCommand<V1.Create>>(c =>
new RetryingCommandHandler<V1.Rename>(
new RenameClassifiedAdHandler(c.GetService<RavenDbEntityStore>())));
// more handlers need to be added with the same composition
或者,我们可以将我们的应用程序服务泛化以处理任何类型的命令,并再次使用 C# 7 的高级模式匹配功能(就像我们在事件处理中做的那样)。在这种情况下,应用程序服务的签名将如下所示:
public interface IApplicationService
{
Task Handle(object command);
}
我们之前用于管道的所有过滤器,如重试过滤器或日志过滤器,都可以实现这个简单的接口。由于那些类不需要获取命令的内容,所以一切都会正常工作。我们的分类广告服务将看起来像这样:
using System;
using System.Threading.Tasks;
using Marketplace.Framework;
using static Marketplace.Contracts.ClassifiedAds;
namespace Marketplace.Api
{
public class ClassifiedAdsApplicationService
: IApplicationService
{
public async Task Handle(object command)
{
switch (command)
{
case V1.Create cmd:
// we need to create a new Classified Ad here
break;
default:
throw new InvalidOperationException(
$"Command type {command.GetType().FullName} is
unknown");
}
}
}
}
通过以这种方式实现我们的应用程序服务,我们将有一个单一的依赖关系来处理所有的 API 调用,并且我们保持开放的大门来构建一个更复杂的命令处理管道,就像我们能够对单个命令处理器做到的那样。
当然,这里的权衡是我们有一个处理多个命令的类,有些人可能会认为它违反了 SRP 原则。同时,这个类的内聚度很高,我们将在本章的后面看到更多关于它如何适当地处理多个命令并从边缘调用我们的应用程序服务的内容。
让我们现在添加更多命令并相应地扩展我们的应用程序服务和 HTTP 边缘。
首先,我们需要回到我们的实体并检查我们可以命令它执行哪些操作。这些操作如下:
-
设置标题。
-
更新文本。
-
更新价格。
-
请求发布。
我们可以添加四个命令来执行这些操作,因为我们根据我们的 EventStorming 会议可以预期,这是我们用户想要做的。
扩展后的命令列表将如下所示:
using System;
namespace Marketplace.Contracts
{
public static class ClassifiedAds
{
public static class V1
{
public class Create
{
public Guid Id { get; set; }
public Guid OwnerId { get; set; }
}
public class SetTitle
{
public Guid Id { get; set; }
public string Title { get; set; }
}
public class UpdateText
{
public Guid Id { get; set; }
public string Text { get; set; }
}
public class UpdatePrice
{
public Guid Id { get; set; }
public decimal Price { get; set; }
public string Currency { get; set; }
}
public class RequestToPublish
{
public Guid Id { get; set; }
}
}
}
}
每个命令都需要有它将要操作的实体的 ID。其他属性是命令特定的。
第二,我们可以扩展我们的边缘以接受这些命令作为 HTTP 请求。新 API 版本的代码如下:
using System.Threading.Tasks;
using Marketplace.Contracts;
using Microsoft.AspNetCore.Mvc;
using static Marketplace.Contracts.ClassifiedAds;
namespace Marketplace.Api
{
[Route("/ad")]
public class ClassifiedAdsCommandsApi : Controller
{
private readonly ClassifiedAdsApplicationService
_applicationService;
public ClassifiedAdsCommandsApi(
ClassifiedAdsApplicationService applicationService)
=> _applicationService = applicationService;
[HttpPost]
public async Task<IActionResult> Post(V1.Create request)
{
await _applicationService.Handle(request);
return Ok();
}
[Route("name")]
[HttpPut]
public async Task<IActionResult> Put(V1.SetTitle request)
{
await _applicationService.Handle(request);
return Ok();
}
[Route("text")]
[HttpPut]
public async Task<IActionResult> Put(V1.UpdateText request)
{
await _applicationService.Handle(request);
return Ok();
}
[Route("price")]
[HttpPut]
public async Task<IActionResult> Put(V1.UpdatePrice request)
{
await _applicationService.Handle(request);
return Ok();
}
[Route("publish")]
[HttpPut]
public async Task<IActionResult> Put(V1.RequestToPublish request)
{
await _applicationService.Handle(request);
return Ok();
}
}
}
你可能已经看到了一些创建有用抽象或例程的候选者。你也许也可以预测一些当这段代码在生产环境中运行时可能出现的问题。前面的边缘代码也违反了一个重要的原则,即 API 客户端需要只向命令处理器发送有效的命令。在我们的代码中,没有任何东西进行这样的检查。不用担心;我们将回到 API 代码并解决这些问题。现在,让我们集中精力在关键部分。
如你所见,我们的应用程序服务预计将处理五个命令。我们需要确保它这样做。我们应用程序服务的新代码如下:
using System;
using System.Threading.Tasks;
using Marketplace.Contracts;
using Marketplace.Domain;
using Marketplace.Framework;
using static Marketplace.Contracts.ClassifiedAds;
namespace Marketplace.Api
{
public class ClassifiedAdsApplicationService
: IApplicationService
{
private readonly IEntityStore _store;
private ICurrencyLookup _currencyLookup;
public ClassifiedAdsApplicationService(
IEntityStore store, ICurrencyLookup currencyLookup)
{
_store = store;
_currencyLookup = currencyLookup;
}
public async Task Handle(object command)
{
ClassifiedAd classifiedAd;
switch (command)
{
case V1.Create cmd:
if (await _store.Exists<ClassifiedAd>(cmd.Id.ToString()))
throw new InvalidOperationException(
$"Entity with id {cmd.Id} already exists");
classifiedAd = new ClassifiedAd(
new ClassifiedAdId(cmd.Id),
new UserId(cmd.OwnerId));
await _store.Save(classifiedAd);
break;
case V1.SetTitle cmd:
classifiedAd = await _store.Load<ClassifiedAd>(cmd.Id.ToString());
if (classifiedAd == null)
throw new InvalidOperationException(
$"Entity with id {cmd.Id} cannot be found");
classifiedAd.SetTitle(ClassifiedAdTitle.FromString(cmd.Title));
await _store.Save(classifiedAd);
break;
case V1.UpdateText cmd:
classifiedAd = await _store.Load<ClassifiedAd>(cmd.Id.ToString());
if (classifiedAd == null)
throw new InvalidOperationException(
$"Entity with id {cmd.Id} cannot be found");
classifiedAd.UpdateText(ClassifiedAdText.FromString(cmd.Text));
await _store.Save(classifiedAd);
break;
case V1.UpdatePrice cmd:
classifiedAd = await _store.Load<ClassifiedAd>(cmd.Id.ToString());
if (classifiedAd == null)
throw new InvalidOperationException(
$"Entity with id {cmd.Id} cannot be found");
classifiedAd.UpdatePrice(
Price.FromDecimal(cmd.Price, cmd.Currency, _currencyLookup));
await _store.Save(classifiedAd);
break;
case V1.RequestToPublish cmd:
classifiedAd = await _store.Load<ClassifiedAd>(cmd.Id.ToString());
if (classifiedAd == null)
throw new InvalidOperationException(
$"Entity with id {cmd.Id} cannot be found");
classifiedAd.RequestToPublish();
await _store.Save(classifiedAd);
break;
default:
throw new InvalidOperationException(
$"Command type {command.GetType().FullName} is unknown");
}
}
}
}
这里,我们再次使用IEntityStore抽象。这个接口非常简单:
using System.Threading.Tasks;
namespace Marketplace.Framework
{
public interface IEntityStore
{
/// <summary>
/// Loads an entity by id
/// </summary>
Task<T> Load<T>(string entityId) where T : Entity;
/// <summary>
/// Persists an entity
/// </summary>
Task Save<T>(T entity) where T : Entity;
/// <summary>
/// Check if entity with a given id already exists
/// <typeparam name="T">Entity type</typeparam>
Task<bool> Exists<T>(string entityId);
}
}
我们将为不同的持久化类型实现这个接口。
如您所见,处理Create命令与处理所有其他命令看起来不同。这是自然的,因为当我们创建一个新的实体时,我们需要确保它尚未存在。当我们处理现有实体的操作时,情况正好相反。在这种情况下,我们需要确保实体存在,否则,我们无法执行操作并必须抛出异常。
另一个值得提及的是,应用服务负责将原始类型,如字符串或十进制数,转换为值对象。边缘始终使用可序列化的类型,这些类型不依赖于领域模型。然而,应用服务操作与领域相关;它需要告诉我们的领域模型要做什么,由于我们的领域模型更喜欢以值对象的形式接收数据,因此应用服务负责转换。
处理现有实体命令的代码看起来与处理现有实体更新非常相似。实际上,只有调用实体方法的行有所不同。因此,我们可以通过直接的一般化来显著简化Handle方法,并用 switch 表达式替换switch模式匹配运算符:
using System;
using System.Threading.Tasks;
using Marketplace.Domain;
using Marketplace.Framework;
using static Marketplace.Contracts.ClassifiedAds;
namespace Marketplace.Api
{
public class ClassifiedAdsApplicationService
: IApplicationService
{
private readonly IClassifiedAdRepository _repository;
private readonly ICurrencyLookup _currencyLookup;
public ClassifiedAdsApplicationService(
IClassifiedAdRepository repository,
ICurrencyLookup currencyLookup
)
{
_repository = repository;
_currencyLookup = currencyLookup;
}
public Task Handle(object command) =>
command switch
{
V1.Create cmd =>
HandleCreate(cmd),
V1.SetTitle cmd =>
HandleUpdate(
cmd.Id,
c => c.SetTitle(
ClassifiedAdTitle.FromString(cmd.Title)
)
),
V1.UpdateText cmd =>
HandleUpdate(
cmd.Id,
c => c.UpdateText(
ClassifiedAdText.FromString(cmd.Text)
)
),
V1.UpdatePrice cmd =>
HandleUpdate(
cmd.Id,
c => c.UpdatePrice(
Price.FromDecimal(
cmd.Price,
cmd.Currency,
_currencyLookup
)
)
),
V1.RequestToPublish cmd =>
HandleUpdate(
cmd.Id,
c => c.RequestToPublish()
),
_ => Task.CompletedTask
};
private async Task HandleCreate(V1.Create cmd)
{
if (await _repository.Exists(cmd.Id.ToString()))
throw new InvalidOperationException(
$"Entity with id {cmd.Id} already exists");
var classifiedAd = new ClassifiedAd(
new ClassifiedAdId(cmd.Id),
new UserId(cmd.OwnerId)
);
await _repository.Save(classifiedAd);
}
private async Task HandleUpdate(
Guid classifiedAdId,
Action<ClassifiedAd> operation
)
{
var classifiedAd = await _repository.Load(
classifiedAdId.ToString()
);
if (classifiedAd == null)
throw new InvalidOperationException(
$"Entity with id {classifiedAdId} cannot be found"
);
operation(classifiedAd);
await _repository.Save(classifiedAd);
}
}
}
从应用服务代码中可以看出,应用服务本身在应用边缘和领域模型之间扮演着至关重要的中介角色。边缘可以是任何与外界通信的东西。我们以 HTTP API 为例,但它也可以是消息接口或完全不同的东西。对于边缘来说,重要的要求是能够接受命令、检查它们并让应用服务处理这些命令。
当我们处理一个命令时,无论我们使用多个命令处理器还是单个应用服务,操作序列通常非常相似。命令处理器需要从实体存储中检索持久化的实体,调用领域模型来完成工作,然后持久化更改。在我们的例子中,我们只调用了一个实体方法,但这种情况并不总是如此。我们将在下一章讨论一致性和事务边界时进一步探讨这一点。
摘要
在本章中,我们探讨了如何将用户的意图表示为用户发送给我们的系统的命令。我们学习了如何处理这些命令,查看了一些命令处理器模式的示例,然后转向应用服务。
我们探讨了 API 版本控制;尽管它与本书的主题没有直接关系,但这一实践的重要性不容忽视。我们将在第十章事件溯源中简要提及版本控制话题。
在本章中,我们的应用程序服务得到了扩展,我们使用了 C#的最新特性之一,来自函数式世界的礼物,称为高级模式匹配。我们利用这个特性简化了应用程序服务接口,最终只保留了一个方法。通过这种方式,我们还启用了使用组合,另一种函数式风格的途径,将命令处理与操作关注点(如日志记录和重试)链式连接。我们还将探讨这如何帮助我们检查命令的有效性。
在下一章中,我们将更深入地探讨实体持久化。我们将学习需要处理哪些类型的致性以及理解致性边界的重要性。
第七章:一致性边界
在单体系统中,一切似乎都是完全一致的。为了实现一致性,大量的逻辑被外包给数据库引擎,变得隐含,一眼难以看出,难以测试。数据库事务经常被用来确保一次执行多个状态变更。如果数据变得不一致,通常意味着失败,这需要广泛的调查来解决问题。
领域驱动设计(DDD)意味着避免实体复杂图。相反,开发者需要找到一组最小逻辑实体,这些实体属于一起,因此需要一起更新以确保一致性。这样一组实体被称为聚合。
本章将涵盖以下主题:
-
命令处理作为工作单元
-
一致性和事务
-
聚合和聚合根模式
-
约束和不变性
技术要求
本章的代码可以在 GitHub 上书籍仓库的Chapter07文件夹中找到。由于我们还没有使用任何基础设施组件(我们将在下一章开始使用一些),你仍然只需要 IDE 或代码编辑器。仓库中的代码代表章节的最终版本,如果你想跟上,你可以使用上一章的代码作为起点。
领域模型一致性
当谈到建模时,我们经常听到数据模型需要成为任何系统的中心。如果你想有一个好的系统,你需要一个好的数据模型。我在作为软件工程师的职业生涯中无数次听到这句话。我的一个同事曾经说过这句话,然后补充说:我参与了一个大型项目,我们一开始就定义了数据模型,经过十八个月后,项目被关闭,因为模型不完整。奇怪的是,这两句话对他来说没有造成任何因果关系,因为第一句话是一个公理,而项目失败似乎是由许多原因造成的,而不是因为为复杂系统设计单个数据模型总是死亡行军——许多表,通过外键直接和间接地连接在一起,无尽地追求第三范式以避免数据重复,导致检索有意义数据集的查询变得非常复杂——这些都是采取这种方法的现实。
如果我们首先创建数据模型,然后尝试围绕它创建代码,那么很难理解为什么某些规则被强制执行,为什么那些表中的列是强制性的,以及为什么一个表与另一个表之间存在多对多关系。这些关系也很难测试,即使我们有测试,我们也只能在有一个正确配置的数据库和预填充数据集的情况下运行它们,因此我们的测试也变得以数据库为中心。
当领域模型本质上与持久化分离,并且主要设计用于服务特定的业务规则时,DDD 倡导一种不同的方法。当我们处理领域模型时,我们的设计目标是不同的。我们需要在我们的类中封装足够的信息,以确保我们的模型在任何状态转换后都能保持一致性。我们这里所说的这种一致性不是可以外包给数据库引擎的关系数据库一致性。相反,我们希望确保我们的对象不会违反由业务定义的规则,并且这些规则需要显式地在代码中定义。让我们看看我们可以应用哪些原则,以及我们如何在以领域模型为中心的设计方法中定义不同类型的边界。
事务边界
正如我们之前讨论的,命令表达了用户对系统进行某种操作的意图。可能命令来自另一个系统,甚至来自计时器,但它仍然表达了某种意图。在处理命令之前,我们的领域模型处于一个有效状态。当命令被处理时,领域模型也应该处于一个有效状态。这可能是一个新状态,如果命令处理导致执行了操作,或者与之前相同的状态,如果命令处理失败。
让我们看看我们在上一章中创建的命令处理代码:
private async Task HandleUpdate(Guid classifiedAdId, Action<ClassifiedAd> operation)
{
var classifiedAd = await _store.Load<ClassifiedAd>(
classifiedAdId.ToString());
if (classifiedAd == null)
throw new InvalidOperationException(
$"Entity with id {classifiedAdId} cannot be found");
operation(classifiedAd);
await _store.Save(classifiedAd);
}
这是一个针对任何不创建 ClassifiedAd 实例的新实例且不删除现有实例的操作的通用处理程序。我们之所以能够这样泛化命令处理,仅仅是因为所有命令都是以类似的方式处理的:
-
通过实体 ID 从存储中检索实体
-
执行一个操作
-
将更改提交回存储
如果操作失败,或者存储无法通过给定的 ID 找到任何内容,处理程序将抛出异常。
对于本章,前述代码和在我们应用程序服务处理命令时执行的一系列步骤中,最重要的是我们只对单个实体执行操作。
让我们看看为什么会有这种情况,为此,我们需要反思一种非常常见的实现业务应用的方式,即数据库是应用所做的一切的中心。我们将以电子商务领域为例,因为它相对复杂,代码不会干扰我们与 Marketplace 应用当前的工作。
如果你有多年的 .NET 软件开发经验,你可能见过许多包含此类方法的代码库:
[Route("/api/order/pay/credit/{orderId}")]
public async Task TakeOnCustomerCredit(int orderId)
{
using (var context = new CommerceContext())
using (context.Database.BeginTransaction())
{
var order = await context.Orders
.Where(x => x.Id == orderId).FirstAsync();
var amount = order.UnpaidAmount;
var customer = order.Customer;
if (customer.Credit < amount)
throw new InvalidOperationException("Not enough credit");
customer.Credit -= amount;
order.PaidAmount += amount;
order.UnpaidAmount -= amount;
customer.TotalSpent += amount;
if (customer.TotalSpent > CommerceConstants.PreferredLimit)
customer.Preferred = true;
order.IsPaid = order.UnpaidAmount == 0;
await context.SaveChangesAsync();
}
}
这里,控制器似乎在完成一个单一的逻辑操作,它可能看起来就像直接在 HTTP 端点请求处理方法中处理命令。操作似乎是隔离和简洁的。说实话,在我的职业生涯中,我见过很多更糟糕的代码,其中用户的单个请求会导致许多无关的数据库操作,但让我们坚持这个例子,因为它一开始看起来相当合理。所以,这段代码使用了工作单元模式,并且封装在using块中的DbContext实现了这个模式,因为它将所有更改累积在数据库元素中,并在我们调用context.SaveChangesAsync()时一次性提交这些更改。
让我们看看与这段代码相关的数据模型:

简化的电子商务数据模型
当然,我们可能期望在整体模型中有更多的表。它可能包括像Product、Supplier和Shipment这样的东西。对于我们来说,只有这四个表就足够了。这些表都与其他表有关,而这些关系都是一对一(或零对多)。我们的 Entity Framework 模型使用Order和Customer之间的对象引用。这种引用在 ORM 框架中使用时非常流行,因为它给开发者带来了便利。我们可以通过使用order.Customer属性来访问与特定订单关联的Customer对象,并按我们的意愿修改任何Customer属性,这正是代码所做的。它在一个逻辑操作中更改订单和客户的属性。这个操作需要完全完成,或者失败。我们不能容忍客户信用金额减少但订单未支付的情况。这种行为通常与数据库事务相关。事务以四个原则为特征,被称为ACID:
-
原子性
-
一致性
-
隔离性
-
持久性
现在,让我们专注于原子性。这个特性意味着事务内的所有操作必须全部完成,或者根本不发生,这通常被称为全有或全无命题。
在前面的代码中,我们可以看到事务正在封装整个使用客户信用支付订单的整个操作。这是正确的,我们在这里处理的是事务边界。对于那个特定的方法TakeOnCustomerCredit,事务边界将包括两个表——Customer和Order。如果我们想象在同一个模型上的另一个操作,那可能就像这样:
public async Task ShipOrderLine(int orderLineId)
{
using (var context = new CommerceContext())
using (context.Database.BeginTransaction())
{
var orderLine = await context.OrderLines
.Where(x => x.Id == orderLineId)
.FirstAsync();
orderLine.IsShipped = true;
orderLine.Order.DeliveryStatus =
orderLine.Order.OrderLines.All(x => x.IsShipped)
? DeliveryStatus.Shipped
: DeliveryStatus.PartiallyShipped;
await context.SaveChangesAsync();
}
}
这个方法仍然使用相同的模型,并且有几个令人担忧的问题。但是,现在,让我们看看我们在这里处理的是哪种事务边界。在这个工作单元中,我们在Order和OrderLine表中有一个事务中更改的记录。
这两个代码片段显示了在传统分层架构中,由于没有真正的领域模型,事务边界是由任何执行数据库更改的代码片段故意决定的。模型本身并不强制执行任何类型的边界。两种方法,甚至可能位于一个控制器类中,操作两个不同的事务边界,尽管这两个方法都更改的Order表将是两个事务的一部分。很容易想象,通过调用TakeOnCustomerCredit方法在客户信用上处理剩余订单支付可能会并行发生,其中一条订单行通过ShipOrderLine方法标记为已装运:

由于不同原因的更新导致不合理的冲突事务
从业务逻辑的角度来看,这两个操作是不同的,但由于 ACID 的一致性部分,其中一种方法将会失败。对于这个系统的用户来说,了解到信用和支付处理与装运和交付以某种方式相关是非常奇怪的。
这种模型出现的原因非常清楚。编程中面向对象方法的定义本身就声明,软件程序中的对象代表现实世界中的对象。
数据模型遵循类似的方法。非常常见的是,我们看到一个系统有一个全局数据模型,它紧密地代表了现实世界的模型,涵盖了系统实现的领域所有方面。这自然导致代码中出现了反映这种整体数据模型的大型对象图。尽管如此,领域驱动设计(DDD)在需要集中建模时倡导另一种方法;只需要实现系统的一组用例所必需的现实世界模型方面。我们已经在第四章,设计模型中提到了建模的这个基本方面。
让我们看看我们如何构建我们的模型,以便我们可以定义事务边界,这样当我们的软件需要执行操作于同一现实世界对象时,不同的用例将不会相互冲突,尽管这些对象在软件模型中可以由不同的对象表示,甚至属于不同的模型。
聚合模式
在本章前面我们处理的数据模型的情况下,我们有一个组合——Order是OrderLine元素的组合。你可能知道如果我们从数据模型移动到类图,这个模型会是什么样子:

UML 中的聚合
在 UML 中,组合意味着子元素不能在没有其父元素的情况下存在。确实,拥有没有与任何订单关联的订单行是没有意义的。这样的组合作为一个整体形成一个逻辑上不可分割的结构,尽管在这个结构内部,我们可以找到单个元素。对于外部世界来说,一个订单包括其订单行,被视为一个整体,尽管订单可以有多个订单行。在 DDD 中,这样的结构被称为聚合体。由于我们之前使用了一段时间的 UML,可能会造成一些混淆,因为在 UML 中,聚合意味着其他含义,而 DDD 聚合体最接近的类比是 UML 的概念组合。聚合体与 UML 组合共享相同的命题,即父对象由或拥有所有子对象,当父对象被移除时,所有子对象也必须被移除,因为这些对象的存在就没有意义了。聚合体的父对象被称为聚合根。具有单个父对象的复杂对象图可以像一棵树一样可视化,其中父对象是所有树枝生长的地方,因此根的类比非常合理。
然而,聚合体不仅仅是类的组合。聚合边界还充当事务边界。为了本章的目的,我们将集中讨论其两个方面——原子性和一致性。正如之前提到的,事务意味着操作的全有或全无原则。无论聚合体如何持久化,聚合体都会作为一个整体改变其状态。如果我们使用 ORM 工具,并且我们的聚合体跨越多个数据库表,那么对这些表的所有操作都需要被包含在一个数据库事务中。此外,一致性方面要求聚合体确保在执行在该聚合体上的所有操作时,聚合体的状态正在被验证。因此,它不是数据库或代码,这些代码不是聚合体本身的一部分,例如 API 控制器或应用程序服务。与前面的代码不同,所有这些有效性检查都需要是聚合体代码的一部分,因此它们需要在领域模型内部实现。
我们已经有很多聚合体的特性,所以我们可以看到这个模式是如何应用到我们前面的示例中的。如果我们从数据模型开始看,我们可能会怀疑Order和OrderLine形成某种组合,因为OrderLine记录不能在没有父Order的情况下存在。这适用于原子性和一致性。如果我们因为订单中的一行被标记为已发货而更改订单状态——这些更改需要一起执行;否则,订单状态将变得无效——我们可能会得到一个状态为待处理的订单,而其中一个订单行已经被标记为已发货。因此,我们预计这样的订单将有一个部分交付的状态,如果这不是情况,那么我们的订单状态就不有效。由于我们知道订单行是一个子对象,我们并不真的希望从域模型外部直接暴露任何关于订单行的操作。如果订单行由Order类本身操作,那就更有意义了。在这种情况下,Order类成为我们的聚合根,它将拥有改变其行状态的方法。
同时,聚合体不保证任何外部于聚合体的一致性约束。对于关系数据模型来说,这意味着我们无法在用于持久化我们的聚合根(Order)的表和聚合边界之外的东西之间有引用完整性。
如果我们对数据模型进行一些修改以反映新的洞察,它看起来会是这样:

移除引用创建显式边界
注意,现在Order和Customer表以及OrderLine和Product表之间的关系已经不存在了,但我们保留了引用字段——CustomerId和ProductId。我们仍然需要知道客户是否下过订单以及我们正在销售哪些产品。然而,就我们系统的正常操作而言,我们不需要对象引用在对象关系映射(ORM)中,一些开发者可能认为这是隔离聚合体的负面副作用,但实际上,这给了我们一个新的自由度。例如,如果用于这些行号的产品停止销售并被从Product表中删除,订单行需要保持完整。我们不会讨论标志和其他软删除方法,因为我正在努力将这些事情分开。
现在我们来看看 API 控制器代码会是什么样子:
public async Task ShipOrderLine(int orderId, int orderLineId)
{
var order = await _orderRepository.Get(orderId);
order.ShipOrderLine(orderLineId);
await _orderRepository.Commit();
}
这确实是一个很大的变化,不是吗?当然,逻辑并没有消失;它已经移动到了Order类本身:
public void ShipOrderLine(int orderLineId)
{
var orderLine = OrderLines.First(x => x.Id == orderLineId);
orderLine.IsShipped = true;
DeliveryStatus =
OrderLines.All(x => x.IsShipped)
? DeliveryStatus.Shipped
: DeliveryStatus.PartiallyShipped;
}
如您所见,现在可以移除双向对象引用对于订单行的意外复杂性。另一方面,我们必须更改 API,因为我们不能仅仅请求订单行的 ID。我们需要知道订单 ID,因为订单行的 ID 是特定订单内部的,而我们正在使用我们的聚合根来访问其子元素。
当然,更复杂的操作现在需要更多的工作。我们如何执行像TakeOnCustomerCredit方法所做的那样的事情?由于我们之间没有Customer和Order对象之间的对象关系,并且我们已经决定我们的聚合包含所有关于order处理的方面,但不包括Customer,我们不能在这两个不同的对象上完成一个事务。这听起来可能是一个不可能的任务,而且通常,这样的困境会导致权宜之计和捷径,然后聚合模式被视为一种阻碍,需要在某些特定情况下被忽略。实际上,我们需要做的是相反的。我们必须回到建模空间,以了解更多关于这个问题。
回顾方法代码,我们可以看到它执行以下操作:
-
检查客户是否有足够的信用来支付与订单相关的未支付金额
-
减少订单未支付金额的客户信用金额
-
增加订单的已支付金额
-
减少订单的未支付金额
-
如果未支付金额为零(对于该代码,它始终为真),则将订单状态设置为paid
-
通过支付金额增加客户的总支出金额
-
如果这位客户的花费超过某个阈值,则将客户升级到首选状态
这相当多,现在我们将利用聚合的力量来使整个流程更有意义。
首先,我们需要检查哪些内容不属于这里。第一个候选方案是对列表中的最后两个动作进行评估:更新总支出金额和升级客户。看起来似乎在采取信用支付之后,不仅对信用卡,对现金和任何其他类型的支付也发生了某些事情。保留这段代码意味着我们需要复制粘贴它或者有一些共享代码。这些替代方案中没有一个真正吸引人。最重要的是,这两个动作与订单处理顺序无关。想象一下其中任何一个动作失败。这种失败不应该对订单处理有任何影响。
然后,我们需要检查为了完成操作我们需要知道和做什么。在我们的情况下,我们必须确保剩余的信用额度高于或等于订单的未支付金额。为了我们的代码就是否进行信用支付做出决定,我们需要有订购客户的可用信用额度信息。但是,这些细节现在超出了我们的聚合范围,所以我们能做些什么来确保订单不会违反一致性规则呢?
接下来,我们来看另一个关于新兴聚合边界方面的方面,我们需要评估我们系统所处理的对象变化的速度。Customer 对象现在包含一些构成客户档案的信息——姓名、地址等。同时,它包含一些可能随每个我们处理的订单而变化的财务细节。从我们的代码中可以看出,当我们处理订单时,我们没有任何规则为那些姓名以 A 开头或位于比利时的客户提供折扣。我们可能会因为物流原因想象这样的要求,但这与我们的例子无关。我们的结论应该是,客户档案信息很少改变,而剩余的客户信用额度则相当频繁地改变。同时,关于总允许信用额度的信息可能仍然属于客户档案,并且也很少改变。这意味着我们正在处理客户细节的两个不同方面:
| 客户档案 | 客户信用额度 |
|---|---|
| 姓名、地址、总信用额度 | 可用信用额度 |
| 有时改变 | 每个信用订单改变 |
| 无订单处理规则 | 保持订单处理一致性所必需 |
我们最终得出结论,我们的整体 Customer 对象不适合这些不同的用例。我们模型的解决方案是将确保订单处理一致性所需的信息以及需要移动的业务规则移得更接近订单处理逻辑。我们可以通过将我们的 Customer 实体分成两个,每个都负责其自己的用例集来实现这一点。我们甚至可以给新的实体一个更明确的名称:CustomerCredit,以表达此信息的特定用途。我们的图表将看起来像这样:

将所有相关关注点移动到一个边界
实际上,我们在这里所做的工作更属于寻找语言和上下文边界,这个主题将在第九章[6f50ee65-024a-4c46-89c8-343183b05b8f.xhtml],CQRS - 读取侧中更详细地介绍。现在,我们将继续只讨论聚合边界。
我们的新模型看起来更好,但它有一个问题——聚合现在已转移到CustomerCredit实体,并且它似乎变成了我们的聚合根。从关系一致性角度来看,这是完全正常的。从另一个角度来看,通过在CustomerCredit实体上调用方法来处理所有订单看起来很奇怪。另一个负面方面是对象的所有权也发生了变化。以前,我们有Order,它负责其OrderLine。现在,我们有CustomerCredit,它负责一切。看起来如果我们从系统中删除CustomerCredit对象,我们也需要删除所有相关的订单。这绝对不是我们需要的。客户来来去去,但我们绝对需要跟踪所有订单,包括完成的订单,并且不能删除它们。在这种情况下,我们可以清楚地看到拥有一个较大的、具有可疑支持实体责任的聚合的缺点。
还重要的是要记住,尽管存在约束,但在我们可以继续处理订单之前,我们需要有足够的信用来覆盖订单总额,因为即使违反了此约束,订单本身也可能是有效的。订单有其自己的不变量——一组不可打破的规则,保证了每个订单的一致性。让我们看看Order聚合有哪些不变量:
-
PaidAmount和UnpaidAmount的总和应等于TotalAmount。 -
如果对于所有具有
IsShipped属性的订单行,此属性设置为true,则订单的DeliveryStatus才能设置为Delivered。 -
订单的
TotalAmount必须等于所有订单行的LineTotal之和。 -
对于每个订单行,
LineTotal必须等于ProductPrice乘以Quantity。
如您所见,在这些不变量中没有任何内容要求我们知道客户的可用信用或有关产品的任何信息等。因此,为了决定一个订单是否一致,我们只需要有关订单及其所有行的详细信息就足够了。
关于所有权,很明显,单个订单行不能脱离其所属的订单而存在。
因此,我们的最终举措将是断开订单和客户之间的关系,即使对于更明确的CustomerCredit实体也是如此,同时保持Order和OrderLine实体之间的聚合:

缩小边界可以减少事务范围
在这个模型中,我们有两个聚合在系统的一个隔离部分。我们知道这些聚合需要位于相同的上下文边界内,但它们需要分离,并形成不同的事务和一致性边界,原因是我们之前讨论过的。现在,问题出现了,如果订单没有关于订购客户可用信用的信息,我们如何从聚合对象图中强制执行我们的约束。我们将利用领域服务的力量来执行此检查。
在我们的领域项目中,我们可以为这样的领域服务定义一个接口:
public interface ICustomerCreditService
{
Task<bool> EnsureEnoughCredit(int customerId, decimal amount);
}
注意,我们有一个返回布尔值的EnsureEnoughCredit方法,而不是直接返回可用的信用额度。通过这样做,我们强制使用通用语言,并将信用额度检查逻辑转移到领域服务。例如,服务可能会决定对于优先客户,我们可以允许超过可用额度的透支。当然,在这种情况下,我们还需要将Preferred属性移动到CustomerCredit实体。
然后,我们可以使用我们的应用程序服务来处理TakeOnCustomerCredit命令,其中它将使用领域服务来检查此命令是否可以处理:
public class OrderHandlingApplicationService
{
private readonly IOrderRepository _orderRepository;
private readonly ICustomerCreditService _customerCreditService;
public OrderHandlingApplicationService(
IOrderRepository orderRepository,
ICustomerCreditService customerCreditService)
{
_orderRepository = orderRepository;
_customerCreditService = customerCreditService;
}
public async Task Handle(TakeOnCustomerCredit command)
{
var order = await _orderRepository.Get(command.OrderId);
var hasEnoughCredit =
await _customerCreditService.EnsureEnoughCredit(
command.CustomerId,
order.UnpaidAmount);
if (!hasEnoughCredit)
throw new DomainException(
$"Not enough credit for order {command.OrderId}");
order.TakeOnCredit();
}
}
我必须提到,将客户信用移至单独的实体可能会因为竞争条件而导致信用金额低于零的情况。在单独的事务中应用信用额度变更时,您可能想要检查操作结果是否为负值,然后决定如果结果为负值时应该做什么。一种可能的技术是通过电子邮件通知账户经理这种情况,并让他们与客户解决。从技术角度来看,可以创建一个补偿操作,将订单挂起,直到问题解决。总的来说,这些决策永远不应该被视为技术问题。与领域专家交谈,并询问他们更喜欢哪种解决方案。
这种方法将一些领域逻辑移动到应用程序服务,而在某些情况下这可能不是所希望的。为了解决这个问题,我们可以使用双重分派模式,并让Order聚合决定约束。如果我们决定使用双重分派,它将看起来像这样:
public class Order : Aggregate<OrderId>
{
public async Task TakeOnCredit(ICustomerCreditService customerCreditService)
{
var hasEnoughCredit =
await _customerCreditService.EnsureEnoughCredit(
command.CustomerId,
order.UnpaidAmount);
if (!hasEnoughCredit)
throw new DomainException(
$"Not enough credit for order {command.OrderId}");
// actual domain logic here
}
}
然后,应用程序服务在调用聚合根方法时将传递依赖项:
public async Task Handle(TakeOnCustomerCredit command)
{
var order = await _orderRepository.Get(command.OrderId);
await order.TakeOnCredit(_customerCreditService);
}
可能看起来我们正在创建领域模型和基础设施之间的依赖关系,因为很明显领域服务需要获取CustomerCredit实体以获取数据。然而,我们的Order聚合根只获取接口依赖,如您所记得的,该接口本身是在领域项目中定义的。其实施确实位于应用程序内部,但这完全正常。
我们还没有看到我们的聚合是如何保护其不变量的,但现在是我们回到我们的Marketplace应用并添加一些代码的时候了,基于我们迄今为止对聚合的了解。我们还需要涵盖聚合持久化,因为我们已经使用了负责从数据库获取聚合状态的IOrderRepository接口。
保护不变量
在第五章“实现模型”中,我们讨论了使用值对象来防止无效值甚至被用作实体构造函数和方法参数。这项技术允许我们将许多检查移动到值对象中,提供了良好的封装,并实现了类型安全。然后,当我们创建一个新的实体或使用实体方法执行某些行为时,我们需要执行进一步的检查。由于我们可以相当肯定所有参数已经包含有效的单个值,我们需要确保给定的参数组合、当前实体状态和执行的行为不会使实体进入一个无效状态。
保护内部状态不被无效,从而防止模型进入不一致状态,是聚合最重要的特性之一。聚合的不变量必须在触发状态变化的每个操作中满足;因此,我们需要确保在调用聚合上的任何命令方法时控制聚合状态。
让我们来看看我们为我们的分类广告实体制定了哪些复杂规则。为了找到这些规则,我们可以使用我们在第三章“事件风暴”中详细的事件风暴会议的一些便签,并将它们放在图表上,如下所示:

业务规则可以防止命令执行
分析命令的约束
我们将命令放在左边,事件放在右边,并试图找出什么可能阻止我们的命令以产生预期结果(事件)的方式执行。在我们的案例中,我们需要确保在广告被放入审查队列之前,它必须有一个非空的标题、文本和价格。
我们不能将这些检查与值对象结合使用,因为广告在发送到审查之前可以有一个空的标题和文本,并且可以没有价格。只有当给定命令正在执行时,我们才需要检查这些约束是否得到满足。这就是我们所说的这个实体的不变量——一个处于待审查状态的广告不能有一个空的标题、空的文本或零价格。
确保我们的实体永远不会进入一个无效状态至少有两种方法。第一种也是最明显的方法是在操作代码中添加检查。无法请求发布广告,所以让我们添加它,并做一些与使用值对象来表示实体状态的事实相关的更改:
namespace Marketplace.Domain
{
public class ClassifiedAd
{
public ClassifiedAdId Id { get; }
public ClassifiedAd(ClassifiedAdId id, UserId ownerId)
{
Id = id;
OwnerId = ownerId;
State = ClassifiedAdState.Inactive;
}
public void SetTitle(ClassifiedAdTitle title) => Title = title;
public void UpdateText(ClassifiedAdText text) => Text = text;
public void UpdatePrice(Price price) => Price = price;
public void RequestToPublish()
{
if (Title == null)
throw new InvalidEntityStateException(this,
"title cannot be empty");
if (Text == null)
throw new InvalidEntityStateException(this,
"text cannot be empty");
if (Price?.Amount == 0)
throw new InvalidEntityStateException(this,
"price cannot be zero");
State = ClassifiedAdState.PendingReview;
}
public UserId OwnerId { get; }
public ClassifiedAdTitle Title { get; private set; }
public ClassifiedAdText Text { get; private set; }
public Price Price { get; private set; }
public ClassifiedAdState State { get; private set; }
public UserId ApprovedBy { get; private set; }
public enum ClassifiedAdState
{
PendingReview,
Active,
Inactive,
MarkedAsSold
}
}
}
在新的实体代码中,我们强制实施从我们的详细模型中显现出来的约束,因此只有在所有约束都得到满足的情况下才会执行操作。为了让调用者知道,如果其中一些检查失败,我们的实体尚未准备好发布,我们使用自定义异常,其实现方式如下:
using System;
namespace Marketplace.Domain
{
public class InvalidEntityStateException : Exception
{
public InvalidEntityStateException(object entity, string
message)
: base($"Entity {entity.GetType().Name}" +
$"state change rejected, {message}")
{
}
}
}
在操作方法本身之前检查约束的方法有一个缺点。如果我们现在将价格更改为零,它将通过,因为UpdatePrice方法没有检查价格值。当然,我们可以将价格检查复制到UpdatePrice方法中,但也可能有更多需要相同测试的方法,我们将继续复制控制块。这将导致一种情况,即如果我们需要更改这些规则中的任何一个,我们需要去许多地方替换所有的检查。这种方法非常容易出错。
为了在一个地方组合规则,我们可以使用合同编程技术。合同编程的一部分可以在值对象中看到,因为我们评估操作方法每个参数的先决条件。当我们执行操作而不进行任何额外的检查时,我们需要进行组合测试(后置条件控制)。这个检查可以在整个实体的一个地方实现,并且每个操作都需要在方法的最后一行调用它。
对于我们的分类广告实体,它可能看起来像这样:
namespace Marketplace.Domain
{
public class ClassifiedAd
{
public ClassifiedAdId Id { get; }
public ClassifiedAd(ClassifiedAdId id, UserId ownerId)
{
Id = id;
OwnerId = ownerId;
State = ClassifiedAdState.Inactive;
EnsureValidState();
}
public void SetTitle(ClassifiedAdTitle title)
{
Title = title;
EnsureValidState();
}
public void UpdateText(ClassifiedAdText text)
{
Text = text;
EnsureValidState();
}
public void UpdatePrice(Price price)
{
Price = price;
EnsureValidState();
}
public void RequestToPublish()
{
State = ClassifiedAdState.PendingReview;
EnsureValidState();
}
private void EnsureValidState()
{
var valid =
Id != null &&
OwnerId != null &&
(State switch
{
ClassifiedAdState.PendingReview =>
Title != null
&& Text != null
&& Price?.Amount > 0,
ClassifiedAdState.Active =>
Title != null
&& Text != null
&& Price?.Amount > 0
&& ApprovedBy != null,
_ => true
});
if (!valid)
throw new InvalidEntityStateException(
this, $"Post-checks failed in state {State}");
}
public UserId OwnerId { get; }
public ClassifiedAdTitle Title { get; private set; }
public ClassifiedAdText Text { get; private set; }
public Price Price1 { get; private set; }
public ClassifiedAdState State { get; private set; }
public UserId ApprovedBy { get; private set; }
public enum ClassifiedAdState
{
PendingReview,
Active,
Inactive,
MarkedAsSold
}
}
}
正如你所见,我们添加了一个名为EnsureValidState的方法,它检查在任何情况下,实体状态都是有效的,如果不是有效,将抛出异常。当我们从任何操作方法调用此方法时,我们可以确信无论我们试图做什么,我们的实体都将始终处于有效状态,或者调用者将得到异常。
当实体变得无效时抛出异常是防止不一致性最简单的方法,但它有其缺点。整个应用程序需要能够优雅地处理此类异常,以便用户得到适当的告知。
例如,本章的 Web API 代码没有这样做,并期望所有操作都成功执行。因此,当我们尝试执行将实体带到某些错误状态的命令时,将使 API 方法崩溃,并通过 HTTP 返回异常。随着我们的进展,我们将改进 Web API 代码,并开始从 API 返回适当的错误结果。
当使用事件溯源时,暴露不正确操作的一种技术是发出领域事件,如PriceChangeDenied,它包括应用程序尝试应用于实体的所有值,但失败了。使用这种方法为开发者提供了一个强大的工具,可以找出为什么某些命令没有执行,甚至可能发现用户的恶意行为。
此外,我们将所有私有字段转换为公共只读属性。我们需要公共属性来编写测试,尽管我们不一定需要公开内部实体状态。为了防止在操作方法之外设置这些属性的值,所有属性都有私有设置器,或者对于在构造函数中设置的属性没有设置器。
现在,让我们编写一些测试来确保我们的约束起作用:
using System;
using Marketplace.Domain;
using Xunit;
namespace Marketplace.Tests
{
public class ClassifiedAd_Publish_Spec
{
private readonly ClassifiedAd _classifiedAd;
public ClassifiedAd_Publish_Spec()
{
_classifiedAd = new ClassifiedAd(
new ClassifiedAdId(Guid.NewGuid()),
new UserId(Guid.NewGuid()));
}
[Fact]
public void Can_publish_a_valid_ad()
{
_classifiedAd.SetTitle(
ClassifiedAdTitle.FromString("Test ad"));
_classifiedAd.UpdateText(
ClassifiedAdText.FromString("Please buy my stuff"));
_classifiedAd.UpdatePrice(
Price.FromDecimal(100.10m, "EUR",
new FakeCurrencyLookup()));
_classifiedAd.RequestToPublish();
Assert.Equal(
ClassifiedAd.ClassifiedAdState.PendingReview,
_classifiedAd.State);
}
[Fact]
public void Cannot_publish_without_title()
{
_classifiedAd.UpdateText(
ClassifiedAdText.FromString("Please buy my stuff"));
_classifiedAd.UpdatePrice(
Price.FromDecimal(100.10m, "EUR",
new FakeCurrencyLookup()));
Assert.Throws<InvalidEntityStateException>(
() => _classifiedAd.RequestToPublish());
}
[Fact]
public void Cannot_publish_without_text()
{
_classifiedAd.SetTitle(
ClassifiedAdTitle.FromString("Test ad"));
_classifiedAd.UpdatePrice(
Price.FromDecimal(100.10m, "EUR",
new FakeCurrencyLookup()));
Assert.Throws<InvalidEntityStateException>(
() => _classifiedAd.RequestToPublish());
}
[Fact]
public void Cannot_publish_without_price()
{
_classifiedAd.SetTitle(
ClassifiedAdTitle.FromString("Test ad"));
_classifiedAd.UpdateText(
ClassifiedAdText.FromString("Please buy my stuff"));
Assert.Throws<InvalidEntityStateException>(
() => _classifiedAd.RequestToPublish());
}
[Fact]
public void Cannot_publish_with_zero_price()
{
_classifiedAd.SetTitle(
ClassifiedAdTitle.FromString("Test ad"));
_classifiedAd.UpdateText(
ClassifiedAdText.FromString("Please buy my stuff"));
_classifiedAd.UpdatePrice(
Price.FromDecimal(0.0m, "EUR",
new FakeCurrencyLookup()));
Assert.Throws<InvalidEntityStateException>(
() => _classifiedAd.RequestToPublish());
}
}
}
此规范包含针对一个操作(发布或提交审查)的多个测试,具有不同的前提条件。在这里,我们测试在广告可以提交审查之前,所有必要的细节都正确设置时的愉快路径;我们还测试了几个不允许发布的情况,因为缺少必要的信息。也许测试负面场景甚至更为重要,因为当愉快路径不起作用时,很容易发现——您的用户会立即抱怨。测试负面场景可以防止在控制实体不变性时出现错误,这反过来又防止实体变得无效。
当我们将实体状态检查移动到一个方法中时,我们实际上建立了一套需要为每个操作执行的全面规则。这意味着这些规则不再是命令特定的,我们的EnsureValidState方法已经成为整个对象的守护者。它保护了ClassifiedAd实体的不变性,使其永远不会变得无效。能够保护其自身的不变性是聚合模式的主要方面之一。通过在单个事务中为ClassifiedAd实体执行每个命令,并通过建立不变性保护,我们创建了我们的第一个聚合。
现在,我们已经学会了如何保护我们的实体免于变得无效。但是,我们预计我们的应用程序中会出现更多的实体,ClassifiedAd实体的代码变得相当冗长,因为我们必须在每个操作中调用EnsureValidState方法。此外,确实有可能忘记在实体方法中放置调用,然后有机会在不抛出任何异常的情况下将实体置于无效状态。然而,如果我们想要一个真正的聚合,这种情况是不可能发生的,所以让我们看看我们如何利用事件的力量来确保所有操作的状态有效性。
强制执行规则
让我们现在检查我们如何在实体上执行操作:
-
调用实体方法进行操作(CQS 命令)
-
该方法发出一个事件
-
然后将事件应用于实体状态以执行状态转换
因此,如果我们想确保所有状态转换都不会破坏我们的不变性,我们可以将调用EnsureValidState的方法移动到Apply方法中。需要保护其状态仅适用于聚合根实体,因为它必须确保整个聚合状态是正确的,而不仅仅是其自身状态的有效性。因此,我们可以为这种特殊类型的实体创建一个新的基类:
using System.Collections.Generic;
using System.Linq;
namespace Marketplace.Framework
{
public abstract class AggregateRoot<TId>
where TId : Value<TId>
{
public TId Id { get; protected set; }
protected abstract void When(object @event);
private readonly List<object> _changes;
protected AggregateRoot() => _changes = new List<object>();
protected void Apply(object @event)
{
When(@event);
EnsureValidState();
_changes.Add(@event);
}
public IEnumerable<object> GetChanges()
=> _changes.AsEnumerable();
public void ClearChanges() => _changes.Clear();
protected abstract void EnsureValidState();
}
}
在这里,我们将 _events 集合重命名为 _changes,以使命名更加明确。我们还向 Apply 方法中添加了对 EnsureValidState 的调用。这意味着每次我们执行旨在更改聚合根实体状态的操作时,我们都会在 When 方法中应用一个新的事件,并改变状态。然而,在将新事件添加到更改列表之前,我们会检查新状态是否有效,以及是否没有违反任何不变性。如果新状态违反了不变性,我们会抛出一个异常。
在我们将 ClassifiedAd 类重构为使用新的基类之后,代码变得更加简单:
using Marketplace.Framework;
using static Marketplace.Domain.Events;
namespace Marketplace.Domain
{
public class ClassifiedAd : Entity<ClassifiedAdId>
{
public ClassifiedAdId Id { get; private set; }
public UserId OwnerId { get; private set; }
public ClassifiedAdTitle Title { get; private set; }
public ClassifiedAdText Text { get; private set; }
public Price Price { get; private set; }
public ClassifiedAdState State { get; private set; }
public UserId ApprovedBy { get; private set; }
public ClassifiedAd(ClassifiedAdId id, UserId ownerId) =>
Apply(new ClassifiedAdCreated
{
Id = id,
OwnerId = ownerId
});
public void SetTitle(ClassifiedAdTitle title) =>
Apply(new ClassifiedAdTitleChanged
{
Id = Id,
Title = title
});
public void UpdateText(ClassifiedAdText text) =>
Apply(new ClassifiedAdTextUpdated
{
Id = Id,
AdText = text
});
public void UpdatePrice(Price price) =>
Apply(new ClassifiedAdPriceUpdated
{
Id = Id,
Price = price.Amount,
CurrencyCode = price.Currency.CurrencyCode
});
public void RequestToPublish() =>
Apply(new ClassidiedAdSentForReview {Id = Id});
protected override void When(object @event)
{
switch (@event)
{
case ClassifiedAdCreated e:
Id = new ClassifiedAdId(e.Id);
OwnerId = new UserId(e.OwnerId);
State = ClassifiedAdState.Inactive;
break;
case ClassifiedAdTitleChanged e:
Title = new ClassifiedAdTitle(e.Title);
break;
case ClassifiedAdTextUpdated e:
Text = new ClassifiedAdText(e.AdText);
break;
case ClassifiedAdPriceUpdated e:
Price = new Price(e.Price, e.CurrencyCode);
break;
case ClassidiedAdSentForReview _:
State = ClassifiedAdState.PendingReview;
break;
}
}
protected override void EnsureValidState()
{
var valid =
Id != null &&
OwnerId != null &&
(State switch
{
ClassifiedAdState.PendingReview =>
Title != null
&& Text != null
&& Price?.Amount > 0,
ClassifiedAdState.Active =>
Title != null
&& Text != null
&& Price?.Amount > 0
&& ApprovedBy != null,
_ => true
});
if (!valid)
throw new InvalidEntityStateException(
this, $"Post-checks failed in state {State}");
}
public enum ClassifiedAdState
{
PendingReview,
Active,
Inactive,
MarkedAsSold
}
}
}
如你所见,所有条件现在都集中在一个地方,无论我们做什么,我们都无法发布一个没有价格或没有文本的广告。同样,在应用程序的任何其他地方也不可能存在隐藏的 bug,这可能会在没有首先获得批准的情况下使广告变得活跃并可见。这种确保状态有效性的技术始终非常强大,它还通过给开发者提供线索,告诉他们在试图找出实体必须遵守的所有规则时可以查看哪些地方,从而提高了我们代码的可读性。
聚合内的实体
可能看起来有些奇怪,我们刚刚添加了一个名为 AggregateRoot 的基类,并用它来代替我们之前已有的 Entity 类。我们本可以直接向 Entity 基类中添加新代码。然而,这样做是有意为之的,因为,正如你可能记得的,聚合可以潜在地形成更大的对象图,除了根实体之外,我们还可能有几个实体将成为根的子实体。我们已经讨论了所有权策略,所以当一个聚合被移除时,聚合根及其所有子实体也会从系统中移除。
对于所有子对象,我们将讨论值对象或实体,因为聚合模式的规则非常严格。这些子对象中的任何一个都不应该在聚合边界之外被引用、访问或操作。对聚合的所有操作都需要通过调用聚合根的方法来执行。同样,访问聚合内部任何子对象也需要通过聚合根进行。
让我们通过向我们的 ClassifiedAd 聚合添加一个实体来阐述这个原则。我们的一次 EventStorming 会话帮助我们发现,我们需要将图片添加到广告中,因为没有图片,人们真的会犹豫是否购买任何东西。一个广告可以有多个图片,我们可以将这些图片视为值对象,因为用户不能 更改 图片。他们可以上传新的图片或删除现有的图片。然而,似乎用户需要能够选择图片的顺序以及搜索结果中显示的图片,即 主要 图片。我们可以通过使用一个名为 ImageOrder 的值对象来解决这个问题,每次用户更改图片顺序时,它都会被替换。但是,即使在在这种情况下,我们也需要以某种方式引用图片,使用某种形式的标识符。这让我们确信我们的未来 Picture 对象将是实体,这样我们就可以在聚合内部通过标识符来引用它们。如果我们这样做,我们就不需要有一个 ImageOrder 对象,因为我们可以在 Picture 对象本身内部保持排序属性。因此,我们的实体将具有状态变化的选择,我们也需要处理这一点。
我们可以使用 Entity 基类在 Domain 项目中创建我们的新 Picture 类:
using System;
using System.Collections.Generic;
using Marketplace.Framework;
namespace Marketplace.Domain
{
public class Picture : Entity<PictureId>
{
internal PictureSize Size { get; set; }
internal Uri Location { get; set; }
internal int Order { get; set; }
protected override void When(object @event)
{
}
}
public class PictureId : Value<PictureId>
{
public PictureId(Guid value) => Value = value;
public Guid Value { get; }
}
}
在这里,我们并不期望在实体内部以字节数组的形式保存图像本身。物理图像本身不是我们领域的问题。在领域模型中,我们假设所有图像都存储在某个地方,我们只需要有一个图像位置(一个指向外部资源的 URL)与分类广告连接。
我们仍然需要记住,所有操作都是通过调用聚合根来执行的,所以我们向 ClassifiedAd 类添加了一个操作:
public void AddPicture(Uri pictureUri, PictureSize size) =>
Apply(new Events.PictureAddedToAClassifiedAd
{
PictureId = new Guid(),
ClassifiedAdId = Id,
Url = pictureUri.ToString(),
Height = size.Height,
Width = size.Width
});
当然,我们还需要为 PictureAddedToAClassifiedAd 事件创建一个类:
namespace Marketplace.Domain
{
public static class Events
{
// all events are still here
public class PictureAddedToAClassifiedAd
{
public Guid ClassifiedAdId { get; set; }
public Guid PictureId { get; set; }
public string Url { get; set; }
public int Height { get; set; }
public int Width { get; set; }
}
}
}
在事件类中,ClassifiedAdId 是我们的聚合根的 ID。图片 ID 是外部生成的,它将由客户端发送到应用服务,但我们永远不会使用这个 ID 直接从聚合边界之外引用图片。此外,我们假设图片总是添加到列表的末尾,因此我们不需要发送顺序号,因为顺序号将由聚合逻辑分配。
我们为 AddPicture 方法使用了两个值对象作为参数。System.Uri 类型是 .NET 框架的标准类型,我们只需要定义 PictureSize 值对象:
using System;
using Marketplace.Framework;
namespace Marketplace.Domain
{
public class PictureSize : Value<PictureSize>
{
public int Width { get; internal set; }
public int Height { get; internal set; }
public PictureSize(int width, int height)
{
if (Width <= 0)
throw new ArgumentOutOfRangeException(
nameof(width),
"Picture width must be a positive number");
if (Height <= 0)
throw new ArgumentOutOfRangeException(
nameof(height),
"Picture height must be a positive number");
Width = width;
Height = height;
}
internal PictureSize() { }
}
}
再次,我们使用值对象的力量并确保其内部的输入值的有效性,因此我们不需要将此逻辑传播到每个地方。我们仍然需要一个内部构造函数,它将允许我们创建此对象而不验证值,因为我们需要能够无条件地从数据库检索现有对象,并且我们不能依赖于验证规则不会随时间变化的想法。
同样在这里,抛出异常并不是保护值对象免于无效的唯一方法。一种替代方法是为值对象创建一个IsValid属性,但随后你需要在使用值时到处检查它,可能是在应用程序服务中。另一种替代方法是创建一个特殊的静态对象实例,用来指示不正确的值。然后你可以检查你试图应用的价值是否有效。虽然实现这两个方法可能需要更多的代码,但你将避免抛出异常。记住,与 Java 不同,C#没有明确通知调用你的对象该方法可以抛出异常的方法。因此,一些调用者可能不会考虑在调用时包裹 try-catch 块,应用程序可能在运行时崩溃。
现在,我们在将新事件应用到聚合根之后,必须更改聚合状态。我们通过向ClassifiedAd类的When方法中的模式匹配case添加一个新的情况来完成此操作:
protected override void When(object @event)
{
switch (@event)
{
// previous cases as before, removed for brevity
// picture
case Events.PictureAddedToAClassifiedAd e:
var newPicture = new Picture{
Size = new PictureSize(e.Height, e.Width),
Location = new Uri(e.Url),
Order = Pictures.Max(x => x.Order) + 1
};
Pictures.Add(newPicture);
break;
}
}
如你所注意到的,我们引用了一个名为Pictures的新属性。它是包含在聚合内的实体列表,因此它们是聚合根的子对象。我们将其声明为一个列表。我们还需要在聚合根构造函数中初始化这个列表,这样当我们没有图片而尝试添加一个时,就不会得到空引用异常:
public class ClassifiedAd : AggregateRoot<ClassifiedAdId>
{
// existing code
public ClassifiedAd(ClassifiedAdId id, UserId ownerId)
{
Pictures = new List<Picture>(); // <-- this is the new line
Apply(new Events.ClassifiedAdCreated
{
Id = id,
OwnerId = ownerId
});
}
public List<Picture> Pictures { get; private set; }
// existing code
}
这看起来似乎没问题,但实际上并不是。我们的聚合根执行属于Picture实体本身的逻辑。目前,它只是一个操作,但我们确实期望至少有重新排序功能。实体需要负责更新其自身状态,并且由于我们使用事件来执行此操作,它需要获取与该实体相关的所有事件。请注意,我们的Picture类实现了基类的When方法,但它完全是空的。我们需要找到一种方法来赋予我们的实体处理它们自己的事件的能力。此外,实体可以有自己的方法,因此聚合根不包含属于实体的逻辑,而是调用实体方法。当我们向实体类添加方法时,它将产生事件以更改实体状态。但那些事件也可能对聚合根有利益,因此我们需要一些代码来遍历从实体级别到聚合根级别的所有事件。最后,我们需要将实体级别上引发的事件添加到整个聚合的更改列表中,而这个列表由聚合根维护。所有这些都需要我们更改基类,这正是我们现在要做的。
首先,我们为我们的实体基类添加一个新的接口。这个接口有一个方法,将域事件应用于实体状态(目前,我们使用When方法):
namespace Marketplace.Framework
{
public interface IInternalEventHandler
{
void Handle(object @event);
}
}
然后,我们对AggregateRoot基类进行了一些修改。这将通过一个私有的显式方法实现新的接口。此外,我们添加了ApplyToEntity方法,这将允许我们将领域事件推送到实体。当我们将实体参数传递为null时,此方法不做任何事情,因为我们计划从聚合根的When方法中调用它,并且它应该永远不会失败。我们将在第八章[4eea9289-d77e-4568-a9c0-c5e1265e3b4e.xhtml]中详细说明这一点,即聚合持久化,届时我们将讨论事件溯源。现在,我们假设聚合根中的操作方法将确保在产生我们将传播到实体的事件之前,子实体是存在的:
using System.Collections.Generic;
using System.Linq;
namespace Marketplace.Framework
{
public abstract class AggregateRoot<TId>
: IInternalEventHandler where TId : Value<TId>
{
public TId Id { get; protected set; }
protected abstract void When(object @event);
private readonly List<object> _changes;
protected AggregateRoot() => _changes = new List<object>();
protected void Apply(object @event)
{
When(@event);
EnsureValidState();
_changes.Add(@event);
}
public IEnumerable<object> GetChanges()
=> _changes.AsEnumerable();
public void ClearChanges() => _changes.Clear();
protected abstract void EnsureValidState();
protected void ApplyToEntity(
IInternalEventHandler entity,
object @event)
=> entity?.Handle(@event);
void IInternalEventHandler.Handle(object @event)
=> When(@event);
}
}
最后,我们需要以实现新接口的方式修改Entity基类代码:
using System;
namespace Marketplace.Framework
{
public abstract class Entity<TId>
: IInternalEventHandler where TId : Value<TId>
{
private readonly Action<object> _applier;
public TId Id { get; protected set; }
protected Entity(Action<object> applier)
=> _applier = applier;
protected abstract void When(object @event);
protected void Apply(object @event)
{
When(@event);
_applier(@event);
}
void IInternalEventHandler.Handle(object @event)
=> When(@event);
}
}
我们还向这个类添加了一个构造函数,它将接受一个applier代理。由于我们总是从聚合根实例化实体,我们将根的Apply方法传递给所有实体。然后,一个实体将使用双重分派来通知聚合根它将产生的事件。通过这样做,我们将确保聚合根也可以处理来自其子实体的事件,它调用EnsureValidState方法来确保聚合边界内没有一致性违规,并且它将新事件添加到整个聚合的单个更改列表中。
我们使用私有方法来实现新接口,因此当使用从AggregateRoot或Entity基类继承的类时,这些方法不会被暴露,这正是我们想要的。
因此,我们的Picture实体现在需要进行一点重构:
using System;
using Marketplace.Framework;
namespace Marketplace.Domain
{
public class Picture : Entity<PictureId>
{
internal PictureSize Size { get; private set; }
internal Uri Location { get; private set; }
internal int Order { get; private set; }
protected override void When(object @event)
{
switch (@event)
{
case Events.PictureAddedToAClassifiedAd e:
Id = new PictureId(e.PictureId);
Location = new Uri(e.Url);
Size = new PictureSize
{ Height = e.Height, Width = e.Width};
Order = e.Order;
break;
}
}
public Picture(Action<object> applier) : base(applier) { }
}
// the identity class code is still here
}
我们更改了两件事:
-
我们添加了一个接受
applier代理引用的构造函数。 -
我们更改了
When方法,使其现在可以处理新图片的创建。
你可能已经注意到,顺序现在来自事件,因此我们需要在事件类中添加一个新属性。目前,我们没有使用applier代理,因为我们还没有向实体添加任何操作,但我们将会在未来使用它。此外,重要的是强调,我们在When方法中不使用PictureSize值对象的公共构造函数,因为公共构造函数始终应用业务规则,并且可能失败,但这发生在我们在应用程序服务中构建值对象之前,甚至还没有达到聚合。在When方法中,我们需要在不检查这些规则的情况下处理事件,因为When方法不应该失败。
然后,我们可以更改聚合根代码。首先,我们更改了AddPicture方法:
public void AddPicture(Uri pictureUri, PictureSize size) =>
Apply(new Events.PictureAddedToAClassifiedAd
{
PictureId = new Guid(),
ClassifiedAdId = Id,
Url = pictureUri.ToString(),
Height = size.Height,
Width = size.Width,
Order = Pictures.Max(x => x.Order)
});
然后,我们更改了When方法中的事件处理(仅显示更改):
case Events.PictureAddedToAClassifiedAd e:
var picture = new Picture(Apply);
ApplyToEntity(picture, e);
Pictures.Add(newPicture);
break;
现在,让我们演示如何向Picture实体添加一些逻辑,并理解applier委托的含义。一件可能发生的事情是图像可以被调整大小,我们得到新的尺寸。我们的页面不能小于 800 x 600 像素。
再次,我们需要向我们的Events类添加一个新的事件:
public class ClassifiedAdPictureResized
{
public Guid PictureId { get; set; }
public int Height { get; set; }
public int Width { get; set; }
}
然后,我们需要在聚合根中添加一个ResizePicture方法。由于命令将获取一个图片id,我们需要能够在列表中找到这张图片。为了避免传播 LINQ 查询,我们可以在ClassifiedAd类中添加以下方法:
private Picture FindPicture(PictureId id)
=> Pictures.FirstOrDefault(x => x.Id == id);
现在,我们可以在同一个类中添加动作方法:
public void ResizePicture(PictureId pictureId, PictureSize newSize)
{
var picture = FindPicture(pictureId);
if (picture == null)
throw new InvalidOperationException(
"Cannot resize a picture that I don't have");
picture.Resize(newSize);
}
当这完成时,我们可以在Picture实体中添加一个新的Resize方法:
public void Resize(PictureSize newSize)
=> Apply(new Events.ClassifiedAdPictureResized
{
PictureId = Id.Value,
Height = newSize.Width,
Width = newSize.Width
});
然后,我们添加了一些代码来在事件被提升到实体的When方法时更改Picture的状态:
case Events.ClassifiedAdPictureResized e:
Size = new PictureSize{Height = e.Height, Width = e.Width};
break;
当我们完成这些更改后,我们可以在我们的聚合中添加一个额外的不变量。我们可以在EnsureValidState方法中的每个检查中直接定义图片大小规则,但这将会非常冗长,并且从语言角度来看并不清晰。相反,让我们为Picture实体创建一个新的扩展方法,使用一个新的PictureRules类:
namespace Marketplace.Domain
{
public static class PictureRules
{
public static bool HasCorrectSize(this Picture picture)
=> picture != null
&& picture.Size.Width >= 800
&& picture.Size.Height >= 600;
}
}
我们使用扩展方法而不是将此逻辑放在实体内部,因为它并不是实体的规则。也许类名PictureRules并不很好,我们需要修复它。另一方面,我们永远不会使用类名本身,因为它只会包含扩展方法。
让我们将不变量检查代码更改为包括一条新规则:
private Picture FirstPicture
=> Pictures.OrderBy(x => x.Order).FirstOrDefault();
protected override void EnsureValidState()
{
var valid =
Id != null &&
OwnerId != null &&
(State switch
{
ClassifiedAdState.PendingReview =>
Title != null
&& Text != null
&& Price?.Amount > 0
&& FirstPicture.HasCorrectSize(),
ClassifiedAdState.Active =>
Title != null
&& Text != null
&& Price?.Amount > 0
&& FirstPicture.HasCorrectSize()
&& ApprovedBy != null,
_ => true
});
if (!valid)
throw new InvalidEntityStateException(
this, $"Post-checks failed in state {State}");
}
你可以看到,我们需要更多的代码来使事情更加明确。我们不会在每次调用时使用 LINQ 表达式来查找第一个图片(我们可能也需要为它找到一个更好的领域名称),我们将使用聚合根的FirstPicture属性。现在检查变得更加技术化,并且在领域语言方面更加明确。我们可能还会创建更多方法来强制执行其他规则的领域语言,我们将在本书的整个过程中这样做。
摘要
在本章中,我们组合了一些聚合,并通过聚合根对它们进行了操作。我们还评估了聚合的可能持久化方法,并了解了存储聚合状态的仓库概念。
现在,是时候找到一种方法来将我们的领域对象存储在数据库中,并看到我们的应用程序第一次运行。在下一章中,我们将深入探讨聚合持久化的主题。
第八章:聚合持久化
我们已经花费足够的时间讨论了如何通过显式定义的业务规则来确保领域模型的一致性。在本章中,我们将进一步讨论将我们的聚合持久化到数据库中。由于我们的模型不是围绕任何数据库设计的,我们在尝试使用数据库引擎存储复杂对象图时可能会遇到问题。这是因为数据库不与对象协同工作。相反,关系型数据库被优化为存储可能使用主键和外键进行关系操作的数据表。文档数据库以机器可读的格式(如 JSON)存储对象,并且根据定义能够以原样持久化复杂对象图;然而,我们不应该自欺欺人,因为这些对象的组织方式仍然存在严重的限制,以便数据库客户端库可以将我们的对象转换为 JSON 并返回。所有这些差异,一方面是具有要持久化的领域对象,另一方面是具有所有怪癖和调整的数据库引擎,将为开发者带来挑战。
在本章中,我们将涵盖以下主题:
-
仓储模式
-
阻抗不匹配
-
使用文档数据库进行持久化
-
使用关系型数据库进行持久化
技术要求
本章的代码可以在 GitHub 上书籍仓库的 Chapter08 文件夹中找到。那里有三个子文件夹。其中一个叫做 before,其中的代码可以用来跟随本章的进度,随着持久化实现的深入。另外两个文件夹,ravendb 和 ef-core,包含使用 RavenDB 文档数据库和 Entity Framework Core 以及 PostgreSQL 实现聚合持久化的最终代码。
您需要使用 docker-compose 来运行基础设施。这意味着您也需要安装 Docker。请遵循 Docker CE 安装指南,网址为 docs.docker.com/install/,以及 Docker Compose 安装指南,网址为 docs.docker.com/compose/install/。
如果您之前从未在您的机器上运行过 Docker,或者您是在一段时间前运行的,您可能需要使用 docker login 命令进行登录。执行该命令需要您在 Docker Hub 上有一个账户,您可以在 hub.docker.com 上免费创建。
聚合持久化
既然我们已经详细讨论了如何使用聚合模式实现具有复杂业务规则的对象图,我们就需要看看如何为我们在系统中使用的聚合启用持久化。在上一章中,我们简要地介绍了存储库模式,它允许我们将持久化从领域抽象出来。我们还开始通过使用 RavenDB 文档数据库来实现持久化层的实现,因为它更容易将复杂对象保存为文档。然而,我们也了解到,当我们试图满足所选持久化方法对我们对象的要求时,我们很可能会遇到阻抗不匹配的问题,这样我们就可以将它们保存到数据库中并检索回来。
存储库和工作单元
让我们回到我们使用存储库模式来持久化聚合的地方。正如你将记得的那样,存储库模式的目的就是抽象聚合的持久化。这正是我们现在要做的。我们仍然有存储库接口,它看起来是这样的:
public interface IClassifiedAdRepository
{
Task<ClassifiedAd> Load(ClassifiedAdId id);
Task Save(ClassifiedAd entity);
}
存储库模式是存在的一些最具争议的模式之一,要理解为什么会这样,我们需要回到定义本身。例如,这是在 Martin Fowler 的《企业应用架构模式》一书中对这种模式是如何定义的(摘自martinfowler.com/eaaCatalog/repository.html)。建议你查看该页面上给出的定义。
你可以在我们之前提到的网页上找到的图表显示,客户端可以要求存储库检索满足某些条件的对象集。客户端还可以从存储库中添加和删除对象。
关于存储库的辩论通常涉及这样一个事实,在许多情况下,开发人员也将存储库实现为一个工作单元。此外,看到通用存储库相当普遍,如下所示:
public interface IRepository<T>
{
void GetById(int id); void Save(T);
IEnumerable<T> Query(Func<T, bool> filter);
}
Query方法允许向类型化存储库发送一个 lambda 表达式,然后通用的存储库实现将查询发送到底层的 ORM 框架或文档数据库 API,而不需要太多思考。
这种方法让人们认为存储库只是 ORM 框架上不必要的抽象。许多人认为,当开发者发送一个自由形式的查询并将其留给 ORM 框架将其转换为 SQL 语句时,这会给开发者一种对数据库技术的无知感,而且很少会有好结果。我们不能只是忽略数据库并向其发送任何查询,因为这可能导致性能问题,由于缺乏查询优化。对于某些文档数据库,这种方法甚至可能不起作用,因为数据库需要有一个预定义的索引来执行查询。RavenDB 可以根据任何查询创建自动索引,但出于性能原因,不推荐这样做。对于关系数据库,通过 ORM 使用 LINQ 查询转换器可能会导致次优查询,这不仅会严重影响应用程序性能,还会严重影响数据库服务器的性能。
同时,如果我们决定不使用存储库,我们可能会在设计领域模型时处理持久化问题,而这不应该发生。领域模型是独立存在的,它被设计用来处理业务规则和不变性,而不是处理数据库。
艾瑞克·埃文斯坚持认为,查询存储库必须通过使用预定义的规范来进行,而不是发送任何查询。这些规范需要使用通用语言来表达客户端从存储库检索一组对象的目的。
例如,我们应优先使用IEnumerable<ClassifiedAd> GetAdsPendingReview()或IEnumerable<ClassifiedAd> Query(Specifications.GetAdsPendingReview)而不是一个通用的调用,如IEnumerable<ClassifiedAd> Query(x => x.State == ClassifiedAdState.PendingReview)。这样做的一个原因是使查询更具表达性并使用领域语言。另一个原因是让存储库决定如何执行特定的查询,因为我们控制着客户端可以使用的所有查询。最后一个原因是我们将查询条件放在规范内部或存储库方法内部,这样我们就可以在需要时自由地更改这些规则,而这些规则只在一个地方定义。
因此,如果我们花更少的时间争论存储库,更多的时间去理解原始定义,我们会发现使用规范执行查询并不等同于将查询推送到对象关系映射(ORM),而是涉及执行特定的查询,这些查询遵循通用语言命名,并且针对我们的应用程序打算使用的数据库进行了优化。
让我们看看我们如何改变我们的仓库以更接近原始定义。首先,我们需要去掉Save方法,因为仓库客户端(我们的应用服务)将控制工作单元,并将最终决定是否需要将更改提交到数据库。然后,我们添加至少一个查询,当我们在数据库中检查对象是否已存在时,我们将在应用服务中使用这个查询:
using System.Threading.Tasks;
namespace Marketplace.Domain
{
public interface IClassifiedAdRepository
{
Task<ClassifiedAd> Load(ClassifiedAdId id);
Task Add(ClassifiedAd entity);
Task<bool> Exists(ClassifiedAdId id);
}
}
通过这个接口,我们无法控制仓库实现的交易,这将成为我们应用层的责任。我们仍然不希望我们的应用服务直接耦合到持久化层,遵循端口和适配器架构。
RavenDB 的实现
现在,让我们开始使用真实数据库做一些事情;我们的第一个练习将是使用 RavenDB 文档数据库。这个数据库是考虑到 NHibernate API 创建的,但没有对象关系映射的负担。它以 JSON 文档的形式存储对象,支持事务,并且可以使用相当复杂的过滤器处理存储文档的查询。RavenDB 是一个商业产品,但它有一个免费许可选项,非常适合构建小型应用程序并将其投入生产。
对于一些读者来说,选择 RavenDB 作为本书的数据库可能并不明显。显然,在流行度方面,MongoDB 可能是一个更好的选择。同样,Azure Cosmos DB 具有 Mongo API,这使得 MongoDB 驱动程序在示例应用程序中使用更具吸引力。同时,RavenDB 在.NET 社区中拥有相当多的吸引力,它还拥有业界最佳的 Web 用户界面,这将对我们随着本章的进展查看数据库中的情况非常有帮助。
选择文档数据库的原因是基于这样一个事实,与关系数据库相比,文档数据库具有更少的阻抗不匹配,因为文档数据库操作对象,而关系数据库处理表和关系。
我们将首先通过使用 RavenDB 持久化实现仓库接口,以便保存和加载单个聚合。
为了使事情更加明确,我们可以将基础设施部分,如特定数据库的类,移动到Marketplace项目中的新文件夹,称为Infrastructure。
由于我们已经在 RavenDB 上实现了我们的仓库,我们可以从这里开始。但现在,我们想要去掉Save方法,因为我们想要将提交责任移除到工作单元。此外,我们现在可以将这个文件移动到新的Infrastructure文件夹。为了实现新的仓库接口,我们需要做一些小的修改,所以我们的代码将看起来像这样:
using System;
using System.Threading.Tasks;
using Marketplace.Domain;
using Raven.Client.Documents.Session;
namespace Marketplace.Infrastructure
{
public class ClassifiedAdRepository : IClassifiedAdRepository
{
private readonly IAsyncDocumentSession _session;
public ClassifiedAdRepository(IAsyncDocumentSession session)
=> _session = session;
public Task Add(ClassifiedAd entity)
=> _session.StoreAsync(entity, EntityId(entity.Id));
public Task<bool> Exists(ClassifiedAdId id)
=> _session.Advanced.ExistsAsync(EntityId(id));
public Task<ClassifiedAd> Load(ClassifiedAdId id)
=> _session.LoadAsync<ClassifiedAd>(EntityId(id));
private static string EntityId(ClassifiedAdId id)
=> $"ClassifiedAd/{id.ToString()}";
}
}
在这里,我们移除了 Save 方法,现在我们有了 Add 方法,它只会在我们将新的聚合添加到数据库时使用。RavenDB 不仅使用会话来控制与数据库的连接,还用于跟踪通过调用新对象的 Store 或 StoreAsync 方法或通过使用会话从数据库加载现有对象来添加到会话中的对象的更改。因此,一旦我们使用存储库的 Load 或 Add 方法,底层的会话将跟踪这些对象中发生的所有更改。实际上,会话本身代表工作单元,因为当我们将更改作为单个事务提交给会话时,所有附加到会话的对象发生的所有更改都将提交到数据库。
跟踪和提交更改作为事务的能力并不是 RavenDB 客户端库的专属属性。对于关系数据库,Entity Framework(EF)和 NHibernate 允许使用相同的技巧。特别是,NHibernate 也有一个 ISession 接口,具有完全相同的性能,因为 RavenDB API 最初设计得非常接近 NHibernate API。此外,使用 PostgreSQL 的原生能力在 JSONB 字段中处理类似文档的结构的开源库 Marten (jasperfx.github.io/marten/) 也有一个会话实现,该会话跟踪连接对象的更改。
为了完成抽象,我们需要为工作单元提供一个接口。我们可以从类似以下的内容开始:
using System.Threading.Tasks;
namespace Marketplace.Framework
{
public interface IUnitOfWork
{
Task Commit();
}
}
由于我们正在使用 RavenDB 会话跟踪对象的更改,因此该接口的实现将非常简单:
using System.Threading.Tasks;
using Marketplace.Framework;
using Raven.Client.Documents.Session;
namespace Marketplace.Infrastructure
{
public class RavenDbUnitOfWork : IUnitOfWork
{
private readonly IAsyncDocumentSession _session;
public RavenDbUnitOfWork(IAsyncDocumentSession session)
=> _session = session;
public Task Commit() => _session.SaveChangesAsync();
}
}
为了使它与我们的应用程序服务一起工作,我们需要确保服务在其构造函数中将存储库和工作单元接口作为参数。ClassifiedAdAplicationService 的新代码如下:
using System;
using System.Threading.Tasks;
using Marketplace.Domain;
using Marketplace.Framework;
using static Marketplace.Contracts.ClassifiedAds;
namespace Marketplace.Api
{
public class ClassifiedAdsApplicationService : IApplicationService
{
private readonly IClassifiedAdRepository _repository;
private readonly IUnitOfWork _unitOfWork;
private readonly ICurrencyLookup _currencyLookup;
public ClassifiedAdsApplicationService(
IClassifiedAdRepository repository, IUnitOfWork unitOfWork,
ICurrencyLookup currencyLookup
)
{
_repository = repository;
_unitOfWork = unitOfWork;
_currencyLookup = currencyLookup;
}
public Task Handle(object command) =>
command switch
{
V1.Create cmd => HandleCreate(cmd),
V1.SetTitle cmd =>
HandleUpdate(
cmd.Id,
c => c.SetTitle(
ClassifiedAdTitle.FromString(cmd.Title)
)
),
V1.UpdateText cmd =>
HandleUpdate(
cmd.Id,
c => c.UpdateText(
ClassifiedAdText.FromString(cmd.Text)
)
),
V1.UpdatePrice cmd =>
HandleUpdate(
cmd.Id,
c => c.UpdatePrice(
Price.FromDecimal(
cmd.Price, cmd.Currency,
_currencyLookup
)
)
),
V1.RequestToPublish cmd =>
HandleUpdate(
cmd.Id,
c => c.RequestToPublish()
)
};
private async Task HandleCreate(V1.Create cmd)
{
if (await _repository.Exists(cmd.Id.ToString()))
throw new InvalidOperationException(
$"Entity with id {cmd.Id} already exists");
var classifiedAd = new ClassifiedAd(
new ClassifiedAdId(cmd.Id),
new UserId(cmd.OwnerId)
);
await _repository.Add(classifiedAd);
await _unitOfWork.Commit();
}
private async Task HandleUpdate(
Guid classifiedAdId, Action<ClassifiedAd> operation)
{
var classifiedAd = await
_repository.Load(classifiedAdId.ToString());
if (classifiedAd == null)
throw new InvalidOperationException(
$"Entity with id {classifiedAdId} cannot be
found");
operation(classifiedAd);
await _unitOfWork.Commit();
}
}
}
你可以看到,我们的应用程序服务现在获得了三个依赖项,而不是之前的那两个。我们添加了工作单元接口,以便服务可以决定何时将更改提交到数据库。这给重写我们的应用程序启动代码带来了挑战,因此我们添加了缺失的依赖项。在那里还有一个问题等待着我们,因为我们的工作单元使用它作为依赖项的文档会话进行提交。存储库也依赖于文档会话。你可能还记得,文档会话跟踪所有加载到会话或显式添加到会话中的对象的更改;这就是存储库所做的工作。但我们在工作单元中执行提交,这意味着在应用程序服务的同一实例中使用的存储库和工作单元必须具有相同的文档会话。
如果我们决定自己实例化依赖关系图,那么这部分会相当棘手。对于我们的应用程序,我们将使用 ASP.NET (www.asp.net/) Core 服务集合来定义依赖关系。服务集合也充当 依赖注入 容器,因此如果我们正确配置它,我们就能得到正确的依赖关系。以下启动代码就起到了这个作用:
public void ConfigureServices(IServiceCollection services)
{
var store = new DocumentStore
{
Urls = new[] {"http://localhost:8080"},
Database = "Marketplace_Chapter8",
Conventions =
{
FindIdentityProperty = m => m.Name == "_databaseId"
}
};
store.Initialize();
services.AddSingleton<ICurrencyLookup, FixedCurrencyLookup>();
services.AddScoped(c => store.OpenAsyncSession());
services.AddScoped<IUnitOfWork, RavenDbUnitOfWork>();
services.AddScoped<IClassifiedAdRepository, ClassifiedAdRepository>();
services.AddScoped<ClassifiedAdsApplicationService>();
services.AddMvc();
services.AddSwaggerGen(c =>
{
c.SwaggerDoc(
"v1",
new Info
{
Title = "ClassifiedAds",
Version = "v1"
});
});
}
这段代码是为 Marketplace 项目的 Startup.cs 文件。在那里,我们使用工厂委托 RavenDbUnitOfWork 和 ClassifiedAdRepository 将文档会话注册为作用域依赖项。我们的应用程序服务也被注册为作用域服务。当我们注册任何依赖项为 作用域 时,其生命周期将限制为单个 HTTP 请求的生命周期。对于我们的代码来说,这意味着只为请求实例化一个文档会话,它将被用作处理请求的所有其他实例化的对象的依赖项。因此,我们将得到一个应用程序服务实例、一个存储库和一个工作单元。最后两个也将获得相同的文档会话实例,这正是我们想要的。
作为旁注,我必须明确指出,当我们遇到强烈的需求,需要依赖注入容器来管理我们的依赖关系,而我们无法手动配置依赖关系时,我们需要注意到我们的代码中可能存在问题。在这种情况下,我们需要重新考虑依赖关系图,并尝试简化它,以便我们减少或不需要使用容器。在这个特定的情况下,我们无法控制 ASP.NET Core 如何实例化它用来处理 HTTP 请求的控制器。因此,我们被迫使用容器。然而,我们将尝试缩小依赖项列表,以避免注入地狱并重新控制请求处理范围。
在本章的代码中,你还可以看到我们在 ClassifiedAdCommandsApi 类中有一个辅助方法,用于通过将请求发送到应用程序服务并包装它可能抛出的任何异常来处理 HTTP 请求。我们本可以使用由 Web API 提供的开发者错误页面;然而,它包含大量的 HTML,而我们正在使用 Swagger,它不会渲染它,而是显示 HTML 源代码。这使得诊断变得更加困难,因为我们需要深入到大量的 HTML 标签中才能找到异常信息和堆栈跟踪。添加的方法如下:
private async Task<IActionResult> HandleRequest<T>(T request, Func<T, Task> handler)
{
try
{
Log.Debug("Handling HTTP request of type {type}",
typeof(T).Name);
await handler(request);
return Ok();
}
catch (Exception e)
{
Log.Error("Error handling the request", e);
return new BadRequestObjectResult(new {error = e.Message,
stackTrace = e.StackTrace});
}
}
由于我们在这里使用的是泛型类型参数,因此我们可以向此方法发送任何请求,同时将应用程序服务 Handle 方法作为一个委托来处理请求。例如,我们控制器中的 Post 方法现在看起来是这样的:
public ClassifiedAdsCommandsApi(
ClassifiedAdsApplicationService applicationService)
=> _applicationService = applicationService;
你可能已经注意到,HandleRequest方法也使用了日志记录。在这本书中,我们使用了开源的Serilog日志库,这是第一个为.NET 空间提供结构化日志的库,并迅速成为.NET 空间最受欢迎的日志库。
我们将聚合保存到 RavenDB 的初始准备阶段已完成。对于下一步,我们需要启动 RavenDB,最简单的方法是使用 Docker Compose 与本书库中提供的配置文件。docker-compose.yml文件包含了 Docker Compose 启动两个容器的指令——一个是 RavenDB,另一个是 PostgreSQL,我们将在本章后面探索使用关系数据库持久化聚合时使用。
您应该能够在终端窗口中从章节文件夹运行docker-compose up命令,您将看到类似以下内容:

docker-compose 命令的终端输出
如果在执行命令时遇到任何问题,请检查本章的技术要求部分。
请记住,您可以通过在运行它的终端窗口中按Ctrl + C来停止您的docker-compose会话,在这种情况下,容器将被停止。容器内的所有数据都将保留,因此当您下次使用docker-compose up时,您将再次看到您的数据库。如果您使用docker-compose down,容器将被删除,当您再次启动它们时,您需要再次创建数据库。如果您无论如何都想保留数据,请考虑在docker-compose.yml文件中指定卷。
当您第一次运行 RavenDB 或容器被重新创建时,您需要通过访问http://localhost:8080来访问数据库 Web UI 并接受许可协议。RavenDB 可以免费用于开发和小型生产系统。当您接受协议后,您将被重定向到 RavenDB Studio 页面:

RavenDB 用户界面
在我们能够将任何内容保存到 RavenDB 之前,我们需要创建一个数据库。对于本章的示例应用程序代码,数据库名称被硬编码为Marketplace_Chapter8。要创建一个新的数据库,您可以使用 RavenDB Studio 主页上的创建数据库按钮:

此按钮允许您创建一个新的数据库
当你点击此按钮时,你会弹出一个窗口,你可以输入数据库名称并点击创建:

新数据库创建屏幕
现在,我们可以开始我们的示例应用程序。应用程序启动后,它产生的输出类似于以下内容:
Hosting environment: Development
Content root path: ~/github/ddd-book/chapter8/Marketplace/bin/Debug/netcoreapp2.2
Now listening on: http://localhost:5000
Application started. Press Ctrl+C to shut down.
默认情况下,任何 ASP.NET Core 应用程序都会在http://localhost:5000上开始监听。我们的应用程序中没有用户界面,但我们有 Swagger UI 来向应用程序配置中添加的 API 发送请求。因此,如果您访问http://localhost:5000/swagger/index.html页面,您将看到我们迄今为止创建的所有 API 端点:

Swagger UI 的命令 API
最后,我们非常接近向我们的应用程序发送命令并查看它的工作方式。首先的事情是;在进行任何更新之前,我们需要创建我们的第一个聚合。因此,我们可以点击 POST,然后点击“尝试一下”按钮。我们将得到两个字段需要填写新 GUID,这些 GUID 可以很容易地通过在线 GUID 生成器生成,或者通过在 JetBrains Rider 或 Visual Studio 中可用的类似工具生成。
在将两个新生成的 GUID 输入到新POST请求的参数字段后,您可以按下执行按钮,片刻之后,您将得到一个响应:

当我们尝试执行命令时,会抛出异常
但是等等;我们有一个错误!让我们看看错误消息告诉我们发生了什么:
Cannot set identity value 'ClassifiedAd/302790d5-735e-445e-a042-b5891ad3cf1f' on property 'Id' for type 'Marketplace.Domain.ClassifiedAd' because property type is not a string.
在这里,我们有我们的第一个阻抗不匹配的例子。我们一直在没有考虑持久性的情况下建模我们的领域类,并且所有决策都是基于我们类结构对领域需求的考虑。当我们开始与数据库一起工作时,尽管这个数据库是基于文档的,并且在理论上应该持久化我们给出的任何对象,但现实情况略有不同。现在,我们被迫开始调整我们的领域类,以便它们可以被持久化。这相当不幸,因为理想情况下,我们应该保持我们的领域模型实现不受任何持久性问题的干扰。
但是,让我们看看我们现在能做什么。RavenDB 要求任何要保存到其中的文档都必须有一个字符串类型的身份属性或字段。我们使用ClassifiedAdId值对象类型作为身份属性。我们明确地告诉 RavenDB 在我们的存储库Add方法中的对象身份,因此它不会使用Id属性。然而,它未能将字符串值写回Id字段,因为它不是字符串。这只能通过向我们的聚合类中添加一个新的字符串类型属性或字段来修复。RavenDB 使用Id作为身份属性的名字,但我们可以配置约定,使数据库客户端 API 使用其他名称。
我们可以通过向ClassifiedAd类添加一个新的private字段来开始修复这个问题:
public class ClassifiedAd : AggregateRoot<ClassifiedAdId>
{
// Properties to handle the persistence
private string DbId
{
get => $"ClassifiedAd/{Id.Value}";
set {}
}
// Aggregate state properties
看起来我们不用属性设置器可能有些奇怪,但数据库会将Id属性读取为一个对象,并且我们会得到返回的值。因此,我们可以安全地使用Id属性进行get操作,而set操作将仅用于使数据库满意。
我们还需要向数据库 API 解释,它需要使用这个新属性作为文档标识符。这是通过在Startup.cs中创建DocumentStore实例时使用约定来完成的:
var store = new DocumentStore
{
Urls = new[] {"http://localhost:8080"},
Database = "Marketplace_Chapter8",
Conventions =
{
FindIdentityProperty = x => x.Name == "DbId"
}
};
store.Initialize();
现在,让我们重新启动应用程序并再次从 Swagger 中调用该调用。我们可以使用相同的值,所以如果你在更改代码时保留了浏览器窗口,你只需执行之前生成错误的相同请求。现在,响应不同:

获取 200 OK 表示一切正常
为了确认我们的持久化代码是否工作,我们需要再次转向 RavenDB Studio,如果我们那里打开数据库,我们将有一个代表新聚合状态的文档:

文档已成功存储
对于下一步,我们可以执行对现有聚合执行状态更改的命令之一。首先,我们可以通过调用 /ad/name/ API 端点并使用 PUT 来设置广告标题。我们需要使用与 POST 调用相同的聚合 ID,因为这是我们当前系统中唯一拥有的对象。在下面的屏幕截图中,你可以看到操作已经执行,API 返回了 200 OK 状态:

更新命令执行成功
让我们检查在 RavenDB 中的文档发生了什么。如果文档在 Studio 中仍然打开,你会看到一个小的弹出窗口说:“此文档在 Studio 外已被修改。点击此处刷新。”你可以继续点击链接,这样文档就会刷新,并显示新版本。现在,我们可以看到文档内容已更改,并且“标题”属性具有适当的值(进一步,我将在“Startup.cs”中创建“DocumentStore”实例时只使用文档内容作为 JSON):
{
"OwnerId": {
"Value": "83508629-d2ee-4798-9ac5-b5bbc3e57731"
},
"Title": {
"Value": "Green sofa"
},
"Text": null,
"Price": null,
"State": "Inactive",
"ApprovedBy": null,
"Pictures": [],
"FirstPicture": null,
"Id": {
"Value": "302790d5-735e-445e-a042-b5891ad3cf1f"
},
"@metadata": {
"@collection": "ClassifiedAds",
"Raven-Clr-Type": "Marketplace.Domain.ClassifiedAd,
Marketplace.Domain"
}
}
现在,让我们尝试调用其他端点。你甚至可以尝试再次调用相同的端点,这样它会尝试将标题设置为其他值。令人惊讶的是,这不会工作。我们可以看到以下错误消息:
Could not convert document ClassifiedAd/7b0a443f-af9b-4f0d-8876-7896c9921cbc to entity of type Marketplace.Domain.ClassifiedAd.ClassifiedAd
这条消息不是很 informative,但 RavenDB 正在试图告诉我们我们有一个序列化问题。让我们看看内部异常可以告诉我们什么。信息如下:
Unable to find a constructor to use for type Marketplace.Domain.ClassifiedAd.ClassifiedAdTitle. A class should either have a default constructor, one constructor with arguments or a constructor marked with the JsonConstructor attribute. Path 'Title.Value'.
这里的问题在于,我们只允许通过工厂方法创建值对象,以防止创建具有无效内容的值对象。当我们绕过验证时,序列化器将不会使用它,除非我们在它上面放置一个 [JsonConstructor] 属性。我们绝对不希望这样做,因为这样做会使我们的领域模型依赖于 Newtonsoft.Json 库,这是一个纯粹的基础设施问题。为了避免这种情况而不损害我们领域项目的纯洁性,唯一的方法是创建一个无参数的私有构造函数。这将允许我们保持封装并满足序列化器的需求。这是持久层和领域层之间不匹配的另一个问题。
让我们在 ClassifiedAdTitle 类中添加这一行代码来解决该问题:
// Satisfy the serialization requirements
protected ClassifiedAdTitle() { }
除了身份类型之外,需要将类似的行添加到所有值对象类型中,因为它们已经具有一个参数为 Guid 的公共构造函数,序列化器也乐于使用它。完成所有这些更改后,所有的 HTTP 端点都将开始工作。
因此,我们可以得出结论,我们为了克服阻抗不匹配所做的微小更改是有效的。你可能已经注意到,所有具有值对象类型的属性都存储为 JSON 对象。这是任何可以存储和检索复杂对象图作为单个文档的文档数据库的一个很好的特性。
可以采用类似的方法通过使用支持会话和会话内变更跟踪的其他类型的文档存储来实现持久化。我在本章前面已经提到了 Marten。然而,对于其他文档数据库,如 MongoDB 或 Cosmos DB,您需要从集合式存储库中退出,并开始从存储库内部提交更新,而不是使用工作单元。这种做法可能看起来有些不符合常规,因此开发者们在实施时有时会感到内疚。然而,如果我们记住聚合必须被视为事务边界,那么在单个事务中更新多个对象的可能性几乎为零。如果违反了这一规则,那么您可能面临的问题将不仅仅是存储库接口中存在一个Save方法那么简单。但是,当我们只在对应用程序服务中的一个聚合进行操作时,使用单独的工作单元的整个故事开始显得多余。当我们的应用程序服务符合load-act-save模式时,可能没有实际的理由将存储库从工作单元中分离出来。应用程序服务仍然负责提交更改,但它可以通过调用_repository.Save()来实现,就像在我们的代码中调用_unitOfWork.Commit()一样。当我们开始讨论基于事件的持久化时,即第十章的事件溯源,我们将更详细地探讨存储库模式和它的有用性。
实现 Entity Framework Core
尽管如今开发者有多种数据库可供选择,但在许多情况下,关系型数据库仍然更受欢迎。这种选择的原因可能各不相同,但最常见的原因包括某些关系型数据库管理系统(RDBMS),如 Oracle 或 Microsoft SQL Server,已经在组织中使用,并且有人员可以维护它们,或者开发团队本身在处理关系型数据库方面拥有丰富的经验。当然,这通常会导致问题,因为领域模型可能会迅速变成数据模型,整个应用程序都会围绕持久化构建。
关系型数据库也因存在显著的阻抗不匹配而臭名昭著。尽管开发者们往往倾向于认为类可以完美地存储在表中,并且类之间的关系可以用外键表示,但这并不是全部。在我们完成对单个聚合的持久化实现的第一轮迭代之后,我们将很快看到这一点。
为了克服阻抗不匹配,使关系型数据库的持久化对使用对象的开发者更加透明,我们的行业发明了一种解决方案。我们大多数人熟悉对象关系映射器(ORM),它承诺可以透明地将对象放入数据库并检索它们。在 .NET 领域,特别是,我们有两大 ORM 框架被广泛使用。这些框架是 NHibernate 和 Entity Framework。NHibernate 拥有悠久的历史,它最初是一个流行的 Java ORM 框架(Hibernate)的克隆。在几年时间里,NHibernate 是 .NET 领域唯一的 ORM 工具。然后,在微软尝试进入 ORM 领域的 LINQ2SQL 失败之后,Entity Framework 诞生了。尽管许多人批评它速度慢、僵化且设计不佳,但它仍然迅速成为许多 .NET 开发者的首选工具,唯一的理由是它得到了微软的支持。Entity Framework 还推出了第一个可视化设计工具,允许通过拖放创建与持久化层映射的类模型。经过几年的框架持续改进,Entity Framework 获得了大量采用,在某个时刻,许多人认为 NHibernate 已经死亡。然而,在过去的几年里,NHibernate 社区发布了带有 async/await 支持的版本 4,然后是带有 .NET Core 支持的版本 5。Entity Framework 团队决定退后一步重新思考设计,推出了 Entity Framework Core。这个版本现在正在积极开发中,并且也包含在 Microsoft.AspNet.Core NuGet 包组中,因此它可以直接用于所有 .NET Core 应用程序。
多亏了像 Julie Lerman 这样的社区成员在微软的影响力,她深入研究了领域驱动设计(DDD)及其原则,并为 Entity Framework 团队提供了大量宝贵的输入,以改善他们的产品,减轻阻抗不匹配,并支持诸如不可变值对象等概念。因此,我决定包括一个示例,说明如何将此框架用作关系型数据库的领域模型持久化,尽管这本书更倾向于事件溯源。
本部分的代码可在本书的 GitHub 仓库中找到,位于 Chapter08/ef-core 文件夹中。
在本例中,我们将使用 PostgreSQL 数据库,但代码可以轻松转换为 Microsoft SQL Server,因为我们不会使用任何特定于 PostgreSQL 的功能。本章 ef-core 文件夹中的 docker-compose.yml 文件将帮助您在容器内启动数据库,就像我们使用 RavenDB 一样。初始化脚本将在容器创建时自动执行。该脚本负责创建一个数据库用户和一个名为 Marketplace_Chapter8 的新数据库,因此您在启动应用程序之前无需做任何事情。
现在,让我们看看为了使用关系数据库来持久化我们的聚合,我们需要做什么。由于我们已经在项目中有了对Microsoft.AspNetCore.App包集的引用,因此 Entity Framework Core 本身可以直接使用。我们需要向我们的项目中添加一个名为Npgsql.EntityFrameworkCore.PostgreSQL的 PostgreSQL 驱动程序包。
我们需要告诉框架它需要将我们的ClassifiedAd类映射到数据库。为此,我们需要在Marketplace项目的Infrastructure文件夹中创建一个新的类ClassifiedAdDbContext。我们将使用代码优先的方法,让框架决定表的结构以及如何将ClassifiedAd类的属性映射到表列。以下是类的第一个版本:
using Marketplace.Domain;
using Microsoft.AspNetCore.Builder;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Marketplace.Infrastructure
{
public class ClassifiedAdDbContext : DbContext
{
private readonly ILoggerFactory _loggerFactory;
public ClassifiedAdDbContext(
DbContextOptions<ClassifiedAdDbContext> options,
ILoggerFactory loggerFactory)
: base(options) => _loggerFactory = loggerFactory;
public DbSet<ClassifiedAd> ClassifiedAds { get; set; }
protected override void OnConfiguring(
DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.UseLoggerFactory(_loggerFactory);
optionsBuilder.EnableSensitiveDataLogging();
}
protected override void OnModelCreating(ModelBuilder
modelBuilder)
=> modelBuilder.ApplyConfiguration(
new ClassifiedAdEntityTypeConfiguration());
}
public class ClassifiedAdEntityTypeConfiguration
: IEntityTypeConfiguration<ClassifiedAd>
{
public void Configure(EntityTypeBuilder<ClassifiedAd> builder)
=> builder.HasKey(x => x.ClassifiedAdId);
}
public static class AppBuilderDatabaseExtensions
{
public static void EnsureDatabase(this IApplicationBuilder app)
{
var context = app.ApplicationServices
.GetService<ClassifiedAdDbContext>();
if (!context.Database.EnsureCreated())
context.Database.Migrate();
}
}
}
在这里我们并没有做很多。通过添加一个类型为DbSet<ClassifiedAd>的属性,我们告诉框架它需要映射这个类。然后,我们还解释了应该使用ClassifiedAd.ClassifiedAdId属性作为主键。我们之前没有这个属性,但在之前的 RavenDB 部分中我们已经使用了类似的东西,因为数据库需要知道用作实体标识的值是什么,而且它不能是一个值对象。
因此,我们还需要将这个属性添加到我们的聚合中。我们希望封装尽可能多的内容,但由于我们需要从基础设施配置中访问这个属性,我们被迫至少将其设置为公共的,以便获取其值。与 RavenDB 不同,我们无法通过名称指定属性。
在我们的DbContext实现中,我们还要做的一件事是告诉 Entity Framework Core 进行日志记录,并且记录敏感数据。这对于调试很有用,因为它允许我们看到 Entity Framework Core 在幕后做了什么,包括所有 SQL 语句和参数。请记住,在生产环境中不应使用EnableSensitiveDataLogging,因为它会暴露所有数据,可能会导致一些敏感数据通过日志文件或日志服务器暴露出来。
在前面的代码中还有一个类实现了对IApplicationBuilder的扩展。我们将使用这个扩展来创建或迁移必要的表。这种方法也不适合生产环境,因为你可能希望单独进行迁移。
因此,我们需要在我们的聚合类中进行以下更改:
public class ClassifiedAd : AggregateRoot<ClassifiedAdId>
{
// Properties to handle the persistence
public Guid ClassifiedAdId { get; private set; }
protected ClassifiedAd() { }
... more code here...
protected override void When(object @event)
{
Picture picture;
switch (@event)
{
case Events.ClassifiedAdCreated e:
Id = new ClassifiedAdId(e.Id);
OwnerId = new UserId(e.OwnerId);
State = ClassifiedAdState.Inactive;
// required for persistence
ClassifiedAdId = e.Id;
break;
... rest of the code ...
这将算作我们第一次解决阻抗不匹配问题,并添加一个属性,只是为了满足持久化的需求。对于 RavenDB 来说,这就是我们开始所需做的全部。让我们看看这对 EF Core 是否足够。
作为下一步,我们需要对工作单元进行新的实现。我们将在Infrastructure命名空间中添加一个新的类EfUnitOfWork:
using System.Threading.Tasks;
using Marketplace.Framework;
namespace Marketplace.Infrastructure
{
public class EfCoreUnitOfWork : IUnitOfWork
{
private readonly ClassifiedAdDbContext _dbContext;
public EfCoreUnitOfWork(ClassifiedAdDbContext dbContext)
=> _dbContext = dbContext;
public Task Commit() => _dbContext.SaveChangesAsync();
}
}
然后,我们将在仓库类ClassifiedAdRepository中进行必要的更改:
using System;
using System.Threading.Tasks;
using Marketplace.Domain;
namespace Marketplace.Infrastructure
{
public class ClassifiedAdRepository : IClassifiedAdRepository
{
private readonly ClassifiedAdDbContext _dbContext;
public ClassifiedAdRepository(ClassifiedAdDbContext dbContext)
=> _dbContext = dbContext;
public Task Add(ClassifiedAd entity)
=> _dbContext.ClassifiedAds.AddAsync(entity);
public async Task<bool> Exists(ClassifiedAdId id)
=> await _dbContext.ClassifiedAds.FindAsync(id.Value) !=
null;
public Task<ClassifiedAd> Load(ClassifiedAdId id)
=> _dbContext.ClassifiedAds.FindAsync(id.Value);
}
}
如您所见,我们依赖于DbContext在每个作用域中实例化。实际上,DbContext是 Entity Framework 对工作单元模式的实现,因为它在其生命周期内跟踪所有附加到它的对象的变化,并在我们调用_dbContext.SaveChangesAsync()时创建所有必要的 SQL 语句以将那些更改提交到数据库。
最后部分是连接。我们需要更改Startup.cs文件,告诉 ASP.NET Core 使用我们的上下文并在其 IoC 容器中注册数据库上下文。当然,我们还需要注册工作单元的新实现。我们都在ConfigureServices方法中完成所有这些操作,如下所示:
public void ConfigureServices(IServiceCollection services)
{
const string connectionString =
"Host=localhost;Database=Marketplace_Chapter8;
Username=ddd;Password=book";
services
.AddEntityFrameworkNpgsql()
.AddDbContext<ClassifiedAdDbContext>(
options => options.UseNpgsql(connectionString));
services.AddSingleton<ICurrencyLookup, FixedCurrencyLookup>();
services.AddScoped<IUnitOfWork, EfCoreUnitOfWork>();
services.AddScoped<IClassifiedAdRepository, ClassifiedAdRepository>
();
services.AddScoped<ClassifiedAdsApplicationService>();
services.AddMvc();
services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1",
new Info
{
Title = "ClassifiedAds",
Version = "v1"
});
});
}
在这里,我们还指示 Entity Framework Core 使用 PostgreSQL 作为数据库,并使用硬编码的连接字符串。请记住,你应该避免硬编码连接字符串,因为它们必须是配置的一部分。我们使用简化的方法使连接字符串可见。
在启动应用程序之前,我们需要做的最后一件事是调用我们的扩展方法来创建或迁移数据库对象。我们在Startup类的Configure方法中执行此操作:
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
app.EnsureDatabase();
app.UseMvcWithDefaultRoute();
app.UseSwagger();
app.UseSwaggerUI(c =>
c.SwaggerEndpoint("/swagger/v1/swagger.json", "ClassifiedAds
v1"));
}
我们所做的更改或多或少代表了我们需要对使用 RavenDB 进行持久化所做的所有更改。理想情况下,现在一切应该都能正常工作。让我们启动应用程序并看看会发生什么。
在按下F5后,我们会看到应用程序无法启动。相反,它会立即抛出一个异常:
实体类型ClassifiedAdId需要定义一个主键。
这听起来很奇怪,因为ClassifiedAdId不是一个实体。接下来就是麻烦。Entity Framework Core 认为所有对象到对象的关系都是不同实体之间的关系。它想要创建一个单独的表来存储ClassifiedAdId对象,但作为一个实体,它必须有一个标识符。大约两年前,我们就会在这里卡住,克服这种限制的唯一方法就是使用备忘录模式。这种模式的本质是能够持久化对象状态,并能够在以后恢复它。有时,它被称为撤销回滚,但这只是这种模式的一个狭窄用例。本质上,每种对象持久化方法都使用 memento 模式的某种实现。
为了实现备忘录模式,我们需要有一种方法将我们的复杂对象转换为可以持久化的东西,比如文本文件、关系表或 JSON 对象。在任何保存操作之后,我们需要手动将我们的聚合状态转换为备忘录,当我们需要恢复状态时,需要执行反向操作。然而,今天,我们可以通过告诉 Entity Framework Core 我们实际上正在处理值对象而不是工作实体来解决这个问题。实际上,EF Core 已经为我们实现了模式的所有部分。要使用此功能,我们需要向ClassifiedAdEntityTypeConfiguration类添加更多代码:
public class ClassifiedAdEntityTypeConfiguration : IEntityTypeConfiguration<ClassifiedAd>
{
public void Configure(EntityTypeBuilder<ClassifiedAd> builder)
{
builder.HasKey(x => x.ClassifiedAdId);
builder.OwnsOne(x => x.Id);
builder.OwnsOne(x => x.Price, p => p.OwnsOne(c => c.Currency));
builder.OwnsOne(x => x.Text);
builder.OwnsOne(x => x.Title);
builder.OwnsOne(x => x.ApprovedBy);
builder.OwnsOne(x => x.OwnerId);
}
}
OwnsOne方法告诉 EF Core,它需要将给定的属性持久化为同一表的一部分,而不是作为单独的实体保存在单独的表中。由于 EF Core 只会保存公共属性的内容,我们需要公开我们的值对象属性以供get部分使用。我们仍然想要封装,所以设置器保持为私有。这就是我们需要添加到PictureSize值对象代码中的内容:
public class PictureSize : Value<PictureSize>
{
public int Width { get; internal set; }
public int Height { get; internal set; }
internal PictureSize() { }
... rest of the code ...
EF Core 还要求它持久化的所有对象要么有一个接受所有属性值的构造函数,要么有一个无参构造函数。我们使用第二种选择,但我们将构造函数设置为内部,这样就没有人可以从Domain项目外部使用它。
现在,我们也知道 EF Core 想要了解如何将对象映射到表上;这也已经变得很清楚,我们还需要映射Picture实体。在那里,我们希望将持久化的对象保存在一个单独的表中。为了做到这一点,我们需要添加一个新的类PictureEntityTypeConfiguration。它可以添加到同一个ClassifiedAdDbContext.cs文件中:
public class PictureEntityTypeConfiguration : IEntityTypeConfiguration<Picture>
{
public void Configure(EntityTypeBuilder<Picture> builder)
{
builder.HasKey(x => x.PictureId);
builder.OwnsOne(x => x.Id);
builder.OwnsOne(x => x.ParentId);
builder.OwnsOne(x => x.Size);
}
}
注意,我们需要对图片 ID 执行与分类广告 ID 相同的技巧。由于为了简洁起见,我没有在文本中放入代码更改,因为所有代码都可以在本书的代码库中找到。
ClassifiedAdDbContext.OnModelCreating现在需要包含这个额外的映射配置:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfiguration(new
ClassifiedAdEntityTypeConfiguration());
modelBuilder.ApplyConfiguration(new
PictureEntityTypeConfiguration());
}
现在让我们再次运行应用程序。这次,它启动得很好;乍一看,所有映射似乎都是正确的。如果我们还查看数据库(使用您选择的工具,如 Visual Studio 中的数据库资源管理器或 Rider 中的数据库工具窗口),我们会看到创建了两个表:

Visual Studio 数据库资源管理器中的数据库结构
如你所见,对于每个实体类型都有两个表。由于Picture实体是ClassifiedAd聚合的一部分,我们使用对象到对象的关系,并在数据库中将其映射为外键。对于每个值对象,EF Core 已经创建了一组列来存储与父实体相同的表中的每个值对象的全部属性。到目前为止,一切顺利;现在,我们可以尝试调用我们的 API。你需要在 Swagger 中填写两个 GUID,然后点击执行,我们会有一段相当长的等待时间。这是因为 EF Core 初始化是隐式的,并且在我们第一次尝试使用DbContext做任何事情时被调用。后续调用处理得更快,因为初始化后的模型将被缓存。
让我们看看我们从 HTTP 调用中得到了什么。这并不令人惊讶,它又是一个异常。我们正在逐个获取与阻抗不匹配相关的问题!新的错误信息如下:
The entity of type 'ClassifiedAd' is sharing the table 'ClassifiedAds' with entities of type 'ClassifiedAdText', but there is no entity of this type with the same key value '{ClassifiedAdId: 302790d5-735e-445e-a042-b5891ad3cf1f}' that has been marked as 'Added'.
这次,错误信息并不十分清晰。实际上,EF Core 现在告诉我们它无法处理ClassifiedAd对象的值为 null 的值对象属性。当我们应用ClassifiedAdCreated事件在When方法中时,我们只分配我们拥有的属性值——ID 和所有者 ID。
有几种方法可以绕过这个限制,最突出的一种是使用表示无值的值对象实例。实际上,这种方法也允许我们减轻获取空引用异常的风险。在这本书的早期我们已经提到了 null 的问题。为所有我们的值对象提供特定的无值实例将类似于在函数式语言中常用到的可选类型。在下面的代码中,你可以看到如何通过向其中添加静态属性来实现ClassifiedAdTitle类的这样一个值:
public static ClassifiedAdTitle NoTitle =
new ClassifiedAdTitle();
当我们在所有可能为空的值对象类型中都有这样的属性(例如,PictureSize或ClassifiedAdId总是被分配,因此我们可以跳过这些类型),我们需要在ClassifiedAd类的When方法中为ClassifiedAdCreated事件处理器分配空值:
protected override void When(object @event)
{
Picture picture;
switch (@event)
{
case Events.ClassifiedAdCreated e:
Id = new ClassifiedAdId(e.Id);
OwnerId = new UserId(e.OwnerId);
State = ClassifiedAdState.Inactive;
Title = ClassifiedAdTitle.NoTitle;
Text = ClassifiedAdText.NoText;
Price = Price.NoPrice;
ApprovedBy = UserId.NoUser;
ClassifiedAdId = e.Id;
break;
... rest of the code ...
经过这些更改完成后,我们可以再次进行 API 调用,现在,它应该可以正常工作。在控制台,我们可以看到以下调试输出:
[17:44:32 INF] Executed DbCommand (13ms) [Parameters=[@p0='302790d5-735e-445e-a042-b5891ad3cf1f', @p1='2', @p2='302790d5-735e-445e-a042-b5891ad3cf1f', @p3='', @p4='', @p5='', @p6='0', @p7='False', @p8='-1', @p9='00000000-0000-0000-0000-000000000000', @p10='83508629-d2ee-4798-9ac5-b5bbc3e57731'], CommandType='Text', CommandTimeout='30']
INSERT INTO "ClassifiedAds" ("ClassifiedAdId", "State", "Id_Value", "Text_Value", "Title_Value", "Price_Currency_CurrencyCode", "Price_Currency_DecimalPlaces", "Price_Currency_InUse", "Price_Amount", "ApprovedBy_Value", "OwnerId_Value")
VALUES (@p0, @p1, @p2, @p3, @p4, @p5, @p6, @p7, @p8, @p9, @p10);
我们还可以调用/ad/title的PUT方法,并得到以下调试输出:
[17:46:48 INF] Executed DbCommand (5ms) [Parameters=[@p1='302790d5-735e-445e-a042-b5891ad3cf1f', @p0='Green sofa'], CommandType='Text', CommandTimeout='30']
UPDATE "ClassifiedAds" SET "Title_Value" = @p0
WHERE "ClassifiedAdId" = @p1;
我们还可以查看ClassifiedAd表的内容,并看到值确实被分配了:

已更新“分类广告”表中的数据
到目前为止,我们已经成功处理了来自我们的领域模型和数据模型之间不匹配的所有挑战。这不是使用关系数据库处理聚合持久化的唯一方法。许多开发者更喜欢将领域模型和数据模型完全分离。通过这样做,他们在不需要始终关注持久化问题时,获得了更多的灵活性来更改领域模型。然而,这种灵活性伴随着持久化层复杂性过度增加的相关成本,因为需要在领域对象和数据对象之间手动处理映射。对于大型应用程序,这种方法可能更受欢迎,因为它还允许调整数据模型以满足底层数据库的性能需求。当我们使用 ORM 框架来处理我们的领域对象并将它们直接持久化时,我们非常信任框架的能力来正确处理数据方面。同时,EF Core 不断改进,以使数据库调用更加优化,并更透明地减轻开发者的阻抗不匹配。到目前为止,我们通过在基础设施配置类中应用更高级的配置,已经能够解决大多数问题,而领域模型本身的变更并不那么显著。
摘要
在本章中,我们深入探讨了聚合持久化的主题。您已经看到了许多与所谓的阻抗不匹配相关联的挑战,当我们清楚地看到,由于不同类型的数据库具有特定的要求,数据库并不完全愿意以这种方式持久化复杂的对象图。此外,您还学习了如何使用仓储模式来抽象持久化,并使我们的领域模型和应用服务远离数据库相关的问题。
您学习了如何使用 RavenDB 将我们的聚合作为文档进行持久化,以及我们可能会在这条路上遇到哪些挑战。很明显,文档数据库通常更适合持久化复杂对象,但仍然需要解决一些问题,例如处理标识符和公开属性。
本章还涵盖了在关系数据库中持久化聚合的主题。我们使用 Entity Framework Core 和 PostgreSQL 数据库,将我们的聚合表示为一系列具有相互关系的表。尽管 EF Core 团队在过去几年中取得了显著的改进,但持久化值对象是一个特别具有挑战性的主题,我们不得不进行相当多的配置更改才能使其工作。然而,我们必须在领域模型中进行的更改并不那么剧烈,我们仍然能够直接使用值对象,包括它们的重要特性——不可变性。
然而,我们还没有涉及到从数据库中检索数据的话题。我们的 API 仍然只有创建新的领域对象和在现有对象中执行状态转换的端点。实际上,我们只能处理命令。你可能想知道为什么我们不开始向我们的存储库添加更多方法来根据某些标准或规范检索聚合集合。这就是我们在尝试应用领域驱动设计(DDD)原则时,在许多系统中看到的持久化实现中的一个谬误。这就是为什么我将下一章专门用于查询。正如你将看到的,这并不简单,前方还有许多有趣的事情等待发现。
第九章:CQRS - 读取侧
在上一章中,我们学习了将聚合持久化到不同类型的数据库。然而,我们还没有探讨从数据库中检索数据的话题,除了使用存储库的Load方法来检索单个聚合。
现在是时候掌握我们成功存储在数据库中的数据,并在 API 中添加一些GET端点。对于这本书,我本来没有计划向您展示如何构建具有众多GetByThat方法的存储库,或者更糟糕的是,一个返回IQueryable<T>的通用存储库。这种看似吸引人的方法,却从查询中移除了通用语言,因为开发者开始通过过滤属性来检索聚合。例如,一个查询如_repository.Query(x => x.State == State.IsActive && x.Price.Amount > 100)告诉我们很少关于查询消费者的意图。这个过滤器对业务意味着什么?除非我们研究调用此查询的每一行代码,否则我们永远不会知道,而且可能只有在那时我们才能弄清楚它的目的。此外,自由过滤查询打开了潘多拉的盒子,在没有优化的情况下击中数据库服务器。对于关系数据库,我们最终会有许多复杂的连接和未索引的查询。对于文档数据库,如果没有数据库引擎支持的自动索引,我们甚至可能会得到失败。RavenDB 足够聪明,当我们执行服务器尚未构建索引的查询时,会创建自动索引。虽然这在开发期间不是大问题,但它将对生产系统产生严重影响,其中服务器处理大量的文档。
因此,在这本书中,我们将应用 CQRS 原则,并将命令与查询分开。我们的存储库足够好,当我们执行命令时,可以持久化新的聚合并对现有聚合进行更新。这意味着我们的命令侧是好的。现在,我们需要实现查询侧,我们将以不同的方式来实现,而不使用存储库。
在本章中,您将学习以下主题:
-
CQRS 的读取侧
-
什么是读取模型?
-
使用通用语言进行查询
-
使用单个数据库实现 CQRS
技术要求
本章的代码可以在 GitHub 上书籍存储库的Chapter09文件夹中找到。那里有两个子文件夹,ravendb和ef-core,包含使用 RavenDB 文档数据库、Entity Framework Core 和 PostgreSQL 实现聚合持久化和查询的最终代码。作为一个起点,我们将使用来自第八章,聚合持久化的最终代码。
您需要使用docker-compose来运行基础设施。如果您之前没有完成安装,请检查上一章的要求。
添加用户资料
在我们开始探索应用程序的读取方面之前,我们最好在领域本身中添加一些更多的关注点。到目前为止,我们一直专注于分类广告的核心领域。当我们创建一个新系统时,我们应该专注于核心领域。在我们的场景中,我们已经实施了一些核心领域的进展,现在团队正在讨论在开始创建原型之前,系统必须添加的绝对必须具备的功能。
你可能记得,我们已经在部分解决了广告所有权的关注点。我们在ClassifiedAd聚合中已经有了UserId类型的OwnerId属性,但到目前为止,我们还没有找到OwnerId的来源。显然,我们的系统需要用户在创建新广告之前必须注册自己。我们需要知道他们是谁以及如何与他们取得联系。因此,作为最低限度的要求,我们需要有他们的姓名和联系方式,例如电子邮件地址和电话号码。大多数时候,人们不喜欢在分类广告中展示他们的真实姓名,而更喜欢使用昵称,我们称之为显示名。我们也必须解决这个问题。
在 EventStorming 板上简短讨论后,团队提出了一个非常基本的方案来支持这些需求。我们不在乎整个商店的用户注册过程;这本身就是一个复杂的话题。它可能涉及社交媒体登录、电子邮件和电话号码确认、密码要求以及双因素认证。在你构建新系统时开始实现这些功能从来都不是一个好主意。很多时候,开发者会陷入用户注册的陷阱,花费数周甚至数月来完善注册/登录屏幕体验,而核心领域的工作却没有任何进展。记住,身份验证领域是一个通用的支持子领域,在大多数情况下,考虑使用第三方系统来处理这些关注点是非常有用的。
目前,我们只需要实现一些基本功能,这样我们就可以在显示单个广告或广告列表时,同时显示用户信息。
以下图表显示了团队在快速建模会议后生成的内容:

我们可以很容易地看出用户和分类广告之间几乎没有联系。事实上,我们只需要用户 ID,就可以用它作为所有者 ID。考虑到这一点,我们可以在我们的领域项目中尝试实现UserProfile作为一个新的聚合。我强烈建议你不要把这个对象称为User,因为它暗示了同一个对象用于身份验证,因此必须包含诸如密码和社交媒体登录信息之类的信息。但我们已经决定将其搁置,稍后解决。
用户资料领域关注点
首先,我们目前确定的是,我们有四个不同的事件要实现。我们还知道,我们正在向已经存在的相同领域项目中添加一个新的聚合。由于我们的聚合将有一个状态,我们可能还需要创建新的值对象。
在向项目中添加新对象之前,组织项目以便我们更好地了解其各个部分是一个好主意。
领域项目组织
我们首先创建一个新的项目文件夹,命名为 ClassifiedAd,并将现有的相关文件移动到那里。之后,我们需要为所有移动的文件修复命名空间。IDE 的自动重构功能使得这个过程相当简单。使用 ReSharper 或 Rider,你只需在类的命名空间名称上按 Alt + Enter,然后告诉它相应地调整命名空间。然后,我们为 UserProfile 对象添加一个新的文件夹。将所有共享领域关注点,例如 Exception 类、Money 类等,移动到 Shared 文件夹也是有意义的。现在,项目结构变为如下:

现在,我们可以开始实现这四个新事件。让我们在 UserProfile 文件夹中添加一个新的 Events 类,并为这些事件编写一些代码,如下所示:
using System;
namespace Marketplace.Domain.UserProfile
{
public static class Events
{
public class UserRegistered
{
public Guid UserId { get; set; }
public string FullName { get; set; }
public string DisplayName { get; set; }
}
public class ProfilePhotoUploaded
{
public Guid UserId { get; set; }
public string PhotoUrl { get; set; }
}
public class UserFullNameUpdated
{
public Guid UserId { get; set; }
public string FullName { get; set; }
}
public class UserDisplayNameUpdated
{
public Guid UserId { get; set; }
public string DisplayName { get; set; }
}
}
}
我们有意识地将其他东西,如电子邮件地址和电话号码,排除在版本之外,因为我们现在不需要这些信息来满足应用程序的需求,但未来我们添加更多事件和更多细节到领域模型不会有任何阻碍。
添加新的值对象
对于下一步,我们添加一个新的小聚合 UserProfile 来执行将产生这些事件并执行聚合状态转换的命令。对于聚合状态,为 DisplayName 和 FullName 状态属性实现值对象是一个好主意,所以我们从这里开始。
FullName 类相当简单,大部分代码与 ClassifiedAdTitle 类重复,如下所示:
using System;
using Marketplace.Framework;
namespace Marketplace.Domain.UserProfile
{
public class FullName : Value<FullName>
{
public string Value { get; }
internal FullName(string fullName) => Value = fullName;
public static FullName FromString(string fullName)
{
if (fullName.IsEmpty())
throw new ArgumentNullException(nameof(fullName));
return new FullName(fullName);
}
public static implicit operator string(FullName fullName)
=> fullName.Value;
// Satisfy the serialization requirements
protected FullName() { }
}
}
我添加了一个小的静态类来持有 string 类的扩展方法,目前那里只有一个方法,名为 IsEmpty,我用它来代替 string.IsNullOrWhitespace,所以你可以在值对象的代码中看到它的使用。
然后,我们可以实现DisplayName类。这并不像全名那样直接,因为,与全名不同,显示名将在公共页面上显示。我们肯定可以预期一些恶意用户会写一些我们不希望在网站上显示的内容。虽然我们的广告在发布前会经过审查阶段,但用户资料将不会进行审核。通常,用户资料会在不同的地方显示,例如讨论区、评论系统等。尽管我们还没有开始建模这些功能,但我们知道我们的路线图以及我们需要防止用户在他们的公共资料中放置恶意内容的事实。
我们可以做的事情之一是自动检查显示名是否包含粗俗词汇。有一些公开可用的服务可以帮助我们。实现细节不会成为Domain项目的一部分,因为它是一个基础设施关注点。相反,我们将为这样的服务创建一些抽象,这样我们就可以在不与实现耦合的情况下检查文本中的粗俗词汇。我们已经使用了一个类似的技术,即货币查找领域服务。然而,领域服务不一定需要实现为接口。另一种创建单方法领域服务的方式是使用委托。下面的代码显示了粗俗词汇检查领域服务,该服务被声明为一个委托:
namespace Marketplace.Domain.Shared
{
public delegate bool CheckTextForProfanity(string text);
}
我将代码放在了Shared/ContentModeration.cs文件中。现在我们需要实现DisplayName值对象并利用新的领域服务,如下面的代码所示:
using System;
using Marketplace.Domain.Shared;
using Marketplace.Framework;
namespace Marketplace.Domain.UserProfile
{
public class DisplayName : Value<DisplayName>
{
public string Value { get; }
internal DisplayName(string displayName) => Value =
displayName;
public static DisplayName FromString(
string displayName,
CheckTextForProfanity hasProfanity)
{
if (displayName.IsEmpty())
throw new ArgumentNullException(nameof(FullName));
if (hasProfanity(displayName))
throw new DomainExceptions.ProfanityFound(displayName);
return new DisplayName(displayName);
}
public static implicit operator string(DisplayName displayName)
=> displayName.Value;
// Satisfy the serialization requirements
protected DisplayName() { }
}
}
在这里,你可以看到,如果委托返回true,代码将抛出ProfanityFound类型的异常,这意味着用户在显示名中使用了不良语言。我已经将所有领域异常移动到Shared文件夹中的一个地方。新代码位于DomainExceptions.cs文件中,该文件的代码如下所示:
using System;
namespace Marketplace.Domain.Shared
{
public static class DomainExceptions
{
public class InvalidEntityState : Exception
{
public InvalidEntityState(object entity, string message)
: base($"Entity {entity.GetType().Name} state change
rejected, {message}")
{ }
}
public class ProfanityFound : Exception
{
public ProfanityFound(string text)
: base($"Profanity found in text: {text}")
{ }
}
}
}
当需要时,我们将向此文件添加更多异常。
用户配置文件聚合根
现在是时候编写UserProfile聚合的代码了。我们之前已经创建了一个聚合,所以创建一个新的聚合不会是一个大问题,尤其是考虑到用户资料是一个相对简单的对象。以下是新UserProfile.cs文件的代码:
using System;
using Marketplace.Framework;
namespace Marketplace.Domain.UserProfile
{
public class UserProfile : AggregateRoot<UserId>
{
// Properties to handle the persistence
private string DbId
{
get => $"UserProfile/{Id.Value}";
set {}
}
// Aggregate state properties
public FullName FullName { get; private set; }
public DisplayName DisplayName { get; private set; }
public string PhotoUrl { get; private set; }
public UserProfile(UserId id, FullName fullName, DisplayName
displayName)
=> Apply(new Events.UserRegistered
{
UserId = id,
FullName = fullName,
DisplayName = displayName
});
public void UpdateFullName(FullName fullName)
=> Apply(new Events.UserFullNameUpdated
{
UserId = Id,
FullName = fullName
});
public void UpdateDisplayName(DisplayName displayName)
=> Apply(new Events.UserDisplayNameUpdated
{
UserId = Id,
DisplayName = displayName
});
public void UpdateProfilePhoto(Uri photoUrl)
=> Apply(new Events.ProfilePhotoUploaded
{
UserId = Id,
PhotoUrl = photoUrl.ToString()
});
protected override void When(object @event)
{
switch (@event)
{
case Events.UserRegistered e:
Id = new UserId(e.UserId);
FullName = new FullName(e.FullName);
DisplayName = new DisplayName(e.DisplayName);
break;
case Events.UserFullNameUpdated e:
FullName = new FullName(e.FullName);
break;
case Events.UserDisplayNameUpdated e:
DisplayName = new DisplayName(e.DisplayName);
break;
case Events.ProfilePhotoUploaded e:
PhotoUrl = e.PhotoUrl;
break;
}
}
protected override void EnsureValidState()
{
}
}
}
与ClassifiedAd聚合根类的代码相比,前面的代码没有做任何新的工作。我们也使用了相同的解决方案来满足对身份属性的数据库要求。由于我们从 RavenDB 实现开始,我们需要一个字符串属性来保存文档的身份。
如你所见,我使用了UserId值对象作为聚合根 ID。这是因为,本质上,配置文件身份必须是对应用户的 ID。这要求我通过从值对象的基础类Value<UserId>继承来更改UserId类的实现。
最后,我们需要一个存储库接口,以便我们的应用程序服务知道如何检索和持久化新实体。该接口与我们为 ClassifiedAd 实体创建的接口相同,正如以下代码所示:
using System.Threading.Tasks;
using Marketplace.Domain.Shared;
namespace Marketplace.Domain.UserProfile
{
public interface IUserProfileRepository
{
Task<UserProfile> Load(UserId id);
Task Add(UserProfile entity);
Task<bool> Exists(UserProfile id);
}
}
现在我们已经完成了域项目中更改。让我们继续添加用户资料应用程序服务和命令 API。
用户资料的应用程序端
是时候查看我们的应用程序项目并检查我们需要做什么来支持用户资料功能了。在我们开始添加新类之前,让我们执行类似的项目重构,使不同的核心功能在 解决方案资源管理器 中具有不同的视觉表现。我不想让应用程序项目根据基础设施关注点进行组织,但当前的项目组织方式暗示了这一点,因为我们使用了 Api、Contracts 和 Infrastructure 文件夹。为此,我已经将 ClassifiedAd 关注点移动到 Marketplace 项目中的一个单独文件夹,并创建了一个名为 UserProfile 的新文件夹。
项目现在在解决方案资源管理器中看起来不同,如下面的截图所示:

现在我们有一个地方可以添加所有支持新域功能所需的内容。我们首先向新的 UserProfile/Contracts.cs 文件中添加命令。我们可以通过查看事件来了解需要哪些命令,因为所有这些事件都是用户驱动的。看看以下代码:
using System;
namespace Marketplace.UserProfile
{
public class Contracts
{
public static class V1
{
public class RegisterUser
{
public Guid UserId { get; set; }
public string FullName { get; set; }
public string DisplayName { get; set; }
}
public class UpdateUserFullName
{
public Guid UserId { get; set; }
public string FullName { get; set; }
}
public class UpdateUserDisplayName
{
public Guid UserId { get; set; }
public string DisplayName { get; set; }
}
public class UpdateUserProfilePhoto
{
public Guid UserId { get; set; }
public string PhotoUrl { get; set; }
}
}
}
}
到现在为止,你可能已经注意到 ProfilePhotoUploaded 事件名称与我们的 UpdateUserProfilePhoto 命令之间存在不匹配。我们必须保持语义清晰,因为我们的应用程序服务不会自己处理上传。相反,它将接收一个指向已上传文件的 URL。这通常发生在我们有一个复杂的用户界面,能够在浏览器中执行文件上传以及额外的操作,如调整大小和裁剪的情况下。或者,我们可以让 Web API 控制器处理上传。因此,实际上,我们的命令需要表示域中将要发生的事情——它将更新照片 URL 而不是上传照片本身。然而,如果你决定你的用户资料应用程序服务也需要处理上传,命令名称(以及相应的事件名称)需要更改以反映动作的性质。
让我们开始编写新的应用程序服务。如果我们以 ClassifiedAdApplicationService 类为例,这项工作相当简单。我最终得到了以下代码:
using System;
using System.Threading.Tasks;
using Marketplace.Domain.Shared;
using Marketplace.Domain.UserProfile;
using Marketplace.Framework;
namespace Marketplace.UserProfile
{
public class UserProfileApplicationService : IApplicationService
{
private readonly IUserProfileRepository _repository;
private readonly IUnitOfWork _unitOfWork;
private readonly CheckTextForProfanity _checkText;
public UserProfileApplicationService(
IUserProfileRepository repository, IUnitOfWork unitOfWork,
CheckTextForProfanity checkText)
{
_repository = repository;
_unitOfWork = unitOfWork;
_checkText = checkText;
}
public async Task Handle(object command)
{
switch (command)
{
case Contracts.V1.RegisterUser cmd:
if (await
_repository.Exists(cmd.UserId.ToString()))
throw new InvalidOperationException($"Entity
with id {cmd.UserId} already exists");
var userProfile = new
Domain.UserProfile.UserProfile(
new UserId(cmd.UserId),
FullName.FromString(cmd.FullName),
DisplayName.FromString(cmd.DisplayName,
_checkText));
await _repository.Add(userProfile);
await _unitOfWork.Commit();
break;
case Contracts.V1.UpdateUserFullName cmd:
await HandleUpdate(cmd.UserId,
profile =>
profile.UpdateFullName(FullName.FromString(cmd.FullName)));
break;
case Contracts.V1.UpdateUserDisplayName cmd:
await HandleUpdate(cmd.UserId,
profile => profile.UpdateDisplayName(
DisplayName.FromString(cmd.DisplayName,
_checkText)));
break;
case Contracts.V1.UpdateUserProfilePhoto cmd:
await HandleUpdate(cmd.UserId,
profile => profile.UpdateProfilePhoto(new
Uri(cmd.PhotoUrl)));
break;
default:
throw new InvalidOperationException(
$"Command type {command.GetType().FullName} is
unknown");
}
}
private async Task HandleUpdate(Guid userProfileId,
Action<Domain.UserProfile.UserProfile> operation)
{
var classifiedAd = await
_repository.Load(userProfileId.ToString());
if (classifiedAd == null)
throw new InvalidOperationException($"Entity with id
{userProfileId} cannot be found");
operation(classifiedAd);
await _unitOfWork.Commit();
}
}
}
在这个类中,我有一个依赖项——IUserProfileRepository。你可能已经猜到,UserProfile 实体的存储库实现几乎与 ClassifiedAd 相同,只是它将使用另一个类类型。你可能认为你可以使用一个通用存储库。但是,我们已经触及了这个话题,你可以重新审视它,或者阅读一些互联网上的文章,这些文章肯定会劝阻你使用通用存储库;然而,我们仍然可以使用通用类型作为我们特定存储库的依赖项,或者我们可以从一个通用存储库继承特定的存储库。让我们尝试后者,看看它是什么样子。我将为应用程序项目在 Infrastructure 文件夹中添加一个新的类,名为 RavenDbRepository,如下面的代码所示:
using System;
using System.Threading.Tasks;
using Marketplace.Framework;
using Raven.Client.Documents.Session;
namespace Marketplace.Infrastructure
{
public class RavenDbRepository<T, TId>
where T : AggregateRoot<TId>
where TId : Value<TId>
{
private readonly IAsyncDocumentSession _session;
private readonly Func<TId, string> _entityId;
public RavenDbRepository(
IAsyncDocumentSession session,
Func<TId, string> entityId)
{
_session = session;
_entityId = entityId;
}
public Task Add(T entity)
=> _session.StoreAsync(entity, _entityId(entity.Id));
public Task<bool> Exists(TId id)
=> _session.Advanced.ExistsAsync(_entityId(id));
public Task<T> Load(TId id)
=> _session.LoadAsync<T>(_entityId(id));
}
}
我们仍然想使用特定的存储库接口和类,但现在我们可以使用以下代码实现 UserProfileRepository(需要添加到 Marketplace 项目的 UserProfile 文件夹中的文件):
using Marketplace.Domain.Shared;
using Marketplace.Domain.UserProfile;
using Marketplace.Infrastructure;
using Raven.Client.Documents.Session;
namespace Marketplace.UserProfile
{
public class UserProfileRepository
: RavenDbRepository<Domain.UserProfile.UserProfile, UserId>,
IUserProfileRepository
{
public UserProfileRepository(IAsyncDocumentSession session)
: base(session, id => $"UserProfile/{id.Value.ToString()}") { }
}
}
同样,可以用来实现 ClassifiedAdRepository,尽管未更改的版本将像以前一样工作。本章的最终代码包括简化后的代码。
现在是时候实现 API 控制器类了。如果你看看 ClassifiedAdsCommandApi 类,你可以看到一个名为 HandleRequest 的私有方法,它通过一行代码调用应用程序服务来帮助我们简化请求处理。新控制器的代码几乎相同,因此我们可以通过在 Infrastructure 文件夹中创建一个新的静态类 RequestHandler 来重用请求处理器,如下面的简单代码所示:
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Serilog;
namespace Marketplace.Infrastructure
{
public static class RequestHandler
{
public static async Task<IActionResult> HandleRequest<T>(
T request, Func<T, Task> handler, ILogger log)
{
try
{
log.Debug("Handling HTTP request of type {type}",
typeof(T).Name);
await handler(request);
return new OkResult();
}
catch (Exception e)
{
log.Error(e, "Error handling the request");
return new BadRequestObjectResult(new {error =
e.Message, stackTrace = e.StackTrace});
}
}
}
}
这个通用方法现在可以在两个控制器中使用。让我们看看新的控制器会是什么样子。我在应用程序项目的 UserProfile 文件夹中创建了一个新的 UserProfileCommandApi 类,如下面的代码所示:
using System.Threading.Tasks;
using Marketplace.Infrastructure;
using Microsoft.AspNetCore.Mvc;
using Serilog;
namespace Marketplace.UserProfile
{
[Route("/profile")]
public class UserProfileCommandsApi : Controller
{
private readonly UserProfileApplicationService
_applicationService;
private static readonly ILogger Log =
Serilog.Log.ForContext<UserProfileCommandsApi>();
public UserProfileCommandsApi(UserProfileApplicationService
applicationService)
=> _applicationService = applicationService;
[HttpPost]
public Task<IActionResult> Post(Contracts.V1.RegisterUser
request)
=> RequestHandler.HandleRequest(request,
_applicationService.Handle, Log);
[Route("fullname")]
[HttpPut]
public Task<IActionResult> Put(Contracts.V1.UpdateUserFullName
request)
=> RequestHandler.HandleRequest(request,
_applicationService.Handle, Log);
[Route("displayname")]
[HttpPut]
public Task<IActionResult>
Put(Contracts.V1.UpdateUserDisplayName request)
=> RequestHandler.HandleRequest(request,
_applicationService.Handle, Log);
[Route("photo")]
[HttpPut]
public Task<IActionResult>
Put(Contracts.V1.UpdateUserProfilePhoto request)
=> RequestHandler.HandleRequest(request,
_applicationService.Handle, Log);
}
}
广告控制器可以用类似的方式使用 RequestHandler;新的实现可以在章节代码中找到。
我们几乎完成了所有的更改,但还有一件事仍然缺失,那就是对粗俗检查函数的实现。我将使用 PurgoMalum,一个免费的在线服务来过滤内容并移除粗俗、下流和其他我们不希望在我们公共网站上看到的内容。
实现非常简单,因为我需要做的只是调用一个带有单个参数的 HTTP 端点。为此,我在 Infrastructure 文件夹中添加了一个名为 PurgomalumClient 的类,如下面的代码所示:
using System.Net.Http;
using System.Threading.Tasks;
using Microsoft.AspNetCore.WebUtilities;
namespace Marketplace.Infrastructure
{
/// <summary>
/// PurgoMalum is a simple, free, RESTful web service for filtering
// and removing content of profanity, obscenity and other unwanted
// text.
/// Check http://www.purgomalum.com
/// </summary>
public class PurgomalumClient
{
private readonly HttpClient _httpClient;
public PurgomalumClient() : this(new HttpClient()) { }
public PurgomalumClient(HttpClient httpClient) => _httpClient =
httpClient;
public async Task<bool> CheckForProfanity(string text)
{
var result = await _httpClient.GetAsync(
QueryHelpers.AddQueryString(
"https://www.purgomalum.com/service
/containsprofanity", "text", text));
var value = await result.Content.ReadAsStringAsync();
return bool.Parse(value);
}
}
}
对于最后一步,我们需要在 Startup 类代码中进行配置。我们需要更改的唯一方法是 ConfigureServices 方法。以下是新的代码:
public void ConfigureServices(IServiceCollection services)
{
var store = new DocumentStore
{
Urls = new[] {"http://localhost:8080"},
Database = "Marketplace_Chapter9",
Conventions =
{
FindIdentityProperty = x => x.Name == "DbId"
}
};
store.Initialize();
var purgomalumClient = new PurgomalumClient();
services.AddSingleton<ICurrencyLookup, FixedCurrencyLookup>();
services.AddScoped(c => store.OpenAsyncSession());
services.AddScoped<IUnitOfWork, RavenDbUnitOfWork>();
services.AddScoped<IClassifiedAdRepository, ClassifiedAdRepository>
();
services.AddScoped<IUserProfileRepository, UserProfileRepository>
();
services.AddScoped<ClassifiedAdsApplicationService>();
services.AddScoped(c =>
new UserProfileApplicationService(
c.GetService<IUserProfileRepository>(),
c.GetService<IUnitOfWork>(),
text => purgomalumClient.CheckForProfanity(text).
GetAwaiter().GetResult()));
services.AddMvc();
services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1",
new Info
{
Title = "ClassifiedAds",
Version = "v1"
});
});
}
UserProfileApplicationService 的注册比 ClassifiedAdApplicationService 要复杂一些,因为我们使用的是函数而不是接口。一般来说,使用代表一个方法的委托而不是接口会更简单、更干净,但在连接东西时你可能需要处理更多的代码。
最后,你可以运行应用程序并尝试创建一个新的用户资料,然后更改用户的完整名称或显示名称。尝试使用一些不雅的词作为显示名称,以查看 ProfanityFound 异常的实际效果。记住,在启动应用程序之前,请从章节代码存储库的 Chapter09/ravendb 文件夹中调用 docker-compose up;否则,你将无法访问 RavenDB。
现在所有这些新事物都已经到位,我们系统中有了两个很好的聚合体,而不是只有一个,我们可以看看查询方面的事情。
查询方面
到目前为止,我们一直关注系统中表示为事件的州转换。当我们的领域模型发出一个新事件时,根据定义,这意味着我们已经将系统置于一个新的状态。状态转换是由命令触发的——这就是为什么我们有两个故意命名为 ClassifiedAdCommandApi 和 UserProfileCommandApi 的 API。这并不令人惊讶,因为我在这本书中已经多次提到了 CQS 和 CQRS。命令触发聚合的方法,根据 CQS 原则,这些方法中没有任何一个返回任何内容,而是改变系统状态。但没有任何系统只能有命令。我们的用户希望在屏幕上看到一些东西,而不仅仅是静态文本和图片。对于我们这个市场,应用程序的主要目的并不是让人们可以在其中发布分类广告;恰恰相反:我们期望人们浏览这些广告,选择他们喜欢的,并达成交易。这意味着我们的系统需要将其拥有的信息传递给外部世界,以便可以在屏幕上显示。就在这个时候,我们只能通过查看数据库本身来看到存储在数据库中的分类广告和用户资料。从逻辑上讲,我们现在需要通过一些 API 使这些信息可用。
CQRS 和读-写不匹配
如果你已经研究过其他关于 领域驱动设计(DDD)的书籍,并且使用过 DDD 战术模式进行了一些工作,包括聚合和仓储,你可能想知道为什么我没有在 ClassifiedAdRepository 中简单地包含几个查询方法,然后就此结束。当然,我可以那样做,但如果没有其他东西可以讨论,我就不会开始写这本书。
仓库的核心在于它们始终在处理聚合状态。正如你可能从第八章中埃里克·埃文斯的引言中记得的,“聚合持久性”,仓库的作用是以内存中对象集合的方式表示系统中存在的聚合,因此我们迄今为止的两个仓库的名称——ClassifiedAdRepository和UserProfileRepository。这意味着我们能从仓库中获取的唯一细节就是我们添加到其中的内容。由于一个仓库处理一个聚合的状态——例如ClassifiedAd——我们只能从仓库中查询和检索分类广告的详细信息,而无法获取其他信息。这里的问题是,在有限上下文中只有一个聚合(更多关于这一点在第十二章,“有限上下文”)是一种罕见的情况,而我们已经在处理两个了。
我们需要展示的信息目前还没有在 EventStorming 模型中表示出来。同样,这是因为我们的整体模型更关注系统的行为,而我们展示在屏幕上的内容并不被视为行为。然而,正如我刚才提到的,这仍然是我们的核心业务,我们可以考虑一些非常明显的事情。如果我们上线并且有人发布广告,他们需要看到广告对其他用户看起来如何。当另一个用户寻找要购买的东西并想查看所有详细信息——广告标题、描述以及所有可用的图片,以及卖家的信息时,也需要相同的屏幕。即使我们还没有考虑购买过程,这也是我们绝对需要的最基本的东西。此外,人们希望看到所有发布的广告。当系统拥有更多用户并且有更多广告被发布时,我们可能会考虑按类别展示广告。但在初始阶段,展示所有广告并提供一些搜索功能可能就足够了。
我所描述的所有屏幕都是读取模型。这些模型可能代表单个聚合的状态,但在许多情况下,或者大多数情况下,多个聚合的信息可能会在一个屏幕上组合。例如,单个广告的屏幕需要包含系统中当前拥有的两种聚合类型的信息。显示已发布广告列表的屏幕需要显示多个聚合的简短版本,尽管只是单一类型。虽然后一种情况可以通过向IClassifiedAdRepository接口添加查询方法来解决,但第一种情况则需要其他方法。很多时候,当我们有查询作为存储库的一部分时,我们通常需要在客户端进行一些聚合。然而,当我们只需要从每个聚合中显示几个属性时,返回大量对象图(存储库应该做的事情)往往远非最佳解决方案。更糟糕的是,我们可能会发现自己处于对象图太大而开始使用那些在测试中可能表现良好的尴尬的 ORM 延迟加载特性,但在无状态的 Web 世界中却失败,这仅仅是因为我们想要访问以延迟加载更多数据的会话在尝试请求更多数据时已经不存在了。
所有这些问题都有效地通过 CQRS 得到了解决。该模式假设几乎不存在读写数量相等的系统。大多数面向用户的面板系统读操作的数量要多于写操作。想想在 Craigslist 上创建的分类广告数量与请求在某人屏幕上检索和显示这些广告的数量相比。它们之间的差异可能是两个数量级。即便如此,今天大多数系统之所以会面临扩展问题,仅仅是因为它们忽略了这个简单的事实。没有数据库可以预先优化以同时处理读和写——你需要选择一个。最初,我们只处理写操作并试图仅针对它们进行优化。关系数据库管理系统(RDBMS)的第三级规范化正是针对这一点——它显示了需要写入的最少信息量,以节省空间并避免数据重复。然而,当涉及到读操作时,第三级规范化并不那么有效。我们不断地添加连接,一个接一个,以将我们在写入时故意分开的信息再次组合在一起。很快,读操作的数量就超过了写操作的数量。不久之后,我们看到读操作的数量比写操作多出十倍甚至百倍,这就是麻烦开始的地方。当然,还有其他类型的系统,比如物联网(IoT)的世界、高频交易以及其他商业领域。在这些情况下,写入数据库的信息量远远大于需要向任何人展示的信息量。开发者开始优化写操作,减少事务时间。这通常是以移除索引和使数据扁平化为代价的。当涉及到读操作时,我们开始看到响应时间变慢,仅仅是因为这个原因。
从本质上讲,我试图说明的是,当开发者试图解决写或读的担忧,以使它们在命令端更加高效时,查询端开始受到影响。反过来也是一样。这就是为什么 CQRS 应运而生。在本章中,我们将只关注使用单个数据库时的查询端。鉴于你刚刚读到的内容,这可能会听起来有些矛盾,因为,再次强调,数据库很少能同时优化。但我们需要从某个地方开始,既然优化是所有邪恶的根源,我们就慢慢来。当我们到达第十一章,特别是“投影和查询”这一特定部分时,我们将探讨将读和写分离到不同类型的存储中。但现在,让我们看看我们如何能够从现有的存储中执行更有效的读操作,而完全不使用仓库。
查询和读模型
当我们在构建领域模型时,我们需要小心地处理依赖关系,以确保领域模型保持纯净,不受任何基础设施问题的干扰。我们的目标是提供一定程度的自由度,以便从业务的角度出发,使用通用语言来实现领域逻辑。我们还定义了存储库接口作为领域模型的一部分,以便应用层能够实现这些接口,从而实现聚合持久化。然后,我们定义命令为我们的应用服务可以接受的合约,以便对领域对象执行操作并可能执行状态转换。总体而言,命令处理序列不同部分之间的隔离和关注点分离是根据以下图表实现的:

典型的命令流程
当我们需要使用存储库查询我们放置领域模型状态的数据库时,我们的目标发生了变化。查询中不需要任何业务规则;查询不会改变领域模型的状态。但我们确实需要知道我们的应用实现的存储库实现如何在数据库中表示我们的聚合状态。这使得查询成为纯粹的应用侧关注点。当我们添加一个 API 从我们的系统中获取某些内容时,我们需要 API 只处理持久化。API GET 端点返回的模型成为我们对外界的合约。我们不需要任何其他模型,例如数据模型,来获取数据;我们可以直接将我们从数据库中得到的结果作为响应返回。
查询的流程变得更为简单,我们可以将其可视化如下所示:

典型的查询流程
我们通过 API 看到的返回结果是读取模型。我们需要以这种方式进行查询,以便可以从数据库中检索读取模型,而无需在不同模型之间进行任何转换——我们直接返回它。这使我们能够简化获取数据并将其返回给那些需要在屏幕上显示或用于任何其他可想象目的的人的方式。这就是 CQRS 读取侧背后的整个想法。现在,是时候编写一些代码并展示它是如何工作的了。
实现查询
在实现读取侧时,我们不需要触及领域模型中的任何内容。我们将集中精力在应用程序侧。然而,这并不意味着我们需要忘记通用语言。最终,读取模型是整个模型的一部分;我们在 EventStorming 会话中看到了它们,作为绿色的便利贴。读取模型帮助人们和其他系统根据他们通过执行我们的查询接收到的数据做出决策。正如命令表明外部各方意图在我们的领域上运行某些操作一样,读取模型和查询表达了他们获取某些东西的意图。
例如,对于我们的 Marketplace 应用程序,我们期望购物者浏览已发布的广告。广告所有者需要查看他们广告的列表。每个人都应该能够打开单个广告并查看其中所有公开的内容,以及所有者的公开详细信息,例如他们的照片和显示名称。这些都是显而易见的查询和读取模型,我们可以开始实施。我们已经有所有数据,以多个聚合的状态形式存在,我们只需要从数据库中以可用的形式获取这些数据。
查询 API
我们可以通过指定读取模型和 API 来开始实施查询。在我们的 Marketplace 项目中,我们已经为不同的应用程序功能创建了文件夹。在那里添加查询似乎合乎逻辑,直到我们想起一些读取模型会结合来自不同聚合的数据,例如分类广告的详细信息以及所有者的个人资料信息。这种类型的模型和查询应该放在哪里呢?好吧,有一些选择,但我倾向于专注于所需信息的本质。如果我们需要查看单个广告,那就是我们主要想看的内容。尽管我们可能也会提供一些所有者详情,但所有者详情并不是我们想要展示的主要内容。最终,我们通过广告 ID 查询单个广告,用户的个人资料信息是从广告详情中派生出来的,例如所有者 ID,因此我们仍然可以相当清楚地确定放置这些查询的位置。
由于我们已经发现了一些我们想要通过 API 返回的东西,而且所有这些都与广告相关,所以我们把它们放在那里。现在我们需要向 Marketplace 项目的 ClassifiedAd 文件夹中添加两个文件——一个是 ReadModels.cs,另一个是 ClassifiedAdsQueryApi.cs。
根据查询要求,让我们首先使用以下代码定义读取模型:
using System;
namespace Marketplace.ClassifiedAd
{
public static class ReadModels
{
public class ClassifiedAdDetails
{
public Guid ClassifiedAdId { get; set; }
public string Title { get; set; }
public decimal Price { get; set; }
public string CurrencyCode { get; set; }
public string Description { get; set; }
public string SellersDisplayName { get; set; }
public string[] PhotoUrls { get; set; }
}
public class ClassifiedAdListItem
{
public Guid ClassifiedAdId { get; set; }
public string Title { get; set; }
public decimal Price { get; set; }
public string CurrencyCode { get; set; }
public string PhotoUrl { get; set; }
}
}
}
实施查询的一种方式是创建一个查询服务接口,该接口将被 API 使用。然后,在启动时将其连接到特定于数据库的实现。就我们的目的而言,它可能看起来像这样:
public interface IClassifiedAdQueryService
{
Task<IEnumerable<ClassifiedAdListItem>> GetPublishedAds(
int page, int pageSize);
Task<ClassifiedAdDetails> GetPublicClassifiedAd(
Guid classifiedAdId);
Task<IEnumerable<ClassifiedAdListItem>>
GetClassifiedAdsOwnedBy(Guid userId, int page, int pageSize);
}
这种方法有一个缺点——所有参数都是分开的,当我们实现 API 时,我们需要将这些参数全部添加到 API 方法中。如果你决定为你的边缘使用其他东西,比如 ServiceStack 或消息框架,你将不得不使用类型化请求,然后扩展请求属性到查询服务方法参数。如果你需要出于某种原因更改参数,你将需要更改多个地方中的代码。这也可能影响 UI。例如,如果你使用一个单页应用(SPA)并且 API 是从前端 JavaScript 代码中调用的,你可能需要在某种服务中抽象 API 调用,该服务需要更改以添加新的参数到调用中。
ServiceStack 框架之所以提倡为 HTTP API 使用消息驱动的方法,是有原因的。它使请求类型化,并消除了 API 方法和所有与 API 通信的层的长参数列表的需求。因此,在代码的后续部分,我们也将使用类型化请求,因此也将使用类型化查询。如果我们想使用类型化请求来实现查询服务,它将看起来像这样:
public interface IClassifiedAdQueryService
{
Task<IEnumerable<ClassifiedAdListItem>>
Query(GetPublishedClassifiedAds query);
Task<ClassifiedAdDetails> Query(GetPublicClassifiedAd query);
Task<IEnumerable<ClassifiedAdListItem>>
Query(GetOwnersClassifiedAds query);
}
在这里,我们有几个Query方法的重载,它们都接受类型化的查询请求。查询请求的类型定义了我们期望得到的内容。我们也可以为 API 使用相同的类型。现在,让我们在不使用查询服务的情况下实现这些查询合约和ClassifiedAdQueryApi类,如下面的代码所示:
using System;
namespace Marketplace.ClassifiedAd
{
public static class QueryModels
{
public class GetPublishedClassifiedAds
{
public int Page { get; set; }
public int PageSize { get; set; }
}
public class GetOwnersClassifiedAd
{
public Guid OwnerId { get; set; }
public int Page { get; set; }
public int PageSize { get; set; }
}
public class GetPublicClassifiedAd
{
public Guid ClassifiedAdId { get; set; }
}
}
}
接下来是 API:
using System.Collections.Generic;
using System.Net;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
namespace Marketplace.ClassifiedAd
{
[Route("/ad")]
public class ClassifiedAdsQueryApi : Controller
{
[HttpGet]
[Route("list")]
public Task<IActionResult>
Get(QueryModels.GetPublishedClassifiedAds request)
{
}
[HttpGet]
[Route("myads")]
public Task<IActionResult>
Get(QueryModels.GetOwnersClassifiedAd request)
{
}
[HttpGet]
[ProducesResponseType((int) HttpStatusCode.OK)]
[ProducesResponseType((int) HttpStatusCode.NotFound)]
public Task<IActionResult>
Get(QueryModels.GetPublicClassifiedAd request)
{
}
}
}
这看起来相当整洁。我们使用了两种类型的请求和类型化响应。当我们通过 ID 获取一个广告时,我们可以返回200 OK或404 Not Found响应。
现在,让我们回到查询服务。你可能会有一个很大的疑问:如果我只有一个实现,为什么我还需要一个接口?这个问题是完全合理的。接口并不是为了仅仅为了测试而使用,以使依赖项可以被模拟。基本上,如果你只使用接口进行测试,你可能需要重新考虑是否真的需要这个接口。在这个上下文中,查询尤其相关,因为使用模拟查询来测试 API 几乎没有什么意义。
如果你想要测试你的查询,并且你知道它们属于基础设施和应用,那么在不使用基础设施的情况下测试它们有什么意义呢?你最终只会测试序列化,虽然这可能不是一个坏主意,但查询的主要功能将不会被测试。查询测试确实需要使用它们所交谈的数据库。
在澄清了这个重要问题之后,我们可以考虑在接近数据库级别 API 的地方实现查询。实现这一目标的一种方法是通过使用扩展方法。在接下来的两个部分中,我们将使用这种方法来实现 RavenDB 和 Entity Framework 的查询,并相应地完成 API。
使用 RavenDB 的查询
如我们所知,RavenDB 客户端库允许我们使用文档会话接口将文档存储在数据库中。文档会话接口代表与数据库的单个、短暂连接。通常,它是按请求范围,因为单个请求是我们工作单元的范围。它不仅适用于命令;我们还可以在查询端点使用在应用程序的Startup类中注册的文档会话。
在以下代码中,我尝试使用IAsyncDocumentSession接口的扩展方法实现一个数据库查询,该接口我们在 API 的服务容器中进行了注册:
using System.Collections.Generic;
using System.Linq;
using System.Reflection.Metadata.Ecma335;
using System.Threading.Tasks;
using Raven.Client.Documents;
using Raven.Client.Documents.Linq;
using Raven.Client.Documents.Session;
using static Marketplace.ClassifiedAd.ReadModels;
using static Marketplace.Domain.ClassifiedAd.ClassifiedAd;
namespace Marketplace.ClassifiedAd
{
public static class Queries
{
public static Task<List<PublicClassifiedAdListItem>>
Query(
this IAsyncDocumentSession session,
QueryModels.GetPublishedClassifiedAds query) =>
session.Query<Domain.ClassifiedAd.ClassifiedAd>()
.Where(x => x.State == ClassifiedAdState.Active)
.Select(x => new PublicClassifiedAdListItem
{
ClassifiedAdId = x.Id.Value,
Price = x.Price.Amount,
Title = x.Title.Value,
CurrencyCode = x.Price.Currency.CurrencyCode
})
.Skip(query.Page * query.PageSize)
.Take(query.PageSize)
.ToListAsync();
}
}
最新版本的 RavenDB 支持内联投影,所以我们在这里需要做的就是运行一个带有Where的正常查询,然后将复杂的ClassifiedAd聚合状态文档投影到读取模型对象上。然后,我们需要应用分页并调用ToListAsync,以便在服务器上执行查询。
准备好这个查询后,我们可以从 API 中调用它。我将注释掉当前没有查询可用的 API 方法。因此,API 类将有一个方法,如下面的代码所示:
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Raven.Client.Documents.Session;
using Serilog;
namespace Marketplace.ClassifiedAd
{
[Route("/ad")]
public class ClassifiedAdsQueryApi : Controller
{
private readonly IAsyncDocumentSession _session;
public ClassifiedAdsQueryApi(IAsyncDocumentSession session)
=> _session = session;
[HttpGet]
[Route("list")]
public async Task<IActionResult>
Get(QueryModels.GetPublishedClassifiedAds request)
{
try
{
var ads = await _session.Query(request);
return Ok(ads);
}
catch (Exception e)
{
Log.Error(e, "Error handling the query");
throw;
}
}
}
}
由于我在数据库中已经有了一些数据(一个广告),我现在可以使用 Swagger 执行它。我将页面数设置为0,将页面大小设置为10,但查询没有返回任何结果。这是因为我的分类广告尚未发布,所以查询条件将其过滤掉了。我需要发送广告进行审查,然后使用新的Guid为ApproverId进行批准。完成此操作后,我可以看到 API 返回的读取模型。您也可以通过访问http://localhost:5000/ad/list?Page=0&PageSize=10直接在浏览器中调用 API。浏览器应显示以下 JSON:
[
{
classifiedAdId: "d338696a-342e-45cf-a02e-178dcb8e95f8",
title: "Red sofa",
price: 100,
currencyCode: "EUR",
photoUrl: null
}
]
我们现在想添加其他查询,但这意味着我们需要将所有查询包装在这个try/catch块中。让我们看看我们是否可以遵循DRY(即不要重复自己)原则,并创建一个有用的函数来处理所有查询。我们已经在使用静态RequestHandler类的命令中这样做过了。我将在下面添加一个新的函数来处理查询,如下面的代码所示:
public static async Task<IActionResult> HandleQuery<TModel>(
Func<Task<TModel>> query, ILogger log)
{
try
{
return new OkObjectResult(await query());
}
catch (Exception e)
{
log.Error(e, "Error handling the query");
return new BadRequestObjectResult(new
{
error = e.Message, stackTrace = e.StackTrace
});
}
}
添加下一个查询以获取所有按所有者 ID 分类的广告相当简单;我们只需更改条件并保持从文档到读取模型的读取模型投影。为了使方法更短,我添加了PagedList扩展方法,因此整个类现在看起来如下所示:
using System.Collections.Generic;
using System.Threading.Tasks;
using Raven.Client.Documents;
using Raven.Client.Documents.Linq;
using Raven.Client.Documents.Queries;
using Raven.Client.Documents.Session;
using static Marketplace.ClassifiedAd.ReadModels;
using static Marketplace.Domain.ClassifiedAd.ClassifiedAd;
namespace Marketplace.ClassifiedAd
{
public static class Queries
{
public static Task<List<PublicClassifiedAdListItem>> Query(
this IAsyncDocumentSession session,
QueryModels.GetPublishedClassifiedAds query
) =>
session.Query<Domain.ClassifiedAd.ClassifiedAd>()
.Where(x => x.State == ClassifiedAdState.Active)
.Select(
x =>
new PublicClassifiedAdListItem
{
ClassifiedAdId = x.Id.Value,
Price = x.Price.Amount,
Title = x.Title.Value,
CurrencyCode =
x.Price.Currency.CurrencyCode
}
)
.PagedList(query.Page, query.PageSize);
public static Task<List<PublicClassifiedAdListItem>> Query(
this IAsyncDocumentSession session,
QueryModels.GetOwnersClassifiedAd query
)
=>
session.Query<Domain.ClassifiedAd.ClassifiedAd>()
.Where(x => x.OwnerId.Value == query.OwnerId)
.Select(
x =>
new PublicClassifiedAdListItem
{
ClassifiedAdId = x.Id.Value,
Price = x.Price.Amount,
Title = x.Title.Value,
CurrencyCode =
x.Price.Currency.CurrencyCode
}
)
.PagedList(query.Page, query.PageSize);
public static Task<ClassifiedAdDetails> Query(
this IAsyncDocumentSession session,
QueryModels.GetPublicClassifiedAd query
)
=> (from ad in session.Query<Domain.ClassifiedAd.
ClassifiedAd>()
where ad.Id.Value == query.ClassifiedAdId
let user = RavenQuery
.Load<Domain.UserProfile.UserProfile>(
"UserProfile/" + ad.OwnerId.Value
)
select new ClassifiedAdDetails
{
ClassifiedAdId = ad.Id.Value,
Title = ad.Title.Value,
Description = ad.Text.Value,
Price = ad.Price.Amount,
CurrencyCode = ad.Price.Currency.CurrencyCode,
SellersDisplayName = user.DisplayName.Value
}).SingleAsync();
private static Task<List<T>> PagedList<T>(
this IRavenQueryable<T> query, int page, int pageSize
) =>
query
.Skip(page * pageSize)
.Take(pageSize)
.ToListAsync();
}
}
如果我们想使用 LINQ-to-objects,我们也可以将投影移动到单独的函数中。然而,这里的查询是发送到服务器的,服务器对我们的客户端代码一无所知。因此,我们需要在每个方法中重复投影代码。
现在,我可以添加 API 调用并使用新的HandleQuery函数。以下是整个ClassifiedAdQueryApi类的代码,它使用了两个查询:
using System.Threading.Tasks;
using Marketplace.Infrastructure;
using Microsoft.AspNetCore.Mvc;
using Raven.Client.Documents.Session;
using Serilog;
namespace Marketplace.ClassifiedAd
{
[Route("/ad")]
public class ClassifiedAdsQueryApi : Controller
{
private static ILogger _log =
Log.ForContext<ClassifiedAdsQueryApi>();
private readonly IAsyncDocumentSession _session;
public ClassifiedAdsQueryApi(IAsyncDocumentSession session)
=> _session = session;
[HttpGet]
[Route("list")]
public Task<IActionResult>
Get(QueryModels.GetPublishedClassifiedAds request)
=> RequestHandler.HandleQuery(() =>
_session.Query(request), _log);
[HttpGet]
[Route("myads")]
public Task<IActionResult>
Get(QueryModels.GetOwnersClassifiedAd request)
=> RequestHandler.HandleQuery(() =>
_session.Query(request), _log);
}
}
你可以看到,API 端点的处理方法变得和命令 API 方法一样简洁,所有的查询逻辑都被移动到了扩展方法中。
最后,我们到达了需要创建一个查询以结合一个读取模型中两个不同文档的数据的点。为了处理这种情况,RavenDB 提供了一个使用已加载文档的投影功能。使用这个功能,我们可以使用来自我们查询的文档(ClassifiedAd)的 ID 加载另一个文档(UserProfile)。
查询看起来稍微复杂一些,但并不太多。下面是(我只列出了以下代码中的新功能):
public static Task<ReadModels.ClassifiedAdDetails> Query(
this IAsyncDocumentSession session,
QueryModels.GetPublicClassifiedAd query)
=> (from ad in session.Query<Domain.ClassifiedAd.ClassifiedAd>()
where ad.Id.Value == query.ClassifiedAdId
let user = RavenQuery
.Load<Domain.UserProfile.UserProfile>("UserProfile/" +
ad.OwnerId.Value)
select new ReadModels.ClassifiedAdDetails
{
ClassifiedAdId = ad.Id.Value,
Title = ad.Title.Value,
Description = ad.Text.Value,
Price = ad.Price.Amount,
CurrencyCode = ad.Price.Currency.CurrencyCode,
SellersDisplayName = user.DisplayName.Value
}).SingleAsync();
现在,我们可以通过添加一个方法来完成查询 API,这个方法与前面两个一样简短,如下所示:
[HttpGet]
public Task<IActionResult> Get(QueryModels.GetPublicClassifiedAd request)
=> RequestHandler.HandleQuery(() => _session.Query(request), _log);
这里的不同之处在于我们没有路由,因为我们想从ad路由本身通过 ID 获取资源。现在,我可以启动应用程序并访问http://localhost:5000/ad?ClassifiedAdId=d338696a-342e-45cf-a02e-178dcb8e95f8来查看以下结果:
{
classifiedAdId: "d338696a-342e-45cf-a02e-178dcb8e95f8",
title: "Red sofa",
price: 100,
currencyCode: "EUR",
description: "Really good",
sellersDisplayName: "prejudice",
photoUrls: null
}
请注意,这个 GUID 是我数据库中的广告 ID;你可能需要检查数据库以找出你自己的使用情况。一些读者可能也不满意 URL 不是完全符合 REST 规范,因为它使用查询参数而不是路由。我相信这是一个简单的修复,但那时你需要移除查询对象,并在 API 方法中使用映射到路由参数的参数。
如你所见,我们成功地实现了所有想要的查询,并将它们与底层持久性保持紧密。这些查询可以直接从 API 调用,我们的领域模型将保持不变。我们还能够在一个读取模型中结合来自两个不同聚合的数据,这是使用存储库无法做到的,因为存储库总是只处理聚合根。
我们的查询直接使用数据库功能,没有在IAsyncDocumentSession之上引入任何抽象。使用扩展方法还允许我们消除对接口的需求,并且我们的查询不会因为这一点而变得难以测试。我们可以很容易地编写集成测试,这些测试将直接使用数据库,这样我们就可以检查我们的查询是如何工作的。
我们还使用了查询对象作为我们的 API 合约和查询方法的参数。同样,通过使用读取模型,我们能够使用相同的对象作为查询结果和 API 调用响应,因此我们不需要在不同层之间进行任何无用的模型映射。
使用 Entity Framework 的查询
现在我们来看看如何使用 SQL 做同样的事情。你可能想知道我是否真的指的是 Entity Framework 而不是 SQL。实际上,我并不打算使用 Entity Framework 本身的任何功能,因为使用关系数据库实现 CQRS 查询的最佳方式是直接使用 SQL。我们将在稍后讨论使用 Entity Framework 时出现的一个问题。
在上一节中,我们做了很多工作,包括创建查询、读取模型和使用了 RavenDB 的 API 端点。我不会逐一介绍所有相同的步骤。以下是对这两个实现中完全相同的阶段的简要描述;您可以使用这个列表,只需将相关文件从一种实现复制到另一种实现:
-
UserProfile聚合和相关的值对象 -
命令 API 和用户资料及分类广告的应用服务
-
ContentModeration委托 -
使用功能文件夹重构
-
读取模型
-
查询类
-
两个查询 API
当然,我们还需要在ClassifiedAd聚合中复制一个小改动来处理Publish命令。
我还需要在 Entity Framework 风格的项目中添加一个UserProfileRepository。它需要实现的接口与之前完全相同。仓库实现本身与我们在上一章中制作的ClassifiedAdRepository相同。下面是代码:
using System;
using System.Threading.Tasks;
using Marketplace.Domain.Shared;
using Marketplace.Domain.UserProfile;
using Marketplace.Infrastructure;
namespace Marketplace.UserProfile
{
public class UserProfileRepository : IUserProfileRepository,
IDisposable
{
private readonly MarketplaceDbContext _dbContext;
public UserProfileRepository(MarketplaceDbContext dbContext)
=> _dbContext = dbContext;
public Task Add(Domain.UserProfile.UserProfile entity)
=> _dbContext.UserProfiles.AddAsync(entity);
public async Task<bool> Exists(UserId id)
=> await _dbContext.UserProfiles.FindAsync(id.Value)
!= null;
public Task<Domain.UserProfile.UserProfile> Load(UserId id)
=> _dbContext.UserProfiles.FindAsync(id.Value);
public void Dispose() => _dbContext.Dispose();
}
}
看起来我们唯一需要做的是将现有的ClassifiedAdDbContext重命名为MarketPlaceDbContext,为值对象添加所有必要的配置,然后进行连接。这也是我的第一个想法。我添加了一个新的实体类型配置,如下面的代码所示:
public class UserProfileEntityTypeConfiguration
: IEntityTypeConfiguration<Domain.UserProfile.UserProfile>
{
public void Configure(EntityTypeBuilder<Domain.UserProfile.UserProfile> builder)
{
builder.HasKey(x => x.UserProfileId);
builder.OwnsOne(x => x.Id);
builder.OwnsOne(x => x.DisplayName);
builder.OwnsOne(x => x.FullName);
}
}
注意,在这里,我必须使用一个额外的属性来保存 ID 的原始值,就像我们在第八章中为ClassifiedAd所做的那样,聚合持久化。
然后,我只需再添加一个DbSet,如下面的代码所示:
public DbSet<Domain.UserProfile.UserProfile> UserProfiles { get; set; }
完成这些后,我可以更改OnModelCreating的重写,如下所示:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfiguration(new
ClassifiedAdEntityTypeConfiguration());
modelBuilder.ApplyConfiguration(new
PictureEntityTypeConfiguration());
modelBuilder.ApplyConfiguration(new
UserProfileEntityTypeConfiguration());
}
现在,我们需要利用它来更改连接,如下面的代码所示:
public void ConfigureServices(IServiceCollection services)
{
const string connectionString =
"Host=localhost;Database=Marketplace_Chapter9;
Username=ddd;Password=book";
services.AddEntityFrameworkNpgsql();
services.AddPostgresDbContext<MarketPlaceDbContext>
(connectionString);
var purgomalumClient = new PurgomalumClient();
services.AddSingleton<ICurrencyLookup, FixedCurrencyLookup>();
services.AddScoped<IUnitOfWork, EfCoreUnitOfWork>();
services.AddScoped<IClassifiedAdRepository, ClassifiedAdRepository>
();
services.AddScoped<IUserProfileRepository, UserProfileRepository>
();
services.AddScoped<ClassifiedAdsApplicationService>();
services.AddScoped(c =>
new UserProfileApplicationService(
c.GetService<IUserProfileRepository>(),
c.GetService<IUnitOfWork>(),
text => purgomalumClient.CheckForProfanity(text)
.GetAwaiter().GetResult()));
services.AddMvc();
services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1",
new Info
{
Title = "ClassifiedAds",
Version = "v1"
});
});
}
前面的代码使用了AddPostgresDbContext扩展方法;它只是包装了之前的内容。您可以在章节仓库中找到代码。
到目前为止,我们可以执行应用程序并创建一个新的用户资料,然后使用相同的用户 ID 创建几个分类广告,所有这些操作都使用命令 API。
下一步是开始实现查询。按照计划,我们采用纯 SQL。然而,使用 SQL 将需要我们创建额外的上下文并为每个读取模型创建DbSet。这似乎有些过度。因此,我将使用 Dapper,这是一个轻量级的 SQL 到对象辅助库。它为SqlConnection类添加了多个有用的扩展,我们还将添加更多扩展,所以这应该没问题。因此,我在Marketplace项目中添加了 Dapper 包,并在Startup.ConfigureServices方法中通过添加以下代码进行连接:
using System.Threading.Tasks;
using static Marketplace.ClassifiedAd.ReadModels;
using static Marketplace.Domain.ClassifiedAd.ClassifiedAd;
using static Marketplace.ClassifiedAd.QueryModels;
using Dapper;
namespace Marketplace.ClassifiedAd
{
public static class Queries
{
public static Task<IEnumerable<PublicClassifiedAdListItem>>
Query(
this DbConnection connection,
GetPublishedClassifiedAds query)
=> connection.QueryAsync<PublicClassifiedAdListItem>(
"SELECT \"ClassifiedAdId\", \"Price_Amount\",
\"Title_Value\" " +
"FROM \"ClassifiedAds\" WHERE \"State\"=@State LIMIT
@PageSize OFFSET @Offset",
new
{
State = (int)ClassifiedAdState.Active,
PageSize = query.PageSize,
Offset = Offset(query.Page, query.PageSize)
});
我没有关闭连接的原因是DbConnection在容器中注册为作用域,因此它按请求实例化。由于我们只使用一次连接,它将被自动释放并关闭。当然,你也可以显式地关闭它。
然而,如果我们尝试这样做,我们会得到一个空的结果。如果我们检查数据库表以查看活动分类广告存储的数据,我们可以看到活动广告的State列的值为2。同时,ClassifiedAdState枚举在第二个位置有Active值。在 Entity Framework 中有一个奇怪的事情,那就是它从1开始计数枚举值,而(int)类型总是从0开始计数。当然,我们可以在查询中使用静态的2值,但这在我们需要更改枚举并重新排序值时不会提供任何安全性。重新排序这个枚举对写入端也会很危险,因为 Entity Framework 也不会保持正确的计数器。为了解决这个问题,让我们在枚举中添加显式的int值而不是使用以下代码:
public enum ClassifiedAdState
{
PendingReview = 1,
Active = 2,
Inactive = 3,
MarkedAsSold = 4
}
在进行此更改后,我们得到了正确的结果。我在数据库中有一个活动广告,所以我得到了 JSON 数组中的一个元素,如下所示:
[
{
"classifiedAdId": "556bc798-bacc-4bb8-a55b-50144add4f17",
"title": "Green sofa",
"price": 110,
"currencyCode": null,
"photoUrl": null
}
]
到目前为止,一切顺利。接下来,我们可以实现myads路由,该路由查询特定用户拥有的所有分类广告。查询几乎与上一个相同;我们只需更改以下代码中的WHERE条件:
public static Task<IEnumerable<PublicClassifiedAdListItem>> Query(
this DbConnection connection,
GetOwnersClassifiedAd query) =>
connection.QueryAsync<ReadModels.PublicClassifiedAdListItem>(
"SELECT \"ClassifiedAdId\", \"Price_Amount\" price,
\"Title_Value\" title " +
"FROM \"ClassifiedAds\" WHERE \"OwnerId_Value\"=@OwnerId LIMIT
@PageSize OFFSET @Offset",
new
{
OwnerId = query.OwnerId,
PageSize = query.PageSize,
Offset = Offset(query.Page, query.PageSize)
});
如果我使用之前添加到系统中的广告的正确所有者 ID,我会得到与之前完全相同的结果。为了更好地展示这些内容,我为相同的所有者 ID 添加了一个新的分类广告并更改了其标题,但尚未发布。因此,当我执行list查询时,我仍然得到单个广告的相同结果。当我执行myads查询时,我得到以下两个结果而不是一个:
[
{
"classifiedAdId": "556bc798-bacc-4bb8-a55b-50144add4f17",
"title": "Green sofa",
"price": 110,
"currencyCode": null,
"photoUrl": null
},
{
"classifiedAdId": "21f750fa-5a24-405b-8aad-20935b5974ed",
"title": "Not ready yet",
"price": -1,
"currencyCode": null,
"photoUrl": null
}
]
在这里,我们可以看到价格属性值为-1,这代表Price.NoPrice值,当我们在 UI 中渲染时可以相应地处理。
最后一个查询稍微复杂一些,因为它需要一个连接。但是,由于连接是 SQL 操作中非常常见的操作,因此实现这个查询也不会很难;我们只需使用以下代码:
public static Task<ClassifiedAdDetails> Query(
this DbConnection connection,
GetPublicClassifiedAd query) =>
connection.QuerySingleOrDefaultAsync<ClassifiedAdDetails>(
"SELECT \"ClassifiedAdId\", \"Price_Amount\" price,
\"Title_Value\" title, " +
"\"Text_Value\" description, \"DisplayName_Value\"
sellersdisplayname " +
"FROM \"ClassifiedAds\", \"UserProfiles\" " +
"WHERE \"ClassifiedAdId\" = @Id AND
\"OwnerId_Value\"=\"UserProfileId\"",
new { Id = query.ClassifiedAdId });
然后,我可以使用现有的分类广告 ID 调用 API,并得到以下结果:
{
"classifiedAdId": "556bc798-bacc-4bb8-a55b-50144add4f17",
"title": "Green sofa",
"price": 110,
"currencyCode": null,
"description": "Very nice sofa, almost as new",
"sellersDisplayName": "prejudice",
"photoUrls": null
}
好吧,就是这样!查询似乎比命令更容易实现。通过直接访问数据库引擎本身而不进行太多抽象,我们可以充分利用数据库的全部功能。然而,我们需要意识到一些写入端持久性创建的怪癖,例如枚举问题和值对象的一些奇怪的列标题,但最终,这并不那么糟糕。
很有趣,我们在两个不同的实现之间根本不需要在查询 API 类中做任何更改,除了使用不同类型的数据库连接。
摘要
在本章中,我们终于掌握了我们放入数据库中的数据。现在,我们的项目包含几个GET端点来检索底层数据库的内容。我们使用 CQRS 创建查询,这些查询与我们的领域中的模型完全分离。当然,我们必须使用聚合类型来查询 RavenDB,因为这些也是文档类型。这可以通过将状态模型从聚合中分离出来来避免,但这是你自己可以探索的事情。
我们拥抱了原生访问数据库引擎的力量,去做那些如果我们仅仅使用存储库就无法或难以完成的事情。这是因为存储库类型代表了一个单一类型的聚合集合,如果我们需要在单个读模型中结合来自不同聚合的数据,我们就会遇到麻烦。
读模型使我们能够创建可以映射到数据库查询结果的对象,并通过 API 直接返回,而无需任何额外的映射。查询对象很好地封装了我们传递给查询本身所需的所有参数。通过使用查询对象,如果需要向两个不同的查询发送相同的参数,也更容易避免签名冲突。
使用扩展方法来构建查询是一个强大的模式。你不需要接口和额外的注册,只需将依赖项传递到控制器。如果你担心测试,请记住,查询应该始终针对实际数据库进行测试。因此,在那里使用接口实际上并没有真正的必要;你可以在测试中使用这些扩展方法,就像我们在控制器中使用它们一样。
通过添加几个有用的通用静态方法,例如HandleCommand和HandleQuery,我们减少了控制器中的行数,并移除了所有重复的代码。当你看到代码中的重复时,寻找有用的抽象总是一个好主意;这会使你的代码更干净、更短,并且在重复粘贴相同代码时犯错的几率几乎会消失。
这是我们在 CQRS 的写侧使用传统持久化的最后一章。所有随后的章节都将使用事件溯源来持久化聚合,但我们仍然会通过使用投影来采用各种方法来构建读模型。
第十章:事件溯源
你应该已经理解了什么是领域事件,为什么它们很重要,以及如何找到和编码它们。现在,我们将探讨事件的其他用途。希望阅读完这一章后,你会明白为什么我们需要使用事件来更新聚合状态。在此之前,我们只在聚合内部使用事件,将事件提升并单独在When方法中进行状态转换可能看起来有些过度。
这次,你将学习如何使用事件来持久化对象的状态,而不是使用传统的持久化机制,如 SQL 或文档数据库。这并不是一件容易掌握的事情,但回报是令人满意的。使用事件来表示系统行为并推导出任何给定时间点的状态有许多优点。当然,没有银弹,在决定事件溯源是否适合你之前,了解可能的缺点是至关重要的。
我们将继续使用更多的事件处理器来开发我们的聚合。同时,我们还将介绍事件流的概念以及流与聚合之间的关系。我们将使用事件存储在流中持久化我们的聚合,并将它们加载回来。
本章将涵盖以下主题:
-
什么是事件溯源?
-
我们为什么使用事件溯源?
-
事件溯源的挑战和缺点
-
为什么事件溯源在领域驱动设计社区中变得流行
-
使用事件存储
技术要求
在本章中,我们将使用事件存储(eventstore.org),这是一个开源数据库。
运行事件存储最简单的方法是使用 Docker。我们在前面的章节中使用了docker-compose,所以使用事件存储会有同样的体验。
本章的代码包含一个docker-compose.yml文件,允许你通过执行以下命令来使用事件存储:
docker-compose up
Docker 将从 Docker Hub 拉取最新镜像并启动一个命名容器。此命令将两个端口从容器映射到你的机器:2113和1113。端口2113用于通过 HTTP 访问事件存储,端口1113用于 TCP 连接。
容器启动后,你可以在浏览器中打开http://localhost:2113来检查其状态。你将得到以下登录提示:

在那里,你需要输入默认凭据:用户名为admin,密码为changeit。然后,点击“登录”按钮,应该会出现以下屏幕:

产品版本和菜单项可能因 Event Store 的最新版本而有所不同。
为什么事件溯源
在本节中,我们不仅将讨论为什么有人可能想要使用事件溯源——我们还将探讨这个模式的定义及其背后的历史。就像 Greg Young 经常说的那样,“事件溯源并不新鲜”,我们将探讨一些历史,这应该有助于你更好地理解这个概念。
之后,我们将探讨为什么。有了对其历史的了解,理解为什么这种存储数据的方式变得越来越流行就不会很难。
到本节结束时,我们将清楚地说明为什么有人可能不想在他们的系统中使用事件溯源,以及那些第一次开始使用它的人将面临哪些挑战。
状态持久化的问题
在前几章中,我们多次使用了术语领域事件。在设计阶段,我们使用橙色便利贴在白板上可视化领域事件。后来,在实现阶段,我们为领域事件创建了类。这些类将系统中发生的事情转换成机器可以读取的内容。
领域模型中的每个动作,作为聚合中的方法表示,都会在系统状态中引起变化。我们还让我们的聚合使用事件来描述这些变化。当这种变化发生时,我们随后使用模式匹配代码在将其持久化到数据库之前修改聚合状态。
现在,让我们假设我们不是像在第八章 CQRS - 读取侧中做的那样将聚合状态保存到数据库中,相反,我们将收集在执行动作时生成的新事件。例如,在我们的ClassifiedAd聚合代码中,我们有一个UpdatePrice方法:
public void UpdatePrice(Price price) =>
Apply(new Events.ClassifiedAdPriceUpdated
{
Id = Id,
Price = price.Amount,
CurrencyCode = price.Currency.CurrencyCode
});
当我们从应用程序服务调用它时,这种方法已经创建了一个新的事件。我们还有一个When方法用于将事件投影到聚合状态,因此当我们调用Apply方法,例如在前面的代码片段中,聚合状态会相应地改变:
protected override void When(object @event)
{
switch (@event)
{
// only a part of the When method is shown
case Events.ClassifiedAdPriceUpdated e:
Price = new Price(e.Price, e.CurrencyCode);
break;
}
}
因此,如果我们观察聚合状态随时间的变化,当我们按时间线对其应用不同的事件时,它将看起来像这样:

在前几章中,我们通过将聚合状态提交到该聚合类型的存储库来将其保存到数据库中。每次我们需要执行聚合的操作时,我们都会通过调用存储库的Get(int id)方法从数据库中检索其状态。
每次我们提交新的状态时,之前的状态就会被覆盖,因此在任何给定时刻,我们的数据库都包含系统状态的快照,尽管可能有许多变化使我们的系统达到那种状态。我们可以使用时间线来可视化它:

这就是执行聚合上的任何动作将看起来像什么:

如果我们只对事物的当前状态感兴趣,那么效果非常好。我们知道特定分类广告的当前售价。然而,当产品所有者说我们需要显示售价历史图时,我们无法做到。另一个典型用例是只显示在过去几天内更新了价格的广告。我们可以通过将最后价格更新的日期添加到我们的聚合(仅用于显示这个新的搜索结果)来实现这一点,但这只适用于新的更新。这意味着在我们收集足够的数据之前,我们不能向用户展示这个功能,因为我们的持久化模型无法提供任何历史数据。
作为开发者,我们经常遇到系统某些元素处于意外或无效状态的情况。通常,我们使用日志文件来找出发生了什么。当这种方法失败时,我们开始审问常被怀疑的对象——我们的用户,他们肯定做了什么错误的事情,甚至他们根本不应该能够做的事情。当然,用户否认一切,说他们什么都没做,或者什么都没做;事情完全是自发发生的。
任何曾经陷入这种困境的人都会记得,通常与无法找到原因相关联的绝望程度。我们最终只能处理后果,根据我们最好的知识修复系统状态。有时这些问题存在数月甚至数年,开发者都无法确定问题的原因。这是因为他们不知道系统中发生的事件序列,导致了这种无效状态。
Mathias Verraes 在他 2014 年的博客文章 Domain-Driven Design is Linguistic (verraes.net/2014/01/domain-driven-design-is-linguistic/) 中很好地描述了保持导致特定状态的事件历史的重要性。
正如你所读到的,拥有五十万欧元是最终的系统状态。然而,之前的事件序列可能会让我们对系统状态的某些其他方面得出不同的结论,这是我们之前没有考虑到的。如果我们想将我们的主题的情感状态或幸福水平添加到系统状态中,如果我们没有存储事件的历史,我们将无法获取这些信息。
对于收集变更历史,无论是为了报告还是调试,通常可以通过引入一个人工的变更日志来解决。这样,似乎所有变更都被捕获以供未来分析。同时,事件处理与审计日志中的记录之间将没有直接关系。这可能导致某些变更不会被记录。
仅保留最新状态的问题的另一个问题是,要获取关于系统的任何信息,我们只能依赖于我们用来持久化聚合的表或文档。当然,如果我们有一个具有两个数据库的 CQRS 系统,我们将从读取端获取信息。但对于那些需要在新屏幕中包含来自不同现有读取模型的数据的情况,我们唯一能做的就是执行一个复杂的带有连接的查询来获取所需的数据。随着时间的推移,这可能会削弱使用 CQRS 的优势,因为我们以前为了优化读取而优化的东西现在不再调整,考虑到一段时间前看起来完美无瑕的模型,现在却有一系列新的查询跨越了它。
什么是事件溯源?
我们经常需要看到哪些行为触发了状态转换,这就是我们开始使用领域事件的原因。然而,如果没有将这些事件存储在某个地方,用作系统状态的真相来源,我们就永远无法确定我们记录的行为是否正是将我们的系统带到当前状态的那个行为。
事件溯源的原则体现在其名称中。它相当简单。我们已经在代码中实现了事件生成。因此,我们不是持久化聚合的状态,而是将所有新事件保存到数据库中。当我们从数据库中检索聚合时,我们不是像在表或文档中读取一个记录的状态,而是读取之前保存的所有事件,并对每个事件调用When方法。通过这样做,我们根据历史记录重建了聚合状态。
然后,当我们需要执行一个命令时,我们调用聚合的方法,它生成新的事件,我们将这些事件添加到数据库中该聚合已经存在的事件列表中。这意味着我们永远不会更改或删除数据库中的任何内容;我们只追加新的事件。
我们可以这样可视化单个操作的执行:

注意,尽管读取聚合可能看起来更复杂,因为我们正在进行两个活动(读取和执行When),但在代码中,它似乎是一样的。我们需要将执行整个Get的代码放入持久化实现中,这将使我们能够保持持久化实现不变,至少对于读取部分来说是这样。
这种方法解决了为不同目的保留历史数据的问题——作为一个审计日志、作为一个账本、作为一个需要从过去获取数据的报告来源,以及作为一个可能帮助找到导致系统进入无效状态的路径。
事件源的一个显著优势是它消除了阻抗不匹配。我们曾在第七章“一致性边界”中讨论过这个问题,当时我们谈到将聚合体持久化到关系数据库和文档数据库。自从使用事件源以来,我们完全停止了以原样持久化对象,阻抗不匹配就变得无关紧要了。还记得对象和数据库之间映射可能多么复杂吗?能够从软件开发过程中移除这一负担是使用事件持久化对象的宝贵特性。
周围的事件源
尽管事件源可能看起来是一种新技术,但它并不是。
回到 2007 年,格雷格·杨(Greg Young)开始将事件源塑造成我们现在的形式。但是,正如格雷格多次提到的,我们可以将这些类似的技术追溯到古代美索不达米亚。文字的起源与会计有关,楔形文字,已知的第一种文字,最初是为了会计目的而开发的。我们知道,从公元前 3500 年左右开始,书记员在泥板上记录商业交易。这些泥板随后被晾干,形成永久、不可更改的记录。
会计自美索不达米亚和苏美尔时代以来已经发生了很大变化。尽管如此,现代会计原则与事件源相似。复式记账法中的每一笔交易至少记录两次——一次在借方账户上,一次在贷方账户上。这两条记录构成一笔交易。一笔交易中金额的总和必须为零。在账户表中没有账户的状态概念。运行余额是期初余额和该账户上任何记录的金额之和。因此,要获取当前余额,我们需要读取该账户的所有记录。
同样的技术在金融的许多领域都被使用。我们都很熟悉的例子是银行业务。银行账户遵循与簿记中账户相同的规则。在名为Accounts的大 SQL 表中,没有存储在名为Balance的字段中的账户余额。在发生任何争议的情况下,银行无法证明余额是正确的。因此,余额是通过计算该账户所有交易的金额总和来计算的。当然,对于非常频繁使用的账户,这样的总和可能需要太长时间才能计算出来。在这种情况下,银行会偶尔制作账户快照。我们大多数人都熟悉财政年度的概念。在财政年度结束的那一天,所有余额都会固定下来,所有会计工作都会重新开始,只是将上一年度的余额转移过来。
在任何情况下,在现实世界的应用中,如会计和银行业务,都观察到了事件源的两个常见原则:
-
每笔交易都会记录事件,因此可以通过读取所有这些事件来重建对象状态。
-
事件不能被更改或删除,因为这会破坏整个审计日志的概念并使其无效。
为了纠正错误,会计人员会创建新的交易来补偿之前输入的看似不正确的操作。在银行中也是如此。如果你账户上被错误地放置了一笔金额,银行永远不会删除这笔交易,尽管它是错误的。你会在账户上看到另一笔交易,从你那里扣除相同的金额。我们也可以在获得部分退款时看到这种情况。我们不会改变部分退款交易的金额,而是会得到一笔新的交易,用于部分退款的金额。
事件源聚合体
现在,是时候更深入地了解我们如何通过保存变更历史来持久化聚合体了。在本节中,我们将讨论事件流是什么,以及我们如何使用流将聚合体持久化到事件存储中并检索它们。当然,这也意味着我们将涵盖事件存储的主题。
事件流
到目前为止,在所有图表中,我们只看到了一个聚合体的事件。当然,这样的系统是没有用的,我们需要找到一种方法来存储不同聚合体的事件,以便使系统功能化。这里的主要要求是我们需要能够检索单个聚合体的事件,最好是单次读取。当然,如果有成千上万的事件,我们需要将读取分成多个批次,但这目前不在我们的范围内。为了实现只读取单个聚合体事件的这种能力,我们需要写入带有一些元数据的事件,这些元数据指示聚合体标识符。第二个要求是事件需要按照它们被写入的严格顺序读取;当我们将更改作为事件写入数据库时,这些事件需要按照我们发送到数据库的确切顺序写入。
按照特定顺序进入系统的事件形成事件流。为了实现事件源,最舒适的解决方案是拥有一个数据库,允许我们为每个聚合体创建一个单独的事件流。在这种情况下,我们将向一个已知的流中写入并从中读取。流名称将是聚合体类型和聚合体标识符的组合;例如,对于我们的ClassifiedAd聚合体,其 ID 为e99460470a7b4133827d06f32dd4714e,流名称将是ClassifiedAd-e99460470a7b4133827d06f32dd4714e。一个聚合体流包含在聚合体生命周期中发生的所有事件。当我们决定系统不再需要聚合体时,我们可以删除整个流或写入一个最终事件,例如ClassifiedAdRemoved。
数据库的一个关键特性,我们可以用它来持久化事件,是除了单个流之外,还有一个包含系统中所有事件的单一流。这不会是最理想的,但我们可以通过控制流 ID 元数据属性来推断聚合流,以防我们的数据库不支持原生分离的流。然而,拥有包含所有事件的单一流是绝对必要的。在整个本书的过程中,我们将把这个主流称为$all流,因为在 Event Store(我们将用于示例的数据库)中,它就是这样被称呼的。
理解这一点至关重要,当我们处理$all流和聚合流时,我们指的是相同的事件。你可以这样理解,所有事件始终存在于$all流中,但除此之外,还有一个对这些事件建立的索引。这个索引告诉系统一个事件属于哪个单个流。
以下图表表示了包含一些按聚合流索引的事件的$all流:

聚合流和$all 流
到目前为止,我们已经能够制定出我们可以用来持久化我们的聚合为事件流的数据库的要求。现在,我们将查看此类数据库的具体示例。
事件存储
在上一节中,我们讨论了为了将数据库视为事件存储,我们需要确保这个数据库可以存储事件和元数据,并在元数据上建立索引。我们不能在事件上建立任何索引,因为事件对象没有单一的公因数;它们都是不同的。然而,元数据是以已知的方式结构化的。例如,流名称必须在所有事件的元数据中存在。
这样的定义可能会让我们得出结论,任何支持通过流 ID 查询事件的数据库都可以用作事件存储。这是真的。在这里,你可以找到不同数据库如何用作事件存储的例子:
| 数据库 | 如何存储事件 | 如何读取单个流 |
|---|---|---|
| 关系数据库管理系统(SQL Server、PostgreSQL 等) | 使用单个表;为流名称添加一个列,为事件负载添加一个列。一行是一个事件。 | 选择所有流名称是我们想要的行。 |
| 文档数据库(MongoDB、Azure Cosmos DB、RavenDB) | 使用文档集合。每个文档都应该有一个元数据对象和一个用于存储负载的字段。一个文档就是一个事件。 | 查询所有流名称(元数据的一部分)是我们需要的所有文档。 |
| 分区表(Azure Table Storage、AWS DynamoDB) | 使用单个表;添加一个用于流名称(或 ID)的字段,作为分区键,并添加另一个字段作为行键(Azure)或排序键(DynamoDB)。第三个字段将包含事件负载。一条记录代表一个事件。 | 查询所有分区键为正在读取的流名称的记录。 |
| 专用数据库(事件存储) | 原生支持流。 | 从单个流中读取所有事件。 |
注意,对于某些关系型数据库,存在一些工具和库可以帮助存储事件源系统的数据。例如,Marten 框架(jasperfx.github.io/marten/)利用原生 PostgreSQL 功能在 JSONB 类型的列中存储非结构化数据,并基于该数据库实现了一个事件存储。SQL Stream Store (github.com/SQLStreamStore/SQLStreamStore)也可以帮助你使用各种关系型数据库,包括 Microsoft SQL Server 和 PostgreSQL,作为事件存储。这两个开源工具在全球的生产系统中被积极使用,并且背后都有活跃的社区支持。
到目前为止,我们一直专注于将单个聚合体作为事件流进行持久化,并从数据库中读取单个聚合体的所有事件。然而,这并不是我们需要关注的事件存储的唯一特性。如果你还没有注意到,我们还没有涉及到查询部分,当我们需要根据某些标准读取某些聚合体的数据时。我们对事件存储的主要要求并不包括通过流名称查询任何内容的能力。显然,像ClassifiedAdsPendingReview这样的查询是不可能的,因为我们可能需要读取所有分类广告的所有事件(可能数百万),然后在内存中进行查询。这种方法对于生产环境来说并不可行,尽管它可能对原型设计非常有用。为了解决这个问题,我们需要回到 CQRS,这次我们需要使用领域事件来构建我们的读取模型。在事件源系统中,我们将不得不使用一个传统的数据库,SQL 或 NoSQL,它可以被查询,以处理 CQRS 的查询方面,而这个查询方面只能从事件中构建。因此,我们需要有一种可靠的方法,从事件存储实时(或接近实时)地获取所有新事件的信息,并将其传递给我们的读取模型构建者。如果我们使用传统的关系型数据库来存储事件,我们几乎不可避免地会转向频繁轮询。一些 NoSQL 数据库,如 Azure Cosmos DB、RavenDB 和 AWS DynamoDB,允许我们订阅变更流,并获取所有数据库操作的信息。当我们讨论这个特性时,我们将使用术语“订阅”。
对于本书中的所有示例,我们将使用事件存储库(eventstore.org,)因为它包含了其创建者,CQRS 的之父,长期倡导事件源模式的 Greg Young,以及支持该产品的公司以及开源开发者社区多年的经验。此外,此产品是免费的,您只需付费即可获得生产级支持。事件存储库原生支持存储事件,并具有事务性写入;我们可以订阅事件流以获取所有新的(和现有的)事件,等等。
在继续之前,请确保您已经完成了技术要求部分中描述的步骤。
以事件为导向的持久化
现在,我们将编写一些代码,使我们能够使用事件来持久化我们的聚合。
在第九章,CQRS - 读取侧中,我们使用存储库来存储聚合,但现在,我们将做些不同的事情。将事件追加到ClassifiedAd聚合的流中与对UserProfile聚合做同样的事情没有区别。因此,存储库的具体细节消失了,关于持久化聚合和检索它们的所有事情都是完全以相同的方式进行。因此,我们可以使用一个接口,IAggregateStore,它将处理任何类型聚合的持久化。
现在,让我们开始实现一些低级代码,以将事件写入事件存储库流并读取它们。它将包括序列化、分页、类型处理和乐观并发。
在本章中,当我们谈论可以写入事件到流并读取它们的地方时,我们将使用术语事件存储库。当我们使用术语 Event Store 时,我们将指的是您应该能够通过遵循技术要求部分来执行的产品。
写入事件存储库
在任何读取之前,必须有一个写入,所以这就是我们将开始的地方。让我们看看事件存储库 API 来写入事件到流。我们最可能使用的方法是以下这个:
Task<WriteResult> AppendToStreamAsync(string stream, long expectedVersion, IEnumerable<EventData> events)
这里所有的参数都很清晰:一个流名称和要保存到流中的事件列表。此外,我们还需要提供聚合版本以处理乐观并发。它将防止其他人通过处理同一聚合的另一个命令并行进行的更改被覆盖。事件存储库默认支持流版本控制,我们只需在尝试将新事件保存到流中时提供预期的版本即可。
我们将通过向Marketplace.Framework项目添加以下接口来开始编写代码:
using System.Threading.Tasks;
namespace Marketplace.Framework
{
public interface IAggregateStore
{
Task<bool> Exists<T, TId>(TId aggregateId);
Task Save<T, TId>(T aggregate) where T : AggregateRoot<TId>;
Task<T> Load<T, TId>(TId aggregateId)
where T : AggregateRoot<TId>;
}
}
你可以将它与我们在第八章,“聚合持久化”中使用的存储库接口进行比较,你会发现新的接口是一种通用的存储库。尽管我们讨论了为什么通常使用通用存储库不是一个好主意,但在我们的情况下,这是完全可以接受的,因为所有聚合的持久化方面都以相同的方式处理。
序列化代码需要安装一些外部依赖项。在前面提供的代码片段中,我们使用了JsonConvert类将事件序列化为 JSON。因此,我们需要将Newtonsoft.Json包添加到我们的Marketplace.Framework项目中。为了获取事件存储 API,我们还需要EventStore.ClientAPI.NetCore包。我们可以通过项目上的“管理 NuGet 包”上下文菜单,或者在终端窗口中运行以下两个命令来完成:
dotnet add Marketplace.Framework package Newtonsoft.Json
dotnet add Marketplace.Framework package EventStore.ClientAPI.NetCore
现在,我们可以在一个新的类EsAggregateStore中开始实现这个接口,我们将把这个类添加到Marketplace项目的Infrastructure文件夹中。
首先,是流名称。在本章的开头,我们已经讨论了事件流的概念,并且由于写入一个流是一个事务,流就成为了我们的事务边界,连同聚合边界一起。我们将使用聚合-流策略,因此我们可以安全地将流名称从我们的聚合名称派生出来。但是,我们的聚合名称是什么呢?嗯,我们可以从 CRL 类型开始,比如Marketplace.Domain.ClassifiedAd。然后,我们需要使这些名称唯一。为此,一个明显的解决方案是添加一个聚合 ID。我想讨论两种创建流 ID 的情况:当我们需要持久化一个聚合时,以及当我们只想加载一个聚合的 ID 时。为了做到这一点,我将在EsAggregateStore类中添加两个方法:
private static string GetStreamName<T, TId>(TId aggregateId)
=> $"{typeof(T).Name}-{aggregateId.ToString()}";
private static string GetStreamName<T, TId>(T aggregate)
where T : AggregateRoot<TId>
=> $"{typeof(T).Name}-{aggregate.Id.ToString()}";
进一步查看AppendToStreamAsync方法的参数列表,这个方法不接受IEnumerable<object>,而是期望一个具有EventData类型的对象集合。这个类有以下公共成员:
public sealed class EventData
{
public readonly Guid EventId;
public readonly string Type;
public readonly bool IsJson;
public readonly byte[] Data;
public readonly byte[] Metadata;
}
对于我们来说,重要的是要理解我们需要将事件类型保存为字符串,这样我们才能将事件反序列化回事件 CLR 类型的对象。在保存事件时,我们还需要将事件对象转换为字节数组,在读取事件时将字节数组转换为对象。因此,对于Type,我们再次可以使用事件对象的 CLR 类型名称。对于有效载荷(Data),我们可以使用任何有用的序列化方式。
然而,事件存储有一个不错的用户界面,可以显示事件的内容,但它只会在事件被序列化为 JSON 格式时这样做。这正是IsJson布尔属性的作用所在。对于大多数不需要通过使用更紧凑的表示和更快的序列化过程(如 protobuf)来优化性能的应用程序,使用 JSON 就足够了,这正是我们打算做的。
由于我们需要将我们的对象转换为字节数组并仍然使用 JSON,我们可以创建一个方法来帮助我们完成这个任务:
private static byte[] Serialize(object data)
=> Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(data));
然后,我们需要考虑如何从一个聚合中获取新事件的列表,并构建一个表示这些事件的EventData对象集合。
从我们应用的早期版本开始,我们就有了GetChanges方法。最初,它在Entity基类中,后来我们将其重命名为AggregateRoot。我们最终可以开始使用这个方法来获取作为命令执行一部分生成的新事件。以下是获取聚合的所有更改并构建一个EventData对象集合的代码,正如我们调用AppendToStreamAsync方法所需的那样:
var changes = aggregate.GetChanges()
.Select(@event => new EventData(
eventId: Guid.NewGuid(),
type: @event.GetType().Name,
isJson: true,
data: Serialize(@event),
metadata: null));
在前面的代码片段中,我们指定了要作为事件类型在事件存储中使用的短事件类型名称。它可能类似于ClassifiedAdRenamed。但是,当我们开始加载事件时,我们需要将 JSON 字符串反序列化为具体的事件类型。Newtonsoft.Json库不会理解短类型;它需要知道完全限定类名(FQCN)。如果事件定义在不同的程序集(assembly)中,我们还需要包含程序集信息。如果我们使用 FQCN 作为事件存储的事件类型,那么在事件存储 UI 中我们会看到一个相当丑陋的画面,因为它会被所有关于命名空间和程序集名称的技术信息所污染。我不喜欢这样,因此我仍然会使用短类型名称。然而,我们需要一种方法来告诉反序列化器具体的事件类型。存储关于事件的技术信息的最佳位置是元数据,这正是我将要做的。首先,我将添加一个私有的嵌套类,我们将用它来存储事件元数据:
private class EventMetadata
{
public string ClrType { get; set; }
}
现在,我可以修改前面的代码片段,将 FQCN 与事件一起作为元数据保留:
var changes = aggregate.GetChanges()
.Select(@event =>
new EventData(
eventId: Guid.NewGuid(),
type: @event.GetType().Name,
isJson: true,
data: Serialize(@event),
metadata: Serialize(new EventMetadata
{ClrType = @event.GetType().AssemblyQualifiedName})
))
.ToArray();
使用事件 CLR 类型名称作为事件名称和在事件元数据中的 FQCN 是一个临时解决方案。对于生产系统,我建议使用类型映射器的概念,它将 CLR 类型转换为字符串,然后再转换回来。这种方法给你一些灵活性,在需要的情况下更改命名空间,而不会破坏过去持久化的事件的反序列化能力。我不会详细介绍如何使用类型映射器,但你将在第十三章的代码库中找到工作代码,Splitting the System。
让我们将这段代码放入我们的新EsAggregateStore类中:
using System;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using EventStore.ClientAPI;
using Newtonsoft.Json;
namespace Marketplace.Infrastructure
{
public class EsAggregateStore : IAggregateStore
{
private readonly IEventStoreConnection _connection;
public EsAggregateStore(IEventStoreConnection connection)
{
_connection = connection;
}
public async Task Save<T, TId>(T aggregate)
where T : Aggregate<TId>
{
if (aggregate == null)
throw new ArgumentNullException(nameof(aggregate));
var changes = aggregate.GetChanges()
.Select(@event =>
new EventData(
eventId: Guid.NewGuid(),
type: @event.GetType().Name,
isJson: true,
data: Serialize(@event),
metadata: Serialize(new EventMetadata
{ClrType =
@event.GetType().AssemblyQualifiedName})
))
.ToArray();
if (!changes.Any()) return;
var streamName = GetStreamName<T, TId>(aggregate);
await _connection.AppendToStreamAsync(
streamName,
aggregate.Version,
changes);
aggregate.ClearChanges();
}
private static byte[] Serialize(object data)
=> Encoding.UTF8.GetBytes(
JsonConvert.SerializeObject(data));
private static string GetStreamName<T, TId>(TId aggregateId)
=> $"{typeof(T).Name}-{aggregateId.ToString()}";
private static string GetStreamName<T, TId>(T aggregate)
where T : Aggregate<TId>
=> $"{typeof(T).Name}-{aggregate.Id.ToString()}";
}
}
我们之前没有涉及到的唯一一件事是 IEventStoreConnection。我们应用程序和事件存储之间的所有读取和写入操作都需要在打开到事件存储集群的 TCP 连接上执行,这也可以是一个通过运行 Docker 镜像创建的单节点集群。我们的应用程序将在启动时建立连接,我们需要在应用程序停止时关闭连接。我们将把这个基础设施代码添加到我们的可执行项目中。
从事件存储中读取
在我们的应用程序服务中,唯一一个在处理之前不需要读取聚合的命令是 CreateClassifiedAd 命令。对于所有其他操作,我们需要首先读取我们的聚合,这就是我们通过调用 _store.Load<ClassifiedAd>(id.ToString()) 来做的。虽然将聚合保存到存储中,通过收集所有更改并将它们保存到事件流中看起来相当明显,但从事件流中读取聚合要稍微复杂一些。让我们描述从事件存储中检索聚合的步骤:
-
查找聚合的流名称
-
从聚合流中读取所有事件
-
遍历所有事件,并对每个事件调用
When处理器
在完成所有这些步骤之后,我们将恢复给定聚合的所有历史记录,并使用聚合事件处理规则将所有历史事件重新应用到空的聚合对象上。通过这样做,我们将把我们的聚合带到其最新状态。
在代码中,我们将在 EsAggregateStore 类的 Load 方法中执行所有这些步骤:
public async Task<T> Load<T, TId>(TId aggregateId)
where T : AggregateRoot<TId>
{
if (aggregateId == null)
throw new ArgumentNullException(nameof(aggregateId));
var stream = GetStreamName<T, TId>(aggregateId);
var aggregate = (T) Activator.CreateInstance(typeof(T), true);
var page = await _connection.ReadStreamEventsForwardAsync(
stream, 0, 1024, false);
aggregate.Load(page.Events.Select(resolvedEvent =>
{
var meta = JsonConvert.DeserializeObject<EventMetadata>(
Encoding.UTF8.GetString(resolvedEvent.Event.Metadata));
var dataType = Type.GetType(meta.ClrType);
var jsonData =
Encoding.UTF8.GetString(resolvedEvent.Event.Data);
var data = JsonConvert.DeserializeObject(jsonData, dataType);
return data;
}).ToArray());
return aggregate;
}
让我们通过 Load 方法来了解这些步骤。按步骤,它执行以下操作:
-
确保聚合 ID 参数不为空
-
获取给定聚合类型的流名称
-
通过反射创建聚合类型的新的实例
-
将流中的事件读取为
ResolvedEvent对象的集合 -
将这些原始事件反序列化为领域事件的集合
-
调用空聚合实例的
Load方法以恢复聚合状态
有几件事情需要额外的解释。
首先,我们可以在 T 泛型类型参数上使用 new 约束,这样我们就可以使用无参构造函数实例化一个空的聚合。然而,这将破坏封装性,并迫使我们公开一个无参构造函数,而我们不想这样做。使用反射允许我们调用我们已经在所有聚合根类型中存在的受保护的构造函数。你需要记住,如果您的系统处理大量命令,这种解决方案可能会引起性能问题,在这种情况下,需要另一个解决方案。公开一个无参构造函数可能是一个可接受的权衡。
其次,我们使用一个魔法数字 1024 来读取所谓的 流切片,这实际上只是一个页面。您的事件流可能会变得更大,Event Store 不允许我们一次性读取超过 4,096 个事件。对于大型流,我们需要实现分页,但在这个例子中,由于我们的聚合的生命周期不假设有长流,所以这不是必要的。
最后一件事情是 AggregateRoot 抽象类中缺失的 Load 方法。我们之前不需要这个方法,因为我们没有使用事件溯源。Load 方法将完成聚合恢复序列的最后一步,遍历所有事件并为每个事件调用匹配的 When。让我们看看我们如何在 AggregateRoot 类中实现这个方法:
public void Load(IEnumerable<object> history)
{
foreach (var e in history)
{
When(e);
Version++;
}
}
如您所见,这是一段非常简单的代码,本质上,它代表了事件溯源是什么。我们获取我们之前存储的事件集合,然后从这些事件中重建我们的领域对象的状态。When 方法知道如何为集合中的每个事件更改聚合状态,所以当我们为历史中的每个事件调用它时,我们就可以得到我们的聚合回到最后一个已知的状态。
注意,我们还为每个应用的事件增加了聚合的 Version 属性,这样我们就可以知道在将更改提交到存储时我们的聚合应该有什么版本。我们在讨论乐观并发时讨论了聚合版本。与使用状态持久化不同,我们需要在我们的数据库中有一个属性来存储聚合版本,当我们使用事件时,我们实际上并不存储版本,因为每个事件总是将聚合版本增加一,所以我们只需计算事件数量就可以得到当前版本。
最后一件我需要用来最终实现 IAggregateStore 接口的事情是 Exists 方法。没有简单的方法来询问 Event Store 是否存在一个流,但我们可以通过尝试从一个给定的流中读取一个事件来轻松克服这个问题:
public async Task<bool> Exists<T, TId>(TId aggregateId)
{
var stream = GetStreamName<T, TId>(aggregateId);
var result = await _connection.ReadEventAsync(stream, 1, false);
return result.Status != EventReadStatus.NoStream;
}
到现在为止,我们应该有一个使用事件的工作实现聚合持久化。
连接配置基础设施
为了完成工作并使我们的应用程序利用所有这些更改,我们需要为 Event Store 连接编写一些初始化代码,并且还需要为我们的应用程序服务进行连接配置,以便它使用 EsAggregateStore。
首先,我们需要通过使用 .NET Core 配置扩展来配置我们的应用程序。我们将从添加一个简单的 appsettings.json 配置文件开始。目前,这个文件的内容将只是一个本地运行的 Event Store 的连接字符串:
{
"eventStore": {
"connectionString": "ConnectTo=tcp://admin:changeit@localhost:1113;
DefaultUserCredentials=admin:changeit;"
}
}
然后,我们需要读取这个配置,这样我们就可以访问这些值。为此,我们将更改我们的 Program 类的 BuildConfiguration 方法:
private static IConfiguration BuildConfiguration(string[] args)
=> new ConfigurationBuilder()
.SetBasePath(CurrentDirectory)
.AddJsonFile("appsettings.json", false, false)
.Build();
为了将settings文件复制到应用程序输出目录,我们需要在Marketplace.csproj文件中更改其属性,以确保项目文件有如下这些行:
<ItemGroup>
<Content Update="appsettings.json"
CopyToOutputDirectory="Always"
CopyToPublishDirectory="Always" />
</ItemGroup>
当我们的应用程序启动时,需要打开到事件存储的连接,并在关闭应用程序时关闭。为了启用此功能,我们将使用名为HostedService的新类实现Microsoft.Extensions.Hosting.IHostedService接口。为此,我们将向可执行项目添加一个名为HostedService.cs的新文件:
using System.Threading;
using System.Threading.Tasks;
using EventStore.ClientAPI;
using Microsoft.Extensions.Hosting;
namespace Marketplace
{
public class HostedService : IHostedService
{
private readonly IEventStoreConnection _esConnection;
public HostedService(IEventStoreConnection esConnection)
{
_esConnection = esConnection;
}
public Task StartAsync(CancellationToken cancellationToken)
=> _esConnection.ConnectAsync();
public Task StopAsync(CancellationToken cancellationToken)
{
_esConnection.Close();
return Task.CompletedTask;
}
}
}
最终的连接发生在Startup.cs文件中,我们需要更改ConfigureServices方法,使其包括事件存储连接和EsAggregateStore注册。此外,我们需要注册我们的HostingService,以便网络主机知道它需要在启动和关闭时运行某些操作。Startup.ConfigureServices的新版本如下所示:
public void ConfigureServices(IServiceCollection services)
{
var esConnection = EventStoreConnection.Create(
Configuration["eventStore:connectionString"],
ConnectionSettings.Create().KeepReconnecting(),
Environment.ApplicationName);
var store = new EsAggregateStore(esConnection);
var purgomalumClient = new PurgomalumClient();
services.AddSingleton(esConnection);
services.AddSingleton<IAggregateStore>(store);
services.AddSingleton(new ClassifiedAdsApplicationService(
store, new FixedCurrencyLookup()));
services.AddSingleton(new UserProfileApplicationService(
store, t => purgomalumClient.CheckForProfanity(t)));
services.AddSingleton<IHostedService, HostedService>();
services.AddMvc();
services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1",
new Info
{
Title = "ClassifiedAds",
Version = "v1"
});
});
}
在这里,我们创建了一个新的连接实例,并将其作为单例注册到服务集合中。然后,它将被注入到HostedService构造函数中,并在应用程序启动时打开它。我们还将更改IAggregateStore的注册,使其使用我们新的EsAggregateStore类。然后,我们将注册HostedService。
我们还将使用store作为应用程序服务的参数。此参数替换了我们之前使用的存储库,因此我们需要更改应用程序服务。
应用程序服务中的聚合存储
对应用程序服务的更改相当小。为了使工作更加舒适,我为IApplicationService接口创建了一个小的扩展,允许我们用一行代码处理命令。我们之前已经这样做过了,通过在每个应用程序服务中使用一个私有方法HandleUpdate。现在,由于我们使用IAggregateStore接口而不是存储库,我们可以抽象该方法,使其不依赖于特定的基础设施。因此,我们可以将其放置在Marketplace.Framework项目中。以下是代码:
using System;
using System.Threading.Tasks;
namespace Marketplace.Framework
{
public static class ApplicationServiceExtensions
{
public static async Task HandleUpdate<T, TId>(
this IApplicationService service,
IAggregateStore store, TId aggregateId,
Action<T> operation)
where T : AggregateRoot<TId>
{
var aggregate = await store.Load<T, TId>(aggregateId);
if (aggregate == null)
throw new InvalidOperationException(
$"Entity with id {aggregateId.ToString()} cannot be
found");
operation(aggregate);
await store.Save<T, TId>(aggregate);
}
}
}
然后,我们需要将应用程序服务类中的存储库依赖项替换为IAggregateStore并更改所有调用。这项工作有点无聊,我已经为你全部完成了,所以以下是ClassifiedAdApplicationService的新代码:
using System;
using System.Threading.Tasks;
using Marketplace.Domain.ClassifiedAd;
using Marketplace.Domain.Shared;
using Marketplace.Framework;
using static Marketplace.ClassifiedAd.Contracts;
namespace Marketplace.ClassifiedAd
{
public class ClassifiedAdsApplicationService : IApplicationService
{
private readonly ICurrencyLookup _currencyLookup;
private readonly IAggregateStore _store;
public ClassifiedAdsApplicationService(
IAggregateStore store, ICurrencyLookup currencyLookup
)
{
_currencyLookup = currencyLookup;
_store = store;
}
public Task Handle(object command) =>
command switch
{
V1.Create cmd =>
HandleCreate(cmd),
V1.SetTitle cmd =>
HandleUpdate(
cmd.Id,
c => c.SetTitle(
ClassifiedAdTitle
.FromString(cmd.Title)
)
),
V1.UpdateText cmd =>
HandleUpdate(
cmd.Id,
c => c.UpdateText(
ClassifiedAdText
.FromString(cmd.Text)
)
),
V1.UpdatePrice cmd =>
HandleUpdate(
cmd.Id,
c => c.UpdatePrice(
Price.FromDecimal(
cmd.Price,
cmd.Currency,
_currencyLookup
)
)
),
V1.RequestToPublish cmd =>
HandleUpdate(
cmd.Id,
c => c.RequestToPublish()
),
V1.Publish cmd =>
HandleUpdate(
cmd.Id,
c => c.Publish(new UserId(cmd.ApprovedBy))
),
_ => Task.CompletedTask
};
private async Task HandleCreate(V1.Create cmd)
{
if (await _store.Exists<Domain.ClassifiedAd.ClassifiedAd,
ClassifiedAdId>(
new ClassifiedAdId(cmd.Id)
))
throw new InvalidOperationException(
$"Entity with id {cmd.Id} already exists");
var classifiedAd = new Domain.ClassifiedAd.ClassifiedAd(
new ClassifiedAdId(cmd.Id),
new UserId(cmd.OwnerId)
);
await _store.Save<Domain.ClassifiedAd.ClassifiedAd,
ClassifiedAdId>(classifiedAd);
}
private Task HandleUpdate(
Guid id,
Action<Domain.ClassifiedAd.ClassifiedAd> update
) =>
this.HandleUpdate(
_store,
new ClassifiedAdId(id),
update
);
}
}
如您所见,更改相当小。我们调用_store.Save,由于我们不执行多个聚合的操作,因此不需要提交,因为我们没有显式的单元工作。否则,我们会违反事务边界内聚合的规则,从而没有单元工作,这不是问题。我们也没有检测更改的问题,因为我们的更改始终以事件的形式表示,我们不需要任何 ORM 魔法来找出我们需要更新什么。
按照相同的风格,以下是新的UserProfileApplicationService类:
using System;
using System.Threading.Tasks;
using Marketplace.Domain.Shared;
using Marketplace.Domain.UserProfile;
using Marketplace.Framework;
using static Marketplace.UserProfile.Contracts;
namespace Marketplace.UserProfile
{
public class UserProfileApplicationService
: IApplicationService
{
private readonly IAggregateStore _store;
private readonly CheckTextForProfanity _checkText;
public UserProfileApplicationService(
IAggregateStore store,
CheckTextForProfanity checkText
)
{
_store = store;
_checkText = checkText;
}
public Task Handle(object command) =>
command switch
{
V1.RegisterUser cmd =>
HandleCreate(cmd),
V1.UpdateUserFullName cmd =>
HandleUpdate(
cmd.UserId,
profile => profile.UpdateFullName(
FullName.FromString(cmd.FullName)
)
),
V1.UpdateUserDisplayName cmd =>
HandleUpdate(
cmd.UserId,
profile => profile.UpdateDisplayName(
DisplayName.FromString(
cmd.DisplayName,
_checkText
)
)
),
V1.UpdateUserProfilePhoto cmd =>
HandleUpdate(
cmd.UserId,
profile => profile
.UpdateProfilePhoto(
new Uri(cmd.PhotoUrl)
)
),
_ => Task.CompletedTask
};
private async Task HandleCreate(V1.RegisterUser cmd)
{
if (await _store
.Exists<Domain.UserProfile.UserProfile, UserId>(
new UserId(cmd.UserId)
))
throw new InvalidOperationException(
$"Entity with id {cmd.UserId} already exists"
);
var userProfile = new Domain.UserProfile.UserProfile(
new UserId(cmd.UserId),
FullName.FromString(cmd.FullName),
DisplayName.FromString(cmd.DisplayName, _checkText)
);
await _store
.Save<Domain.UserProfile.UserProfile, UserId>(
userProfile
);
}
private Task HandleUpdate(
Guid id,
Action<Domain.UserProfile.UserProfile> update
) =>
this.HandleUpdate(
_store,
new UserId(id),
update
);
}
}
就这样;我们不需要对事件源应用程序做任何其他操作!现在让我们看看它是如何工作的。
运行事件源应用程序
最后,我们可以尝试一些操作,看看我们如何使用我们的 API 执行命令,该 API 与第九章 Chapter 9 中的内容保持一致,即CQRS - The Read Side。然而,您可能已经注意到,查询 API 以及与读取模型相关的所有代码都没有包含在本章中。这是因为 CQRS 的读取部分与我们用于文档和关系型持久化的方式大相径庭。
当您启动应用程序并访问http://localhost:5000上的 Swagger UI 时,您将得到的屏幕与之前完全相同。当然,Event Store 必须在此期间运行,无论是作为 Docker 容器还是可执行文件。在技术要求部分描述了如何使用docker-compose运行 Event Store。我使用了两个新的 GUID 作为新的分类广告 ID 和所有者 ID,以便创建一个新的广告。因此,我调用了POST端点并得到了200 OK的结果。紧接着,我通过使用相同 ID 和一些标题文本执行rename命令来执行PUT请求。这些操作与我们之前所做的是一样的。
现在,我们可以查看我们在新存储中执行这些操作的结果。为此,我们需要通过访问http://localhost:2113上的 Event Store Web UI 并使用admin用户名和changeit密码登录。从那里,我们需要转到流浏览器页面,在右侧面板中有一个最近更改的流列表。在这个列表中,我们可以看到我们新的分类广告的新流:

这里是我们的新聚合流
您可以点击流名称来查看流包含的内容。以下是我有的内容:

流中的两个新事件
在这里,我们可以看到在我执行了两个命令之后添加到流中的两个事件。我可以通过使用 API 继续运行命令,直到广告发布。当我查看执行后的 Event Store 流时,我会看到更多添加到其中的事件:

执行更多命令时将添加更多事件
这看起来非常好。每个命令都会触发状态转换,但我们不是用新状态覆盖旧状态,而是可以看到由事件表示的完整变更历史。例如,我们可以多次更改价格,但我们总是会了解广告过去所有的价格。
现在,让我们看看一个事件看起来像什么。我将通过点击事件名称来打开编号为 1 的事件,该事件具有ClassifiedAdTitleChanged类型。以下是我在浏览器中看到的内容:

事件内容作为 JSON
如你所见,事件数据代表我们的领域事件类——它具有聚合 ID 和标题。元数据只有一个字段,我们决定用于反序列化的目的——事件类型的完全限定名(FQCN)。你可以查看其他事件的内容,看看那里存储了什么。
在每个事件中都包含聚合 ID 可能看起来有些冗余,因为流名称已经包含了 ID,而且从事件中恢复聚合状态时,我们总是只读取一个流。当我们开始构建读取模型时,我们将看到每个事件内部的这个 ID 是如何被使用的。
你还可以在用户配置文件命令 API 上执行一些命令,以查看要在具有不同名称的流中存储的不同类型的聚合。当然,现在可以向系统中添加更多广告和用户,并查看所有这些事件进入 Event Store。
恭喜;我们刚刚将我们的应用程序转换为使用事件溯源而不是更传统的持久化方式。正如你可能已经注意到的,我们不需要对我们的领域对象进行任何更改来使其工作。我们甚至可以移除聚合和值对象属性的 setter,并将这些属性设置为私有以提高封装性。这些更改都不会对使用事件存储聚合的存储和加载方式产生影响。这是因为对于这种类型的持久化,阻抗不匹配已经不存在了。我们所有的事件都是简单的、普通的对象,其属性具有原始或简单类型。这意味着这些领域事件可以轻松序列化,这就是我们为了使事件溯源工作需要确保的唯一事情。顺便说一句,Event Store 使用 JSON 序列化并不是一个要求。你当然可以使用诸如 protobuf 之类的其他东西。然而,在这种情况下,你将失去在 UI 中检查事件内容的能力,因为 UI 只理解 JSON。因此,我们使用了EventData类的IsJson属性来告诉 Event Store,我们的事件实际上是 JSON 字符串。Event Store 还集成了使用 JavaScript 执行操作的投影引擎,以便在存储中处理事件以生成新事件或运行查询。这个特性也要求事件以 JSON 格式存储,因为这是 JavaScript 代码可以轻松解释的格式。我们将在本章中不涉及投影主题,但将在第十一章投影和查询中回到这个话题。
摘要
在本章中,你学习了如何在聚合(aggregate)内部将状态转换表示为事件。我故意从开始就使用这种代码风格,尽管我可以想象这可能会让你感到一些困惑。最终,为什么你需要将每个操作拆分为Apply和When?采用这种方法是为了让读者为这一章做好准备。使用领域事件(domain events)是一种良好的实践。即使你不使用事件源(Event Sourcing),你也应该考虑使用领域事件在聚合之间,甚至在不同的边界上下文(Bounded Contexts)之间进行更新通信,并且使用领域事件进行状态转换会使它变得容易,因为你会始终有一个包含新事件的更改列表。
由于我们已经有了这个集合,我们只需要弄清楚如何将这些更改以原样存储在表示单个聚合的事件流中,并且还需要引入Load方法来遍历我们从该流中读取的所有事件,以便在我们需要在该聚合上执行新操作时恢复聚合状态。这并不难。我们使用了一些代码来了解我们的基础设施将如何工作,并且我们需要正确配置序列化。我们仍然在事件元数据中保留了 FQCN(完全限定类名),以便能够将事件反序列化为 C#对象,但我们将在未来修复它。
事件存储(Event Store)是处理事件源和存储事件流的一个非常高效的产品。与 Kafka 不同,这个产品允许我们创建数百万个流。由于我们的存储聚合的方法是将每个聚合的事件保存在单独的流中,这个解决方案非常适合我们。如果你的公司存在一些问题,比如作为数据库使用的预批准产品数量有限,并且你目前还不能使用事件存储(Event Store),你可以查看一些库,例如 SQL Stream Store (github.com/SQLStreamStore/SQLStreamStore),它在一个关系数据库上实现了事件存储,包括 Microsoft SQL Server;或者 Marten (jasperfx.github.io/marten/),它使用 PostgreSQL 的 JSONB 类型字段来实现文档数据库和事件存储类型的持久化。
在下一章中,我们将探讨查询事件源系统所面临的挑战,并通过使用单独的读取模型和投影来解决这些挑战。
到目前为止,你可以看到事件源(Event Sourcing)并不难!
进一步阅读
目前关于事件源(Event Sourcing)的文献并不多,但我可以推荐观看 Greg Young 的一些演讲,他提出了 CQRS 的概念,并将事件源(Event Sourcing)介绍给了全世界:
-
《十年的 DDD、CQRS、事件源》,作者 Greg Young,DDD Europe 2016:
www.youtube.com/watch?v=LDW0QWie21s -
事件源,格雷格·杨,GOTO 会议 2014:
www.youtube.com/watch?v=8JKjvY4etTY
如果你已经在这个主题上进行了探索,你可能已经遇到了一些关于事件源阴暗面的博客文章,这主要涉及到事件版本和最终一致性方面的问题。我们将在下一章中介绍最终一致性,并且还会简要提及事件的版本控制;对于事件版本主题的更深入探讨,请参考格雷格的书籍:
- 事件源系统中的版本控制,格雷格·杨,LeanPub 2017:
leanpub.com/esversioning
第十一章:投影和查询
在第九章,CQRS - 读取侧中,我们将应用程序改为使用事件作为一致的聚合存储。在处理命令后,我们不再更新状态快照,而是可以向表示单个聚合的事件流中添加新事件。然后,每次再次加载时,我们都可以对这些事件进行左折叠以重建聚合状态,在处理另一个命令之前。以下是用两行伪代码表示事件源本质的示例:
// Loading:
state = foreach(event in history: state = when(state, event))
// Command handling:
event = handle(state, command)
在这里,history是从聚合流中加载的内容,when是AggregateRoot.When方法,而Handle是应用程序服务中的方法之一。
但是,正如我之前提到的,为了第九章,CQRS - 读取侧,我从项目中移除了所有读取模型和与查询相关的代码。这是因为事件源系统中的查询是不同的。在本章中,我们将详细探讨这一点。到本章结束时,我们将拥有已经在第十章,事件源中实现的工作解决方案。
在本章中,我们将讨论以下主题:
-
查询事件流的问题
-
投影是什么?
-
将事件投影到文档数据库
-
将事件投影到关系数据库
-
最终一致性
事件和查询
当与对事件源技术不太熟悉的开发者讨论事件源时,我多次听到的一个说法是事件源不适合用于报告。让我们首先定义一下什么是报告。通常,我们认为它是在需要时从数据库中检索系统状态的能力,使用过滤和分组,以最小的延迟。关系数据库非常适合这个目的,这也是关系数据库最初被发明的主要原因。如果你年纪足够大,你可能还记得 20 世纪 90 年代中期围绕对象数据库(ODBMSes,即面向对象数据库管理系统)的短暂炒作。还有什么比将整个对象存储到数据库中更好,而不必担心阻抗不匹配呢?在一个主要由各种关系数据库管理系统(RDBMS)主导的世界里,很难接受第一个用于马萨诸塞州总医院多编程系统(MUMPS或M)的对象数据库是在 1966 年创建的事实。然而,IBM 的 System R 关系数据库的第一个原型直到 1974 年才开始工作,而第一个通用的 RDBMS 是由 Oracle 创建的,于 1979 年发布。正是在同一年,InterSystems 的 M 数据库问世。然后,在接下来的几十年里,InterSystems 成为对象数据库的主要供应商,并在 20 世纪 90 年代末发布了 Caché,它仍然基于 MUMPS 的许多设计理念。
那么,为什么对象数据库至今没有主导世界呢?对此有许多观点,但我们可以肯定的是,对象数据库并没有针对查询大量数据进行优化。这类数据库中的索引要么是自动的,要么是基于客户端的,无法应对更大的数据集。对象数据库处理写操作非常出色,但并不真正能够执行高效的查询。尽管如此,我必须承认,我并不是对象数据库的专家,这里的观点可能只是推测。当然,另一个明显的理由是,在 20 世纪,磁盘空间确实是一个大问题。关系数据库的第三范式肯定有助于节省宝贵的空间。请注意,文档数据库的复兴,它通过将整个对象图存储为单个文档来承载与对象数据库类似的思想,只有在数据重复不再是问题时,才因为计算能力的提升和存储成本的降低而成为可能。
CQRS 获得动力的主要原因是因为由于读写操作优化技术的严重差异,迫切需要分别处理读和写。对于第三范式的关系数据库,写操作容易而读操作困难。这样的数据库模式几乎无法应对重大的事务负载,同时保持对复杂查询的响应能力。
同样的问题也适用于那些将事件作为真相来源的系统,通过以追加方式存储业务定义的事件。在这样的系统中,正如我们在第十章“事件溯源”中讨论的那样,我们无法直接访问系统状态。为了获取系统中任何对象的状态,我们必须读取表示该特定对象的流中的所有事件,并将该流中的所有事件应用到空对象上,以便事件能够通过状态转换的逻辑流动。基本上,每次我们读取对象时,它就像是在重生。现在,想象一下,如果我们需要查询几千个或几十万个这样的对象。这意味着我们可能需要在询问任何跨越包含多个对象的数据库集的信息之前,将整个系统加载到内存中。这样的系统肯定无法正常工作。
这就是为什么 CQRS 几乎可以在每个事件源系统中找到。当然,有些人称之为事件源的系统,它们在更新系统状态的同时,在另一个数据库(例如关系数据库)中持续更新系统状态,同时生成系统事件。有时,这发生在内存中,或者如果事件存储在系统状态快照相同的数据库中,则在一个事务中。在这样的系统中,你通常会发现在实际上几乎不使用事件。应用程序服务不会从事件中加载对象,而是直接从快照数据库中获取最新的对象状态。在这样一个系统中说事件是真相的来源有点牵强,因为我们不会查看这种场景。同时,你将从本章中学到的技术听起来可能非常相似,我会尽力解释核心差异。
从事件构建读取模型
我们已经从第十章中熟悉了读取模型,即事件溯源。我们了解到,读取-写入模型对于许多我们构建的系统来说是常见的,有时使用不同的模型来持久化系统状态和检索我们需要在屏幕上显示或通过 API 提供给其他方的数据是有益的。对于事件源系统,我们必须使用不同的模型,因为,如前所述,事件流并没有针对检索系统当前状态并对其应用过滤器进行优化。
因此,我们将在其他地方创建我们的系统读取模型;例如,在支持此类查询的数据存储中。在这里,我们可以自由选择使用什么。我们可以使用文档数据库、关系数据库,甚至文件系统,或者上述所有方法的组合。然而,我们如何构建这样的读取模型呢?好吧,我们已经定义了我们的系统状态是从我们存储的所有事件中派生出来的。这可能会给我们这样的想法,即我们还需要使用事件来构建读取模型。我们将使用投影从我们的系统产生的事件流中推导出读取模型的状态。
投影
在关系代数中,投影是一个一元运算,表示为
,其中
是一个元组,而
是
的属性名。当执行此运算时,它返回一个只包含指定属性的集合,并丢弃所有其他属性。
如果这听起来太复杂,我们可以将投影明确表示为 SQL 查询。考虑一个具有以下结构和数据的People表:
| Id | FirstName | LastName | City | Country |
|---|---|---|---|---|
1 |
John |
Smith |
Bristol |
United Kingdom |
2 |
Jorrit |
Bramsma |
Eindhoven |
The Netherlands |
3 |
Jan Tore |
Rosendal |
Alta |
Norway |
如果我们执行SELECT FirstName, LastName FROM People查询,我们实际上执行了一个投影。我们指定了要包含在结果集中的两个属性——FirstName和LastName,因此我们得到以下结果:
| 首名 | 姓氏 |
|---|---|
John |
Smith |
Jorrit |
Bramsma |
Jan Tore |
Rosendal |
所有其他属性都被丢弃。请注意,我们在查询中不包括任何过滤。过滤被称为选择,确实,在大多数情况下,SQL 查询结合了投影和选择,以生成我们感兴趣的一组简洁数据。您可能已经注意到,我们在第十章“事件溯源”中使用了投影,当我们从整个系统状态的大数据集中检索属性子集以用于查询时。
从事件构建状态片段的过程也被称为投影,尽管我们无法说它在一个单集中操作,在那里我们选择要投影的属性数量。然而,我们需要投影整个事件流的一个子集。我们的读取模型也需要尽可能快地更新,但我们只提交事件到存储。这意味着我们需要在事件提交后立即读取所有这些事件并将它们投影。通常,这是通过轮询事件存储或使用存储支持的实时订阅来完成的。
使用订阅和投影从事件构建读取模型的过程可以用以下图表来说明:

读取模型的命令流
当我们执行一个命令时,应用服务会从聚合流中完全加载我们的聚合。然后,聚合生成一个新的事件(或多个事件),代表聚合的状态转换。这些事件被提交到存储中,因此存储将它们追加到聚合流的末尾。订阅会接收这些事件并更新其读取模型。
这是我们聚合类的代码:
protected override void When(object @event)
{
switch (@event)
{
case Events.UserRegistered e:
Id = new UserId(e.UserId);
FullName = new FullName(e.FullName);
DisplayName = new DisplayName(e.DisplayName);
break;
case Events.UserFullNameUpdated e:
FullName = new FullName(e.FullName);
break;
case Events.UserDisplayNameUpdated e:
DisplayName = new DisplayName(e.DisplayName);
break;
case Events.ProfilePhotoUploaded e:
PhotoUrl = e.PhotoUrl;
break;
}
}
在这里,我们通过每个新事件更新我们的UserProfile聚合的状态。现在,是时候坦白一下了——这同样也是一个投影。在这段代码中,我们将When方法接收的事件投影到更新我们的UserProfile对象的属性。关于投影,没有更多要说的,我可以结束这一章了!
开个玩笑,我们还有很多工作要做。首先,我们需要弄清楚我们的读取模型投影将如何接收新事件。
订阅
想象一下,我们的用户正在查看一个屏幕,他们可以看到我们市场的用户界面。我们已经知道如何让人们执行操作。当用户做某事时,我们向 API 发送 POST 或 PUT 请求。然后,API 控制器调用应用程序服务,并执行命令。结果可能是对完成命令的 200 OK 响应,或者如果出现问题,则返回错误。然而,与单数据库系统不同,在那里我们查询的数据是我们执行命令的数据,对于事件源应用程序来说,情况并非如此。我们的读取模型很可能位于不同的数据库中。这个事实使得我们所有的查询最终都是一致的。我们将在本书的后续章节中讨论这个主题,当我们探索更多关于事件源的高级主题时。
现在,我们需要理解我们的目标将是最小化事件附加到流和读取模型更新之间的时间间隔。在这两个操作之间,我们向用户展示的数据是过时的。过时并不意味着不一致,它只是不是完全最新的,经过一小段时间的延迟后,查询最终会返回更多实际数据。注意差距!
为了最小化时间间隔,我们需要确保我们的投影能够实时接收新事件。Event Store 可以在这里提供帮助,因为它有一个非常不错的订阅功能。在 Event Store 中有两种类型的订阅——追赶订阅和持久订阅,也称为竞争消费者。主要区别是检查点的所有权。在这一章中,我还要向你介绍一个新术语!
检查点是在流中的特定位置。当投影处理了一个事件后,它可以存储检查点,所以如果投影被重新启动,它将知道从哪里开始处理,而不是从生命的开始就投影所有事件。检查点的概念在所有处理实时事件处理的系统中都是众所周知的,例如 Kafka 或 Azure Event Hub。
如果你决定使用其他产品来存储你的事件,你需要弄清楚是否可以对该存储进行实时或几乎实时的订阅,以及你可以使用什么作为检查点。例如,你可以使用 SQL Server 表来存储事件,并使用自动增长的唯一主键作为流位置。然后,你可以持续轮询这个表以获取新事件,通过这样做,你将拥有一个工作的订阅。
检查点是读取模型独有的。我引用了前面UserProfile.When方法的代码,并提到它也执行投影的工作。虽然这是真的,但对于聚合实例,当我们从存储中读取聚合流并在执行命令之前读取聚合流时,When方法会为单个聚合的所有事件执行。再次强调,我们读取单个流中的所有事件,并为从存储中获取的每个事件调用When方法。这并不难。然而,投影会持续更新它们的模型。
我们不能允许自己在每次更新时都从整个存储中读取所有事件,因为这会违背拥有读取模型的目的。让我们看看读取模型是如何监听存储中到来新事件的:

读取模型通过新事件进行更新
例如,如果我们的“我的广告”****投影接收到一个AdRenamed事件并开始更新其读取模型,会发生一些事情——比如网络故障、数据库故障,或者有人关闭了运行投影的机器的电源。在问题解决后,投影本身需要找出它需要从哪个位置开始读取事件以继续更新其读取模型。对于我刚才描述的情况,我们需要在成功投影AdPublished事件后,将数字3保存在某个地方。因此,当我们的服务重新启动并且投影启动时,它需要从事件编号4开始读取,忽略之前发生的一切。通过存储这个数字,我们正在建立一个检查点。我们的投影负责将其自己的检查点保存在某个地方。只有当新事件成功投影后,检查点才会更新,所以我们保证每个事件至少被投影一次。
存储检查点有两种方式——通过基于客户端的检查点或基于服务器的检查点。我们可以逻辑上得出结论,基于客户端的检查点由客户端(订阅)维护和存储,而在使用基于服务器的变体时,事件存储负责这项工作。
在 Apache Kafka 中,术语偏移量用于相同的概念,并且默认情况下,偏移量由服务器维护。
基于服务器的检查点功能使我们能够运行多个事件消费者(在我们的例子中是投影)的实例,每个消费者都将获得事件的一部分。这个概念在消息传递世界中广为人知。所有消息代理都支持类似的模式,这被称为竞争消费者。这种模式允许我们轻松扩展处理消息(或事件)的过程,但这里的主要问题是无法保证竞争消费者处理消息的顺序。这很容易解释。消息处理时间无法完全预测,因为网络中总是会发生一些故障,甚至在与其他进程共享计算和磁盘资源的机器上。因此,处理一条消息所需的时间在不同消费者之间可能会有所不同,甚至在相同消费者对相同消息的处理中也是如此。如果我们有多个竞争消息的消费者,我们几乎肯定会遇到一种情况,其中一个消费者已经完成处理事件 E[n],而另一个消费者仍在忙于处理事件 E[n+1]。然后,空闲的消费者在处理 E[n+1]事件甚至尚未完成之前就开始处理 E[n+2]事件。显然,这里没有顺序保证。对于投影来说,按顺序处理事件至关重要。如果连续执行两次重命名,我们希望第二个更新在第一个更新之后应用于读取模型,没有任何例外。然而,在使用服务器维护的检查点时,没有必要使用竞争消费者。如果您只有一个持久订阅的单一订阅者,它将按顺序接收事件。
当客户端维护自己的检查点时,事情变得既容易又困难。如果我们控制检查点,我们可以轻松地重置或移动它。对于投影,这意味着我们可以通过重置检查点并删除现有数据来轻松重建读取模型。这个过程被称为重放,实际上,这是事件源中最强大的功能之一。更困难的部分是我们需要将检查点存储在某个地方,并且每次投影处理新事件时都要更新它。这里的最佳实践是将检查点存储在与读取模型本身相同的地方(即,数据库)。记住关于重放的事情吗?如果我们杀死了存储我们的读取模型的数据库,检查点将立即消失;如果我们再次运行投影,它将从头开始处理事件,最终,读取模型将从零开始重建!
因此,在这本书中,我们将使用由客户端维护的实时订阅和检查点。在事件存储中,这种订阅被称为追赶订阅。为什么叫追赶订阅?很容易猜到。还记得重放吗?不,我不是在重复自己。如果我们想要或需要重建读取模型并删除其数据和检查点,投影将订阅事件流,从零位置开始。直到它处理完所有历史事件,它将不会消费任何新事件。只有当投影最终赶上流末尾时,我们才能将其切换到实时处理新事件。事件存储会自动完成这项工作,这就是为什么我们称之为追赶订阅。使用这种类型的订阅,我们可以为已经运行了一段时间并且有很多事件的系统添加新的投影和构建新的读取模型。我们的新投影将赶上所有这些历史事件,切换到实时处理,从那时起,我们的新读取模型将可用。
实现投影
现在是时候开始编写一些代码了。我们将使用第九章的最终代码,CQRS - 读取侧作为起点。这一章的最终代码位于 GitHub 仓库的Chapter11文件夹中。
我们将一步一步来,首先实现一个订阅,以便了解在事件存储中订阅是如何工作的。然后,我们将使用 RavenDB 和 PostgreSQL 创建几个真实的读取模型。
追赶订阅
实际上,要开始制作投影,我们不需要任何数据库。我将向您展示一个简单的技巧,它允许您快速进行初始开发,而不必考虑将用于存储读取模型的数据库引擎。我们往往真的不知道现实生活会带来什么,我们最初计划使用的数据库引擎可能甚至不适合我们想要完成的任务。在任何项目的早期阶段,我们处理的事件数量并不大,除非我们想在系统中进行一些合成测试,或者我们正在处理一个高频事件处理系统。在我们的情况下,我们处理的是一个相当简单的分类广告网站,所以我们不期望在开发环境中以及我们希望向产品所有者和 QA 展示的系统中有很多事件。
我们能做些什么来使事情保持非常简单?嗯,如果我们的系统的事实来源在事件存储中,为什么我们需要在某个地方持久化读取模型?将它们保存在内存中并在每次应用程序启动时重建是完全可行的。这正是我们打算做的。
首先,我想使用第九章,CQRS - 读取侧的代码作为起点。记住,它没有查询和读取模型。为了引入这些内容,我从第十章,事件溯源中复制了一些代码文件,并进行了最小修改以减少工作量。因此,我只从主应用程序项目的ClassifiedAd文件夹中提取读取模型和查询。然后,我从Queries文件中删除了一些查询,因为我最初只想实现一个。我的新Queries类现在看起来如下所示:
using System.Collections.Generic;
using System.Linq;
namespace Marketplace.ClassifiedAd
{
public static class Queries
{
public static ReadModels.ClassifiedAdDetails Query(
this IEnumerable<ReadModels.ClassifiedAdDetails> items,
QueryModels.GetPublicClassifiedAd query)
=> items.FirstOrDefault(
x => x.ClassifiedAdId == query.ClassifiedAdId);
}
}
您可以看到,我这里没有使用任何数据库连接。相反,扩展方法被应用于一个简单的IEnumerable,这意味着我将使用内存中的项目集合。
然后,我还需要移除不会使用的 API 端点,只保留其中一个。此外,我需要将数据库连接替换为IEnumerable:
using System.Collections.Generic;
using Marketplace.Infrastructure;
using Microsoft.AspNetCore.Mvc;
using Serilog;
namespace Marketplace.ClassifiedAd
{
[Route("/ad")]
public class ClassifiedAdsQueryApi : Controller
{
private static ILogger _log =
Log.ForContext<ClassifiedAdsQueryApi>();
private readonly IEnumerable<ReadModels.ClassifiedAdDetails>
_items;
public ClassifiedAdsQueryApi(
IEnumerable <ReadModels.ClassifiedAdDetails> items) =>
_items = items;
[HttpGet]
public IActionResult Get(
QueryModels.GetPublicClassifiedAd request) =>
RequestHandler.HandleQuery(() => _items.Query(request),
_log);
}
}
注意到Get方法不再async了,因为集合上的操作是同步的。当然,当我们引入适当的持久化时,我们需要将async重新引入。由于这个变化,我还需要更改RequestHandler.HandleQuery方法,使其接受Action而不是Func,因为我们现在不需要返回Task:
public static IActionResult HandleQuery<TModel>(
Func<TModel> query, ILogger log)
{
try
{
return new OkObjectResult(query());
}
catch (Exception e)
{
log.Error(e, "Error handling the query");
return new BadRequestObjectResult(new
{
error = e.Message, stackTrace = e.StackTrace
});
}
}
从第九章,CQRS - 读取侧,我们有EsAggregateStore类。它有一些代码帮助我们反序列化来自事件存储的已解析事件。我们还需要在我们的投影中执行相同的操作。因此,我将这段代码提取到一个扩展方法中,用于ResolvedEvent类,这样我们就可以在投影中使用相同的代码:
using System;
using System.Text;
using EventStore.ClientAPI;
using Newtonsoft.Json;
namespace Marketplace.Infrastructure
{
public static class EventDeserializer
{
public static object Deserialzie(this ResolvedEvent
resolvedEvent)
{
var meta = JsonConvert.DeserializeObject<EventMetadata>(
Encoding.UTF8.GetString(resolvedEvent.Event.Metadata));
var dataType = Type.GetType(meta.ClrType);
var jsonData = Encoding.UTF8.GetString(
resolvedEvent.Event.Data);
var data = JsonConvert.DeserializeObject(
jsonData, dataType);
return data;
}
}
}
如您所记,我们在事件元数据中保留事件类的完全限定类名(FQCN),EventDeserialzier使用它来获取我们的领域事件。我还从EsAggregateStore中移除了这段代码,以避免重复,因此Load方法将如下所示:
public async Task<T> Load<T, TId>(TId aggregateId)
where T : AggregateRoot<TId>
{
if (aggregateId == null)
throw new ArgumentNullException(nameof(aggregateId));
var stream = GetStreamName<T, TId>(aggregateId);
var aggregate = (T) Activator.CreateInstance(typeof(T), true);
var page = await _connection.ReadStreamEventsForwardAsync(
stream, 0, 1024, false);
aggregate.Load(page.Events.Select(resolvedEvent =>
resolvedEvent.Deserialzie()).ToArray());
return aggregate;
}
现在,让我们进行实际的订阅。它将存在于一个新类中,我将将其放置在infrastructure文件夹中。它将处理单个读取模型,因此它不是通用的,实际上也不应该放在那里。然而,这个解决方案不是永久的,我们稍后会对其进行改进。
对于这个类,我需要一个IEventStoreConnection的实例,这样我才能创建对其的订阅。我还需要对读取模型集合的引用,这样我就可以在其中放置项目并更改现有项目的属性。该类将有两个简单的方法——Start和Stop。Start方法创建一个新的订阅。事件将立即开始到来,因此我必须先启动连接才能订阅它。以下是Start方法的代码:
public void Start()
{
var settings = new CatchUpSubscriptionSettings(2000, 500,
Log.IsEnabled(LogEventLevel.Verbose),
true, "try-out-subscription");
_subscription = _connection.SubscribeToAllFrom(Position.Start,
settings, EventAppeared);
}
这里有一些硬编码的值,我们稍后将从应用程序配置中读取。重要的行是我们订阅的地方。我使用的方法是SubscribeToAllFrom。这个方法创建了一个订阅,将获取所有保存在事件存储中的事件。第一个参数是订阅的起始位置。由于我们的读取模型没有持久化,并且每次应用程序启动时我们都会从头开始重建它,我们必须从开始读取,因为这就是为什么参数是Position.Start。最后一个参数是一个委托,它将为每个我们从订阅中接收的事件被调用;我们稍后会回到它。
Stop方法非常简单,它只是停止订阅,如下所示:
public void Stop() => _subscription.Stop();
现在,让我们为EventAppeared方法编写一些代码。在那里,我们将构建我们的读取模型,正如我之前提到的,代码将以非常相似的方式处理我们在聚合体的When方法中处理的相同领域事件。
在我们能够使用高级模式匹配之前,我们需要从ResolvedEvent类实例中获取领域事件,这个实例是EventAppeared方法接收的参数。以下是这个方法的开头部分:
private Task EventAppeared(EventStoreCatchUpSubscription subscription, ResolvedEvent resolvedEvent)
{
var @event = resolvedEvent.Deserialzie();
switch (@event)
{
case Events.ClassifiedAdCreated e:
_items.Add(new ReadModels.ClassifiedAdDetails
{
ClassifiedAdId = e.Id
});
break;
case Events.ClassifiedAdTitleChanged e:
UpdateItem(e.Id, ad => ad.Title = e.Title);
break;
}
return Task.CompletedTask;
}
第一行将获取领域事件,然后我们使用模式匹配在读取模型中进行必要的更改。第一种情况将为每个新的分类广告创建一个新的读取模型,第二种情况将更新标题。
这段代码是好的,但不会工作,因为我之前提到,当我们使用SubscribeToAllFrom进行订阅时,我们将获取所有事件。这些事件来自$all流。但事件存储使用事件进行其内部操作,因此我们也会收到很多这种类型的事件。幸运的是,我们可以通过resolvedEvent.Event.EventType属性的值轻松识别我们不需要的事件。所有具有stat事件类型的技术事件都以美元符号($)开头,因此我们可以过滤掉它们。
对于这个类来说,还有一个需要注意的最后一点,我使用UpdateItem方法来简化现有项目的更新。
下面是EsSubscription类的完整代码:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using EventStore.ClientAPI;
using Marketplace.ClassifiedAd;
using Marketplace.Domain.ClassifiedAd;
using Serilog.Events;
using ILogger = Serilog.ILogger;
namespace Marketplace.Infrastructure
{
public class EsSubscription
{
private static readonly ILogger Log =
Serilog.Log.ForContext<EsSubscription>();
private readonly IEventStoreConnection _connection;
private readonly IList<ReadModels.ClassifiedAdDetails> _items;
private EventStoreAllCatchUpSubscription _subscription;
public EsSubscription(IEventStoreConnection connection,
IList<ReadModels.ClassifiedAdDetails> items)
{
_connection = connection;
_items = items;
}
public void Start()
{
var settings = new CatchUpSubscriptionSettings(2000, 500,
Log.IsEnabled(LogEventLevel.Verbose),
true, "try-out-subscription");
_subscription = _connection.SubscribeToAllFrom(
Position.Start, settings, EventAppeared);
}
private Task EventAppeared(
EventStoreCatchUpSubscription
subscription, ResolvedEvent resolvedEvent)
{
if (resolvedEvent.Event.EventType.StartsWith("$"))
return Task.CompletedTask;
var @event = resolvedEvent.Deserialzie();
Log.Debug("Projecting event {type}",
@event.GetType().Name);
switch (@event)
{
case Events.ClassifiedAdCreated e:
_items.Add(new ReadModels.ClassifiedAdDetails
{
ClassifiedAdId = e.Id
});
break;
case Events.ClassifiedAdTitleChanged e:
UpdateItem(e.Id, ad => ad.Title = e.Title);
break;
case Events.ClassifiedAdTextUpdated e:
UpdateItem(e.Id, ad => ad.Description = e.AdText);
break;
case Events.ClassifiedAdPriceUpdated e:
UpdateItem(e.Id, ad =>
{
ad.Price = e.Price;
ad.CurrencyCode = e.CurrencyCode;
});
break;
}
return Task.CompletedTask;
}
private void UpdateItem(Guid id,
Action<ReadModels.ClassifiedAdDetails> update)
{
var item = _items.FirstOrDefault(
x => x.ClassifiedAdId == id);
if (item == null) return;
update(item);
}
public void Stop() => _subscription.Stop();
}
}
理想情况下,我们需要将反序列化调用包裹在try-catch块中,因为我们可能会遇到一些我们不知道的事件,这会导致我们的投影不优雅地中断。但同样,我们稍后会在代码中做出很多更改。让我们继续到连接部分,尝试一下。
我们必须确保我们的订阅在事件存储连接确实连接到存储之后开始。目前,这个操作发生在HostedService类中。由于它只处理事件存储连接业务,我将它重命名为EventStoreService。我还向其构造函数添加了EsSubscription实例,这样我们就可以在连接后立即开始订阅。在这里,你可以看到这个类在所有这些更改之后的样子:
using System.Threading;
using System.Threading.Tasks;
using EventStore.ClientAPI;
using Marketplace.Infrastructure;
using Microsoft.Extensions.Hosting;
namespace Marketplace
{
public class EventStoreService : IHostedService
{
private readonly IEventStoreConnection _esConnection;
private readonly EsSubscription _subscription;
public EventStoreService(
IEventStoreConnection esConnection,
EsSubscription subscription)
{
_esConnection = esConnection;
_subscription = subscription;
}
public async Task StartAsync(
CancellationToken cancellationToken)
{
await _esConnection.ConnectAsync();
_subscription.Start();
}
public Task StopAsync(CancellationToken cancellationToken)
{
_subscription.Stop();
_esConnection.Close();
return Task.CompletedTask;
}
}
}
在Startup类中,我需要更改注册并创建我们的模拟存储实例(记住,那只是一个集合)。所以我更改了注册托管服务的代码,并在Startup.ConfigureServices方法的那个代码块中添加了几行:
var items = new List<ReadModels.ClassifiedAdDetails>();
services.AddSingleton<IEnumerable<ReadModels.ClassifiedAdDetails>>(items);
var subscription = new EsSubscription(esConnection, items);
services.AddSingleton<IHostedService>(
new EventStoreService(esConnection, subscription));
我们需要注册项目集合,因为我们的查询 API 控制器需要服务提供者通过构造函数参数将其注入。
在第九章,CQRS - 读取侧中,我们还没有做的事情是,尽管我们使用了 Serilog 进行所有日志记录,但在章节代码中它从未被初始化。那时我们实际上没有什么可以记录的,但现在看看我们能记录什么将会很有趣。所以,我在Program.Main方法的开始处添加了几行代码:
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Debug()
.WriteTo.Console()
.CreateLogger();
好了,就是这样。现在,如果你从第九章,CQRS - 读取侧开始运行相同的docker-compose,并且其中有一些数据(如果你按照第九章,CQRS - 读取侧的代码进行操作,你应该有这些数据),你可以运行应用程序,它将产生一种调试信息,如下所示:
Hosting environment: Development
Content root path: /~/Dev/ddd-book/chapter11/Marketplace/bin/Debug/netcoreapp2.1
Now listening on: http://localhost:5000
Application started. Press Ctrl+C to shut down.
[21:00:17 DBG] Projecting event ClassifiedAdCreated
[21:00:17 DBG] Projecting event ClassifiedAdTitleChanged
[21:00:17 DBG] Projecting event ClassifiedAdTextUpdated
[21:00:17 DBG] Projecting event ClassifiedAdPriceUpdated
[21:00:17 DBG] Projecting event ClassidiedAdSentForReview
[21:00:17 DBG] Projecting event ClassifiedAdPublished
[21:00:48 DBG] Projecting event ClassifiedAdCreated
[21:00:48 DBG] Projecting event ClassifiedAdTitleChanged
[21:00:48 DBG] Projecting event ClassifiedAdTextUpdated
[21:00:48 DBG] Projecting event ClassifiedAdPriceUpdated
[21:00:48 DBG] Projecting event ClassidiedAdSentForReview
[21:00:48 DBG] Projecting event ClassifiedAdPublished
[21:00:48 DBG] Projecting event ClassifiedAdCreated
[21:00:48 DBG] Projecting event ClassidiedAdSentForReview
[21:00:48 DBG] Projecting event ClassifiedAdTitleChanged
[21:00:48 DBG] Projecting event ClassifiedAdPublished
[21:00:48 DBG] Projecting event ClassifiedAdPriceUpdated
[21:00:48 DBG] Projecting event ClassifiedAdCreated
[21:00:48 DBG] Projecting event ClassifiedAdTextUpdated
如果你曾经与第十章,事件溯源相关的代码一起工作了一段时间,并且事件存储已经运行了好多天,你可能会注意到属于不同分类广告的事件之间存在一些延迟。这仅仅是因为所有这些技术事件都是由事件存储持续产生的,我们接收到了所有这些事件。我们忽略这些事件,但我们仍然需要读取它们,到达应用程序,检查名称等等。这需要时间。在先前的调试输出中,你可以看到大约 30 秒的延迟,这意味着我让事件存储在我的机器上 24/7 运行,我在第九章,CQRS - 读取侧上花费了一些时间!我们并不真的希望有这种延迟,幸运的是,事件存储为这些技术事件设置了一个小的生存时间(TTL)值。这意味着理论上,大多数应该被删除。然而,标记为删除的事件,直到我们执行清理操作之前都不会被清理。这是因为实时删除事件会对性能产生重大影响。可以通过访问管理员界面并按屏幕右上角的清理按钮来从事件存储 UI 启动清理。在我清理了我的本地存储之后,延迟减少到了六秒。但不要误会,这只会发生一次,当你启动应用程序时。所有新的事件都将立即投影。
现在,我们可以测试我们的GET查询端点,看看投影是否真正起作用。我将使用我在第九章,CQRS - 读取侧工作时创建的分类广告。以下是 Swagger 中的查询结果:

查询从真实模型检索数据
第一个投影成功了!然而,我们可以看到,我们只投影了分类广告事件,因此卖家的显示名称为空。我们将很快修复这个问题。
跨聚合投影
当人们开始尝试事件溯源时,经常会犯的一个错误是开发者尽可能快地试图进入他们聚合持久状态的舒适区。因此,许多人将读取模型视为在可查询存储中保持聚合状态可访问的一种方式。我过去也做过同样的事情,所以你可以相信我。为了在某个数据库中保持聚合状态,我使用了事件存储的一个不错特性——内部投影。你可以通过访问事件存储 Web UI 的投影页面来查看可用的内部投影列表。其中之一是$by-category,它将所有事件链接到特殊的类别流中。例如,$ce-ClassifiedAd将包含ClassifiedAd聚合的所有事件。你可以通过访问http://localhost:2113/web/index.html#/streams/$ce-ClassifiedAd来自行检查它(你需要确保$by-category投影正在运行)。通过为这个流创建一个订阅,例如,你可以构建一个聚合快照。然而,快照不是读取模型,通常不应作为真实模型使用。
读取模型总是服务于某个目的。当我们查看第九章《CQRS - 读取侧》中的 CQRS 时,我们正在设计读取模型来回答特定的查询。通常,我们需要屏幕上的某些信息,因此我们创建一个读取模型,通过一个单一的查询获取所有所需的信息。没有 CQRS,我们可能需要调用几个查询,这些查询会从存储库检索几种不同类型的实体的状态,并在 API 后端将信息组合成一个单一的响应 DTO。当我们使用后端为前端(BFF)方法时,这是一种常见的策略。然而,有了 CQRS,我们可以自由选择查询哪些信息,只要查询只与单个数据库中的实体一起工作。当我们讨论边界上下文和微服务主题时,我们将探讨系统成为多个自主子系统组合的更复杂场景。
从两个聚合中投影事件
现在,让我们考虑创建类似于我们在第十章《事件溯源》中拥有的读取模型。我们已经开始了其中之一,正如你在前面的 Swagger 截图中所看到的,我们得到的响应中的卖家名称为空。这是因为我们处理投影中的唯一事件是ClassifiedAd事件。然而,我们的存储包含整个应用程序的所有事件。
由于我们使用IEventStoreConnection接口的SubscribeFromAllFrom方法,我们的投影将接收所有事件。我们目前过滤我们的系统事件,因此我们应该得到所有其他事件,包括UserProfile聚合的事件。在模式匹配开关中添加一个额外的case来处理UserDisplayNameUpdated事件并正确设置ReadModels属性似乎很简单。这似乎是合理的,所以当所有者更新他们的显示名称时,我们的读取模型也会得到更新。
这里有一个问题是我们不能给UpdateItem方法任何 ID。当用户更新他们的显示名称时,这个动作与任何分类广告都没有关联。这意味着我们需要运行一个查询并更新所有所有者 ID 是更改名称的用户 ID 的广告。这项任务并不难,因此我们可以添加一个名为UpdateMultipleItems的额外方法,并给它一个查询和一个操作,该操作将对查询返回的每个项目执行:
private void UpdateMultipleItems(
Func<ReadModels.ClassifiedAdDetails, bool> query,
Action<ReadModels.ClassifiedAdDetails> update)
{
foreach (var item in _items.Where(query))
update(item);
}
我们可以轻松指定操作,但查询应该是什么?我们的读取模型不包含所有者 ID!嗯,我们可以通过向读取模型添加一个额外的属性来轻松解决这个问题,称为SellerId,我们将从OwnerId分配它。在我们的系统中没有更改广告所有者的方法,所以只有在广告创建时进行此分配是安全的。
投影的新代码将如下所示:
private Task EventAppeared(EventStoreCatchUpSubscription subscription,
ResolvedEvent resolvedEvent)
{
if (resolvedEvent.Event.EventType.StartsWith("$"))
return Task.CompletedTask;
var @event = resolvedEvent.Deserialzie();
Log.Debug("Projecting event {type}", @event.GetType().Name);
switch (@event)
{
case Events.ClassifiedAdCreated e:
_items.Add(new ReadModels.ClassifiedAdDetails
{
ClassifiedAdId = e.Id,
SellerId = e.OwnerId
});
break;
case Events.ClassifiedAdTitleChanged e:
UpdateItem(e.Id, ad => ad.Title = e.Title);
break;
case Events.ClassifiedAdTextUpdated e:
UpdateItem(e.Id, ad => ad.Description = e.AdText);
break;
case Events.ClassifiedAdPriceUpdated e:
UpdateItem(e.Id, ad =>
{
ad.Price = e.Price;
ad.CurrencyCode = e.CurrencyCode;
});
break;
case Domain.UserProfile.Events.UserDisplayNameUpdated e:
UpdateMultipleItems(x => x.SellerId == e.UserId,
x => x.SellersDisplayName = e.DisplayName);
break;
}
return Task.CompletedTask;
}
当然,有一件事要记住的是查询在真实存储中的效率。由于我们使用的是简单的内存列表,这不是一个问题。在现实中,一个人不会有数百万个分类广告,所以相同的查询也会起作用,但我们还需要记住,RavenDB 只支持每个会话有限的操作数,所以如果预期通过此查询更新数千个项目,可能需要使用高级技术。
你在那里看到另一个问题吗?当然,用户并不经常更新他们的名字。我实际上不记得自从我出生以来改变过我的名字。我确实更新了我使用的一些在线服务的无数个个人资料,但通常我只做一次,尤其是在更改名字的时候。对于我们的分类广告,所有者在广告创建后和删除前更新名字的可能性几乎为零。那么,我们能做什么呢?
每个订阅多个投影
首先,似乎我们需要构建另一个投影来处理UserProfile聚合的事件,并从它们构建一个简单的读取模型。我们可以使用相同的存储,并暂时将所有内容保存在内存中。由于我们将有两个投影,因此将事物分开并让我们的订阅处理多个投影是有意义的。
由于我们将有一个可以处理多个投影的订阅,我们可以将我们的EsSubscription类重命名为ProjectionsManager。它需要接受投影作为参数,并且最好将它们保存在单独的类中,因此我们需要一个简单的接口。我们可以称它为IProjection并将文件放置在Marketplace.Framework项目中,如下所示:
using System.Threading.Tasks;
namespace Marketplace.Framework
{
public interface IProjection
{
Task Project(object @event);
}
}
然后,我们需要将模式匹配代码移动到这个接口的新实现中。将投影分组在一起会更好,因此我在Marketplace项目中创建了一个名为Projections的新文件夹,并在其中添加了一个新的ClassifiedAdDetailsProjection类。之后,我将代码从EsSubscription.EventAppeared方法移动到这个新类中:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Marketplace.Domain.ClassifiedAd;
using Marketplace.Framework;
namespace Marketplace.Projections
{
public class ClassifiedAdDetailsProjection : IProjection
{
private List<ReadModels.ClassifiedAdDetails> _items;
public ClassifiedAdDetailsProjection(List<ReadModels.
ClassifiedAdDetails> items)
{
_items = items;
}
public Task Project(object @event)
{
switch (@event)
{
case Events.ClassifiedAdCreated e:
_items.Add(new ReadModels.ClassifiedAdDetails
{
ClassifiedAdId = e.Id,
SellerId = e.OwnerId
});
break;
case Events.ClassifiedAdTitleChanged e:
UpdateItem(e.Id, ad => ad.Title = e.Title);
break;
case Events.ClassifiedAdTextUpdated e:
UpdateItem(e.Id, ad => ad.Description = e.AdText);
break;
case Events.ClassifiedAdPriceUpdated e:
UpdateItem(e.Id, ad =>
{
ad.Price = e.Price;
ad.CurrencyCode = e.CurrencyCode;
});
break;
case Domain.UserProfile.Events.UserDisplayNameUpdated
e:
UpdateMultipleItems(x => x.SellerId == e.UserId,
x => x.SellersDisplayName = e.DisplayName);
break;
}
return Task.CompletedTask;
}
private void UpdateItem(Guid id,
Action<ReadModels.ClassifiedAdDetails> update)
{
var item = _items.FirstOrDefault(
x => x.ClassifiedAdId == id);
if (item == null) return;
update(item);
}
private void UpdateMultipleItems(
Func<ReadModels.ClassifiedAdDetails, bool> query,
Action<ReadModels.ClassifiedAdDetails> update)
{
foreach (var item in _items.Where(query))
update(item);
}
}
}
我们还需要一个新的投影来构建用户详情的读取模型,因此我创建了IProjection接口的另一个实现,并将其命名为UserDetailsProjection。将ReadModels.cs文件移动到Projections文件夹中也是有意义的,以保持事物的一致性。以下是用户详情投影代码:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Marketplace.Domain.UserProfile;
using Marketplace.Framework;
namespace Marketplace.Projections
{
public class UserDetailsProjection : IProjection
{
List<ReadModels.UserDetails> _items;
public UserDetailsProjection(
List<ReadModels.UserDetails> items)
{
_items = items;
}
public Task Project(object @event)
{
switch (@event)
{
case Events.UserRegistered e:
_items.Add(new ReadModels.UserDetails
{
UserId = e.UserId,
DisplayName = e.DisplayName
});
break;
case Events.UserDisplayNameUpdated e:
UpdateItem(e.UserId,
x => x.DisplayName = e.DisplayName);
break;
}
return Task.CompletedTask;
}
private void UpdateItem(Guid id,
Action<ReadModels.UserDetails> update)
{
var item = _items.FirstOrDefault(x => x.UserId == id);
if (item == null) return;
update(item);
}
}
}
当然,我们需要在ReadModels.cs文件中添加一个新的读取模型类,如下所示:
using System;
namespace Marketplace.Projections
{
public static class ReadModels
{
public class ClassifiedAdDetails
{
public Guid ClassifiedAdId { get; set; }
public string Title { get; set; }
public decimal Price { get; set; }
public string CurrencyCode { get; set; }
public string Description { get; set; }
public Guid SellerId { get; set; }
public string SellersDisplayName { get; set; }
public string[] PhotoUrls { get; set; }
}
public class UserDetails
{
public Guid UserId { get; set; }
public string DisplayName { get; set; }
}
}
}
现在,我们需要最终确定投影管理器,以便它可以接受多个投影,并在出现新事件时调用每个投影。我们希望为将来使用真实的持久化存储做好准备,因此我想保持所有方法都是异步的,除了UpdateItem和UpdateItems,这些我可以稍后更改,因为这些都是单个投影类实现的细节。以下是新的ProjectionManager类代码:
using System.Linq;
using System.Threading.Tasks;
using EventStore.ClientAPI;
using Marketplace.Framework;
using Serilog;
using Serilog.Events;
namespace Marketplace.Infrastructure
{
public class ProjectionManager
{
private readonly IEventStoreConnection _connection;
private readonly IProjection[] _projections;
private EventStoreAllCatchUpSubscription _subscription;
public ProjectionManager(IEventStoreConnection connection,
params IProjection[] projections)
{
_connection = connection;
_projections = projections;
}
public void Start()
{
var settings = new CatchUpSubscriptionSettings(2000, 500,
Log.IsEnabled(LogEventLevel.Verbose),
true, "try-out-subscription");
_subscription = _connection.SubscribeToAllFrom(
Position.Start, settings, EventAppeared);
}
public void Stop() => _subscription.Stop();
private Task EventAppeared(EventStoreCatchUpSubscription _,
ResolvedEvent resolvedEvent)
{
if (resolvedEvent.Event.EventType.StartsWith("$"))
return Task.CompletedTask;
var @event = resolvedEvent.Deserialzie();
Log.Debug("Projecting event {type}",
@event.GetType().Name);
return Task.WhenAll(_projections.Select(
x => x.Project(@event)));
}
}
}
我想记录所有我们投影的事件,以了解发生了什么。在EventAppeared方法的最后一行,你可以看到我们正在收集为每个投影收集事件的任务,我们希望所有这些任务都完成。
接下来,我们修复EventStoreService类中的编译错误,使其使用ProjectionManager,而不是已删除(或重命名)的EsSubscription类:
using System.Threading;
using System.Threading.Tasks;
using EventStore.ClientAPI;
using Marketplace.Infrastructure;
using Microsoft.Extensions.Hosting;
namespace Marketplace
{
public class EventStoreService : IHostedService
{
private readonly IEventStoreConnection _esConnection;
private readonly ProjectionManager _projectionManager;
public EventStoreService(IEventStoreConnection esConnection,
ProjectionManager projectionManager)
{
_esConnection = esConnection;
_projectionManager = projectionManager;
}
public async Task StartAsync(
CancellationToken cancellationToken)
{
await _esConnection.ConnectAsync();
_projectionManager.Start();
}
public Task StopAsync(CancellationToken cancellationToken)
{
_projectionManager.Stop();
_esConnection.Close();
return Task.CompletedTask;
}
}
}
在我们可以启动应用程序之前,最后一件事是在Startup中连接这些组件。我们需要一个额外的集合,这次是ReadModels.UserDetails的集合,这样我们就可以在控制器中使用它,并将其作为参数传递给UserDetailsProjection构造函数:
var classifiedAdDetails = new List<ReadModels.ClassifiedAdDetails>();
services.AddSingleton<IEnumerable<ReadModels.ClassifiedAdDetails>>(classifiedAdDetails);
var userDetails = new List<ReadModels.UserDetails>();
services.AddSingleton<IEnumerable<ReadModels.UserDetails>>(userDetails);
var projectionManager = new ProjectionManager(esConnection,
new ClassifiedAdDetailsProjection(classifiedAdDetails),
new UserDetailsProjection(userDetails));
这是一项工作,但现在一切都完成了,我最终可以按下F5并看看会发生什么。好吧,实际上并没有什么特别之处;我只看到了广告被投影的相同事件,但至少这部分工作如预期一样。我们没有看到任何新内容,因为我作弊了,创建了一个没有用户的广告。现在我需要回去创建这个用户。
事件链接和特殊流
假设我们继续从第十章,“事件溯源”,我可以查找事件存储流中我们拥有的唯一广告的所有者 ID。我在那里使用的值是8dd8c5c6-6edb-4e42-ac9e-a232ea445b76,因此我可以使用用户配置文件的 Swagger API 创建以下用户:

通过 UserProfile API 创建新用户
我得到了一个200 OK响应,现在我在日志中看到了更多的行,如下所示:
[21:58:04 DBG] Projecting event UserRegistered
[21:58:04 DBG] Projecting event UserRegistered
[21:58:04 DBG] Projecting event UserRegistered
[21:58:04 DBG] Projecting event UserRegistered
好吧,新的投影是有效的,但为什么这个单一事件被投影了四次呢?通过查看我们用于订阅的$all流,这个谜团可以轻易解决:

$all流的内容
在这里,你可以看到添加了几个事件。如果我们忽略系统事件,那些看起来可疑的0@$ce-UserProfile、0@$et-UserRegistered等事件是链接事件,这些事件是通过事件存储的内部标准投影放置到不同的特殊流中的。这些内部投影非常有帮助。例如,如果你查看$ce-UserProfile流,你会找到为UserProfile聚合的所有实例保存的所有事件。这些$ce流被称为类别流。另一种流类型是事件类型。例如,$et-UserRegistered包含来自存储中所有其他流的所有UserRegistered类型的事件。
然而,我们正在订阅$all流,我们不需要获取与所有那些特殊流链接的单个事件的副本。当然,我们可以通过访问事件存储 UI 的投影选项卡并点击停止所有按钮来禁用标准投影,但这并不是一个好的方法。有人可能会在以后来到我们的存储并重新启用这些投影。但请记住,这些是链接事件。我们有一个对追赶订阅有帮助的参数,称为resolveLinkTos,我们将其设置为true。让我们将其更改为false并看看会发生什么。以下是ProjectionManager类的新代码:
public void Start()
{
var settings = new CatchUpSubscriptionSettings(2000, 500,
Log.IsEnabled(LogEventLevel.Verbose),
false, "try-out-subscription");
_subscription = _connection.SubscribeToAllFrom(Position.Start,
settings, EventAppeared);
}
如果我现在运行应用程序,输入就大不相同:
[22:30:38 DBG] Projecting event ClassifiedAdCreated
[22:30:38 DBG] Projecting event ClassifiedAdTitleChanged
[22:30:38 DBG] Projecting event ClassifiedAdTextUpdated
[22:30:38 DBG] Projecting event ClassifiedAdPriceUpdated
[22:30:38 DBG] Projecting event ClassifiedAdSentForReview
[22:30:38 DBG] Projecting event ClassifiedAdPublished
[22:30:48 DBG] Projecting event UserRegistered
看起来,ClassifiedAd聚合的事件之前也因为同样的原因被多次投影。现在日志变得更加合理,每个事件都只被投影了一次,正如预期的那样。
丰富读取模型
在此刻,我们有一个包含内存中单个ReadModels.UserDetails类型对象的集合。这个对象代表一个单一用户,因此如果我们有用户 ID,我们可以找出用户有什么显示名称。这很有帮助,但如何使用它来展示我们分类广告的完整详情呢?考虑到我们处于单个应用程序边界内,并且为所有读取模型使用相同的存储(目前是在内存中),有两种方法可以做到这一点。
当开发者开始处理分散在多个数据源中的数据时,他们脑海中首先想到的最明显的方法是在边缘聚合数据。最受欢迎的技术之一是构建 bff。当前端需要获取一些聚合数据时,它向后端的单个 API 端点发送一个请求,API 本身调用不同的数据源并合并数据。这个过程可以用以下图表来表示:

BFF 模式
在最简单的场景中,我们可以通过某种类型的连接进行一个数据库调用,因为我们对我们需要查询的两个数据元素的键有知识。在更复杂的情况下,我们可能会发现自己正在处理对拥有所需数据的微服务的远程调用,并在内存中进行连接。关于这种方法有一些担忧。当我们开始使用远程调用时,我们暴露了我们的 API 端点,使其容易受到它需要调用的任何服务的潜在失败以及可能发生的所有网络问题的影响。另一个重要方面是,我们必须在每次调用 bff API 端点时执行连接。如果这个特定的数据集被频繁使用,我们将陷入需要执行可能昂贵的连接的情况,而不是使用读取模型的力量来检索针对特定用例的预处理的去规范化数据集。
在读取模型中获取比投影接收的事件更多的数据有几种方法。
如果我们拥有读取模型所需的所有必要数据,在同一聚合中,我们可以向事件添加一些不需要传达状态转换的属性。例如,如果我们需要构建一个包含一个用户广告列表的读取模型(MyClassifiedAds),我们就需要包括所有投影需要处理的事件的所有者 ID,例如ClassifiedAdTitleChanged或ClassifiedAdTextUpdated。这种方法有时被称为使用胖事件,这与包含解释所发生事情所需的最小数据量的瘦事件相反。但是,这种方法对于跨聚合的读取模型不起作用,因此我们现在不需要探索这个选项。
我们将实现另外两种方法,这将使我们能够从其他来源获取数据——从投影查询和事件上溯。
从投影查询
目前,我们面临的主要问题是我们无法在投影接收到 ClassifiedAdCreated 事件时获取所有者的名称。对于分类广告聚合的所有其他事件,所有者都没有任何影响,因为所有者无法更改。我们已经在投影中处理了 UserDisplayNameUpdated 事件,所以如果广告所有者决定进行此类更新,我们将获取更新的名称。为了获取所需的数据,我们将通过使用事件中的 OwnerId 来联系 UserDetails 读取模型。这个过程看起来如下:

从另一个读取模型增强投影
让我们更改我们的投影代码以执行此查询。首先,我们对 ClassifiedAdDetailsProjection 类中的其他读取模型没有任何了解。现在这并不重要,因为无论数据是存储在内存中还是某些数据库中,我们仍然需要获取它。一种简单的方法是给我们的投影一个对 UserDetails 存储的引用并直接执行查询。但这种方法,虽然实现起来非常简单,却在读取模型和投影之间创建了耦合。当引入这种耦合时,未来的任何更改都将更加困难,测试也将始终是一个挑战,因为我们始终需要确保所有存储都预先填充了我们所需的所有数据。
一个更加优雅和简洁的方法是给我们的投影提供一个明确的方式来检索给定 UserId 的用户的 DisplayName。最简单的方法是提供一个委托函数,这样我们就可以将其作为参数添加到投影构造函数中:
public ClassifiedAdDetailsProjection(
List<ReadModels.ClassifiedAdDetails> items,
Func<Guid, string> getUserDisplayName)
{
_items = items;
_getUserDisplayName = getUserDisplayName;
}
现在,我们可以使用这个函数来获取读取模型所需的额外数据,通过在 ClassifiedAdDetailsProjection.Project 方法的第一个 case 中添加对这个函数的调用来实现:
case Events.ClassifiedAdCreated e:
_items.Add(new ReadModels.ClassifiedAdDetails
{
ClassifiedAdId = e.Id,
SellerId = e.OwnerId,
SellersDisplayName = _getUserDisplayName(e.OwnerId)
});
break;
我们需要做的最后一件事是完成布线,因为 Startup 类现在无法编译了。我们需要将投影构造函数的调用更改为以下内容:
var projectionManager = new ProjectionManager(esConnection,
new ClassifiedAdDetailsProjection(classifiedAdDetails,
userId => userDetails.FirstOrDefault(
x => x.UserId == userId)?.DisplayName),
这就是我们需要的所有操作,现在我可以启动应用程序并查询之前相同的端点以获取增强的结果:

查询结果显示了我们所需的所有数据
你可以看到,响应中的 sellersDisplayName 属性被正确地设置为我们要的值。
在投影中使用查询时,需要考虑很多方面,主要是确保可靠性。这项工作的主要目标是确保投影永远不会失败。当你需要查询的数据位于你正在更新的读取模型相同的存储中时,查询的处理速度和可靠性应该在一个可接受的水平。你可能仍然希望在整个投影上应用重试策略来减轻暂时性网络故障等问题。然而,你真的不应该尝试查询外部数据源以获取额外数据。当我们讨论集成方面时,我们将讨论如何解决这种情况。
提升事件
将更多数据输入到读取模型的最复杂方式是通过使用事件提升。基本上,要实现这一点,我们需要创建一个对事件存储的单独订阅,该订阅接收精简事件,从其他地方获取额外数据,生成一个包含更多数据的新事件,并将其发布到一个特殊的流中。这个流永远不能是聚合流,因为新的事件只需要用来构建读取模型。我们可以为这个流选择一个特殊名称,例如ClassifiedAd-Upcast。由于读取模型投影监听$all流,它也会接收并处理这些事件。这种方法仅在需要为不同的读取模型提供额外数据时才有用,因此我们可以使用一个富集事件来更新所有这些模型,因此我们只需要查询一次额外数据。
我们没有很多读取模型,但我仍然可以在单个ClassifiedAdDetails事件上演示这种方法。假设我们需要在广告发布时立即将所有者照片包含到读取模型中,这样我们就可以丰富ClassifiedAdPublished事件。
首先,我需要将SellersPhotoUrl添加到读取模型本身;它将只是一个字符串,如下所示:
public class ClassifiedAdDetails
{
public Guid ClassifiedAdId { get; set; }
public string Title { get; set; }
public decimal Price { get; set; }
public string CurrencyCode { get; set; }
public string Description { get; set; }
public Guid SellerId { get; set; }
public string SellersDisplayName { get; set; }
public string SellersPhotoUrl { get; set; }
public string[] PhotoUrls { get; set; }
}
我们还需要将所有者 ID 传递给提升器,否则它不知道需要询问哪个用户的照片。在这里,我们可以使用胖事件方法,并将OwnerId属性添加到我们的ClassifiedAdPublished事件中。在我添加属性后,我需要更改ClassifiedAd聚合的Publish方法,使其从聚合状态中填充这个属性:
public void Publish(UserId userId) =>
Apply(new Events.ClassifiedAdPublished
{
Id = Id,
ApprovedBy = userId,
OwnerId = OwnerId
});
我还需要将提升事件作为一个类,所以我按照以下方式添加它:
public static class ClassifiedAdUpcastedEvents
{
public static class V1
{
public class ClassifiedAdPublished
{
public Guid Id { get; set; }
public Guid OwnerId { get; set; }
public string SellersPhotoUrl { get; set; }
public Guid ApprovedBy { get; set; }
}
}
}
我们之前将事件保存到事件存储中,代码位于EsAggregateStore类中。现在,我们需要这段代码,以便我们可以创建一个有用的IEventStoreConnection接口扩展,使保存事件更加方便:
using System;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using EventStore.ClientAPI;
using Newtonsoft.Json;
namespace Marketplace.Infrastructure
{
public static class EventStoreExtensions
{
public static Task AppendEvents(
this IEventStoreConnection connection,
string streamName, long version,
params object[] events)
{
if (events == null || !events.Any()) return
Task.CompletedTask;
var preparedEvents = events
.Select(@event =>
new EventData(
eventId: Guid.NewGuid(),
type: @event.GetType().Name,
isJson: true,
data: Serialize(@event),
metadata: Serialize(
new EventMetadata {ClrType =
@event.GetType().AssemblyQualifiedName})
))
.ToArray();
return connection.AppendToStreamAsync(
streamName,
version,
preparedEvents);
}
private static byte[] Serialize(object data)
=> Encoding.UTF8.GetBytes(
JsonConvert.SerializeObject(data));
}
}
由于这段代码是从EsAggregateStore类复制的,所以在那里使用这个方法也是有意义的,你可以在 GitHub 仓库中看到更改后的代码。
接下来,我需要创建一个新的投影。我可以将其命名为ClassifiedAdUpcasters并将其放入主项目的Projections文件夹中。这个类需要实现IProjection接口,这样我就可以将它们喂给我们的ProjectionManager。在Project方法中,我需要处理单个事件;但为了未来的使用,我仍然可以使用switch语句,尽管它也有一个case。在这个case中,我需要向提升流发出一个新事件,因此我需要将IEventStoreConnection作为依赖项。新类的代码如下所示:
using System;
using System.Threading.Tasks;
using EventStore.ClientAPI;
using Marketplace.Framework;
using Marketplace.Infrastructure;
using static Marketplace.Domain.ClassifiedAd.Events;
using static Marketplace.Projections.ClassifiedAdUpcastedEvents;
namespace Marketplace.Projections
{
public class ClassifiedAdUpcasters : IProjection
{
private readonly IEventStoreConnection _eventStoreConnection;
private readonly Func<Guid, string> _getUserPhoto;
private const string StreamName = "UpcastedClassifiedAdEvents";
public ClassifiedAdUpcasters(
IEventStoreConnection eventStoreConnection,
Func<Guid, string> getUserPhoto)
{
_eventStoreConnection = eventStoreConnection;
_getUserPhoto = getUserPhoto;
}
public async Task Project(object @event)
{
switch (@event)
{
case ClassifiedAdPublished e:
var photoUrl = _getUserPhoto(e.OwnerId);
var newEvent = new V1.ClassifiedAdPublished
{
Id = e.Id,
OwnerId = e.OwnerId,
ApprovedBy = e.ApprovedBy,
SellersPhotoUrl = photoUrl
};
await _eventStoreConnection.AppendEvents(
StreamName,
ExpectedVersion.Any,
newEvent);
break;
}
}
}
public static class ClassifiedAdUpcastedEvents
{
public static class V1
{
public class ClassifiedAdPublished
{
public Guid Id { get; set; }
public Guid OwnerId { get; set; }
public string SellersPhotoUrl { get; set; }
public Guid ApprovedBy { get; set; }
}
}
}
}
如代码所示,我需要一个函数,它将允许投影从某处获取用户照片 URL。一旦这个投影接收到ClassifiedAdPublished事件,它将查询照片 URL 并向UpcastedClassifiedAds流发出一个新的事件。
我们还需要在ClassifiedAdDetails投影中投影提升事件,因此我在Project方法中添加了一个额外的case:
case V1.ClassifiedAdPublished e:
UpdateItem(e.Id, ad => ad.SellersPhotoUrl = e.SellersPhotoUrl);
break;
最后是连接,我需要将这个新投影添加到投影管理器中,以便它将在订阅处理中包含它。因此,我需要更改Startup.cs文件,如下所示:
var projectionManager = new ProjectionManager(esConnection,
new ClassifiedAdDetailsProjection(classifiedAdDetails,
userId => userDetails.FirstOrDefault(
x => x.UserId == userId)?.DisplayName),
new UserDetailsProjection(userDetails),
new ClassifiedAdUpcasters(esConnection,
userId => userDetails.FirstOrDefault(
x => x.UserId == userId)?.PhotoUrl));
在这里,你可以看到我使用了esConnection,它之前已经被实例化用于聚合存储,以及从UserDetails读取模型中查询用户照片 URL 的函数。
当一切完成后,我可以再次运行应用程序。在 Swagger 中,我使用用户配置文件命令 API 将照片 URL 添加到用户,然后使用分类广告命令 API 首先请求发布广告,然后发布它。一旦我通过 API 完成这些操作,我就可以回到查询 API 并获取新的详细信息。新的结果包括预期的照片 URL:
{
"classifiedAdId": "556bc798-bacc-4bb8-a55b-50144add4f17",
"title": "Wooden table",
"price": 10,
"currencyCode": "EUR",
"description": "The table is 100 years old but still solid. Probably
worth a fortune.",
"sellerId": "8dd8c5c6-6edb-4e42-ac9e-a232ea445b76",
"sellersDisplayName": "JustPrejudice",
"sellersPhotoUrl": "https://www.biography.com/.image/t_share
/MTE1ODA0OTcxNTQ2ODcxMzA5/jane-austen-9192819-1-402.jpg",
"photoUrls": null
}
我们还可以查看事件存储 UI,通过访问http://localhost:2113/web/index.html#/streams/UpcastedClassifiedAdEvents来检查提升事件流的内 容。该流显示一个事件,当我点击它时,我看到以下内容:

提升事件的同意
注意,由于我们一切都在内存中,并且我们的投影每次应用程序启动时都从$all流的最开始处开始,因此提升器将再次处理事件,并且会产生与运行应用程序次数一样多的提升事件。当然,避免这种情况的唯一方法是在我们处理每个事件后存储流位置。当我们停止并启动应用程序时,我们读取存储的位置,然后只开始处理新事件。我们将在下一节中深入探讨这一点。
持久化存储
到目前为止,我们已经有了一些投影,它们构建了一些有用的读取模型,我们可能可以用它们来构建应用程序的 UI。然而,这些读取模型不是持久的,当我们停止应用程序时,一切都会消失。当然,当我们再次启动应用程序时,读取模型会迅速重建,尽管这是在开发周期开始时构建读取模型的完美方式,但这在生产系统中是不可行的。此外,如果我们使用上推,每次应用程序启动时都会发出上推事件,因为上推订阅将再次处理所有事件。因此,现在是时候将我们的读取模型持久化到数据库中。
检查点
如我们之前所见,当启动我们的应用程序时,它会重新处理所有投影中的所有事件。这是因为我们在创建ProjectionManager代码中的订阅时,将Position.Start作为初始位置。由于我们希望将我们的读取模型存储在数据库中,我们还需要在处理事件后开始给订阅一个实际的位置。这意味着我们还需要将位置持久化到某个地方。在不同的系统中,这样的位置可能有不同的叫法。在事件日志系统中,如 Kafka 或 Azure Event Hub,使用的是偏移量这个术语。Event Store 使用的是检查点,这正是本书将要使用的。
理想情况下,我们会将检查点存储在与该订阅的读取模型相同的数据库中。对于某些数据库引擎,甚至可以将所有读取模型更新和检查点更新封装在一个单一的事务中,这可能是有益的。使用这种方法无疑会使投影处理代码更加复杂,因为单个事务需要传递到所有投影和检查点存储。
我们将使用 RavenDB,尽管它支持某种多文档事务,但我们不会这样做以保持代码的简洁性。因此,我需要做的第一件事是定义检查点存储的接口。为此,我在Marketplace.Framework项目中添加了一个ICheckpointStore文件。代码非常简单;我们只需要几个方法,如下所示:
using System.Threading.Tasks;
using EventStore.ClientAPI;
namespace Marketplace.Framework
{
public interface ICheckpointStore
{
Task<Position> GetCheckpoint();
Task StoreCheckpoint(Position checkpoint);
}
}
在这里,Position是在 Event Store API 中定义的struct,我们可能想要尽量避免在这个项目中放置基础设施依赖,但暂时它是可行的。
我们还需要一个 RavenDB 文档,其中将存储检查点。它需要一个字符串类型的Id字段和实际的位置:
using EventStore.ClientAPI;
namespace Marketplace.Infrastructure
{
public class Checkpoint
{
public string Id { get; set; }
public Position Position { get; set; }
}
}
接下来,我们需要实现这个接口。由于我计划将所有内容存储在 RavenDB 中,因此将实现命名为RavenDbCheckpointStore是合理的。我将这个类添加到Infrastructure文件夹中的Marketplace项目中:
using System;
using System.Threading.Tasks;
using EventStore.ClientAPI;
using Marketplace.Framework;
using Raven.Client.Documents.Session;
namespace Marketplace.Infrastructure
{
public class RavenDbCheckpointStore : ICheckpointStore
{
private readonly Func<IAsyncDocumentSession> _getSession;
private readonly string _checkpointName;
public RavenDbCheckpointStore(
Func<IAsyncDocumentSession> getSession,
string checkpointName)
{
_getSession = getSession;
_checkpointName = checkpointName;
}
public async Task<Position> GetCheckpoint()
{
using var session = _getSession();
var checkpoint = await session
.LoadAsync<Checkpoint>(_checkpointName);
return checkpoint?.Position ?? Position.Start;
}
public async Task StoreCheckpoint(Position position)
{
using var session = _getSession();
var checkpoint = await session
.LoadAsync<Checkpoint>(_checkpointName);
if (checkpoint == null)
{
checkpoint = new Checkpoint
{
Id = _checkpointName
};
await session.StoreAsync(checkpoint);
}
checkpoint.Position = position;
await session.SaveChangesAsync();
}
}
}
虽然代码有点长,但并不复杂。我们需要给这个类提供一个会话工厂作为参数。我们还需要某种标识符,以防我们有多达多个订阅。这个checkpointName字符串将被用作Checkpoint文档 ID。
在GetCheckpoint方法中,我们尝试加载文档,如果它不存在,则该方法返回Position.Start,这样我们就可以订阅到最开始的位置。它复制了没有检查点的情况,如果你需要从头开始重建所有读取模型,你只需要删除这个文档以及所有读取模型的文档。
当我们保存检查点时,我们必须尝试加载一个来看看它是否存在。如果存在,我们使用新的位置更新它,否则,我们存储一个新的文档。
接下来当然是我们的ProjectionManager。它需要能够与检查点存储一起工作。我们不需要它了解 RavenDb,因为它只需要用于投影和检查点存储。但是,在订阅时需要调用检查点存储,并在每个事件投影后保存位置。因此,我们需要添加ICheckpointStore参数并调用该接口的两个方法,如下所示:
using System.Linq;
using System.Threading.Tasks;
using EventStore.ClientAPI;
using Marketplace.Framework;
using Serilog;
using Serilog.Events;
namespace Marketplace.Infrastructure
{
public class ProjectionManager
{
private readonly IEventStoreConnection _connection;
private readonly ICheckpointStore _checkpointStore;
private readonly IProjection[] _projections;
private EventStoreAllCatchUpSubscription _subscription;
public ProjectionManager(
IEventStoreConnection connection,
ICheckpointStore checkpointStore,
params IProjection[] projections)
{
_connection = connection;
_checkpointStore = checkpointStore;
_projections = projections;
}
public async Task Start()
{
var settings = new CatchUpSubscriptionSettings(2000, 500,
Log.IsEnabled(LogEventLevel.Verbose),
false, "try-out-subscription");
var position = await _checkpointStore.GetCheckpoint();
_subscription = _connection.SubscribeToAllFrom(position,
settings, EventAppeared);
}
public void Stop() => _subscription.Stop();
private async Task EventAppeared(
EventStoreCatchUpSubscription _,
ResolvedEvent resolvedEvent)
{
if (resolvedEvent.Event.EventType.StartsWith("$")) return;
var @event = resolvedEvent.Deserialzie();
Log.Debug("Projecting event {type}",
@event.GetType().Name);
await Task.WhenAll(_projections.Select(
x => x.Project(@event)));
await _checkpointStore.StoreCheckpoint(
resolvedEvent.OriginalPosition.Value);
}
}
}
持久化读取模型
由于我们无论如何都需要 RavenDb,所以我从第十章的代码中使用了部分代码,事件溯源,但使其变得更加高级。我将文档存储的初始化移动到了Startup类的一个单独的方法中。该方法将使用appsettings.json配置部分而不是硬编码的值。如果数据库不存在,它还会创建数据库,因此我们不需要手动创建它。对于本章的内容来说,了解数据库是如何配置的并不重要,你可以查看书籍中的代码片段来了解它是如何实现的。同时也要查看那里的设置文件,以了解配置结构。
在进行这些更改后,我需要更改Startup.ConfigureServices方法,使其调用存储初始化方法。我们需要有一个文档会话工厂,并在服务集合中注册IAsyncDocumentSession,因为我们将在查询 API 控制器中使用它。以下是注册中的一些更改:
var documentStore = ConfigureRavenDb(
Configuration.GetSection("ravenDb"));
Func<IAsyncDocumentSession> getSession =
() => documentStore.OpenAsyncSession();
services.AddTransient(c => getSession());
为了使单个投影更简单,因为它们现在都将使用 RavenDb,我创建了一个简单的基抽象类,称为RavenDbProjection。它接受会话工厂作为其构造函数的参数,并包含一个有用的方法来执行读取模型文档的更新,这就是为什么这个类是一个泛型类,我们将使用读取模型类类型作为泛型参数:
using System;
using System.Linq.Expressions;
using System.Threading.Tasks;
using Marketplace.Framework;
using Raven.Client.Documents;
using Raven.Client.Documents.Linq;
using Raven.Client.Documents.Session;
namespace Marketplace.Infrastructure
{
public abstract class RavenDbProjection<T> : IProjection
{
protected RavenDbProjection(
Func<IAsyncDocumentSession> getSession
)
=> GetSession = getSession;
protected Func<IAsyncDocumentSession> GetSession { get; }
public abstract Task Project(object @event);
protected Task Create(Func<Task<T>> model)
=> UsingSession(
async session =>
await session.StoreAsync(await model())
);
protected Task UpdateOne(Guid id, Action<T> update)
=> UsingSession(
session =>
UpdateItem(session, id, update)
);
protected Task UpdateWhere(
Expression<Func<T, bool>> where,
Action<T> update
) => UsingSession(
session =>
UpdateMultipleItems(
session, where, update
)
);
private static async Task UpdateItem(
IAsyncDocumentSession session, Guid id,
Action<T> update
)
{
var item = await session
.LoadAsync<T>(id.ToString());
if (item == null) return;
update(item);
}
async Task UpdateMultipleItems(
IAsyncDocumentSession session,
Expression<Func<T, bool>> query, Action<T> update
)
{
var items = await session
.Query<T>()
.Where(query)
.ToListAsync();
foreach (var item in items)
update(item);
}
protected async Task UsingSession(
Func<IAsyncDocumentSession, Task> operation
)
{
using var session = GetSession();
await operation(session);
await session.SaveChangesAsync();
}
}
}
你已经看到了 UpdateItem 和 UpdateMultipleItems,这些是在投影类内部实现的。由于代码非常相似,我能够将其隔离在抽象类中。我还将这些方法设为私有,并创建了三个具有更简单签名的三个方法:Create、UpdatedOne 和 UpdateWhere。注意还有 UsingSession 方法。
由于我们使用会话工厂,因此在使用后我们将负责释放它。为了避免在投影代码中 using 语句的无限噪音,我们将调用 UsingSession 方法,它将为我们完成这项工作。它还持久化在会话释放之前由它调用的委托所做的所有更改。
为了将读取模型作为文档保存到 RavenDB,我们必须遵守数据库引擎约定,以使我们的生活更简单。因此,我们必须将所有标识属性更改为具有 Id 名称和字符串类型(现在我们有 Guid)。在所有类型不匹配的地方,我通过调用 ToString() 改变了 Guid 字段的用法。
现在,我们已经准备好将最简单的投影转换为使用 RavenDB,这将是我们所说的 UserDetailsProjection。我将它更改为从 RavenDbProjection 抽象类继承,这样辅助方法就可以消失了。由于基类需要,我们需要一个构造函数,但总体上,代码现在更小了。唯一的真正改变是,我使用了那些新的辅助方法来简化代码。以下是新的代码:
using System;
using System.Threading.Tasks;
using Marketplace.Domain.UserProfile;
using Marketplace.Infrastructure;
using Raven.Client.Documents.Session;
namespace Marketplace.Projections
{
public class UserDetailsProjection
: RavenDbProjection<ReadModels.UserDetails>
{
public UserDetailsProjection(
Func<IAsyncDocumentSession> getSession
) : base(getSession) { }
public override Task Project(object @event) =>
@event switch
{
Events.UserRegistered e =>
Create(
() => Task.FromResult(
new ReadModels.UserDetails
{
Id = e.UserId.ToString(),
DisplayName = e.DisplayName
}
)
),
Events.UserDisplayNameUpdated e =>
UpdateOne(
e.UserId,
x => x.DisplayName = e.DisplayName
),
Events.ProfilePhotoUploaded e =>
UpdateOne(
e.UserId,
x => x.PhotoUrl = e.PhotoUrl
),
_ => Task.CompletedTask
};
}
}
我们的第二个投影更为严重,因此我们需要进行更多更改,但差异并不大。我们需要给它会话工厂,因为它是 RavenDbProjection 基类所必需的。一个重要的改变是,由于我们可以想象用户显示名查询是异步的,我们需要将委托签名更改为返回 Task<string> 而不是字符串。所有其他更改都与通过调用基类的 Update 和 UpdateWhere 方法实现更新有关。需要注意的是,除了这个之外,还与用户资料的异步查询有关,因此当我们调用查询时,我们需要等待调用。以下是完整的代码:
using System;
using System.Linq.Expressions;
using System.Threading.Tasks;
using Marketplace.ClassifiedAd;
using Marketplace.Infrastructure;
using Raven.Client.Documents.Session;
using static Marketplace.Domain.ClassifiedAd.Events;
using static Marketplace.Domain.UserProfile.Events;
using static Marketplace.Projections.ClassifiedAdUpcastedEvents;
using static Marketplace.Projections.ReadModels;
namespace Marketplace.Projections
{
public class ClassifiedAdDetailsProjection
: RavenDbProjection<ClassifiedAdDetails>
{
private readonly Func<Guid, Task<string>>
_getUserDisplayName;
public ClassifiedAdDetailsProjection(
Func<IAsyncDocumentSession> getSession,
Func<Guid, Task<string>> getUserDisplayName
)
: base(getSession)
=> _getUserDisplayName = getUserDisplayName;
public override Task Project(object @event) =>
@event switch
{
ClassifiedAdCreated e =>
Create(async () =>
new ClassifiedAdDetails
{
Id = e.Id.ToString(),
SellerId = e.OwnerId,
SellersDisplayName =
await _getUserDisplayName(
e.OwnerId
)
}
),
ClassifiedAdTitleChanged e =>
UpdateOne(e.Id, ad => ad.Title = e.Title),
ClassifiedAdTextUpdated e =>
UpdateOne(e.Id, ad => ad.Description = e.AdText),
ClassifiedAdPriceUpdated e =>
UpdateOne(
e.Id,
ad =>
{
ad.Price = e.Price;
ad.CurrencyCode = e.CurrencyCode;
}
),
UserDisplayNameUpdated e =>
UpdateWhere(
x => x.SellerId == e.UserId,
x => x.SellersDisplayName = e.DisplayName
),
V1.ClassifiedAdPublished e =>
UpdateOne(
e.Id,
ad => ad.SellersPhotoUrl = e.SellersPhotoUrl
),
_ => Task.CompletedTask
};
}
}
最后一个投影是我们的提升器。由于它不使用 RavenDB,因此没有必要从基类继承。我需要做的唯一改变是更改查询委托,使其返回 Task<string>,并且查询调用需要等待:
var photoUrl = await _getUserPhoto(e.OwnerId);
我不会在这里放置完整的班级代码,因为更改很小。
需要注意的一件事是,当然是在 UserDetails 读取模型中创建这些查询,因为我们不会简单地使用 List。为了使代码更简单,我在 ReadModels.UserDetails 命名空间中创建了一个名为 Queries 的小类,其中有一个方法可以获取单个用户的资料:
using System;
using System.Threading.Tasks;
using Raven.Client.Documents.Session;
using static Marketplace.Projections.ReadModels;
namespace Marketplace.UserProfile
{
public static class Queries
{
public static Task<UserDetails> GetUserDetails(
this Func<IAsyncDocumentSession> getSession,
Guid id
)
{
using var session = getSession();
return session.LoadAsync<UserDetails>(id.ToString());
}
}
}
这是一种扩展方法,不是针对会话对象本身,而是针对会话工厂,因为我们必须在调用此查询之后销毁会话。对于控制器来说,情况将不同,因为在那里,服务集合将给我们一个会话作为临时依赖项。
总结
我们现在需要做的只是进行一些连接和修改查询 API 控制器的一些小改动。
首先,EventStoreService类需要等待对projectionManager.Start()的调用,因为这个方法现在是异步的。然后,我们需要通过修改Queries扩展类以使用文档会话来修复查询 API 控制器。
using System.Threading.Tasks;
using Raven.Client.Documents.Session;
using static Marketplace.ClassifiedAd.QueryModels;
using static Marketplace.Projections.ReadModels;
namespace Marketplace.ClassifiedAd
{
public static class Queries
{
public static Task<ClassifiedAdDetails> Query(
this IAsyncDocumentSession session,
GetPublicClassifiedAd query
) =>
session.LoadAsync<ClassifiedAdDetails>(
query.ClassifiedAdId.ToString()
);
}
}
由于查询现在是异步的,我们需要准备RequestHandler.HandleQuery以便它可以等待查询,并且返回Task<IActionResult>以便控制器也可以是异步的:
public static async Task<IActionResult> HandleQuery<TModel>(
Func<Task<TModel>> query, ILogger log)
{
try
{
return new OkObjectResult(await query());
}
catch (Exception e)
{
log.Error(e, "Error handling the query");
return new BadRequestObjectResult(
new
{
error = e.Message, stackTrace = e.StackTrace
});
}
}
API 的最后更改将是修复控制器本身,以便它可以注入会话作为依赖项并成为异步的:
using System.Threading.Tasks;
using Marketplace.Infrastructure;
using Microsoft.AspNetCore.Mvc;
using Raven.Client.Documents.Session;
using Serilog;
namespace Marketplace.ClassifiedAd
{
[Route("/ad")]
public class ClassifiedAdsQueryApi : Controller
{
private readonly IAsyncDocumentSession _session;
private static ILogger _log =
Log.ForContext<ClassifiedAdsQueryApi>();
public ClassifiedAdsQueryApi(IAsyncDocumentSession session) =>
_session = session;
[HttpGet]
public Task<IActionResult>
Get(QueryModels.GetPublicClassifiedAd request)
=> RequestHandler.HandleQuery(() =>
_session.Query(request), _log);
}
}
最后要修复的是Startup类。我已经提到了 RavenDB 的初始化和前面的注册代码。我们只需要对注册我们的投影管理器和所有投影做一些必要的更改。记住,我们已经将查询委托改为异步,所以接下来的代码行中有相当多的更改:
var projectionManager = new ProjectionManager(esConnection,
new RavenDbCheckpointStore(getSession, "readmodels"),
new ClassifiedAdDetailsProjection(getSession,
async userId => (await
getSession.GetUserDetails(userId))?.DisplayName),
new ClassifiedAdUpcasters(esConnection,
async userId => (await
getSession.GetUserDetails(userId))?.PhotoUrl),
new UserDetailsProjection(getSession));
首先,我们添加了检查点存储参数。然后,我们将会话工厂作为参数添加到两个使用 RavenDB 的投影中。最后,两个查询都变为异步,并使用相同的会话工厂结合我们新的查询扩展。
所有工作都已完成,现在是时候运行应用程序并看看会发生什么。
在我按下F5后,我在日志中看到了消息,显示与我们之前看到相同的相同事件正在被投影。这是预期的,因为我们是从头开始的。现在我可以再次调用查询 API,看看结果是否正如我所预期的那样。如果我现在重新启动应用程序,它不会从投影中产生任何日志,因为我们现在已经持久化了检查点并从我们停止的地方开始订阅。所以,除非我们开始执行新的操作,否则我们不会得到新的事件。从现在开始,我们的应用程序将实时处理所有更新。
让我们看看数据库中有什么。当我通过访问http://localhost:8080打开 RavenDB Studio UI 时,我看到Marketplace_Chapter11数据库存在,因此我可以点击它并检查内容。在数据库中,我找到了不同集合中的三个文档,如下所示:

在 RavenDB 中有三个集合
其中两份文档是我们的读取模型,另一份文档是检查点。让我们看看ClassifiedAdDetails文档包含什么。正如预期的那样,我们得到了从我们的事件中投影的所有信息:

读取模型作为 RavenDB 文档
现在,我们可以检查检查点文档包含的内容。它包含我们的检查点名称的 ID,JSON 内容类似于以下内容(您可能有不同的值):
{
"Position": {
"CommitPosition": 48771203,
"PreparePosition": 48771203
},
"@metadata": {
"@collection": "Checkpoints",
"Raven-Clr-Type": "Marketplace.Infrastructure.Checkpoint, Marketplace"
}
}
到目前为止,我们已将所有读取模型正确存储在数据库中,因此我们可以从中构建更多查询,就像我们在第九章,《CQRS - 读取侧》中所做的那样。
摘要
在本章中,我们将 CQRS 提升到了一个新的高度,并学习了如何查询最初存储为事件流的数据库数据。由于事件流难以按需查询,我们需要构建可以展示给用户的数据快照。事件源读取模型的力量在于我们可以构建几乎无限数量的针对特定用例的读取模型,并具有非常精确的数据集。我们可以避免诸如在对象集合或表之间进行连接,或者在远程服务之间进行连接等问题。我们可以一次性移除所有读取模型,并从零开始重建它们,仅使用事件。如果我们以某种方式创建了一个投影,它显示屏幕上的数据错误,那么我们可以快速捕捉到错误,并通过移除和重建读取模型来修复读取模型。
当然,任何事物都有其权衡。有时,我们无法从投影的事件中获取读取模型所需的所有数据。然而,我们已经讨论了多种避免这种限制的技术。
尽管如此,我们应该记住,当系统增长时,事件的数量也会增长,构建一个新的读取模型或重建之前存在错误且需要修复的读取模型,然后再次在数据库中构建所有记录,可能需要非常长的时间。对于拥有数十亿事件的大型系统,这可能需要几天甚至几周的时间。有方法可以提高投影的性能,我将在讨论事件源的高级主题时提到这些方法。然而,请记住,默认情况下,事件是按顺序投影的,顺序非常重要。因此,我们在消息世界中应用的常规技术,如用于水平扩展的竞争消费者,并不直接适用于投影。然而,通过将$all流根据给定的属性分割成几个流,可以分区投影。用 JavaScript 编写并直接在服务器上运行的 Event Store 投影可用于此目的。
尽管我们已经讨论了一些相当高级的主题,但最后几章内容非常技术性。在第十三章,《系统拆分》中,我们回到了领域驱动设计(DDD)的概念,并将讨论 DDD 最重要的思想——在问题空间中,没有复杂的系统可以作为一个解决方案空间中的单一系统来实现。我们将继续讨论边界上下文和上下文映射。
第十二章:有限上下文
到目前为止,我们已经花费了大量时间在我们的“市场”系统上,因为它将是一个单一的应用程序,有一个 API,可能还有一个与该 API 通信以服务其用户的 Web UI。然而,现在是我们退一步,看看大局的时候了。
我从 15 岁开始编写软件;因此,在撰写这本书的时候,我在行业中的经验接近 30 年。我构建的一些系统已经被新的东西所取代,而一些系统仍然非常活跃,由其他开发者进一步开发。今天,作为一名软件架构师和顾问,我在行业中继续前行,参与了许多实际活动,例如原型设计、建模和编写生产代码。多年来,我不仅作为开发者不断进步,编写出更好的代码,而且对构建复杂系统的基本原理有了更深入的理解。这些知识和经验使我能够为我的公司创造更成功的系统,这些系统将在几年内不断进化,而无需进行大规模的重写。
我认为使我成为一个更好的开发者和架构师的事情之一是意识到系统很少可以被建模为单一且不可分割的东西,作为一个单一单元。我们在第二章中讨论了语言的上下文性质,“语言与上下文”,并希望这能让你理解上下文的重要性。
在我的职业生涯中,我看到了许多被实现为单一代码库的复杂系统,它们有一个单一的数据模型来支持。在本章中,我们将更深入地探讨这种方法,并希望说服你它并不总是有效,而且有更好的方法。我们将花一些时间讨论如何使用语言上下文来发现整个系统中可以更高效地开发的部分,这些部分具有良好程度的隔离和自主性。
在本章中,你将学习:
-
语言边界如何帮助识别系统边界
-
有限上下文的定义
-
将系统拆分为部分的好处
-
当上下文边界不明确时,需要考虑哪些因素
单一模型陷阱
让我们看看软件通常是如何开始的,当开发者第一次接触键盘并开始编写代码时,希望构建一些有用的东西。我们将跟随软件公司(或 IT 部门)在多年中为满足用户需求而采取的通常进展,他们努力使软件更有用,增加功能并修复问题。接下来我所描述的是软件解决方案通常的进化增长,这种增长可以在任何地方找到。可能我即将描绘的画面中的一些部分你会觉得熟悉,并且与你的经验产生共鸣。
从小做起
我们很少遇到那些有宏伟计划用壮丽的软件征服世界,为人类解决巨大问题的公司。好吧,有些企业尝试过但不可避免地失败了,这可能是人们很少忘记的宝贵教训之一。更常见的是,企业试图解决人们经常遇到的真实问题。至少,这是公司运营者所相信的。所以,作为一个开发者,你可能会在一个规模合理的系统上工作,除非你为像微软或 SAP 这样的软件巨头工作。但不要弄错,他们也是从小开始的,尽管大多数人忘记了这一点。
当几个开发者开始对一个系统进行工作时,一切运作得相当顺利,因为团队规模小,目标希望很明确。如果他们试图解决的问题真实存在,解决方案可行,公司可能会在花费一两年时间构建软件的第一个版本后很快开始赚钱。到那时,系统仍然相对较小,构建它的人数不会超过几个工程师。像 Uber、AirBnB 和 GitHub 这样的公司都是这样开始的。
在一开始,一切都很顺利。然后,在某个时刻,系统变得相当大,由于系统规模的原因,生产力开始下降。一个单一的数据模型因为许多不同的原因被修改,多个产品所有者或项目经理之间的利益冲突开始出现,他们各自有自己的团队在不同的系统部分工作,开始因为日益增长的冲突而挣扎。协调工作增加,因为不同的团队在系统中触及了他们未曾预料到的地方,但他们必须这样做才能完成工作。但通常这样的变更会干扰其他团队的工作。发布需要高度协调,有时发布的工作量甚至超过了创建功能的工作量。公司从不周五发布,因为风险太高,人们几乎可以肯定在测试阶段没有发现所有错误,尽管质量保证团队已经尽力了。修复错误变成了一项具有挑战性和艰巨的任务;修复一个错误开发者会创造两个新的错误。
这听起来熟悉吗?如果不熟悉——你非常幸运,可能你工作的公司已经在做领域驱动设计(DDD)或类似的事情。对我来说,在行业里度过了几十年,这种情况非常普遍。说实话,我从没在一家没有这些问题的公司工作过。那么,这样一个既小又简洁的系统是如何最终变成一个无法管理的怪物,让开发者不敢触碰的呢?
复杂性,再次
如果你已经在软件开发行业工作了几年,并在一家或多家公司工作过,你很可能参与过一些生产系统的工作。通常,处于生产状态的系统以某种方式为用户提供价值。这样的系统可能是一个全球范围内的人们用来解决日常任务的产品,比如在线购物或通过实时地图追踪他们的宠物。其他系统则用于内部,以支持公司员工的工作,并通过这种方式间接地贡献到价值链中。这可能包括供应链管理、财务、账单、调度或收益管理系统。许多公司开发他们的内部系统,这些系统包含了公司多年来通过其成功的历史传承下来的最高级别的领域知识。
通常,人们需要软件来解决复杂问题。复杂问题很少能通过简单的解决方案来解决;我们在第一章,“为什么是领域驱动设计?”和第二章,“语言和上下文”中花了大量时间讨论这个问题。处理复杂系统的一个不可避免的特点是,这些系统会随着时间的推移而增长。工程师们越来越掌握业务洞察力,并在代码中捕捉用户不断变化的需求。这种进化进步不可避免地导致软件的复杂性不断增加,反映了软件试图解决的商业问题的复杂性。
因此,即使最初的计划是构建一个相对简单的解决方案来解决我们认为人们在日常工作中遇到的一两个问题,希望他们通过购买我们的产品来感谢我们,我们发现自己从一开始就站在了一个滑梯上。我们的用户永远不会完全满意。没有人愿意为不发展的事物持续付费。所以,除非你正在为智能手机开发一个简单、吸引人的游戏应用,希望有数百万人一次性付费,否则你很可能正在构建一个随着时间的推移而演变的软件。
更多的时候,我们无法为我们的软件创建一个能够持续多年的模型。我们对领域的理解在变化,我们的无知在减少,我们对用户及其愿望的了解也在增加。三个月前还清楚的事情,现在可能就不那么清晰了,去年还非常合适的模型现在可能已经成为了一个障碍。但是,我们是否一直在花费时间寻找更好的模型,并重构我们的代码以反映这些新的见解呢?嗯,不一定。
当系统处于初期且市场竞争压力巨大时,开发者们被迫优先交付新功能。因此,软件在功能数量、代码行数、数据库表数量以及它们之间的关系上不断增长。我们没有时间停下来,深呼吸,审视模型以查看是否可以进行改进。我甚至不会提及为已经交付的功能重构代码的时间——事情已经完成;项目经理很少会理解重写已工作代码的需求。
但这还不是最糟糕的部分。正如我之前提到的,几乎所有系统都是从一个小型的代码库开始的。一个小的持久化模型,或者我们更常听到的,数据模型,随之而来。我们可能甚至没有类图,但肯定有人花时间为系统创建了一个关系模型,因为关系数据库被视为几乎在这个世界上持久化任何事物的默认选择,尤其是如果你在一个由企业和公司主导的.NET 空间工作,这些企业和公司更喜欢使用来自微软的一切。所以,大多数情况下,我们选择 SQL Server。
也许我画出的这幅图太暗了,但在我几十年的行业工作中,我见过它很多次,很难让它变得柔和。这一切是否让你感到熟悉?那么,究竟哪里出了问题?当然,我们可以把所有的问题都归咎于 SQL 数据库,并试图将它们改变得更加花哨,但我们真的能确定通过这样做就能解决上述问题中的任何一个吗?毕竟,数据库只是工具,就像任何其他工具一样,它被使用或误用。
大泥球
我在上一个部分描述的是一种被称为大泥球(或更精确地说,反模式)的图案。这个术语是由布莱恩·马里克提出的,后来由布莱恩·福特和约瑟夫·约德在他们的论文中普及,该论文出人意料地名为大泥球,发表于 1997 年(www.laputan.org/mud/)。
开发者并不是有意创建后来可以用这个可怕的术语来描述的软件。我们在管理层的压力下不断工作,他们希望从我们所构建的软件中获得价值,他们看到的价值是新的功能。因此,我们实际上没有时间来改进我们软件的结构。至少,这是当我们被问及为什么代码如此复杂且难以维护时,我们给出的最常见的借口。
但是,这是真的吗?这是我们忘记架构和设计的唯一原因吗?当然,给出的原因是有效的,但不是唯一的。我们往往忘记的是,我们并没有真正构建系统。这样的系统已经存在,我们的软件只是某个系统的一部分。
想想任何一种商业。如果你在银行工作,银行就是系统。它可能在没有目前银行员工使用的软件的情况下就已经存在了。银行之间相互连接,并且受到严格的监管。他们有客户,客户对银行提供的服务可靠性以及资金安全有一定的期望。这仅仅是我们周围可以找到的无数例子中的一个。
我们反复犯同样的错误,那就是将软件视为一个独立的系统来开发,而忽略了它只是更大系统的一部分,而这个更大的系统本身也是一个系统。正如我之前所写的,软件通常解决复杂问题,因此存在于某个复杂系统的大景观中。那么,什么是系统?
在唐娜·H·米多斯所著的《系统思考》一书中,将系统定义为元素集,这些元素是相互连接的。这些元素以协调的方式组织。
从如此简短的定义中,我们可以学到许多令人惊讶的东西;目前,让我们集中关注这样一个事实:没有系统只由一个部分组成,系统的各个部分总是相互连接,相互之间交换信息,而这正是我们常常忘记的东西。系统部分不是类、模块、数据库表或存储过程。这些都是某个系统部分的原子。我们通常试图把太多的部分放在一个盒子里,称之为软件系统。让我们检查一个简单的例子,以说明我们不可避免地走向一团糟的大泥球的道路。
想想一个处理客户订单的电子商务系统。显然,我们需要一个地方来保存我们销售的所有产品的信息。当然,我们必须能够下订单并跟踪订单周期,从下单的那一刻直到交付。我们从小处着手,创建一个简单的模型,比如这个:

简单的电子商务模型
当然,过了一会儿,我们意识到每条订单行上的价格不能参考产品价格,而必须在创建行时固定,所以我们也将这一点实现。后来,我们得到了一个要求保留供应商信息的要求。然后,我们必须添加产品图片 URL。当我们开始真正做事时,我们必须保留库存水平信息。不久之后,我们得到了一个要求添加产品包装信息以及尺寸和重量,以便能够计算运费。在我们意识到这一点之前,我们的模型看起来已经相当不同了:

随着时间的推移,模型变得越来越大
你可能会注意到完整的模型肯定不同,因为你可以找到像PaymentMethodId、PaymentTermsId和PaymentId这样的引用,这些必须指向某些表。但我认为这已经足够展示其本质了。
我在这里要说明的是,我们可以清楚地看到,一个小的原始模型在来自不同方向的需求驱动下不受控制地增长,试图一次性满足所有需求。你可能注意到Product表中的一个字段,称为UpdatedAt。这不是我发明的,而是在一个样本模型中发现的,作为 Stack Overflow 上某个问题的答案。现在,让我们想象一下我们放入这个字段中的日期的意义。是在那个日期更新了产品的名称吗?或者价格?或者,也许,库存水平?所有这些变化都有完全不同的原因;然而,我们只有一个字段来保存更改的日期和时间。
这种模型的危险是什么?最终,有无数的产品在其后端有这种模型,其中一些甚至相当成功。但我不这么认为。这样的公司和产品通常是在这种模型下繁荣,而不是因为这种模型。让我们看看当不同利益相关者请求功能时会发生什么:

不仅仅是代码需要耦合。它适用于整个组织。
销售团队现在对以套餐形式销售产品感兴趣。他们告诉我们,这相当简单——我们只需要创建一种特殊类型的产品和简单的父子关系。他们并没有真正考虑供应链,但我们目前所有这些信息都集中在一个地方。如果这个虚构的包装产品的不同组件来自不同的供应商,那么实际上谁是它的供应商呢?库存水平似乎更加复杂,我们似乎必须按需计算?
销售团队还希望与客户保持更紧密的关系,并在他们停止从我们这里购买时给他们打电话。也许他们找到了另一个供应商,我们可以给他们提供折扣?为了做到这一点,我们只需跟踪每个客户的销售总额,并将这些数字与我们的历史数据进行比较。但是,我们没有历史数据,那么我们最初从哪里得到这样的数据呢?
财务团队希望看到支付产品后客户的总未结金额,即所谓的信贷额度客户。如果他们付款延迟,财务部门应根据金额决定不同的催收策略。他们认为这只是在客户表中添加另一个字段。
我们还必须处理营销团队的要求。他们希望通过展示更多产品的图片来提高销量。目前我们只有一张图片,他们至少想要三张;但理想情况下,他们需要任何产品无限数量的图片。因此,我们或者需要在产品表中仅添加两个更多字段,或者添加一个包含多对多关系的照片新表;但在后一种情况下,我们必须处理数据迁移。
所有这些请求都有潜在的冲突。我们可以清楚地看到,当开发人员开始工作在这些新功能上时,模型的相同部分将被触及。让我们给这幅图增加更多损害,并记住我们有三个开发团队。一个团队只做后端更改,另外两个团队做前端工作——一个用于网页,另一个用于传统的 WinForm 客户端。
现在的问题是,我们有三种相互冲突的力量推动他们的请求传递给开发者,而且还有三个相互冲突的开发团队在同一个模型的同一部分工作。很难想象,如果前端团队在后台团队对数据库模型进行必要的更改以及所有必要的层之前完成他们的工作,他们能否完成。
总体来看,我们可以看到,对于任何比Hello World应用更不平凡的事物,单一的模型很可能会导致一团糟。这种情况可能在几个月后发生,但如果有一个小团队的好开发者,公司可能会对这样的模型感到满意一段时间。但是,一旦业务和系统增长,泥潭怪物就会到来,可能会造成很多损害。至少,生产力会受到影响。最严重的情况是,客户会因为缺乏进展和整体应用性能而感到非常烦恼,以至于他们可能会离开。
系统结构
我知道到目前为止,我描绘了一幅相当令人恐惧的画面——当软件无疑地滑向成为一个无法管理的意大利面代码团。你可能会想:如果我们最终陷入恐怖之地,那还有什么意义呢?当我们发现自己身处其中时,我们可以从头开始,从过去的学习中构建一个新的、闪亮的、明亮的系统,使用最新的技术,一切都会再次变得顺利。我们稍后会回到大重写的话题,但现在,让我们思考一下为什么新系统会比旧系统更好。
无论我们是在计划创建一个新的软件系统还是重构旧的系统,至少我们可以做一件事来确保我们的软件在相当长的一段时间内保持良好的状态。我们可能无法使用最喜爱的编程语言、新的闪亮的银弹框架或一个花哨的新数据库,因为我们的组织有一些难以或无法对抗的限制。这些限制很少对我们设计模型的方式施加重大的限制。在领域设计方面,模型是我们找到构建更好软件的关键。
在第三章,“事件风暴法”,我们讨论了领域模型。我们应该记住,模型并不代表现实世界。相反,模型提供了一个简化的现实世界版本,这对于构建特定的软件是相关的。在这本书的整个过程中,我们学会了避免给我们的模型提供比解决我们用软件试图解决的具体问题绝对必要的更多信息和行为。
但现在我们看到,随着软件关注点的增加,模型中所需的信息量也在增加。同时,我们清楚地看到,这些信息是由开发者故意组合成一个单一模型的,他们倾向于用无关的属性丰富领域对象。这通常是因为领域对象名称在不同领域之间似乎相同,但开发者没有意识到这一点。
现在,我们将探讨如何更好地构建我们的软件结构,为具有相似名称但含义不同的概念提供清晰的分离。DDD 提供了 边界上下文 的概念来定义这种分离,我们将探讨如何寻找和定义这些上下文的边界。
请不要误解,当我写关于系统结构化、远离单一模型并引入边界时,这并不意味着拥有多个可执行文件、使用微服务、迁移到 Docker 等等。使领域模型变得合理化是最重要的主题,我们将在下一章中触及实现细节。
语言边界
记得我们在讨论聚合设计主题时提到的 变化速度 吗?所以,你可能想知道我们现在是否在原地打转。确实,我们可以清楚地看到,像产品的缩略图 URL 或照片 URL 这样的东西与产品价格或库存水平没有任何关系。如果产品受欢迎并且我们有足够的客户,库存水平可能会每秒变化。价格也可能动态变化,但我们预计它会更加稳定。然而,照片、重量和包装尺寸可能永远不会改变。将所有这些信息放在一个聚合中并不是我们愿意接受的事情,而且我们已经学到了这一点。
那么,我们把它放在哪里呢?我们是否创建几个名为 Product 的聚合?虽然这听起来可能有些奇怪,但如果我们能识别出每个聚合将存在的边界,答案可能是 是的。我们还必须与我们的领域专家交谈,并获取更多见解,至少关于语言方面。那里可能会有一些新的发现,例如库存水平确实被称为 Inventory,而不是 Product。但对于其他领域,如销售、营销和采购,他们可能使用相同的术语,但含义不同。正如我们在 第二章 中学习的,语言与上下文,我们可以看到上下文在那里正在变化。
当我看到像Customer、Person、Contact或Order这样非常常见的词语在一个庞大的代码库中散布时,我的脑海中就会响起警钟。这里可能有龙或,更具体地说,一个大泥球。这个系统的开发者没有足够谨慎地深入研究领域特定性,以找到这些术语在业务的不同部分中的含义。如果我们看看之前图中Product的例子,我们就可以看到没有特定原因地将不相关的概念放在一个对象中。
我无法强调这种做法对软件及其以外的危险程度。想想您可能参加的与两位领域专家的会议。一位会告诉您添加更多图片以丰富目录。另一位只对配送过程感兴趣,需要产品的重量和包装细节来计算运费。您可能会注意到他们语言上的差异,并且从这样的对话中,您可能会了解到您已经拥有或计划实施的单一模型不会满足业务需求。
因此,定义关于词语意义的边界上下文,寻找特殊性,并渴望真正理解这些词语被使用的上下文,这是找到上下文边界的第一种也是最有力的方法:

词语在上下文中改变含义
如您所见,一个在特定上下文中具有不同含义的单个术语可以在其自身的上下文中以简洁的意义进行建模。我们不再只有一个具有无关属性的单一对象,而是有多个。这些新对象要小得多,只包含与特定上下文相关的信息。在一个上下文中对对象所做的更改不应影响其他上下文中具有相同名称的所有其他对象,并且可以由在该上下文中进行更改的开发者自由地进行,从而消除或至少最小化在其他地方引入问题的风险。自然地,这引出了边界上下文的下一个目的和好处:开发团队的自主性。
上下文边界的另一个方面是扩展的告诉,不要询问原则。原始原则是由 Alec Sharp 在他的 1997 年出版的书籍《Smalltalk by Example》中提出的。
如您可能从自己的经验中注意到的,在生产的软件中有很多使用过程式风格的代码,即使它是用面向对象的语言编写的。不幸的是,基于.NET 的项目深受其害,特别是那些使用 WinForms 和 ASP.NET (dotnet.microsoft.com/apps/aspnet) WebForms 编写的项目。我见过许多应用程序,其业务逻辑集中在代码中——在 UI 元素后面,分散在许多OnClick事件处理器中。
设计更好的软件使用独立的类来实现业务逻辑。将业务逻辑从 UI 逻辑和持久性中隔离出来的模式被称为多层架构或n 层架构。维基百科将这个术语定义为一种客户端-服务器类型的架构,但这并不完全准确。如果应用程序是一个桌面富客户端应用程序或具有服务器端渲染功能的 Web 应用程序,其中所有操作都会导致与服务器的一次往返,那么即使没有分离客户端和服务器代码,也可以有多个层次。
当我们在寻找上下文边界时,一个可以帮助我们找到它们的指标是所需做出决策所需信息的可用性。当用户(这可能包括其他系统)向领域模型发送命令时,模型本身必须能够处理这个命令,而无需去系统的其他部分获取信息。这可能在开始时有些令人困惑,因为我们可能会看到一些命令需要大量的信息,而这些信息我们认为是位于执行命令的上下文之外。让我们再次看看电子商务的例子:

做出决定需要几个查询
当我们告诉Sales上下文的领域模型放置一个订单时,它似乎需要从其他上下文中获取大量信息来做出决策。这可能会造成相当多的困惑,如果我们没有经过思考就跟随直觉,我们几乎无法抵制把整个系统的所有信息放在一个地方的冲动。而且,大泥球问题再次出现。
在现实中,我们只需要从其他环境中获取信息的一小部分来做出那个决定。此外,如果我们考虑影响该信息的命令,我们可以清楚地看到,其中没有任何一个会触及销售环境。例如,库存水平仅在Inventory环境中更新。客户的可用信用额度在协议的初始信用额和未付款发票数量之间保持平衡。客户的联系信息以一种完全独立的方式更新,与其他任何事物无关。
但是,你可能已经注意到,仍然存在一定程度的信息交换。库存水平是如何变化的?客户的未付款金额是如何更新的?当然,有一些动作会触发这些更新。我们感觉这些触发器与订单处理有关,这甚至可能会增加我们把这些东西放在一起的确信度。
但等等,订单下单时库存水平并不一定会减少。我们可能需要与领域专家澄清这一点,但可以肯定的是,库存只有在订单发货时才会更新,而这可能不是由“销售”部门处理,而是由“发货”上下文处理。同样,总未结金额也只有在发送订单发票时才会更新。这反过来可能只发生在订单发货或交付时。所以,你可以看到这些上下文之间的联系并不是那么直接和简单。其中涉及更多的逻辑,我们可能一开始甚至都没有考虑到。
在下一章中,我们将简要讨论跨上下文通信的话题,但现在我可以告诉你,它遵循相同的“只告诉,不询问”原则。上下文在执行每个命令后都会发出领域事件,正如我们在第三章“事件风暴”中学到的,甚至在早期章节中已经实现。我们从未使用这些事件来共享信息,但这正是必要且仅必要的数据跨越上下文边界的方式。因此,我们可能需要一些原本未考虑到的上下文特定对象。让我们看看“下单”命令的修订图和执行它所需的详细信息:

只告诉的流程
在这里,你可以看到已经没有“询问”箭头了。我们所做的只是“告诉”其他人去做某事,或者某事已经完成。
有两点我想引起你的注意:
-
你可能会想知道为什么 CRM 上下文没有与“销售”上下文共享任何信息。我们难道不需要客户详情来下单吗?并不完全是这样。我们可能需要这些信息来知道客户的名字和联系详情,以便知道订单需要送到哪里。但没有任何这些详情是决定我们是否可以下单所必需的。当然,我们可能会遇到更复杂的要求,当某些送货地址的订单无法下单时,但这需要与领域专家澄清,并且显然需要更多的领域知识才能将这些约束纳入系统中。
-
我经常听到抱怨,说在不同上下文中保持同一物理实体的信息会导致数据重复。在前面图表中展示的模型中,情况并非如此。我们共享的信息并不完全等同于每个上下文中我们保留的数据。例如,可供销售的产品数量并不一定复制当前的库存项目。可能涉及一些更复杂的规则,而销售领域对这些规则保持愉快的不知情,这些规则完全属于“库存”上下文内部。但是,一些数据肯定会被重复,这是为了保持我们的模型整洁而付出的微小代价。在多个地方保留相同的数据并没有什么害处,现在的磁盘空间并不是一个大问题。
但我们绝对必须确保保持信息同步是可靠的,这一点我们将在下一章中探讨。
团队自主性
如果你曾经使用过看板或者至少了解过它,你可能记得这个方法的圣杯是减少工作在进度中(WIP)。你可以从看板实践者那里学到很多东西,但如果你不限制 WIP,你其实并没有在做看板。顺便说一句,这也是为什么团队在尝试这种方法时经常失败的原因。
最初,将工作分成小批量以消除队列和沿价值创造流储存材料的想法来自制造业。你可能听说过源自看板的丰田之道,或者由 Eliyahu M. Goldratt 提出的约束理论(ToC)。尽管这些方法的哲学来自一个相当不同的行业,其中一些方面与软件行业截然不同,但我想要强调以下两个原则,我们可以直接将这些原则应用到我们的工作中,以便更有效地交付业务价值:限制工作在进度中和提高吞吐量。
限制工作在进度中
当一个团队或个人开发者同时处理一个待办事项列表时,完成列表中的任何一项通常会比他们只专注于一件事情花费更多的时间。这种情况尤其是因为上下文切换,正如我在第二章“语言与上下文”中提到的。当我们从一项任务切换到另一项任务时,总会有一段时间损失,这是为了将我们的思维状态调整到可以高效地处理新任务。我们未完成的事情越多,需要记住的事情就越多,当我们回到未完成的任务时,就需要从记忆和笔记中恢复这些事情。
当团队在一个具有单一模型和单一单体代码库的系统中工作时,这些团队将需要相互协调。协调可能需要防止因无关原因而更改共享类别的变化。他们还可能需要确保在一个团队进行更改之前,另一个团队做一些前置工作。数据迁移、回归测试、协调发布等等——我们对团队之间这种类型的依赖关系非常熟悉。
但是管理层很少关心这种依赖关系。如果在站立会议期间,一个团队报告说他们正在等待另一个团队完成他们的工作,他们的经理会要求他们在等待期间做其他事情。当这种情况发生时,他们会得到一个无法立即完成的 WIP 项目,因此他们会从待办事项中拉出一个项目并开始工作。那个项目可能也会有一些依赖关系,因此未完成的工作数量会像雪球一样滚雪球般增加。团队开始互相指责,因为他们都在等待别人,以至于什么都没有交付。
在这种情况下,受影响最大的是吞吐量。
提高吞吐量
如果交付管道中的某个步骤很慢,整个管道的生产能力不会超过这个单个步骤。当团队在一个单一代码库和单一模型上工作时,他们可能会遇到两个主要问题。首先,正如前一段所述,总是不断增长的工作在进度中。持续的协调和连续的等待导致大量未完成的工作。一些来自进行中列表的项目在等待结束后最终会再次被处理,但随后是上下文切换。有时,团队可能会花费几天或几周的时间来恢复几个月前搁置的工作。在跨团队协调——等待其他人完成前置工作并回到新上下文——这段时间内,时间被浪费了。在这段时间里,没有产生任何价值,但钱就像团队在做一些有用的事情一样被花掉了。
可能影响吞吐量的另一个因素是瓶颈。如果所有变更都需要由两个团队完成,无论一个团队的速度有多快或有多聪明,如果另一个团队速度慢或人员不足或技能不够,那么工作将不会在两个团队中最慢的那个团队完成其部分之前完成。这种情况通常发生在组织不是基于领域专业知识或功能,而是基于技术技能来划分团队的情况下。一个典型的例子是前端团队、后端团队和数据库团队。你不需要所有三个团队,只要有两个就足以对价值链造成巨大的损害。在数据库模式更改之前,后端无法完成任何工作。在后台工作成形之前,至少在 API 合约的形式(如果公司足够有技能来拥抱基于合约的开发)中,前端团队几乎无法做什么,而不会面临重写一半代码的风险。
当团队以这种方式组织时,我们可以在现实生活中观察到康威定律最糟糕的例子。
康威定律
五十年前,在 1968 年,Melvin E. Conway 发表了名为《委员会如何发明?》的论文(www.melconway.com/research/committees.html)。也许这篇论文最被引用的部分就是我们现在称之为康威定律的表述,它指出如果一个组织设计一个系统,它将产生一个与该组织通信结构副本的设计。
我现在不想让你负担太多细节,但请记住,这个定义变得如此相关,以至于 2017 年 DDD 欧洲会议的主题就是康威定律。
在本章的背景下,我个人的观察是,组织如何构建团队直接影响了他们软件的结构,这非常证实了 Mel Conway 的假设。我给出了一个技术导向团队的例子,因为我在我职业生涯中不止一次经历过。在一个组织中,我看到负责系统用户界面 Web 部分的团队与负责同一系统的丰富客户端的团队发生了如此多的冲突,以至于他们决定为各自创建一个独立的领域模型,并支持一个独立的数据库模型,以便使用。这是为了完全避免这些团队之间有阻塞依赖,因为他们找不到一个好的协调方式。这些团队之间因为互相指责而流了很多血,但在我看来,这种情况是不可避免的,没有人应该为此负责,除了决定以这种方式构建团队的管理层。
团队间的协调很少有效,通常会导致延误,花费大量时间在会议和上下文切换上。它还会在团队之间产生紧张关系,并增加陷入责任游戏的危险。我们可以谈论提高沟通多年,但这甚至不会稍微改善协调。
松耦合,高内聚
我可能已经让你淹没在我们行业今天大规模遭受的问题的海洋中。让我为这幅图带来一些光明,并给你一些线索,关于我们如何可以改善这种情况。
作为开发者,我们经常听到我们需要努力追求代码中的松耦合和高内聚。总的来说,一个单元负责一件事的原则不仅适用于我们代码中的类,例如,也适用于面向服务的架构(SOA)中的服务。它同样适用于团队的责任。围绕团队的技术专长来构建团队是不太理想的。让我们看看当有两个不同的功能请求时,这样的团队可能会如何运作:

需要协调的流程
图中展示的流程被大大简化了,但你能理解这个概念。那些团队在完成这两个故事时所做的、分布在各个工作块之间的所有空白区域几乎都是浪费的。这些时间本可以用来等待、上下文切换以及当不同团队的开发者需要就其他团队交付成果中的工作顺序和发现的问题达成一致时进行的小规模协调。最终,在规定的时间内没有完成任何交付,团队不断在任务之间切换,但发布却一直被不断推迟。
然而,如果团队是围绕业务功能或领域组织起来的,情况将完全不同:

无需协调的流程
不要被工作的线性流程所误导,因为它被放置在单一的时间线上。所有这些步骤都可以在迭代中进行,但如果团队需要协调,迭代本身并不能救你。
即使工作量或多或少相同,尽管由于更好的专注度和更深入的领域知识,这可能会更少,但协调努力和上下文切换已经消失了。两个团队都能够独立发布并准备好接受新任务。
高度一致性和松散耦合的团队是备受赞誉的 Netflix 文化的基本原则之一,例如。使这种结构甚至可行的一个先决条件是每个团队都有明确的操作边界。然而,这并不意味着团队必须被分配到单一边界上下文中工作。你可能会识别出六个上下文,但只有三个团队。在这种情况下,首先需要考虑团队规模。如果每个团队有 10 名成员,可能希望有更多规模较小的团队。五人团队在保持信息共享紧密和反馈循环短方面是可行的。
但是,一个团队可以拥有的上下文数量没有限制。我可以很容易地想象一个由只有 10 名工程师的初创公司开发的相当复杂的系统。他们可能会发现,由于他们试图解决的问题的复杂性,系统需要用 10 个边界上下文来建模。如果他们把所有开发人员分成两个五人团队,每个团队可以处理多个上下文。这里最重要的方面是上下文的拥有权,因此团队不应共享上下文。所有权的转让是可能的,但应该是罕见的,并且应该是完整的,以便在转让完成后,只有一个团队拥有该上下文。
边界上下文与团队之间的匹配也可以提供公司可能需要的工程师数量的指示。如果核心业务领域被正确识别,大多数开发人员很可能会在核心领域工作。随着时间的推移,可能会出现一个团队专注于核心业务问题,而另一个团队则处理所有支持性子域的工作,例如账户管理、支付处理和账单。当软件的复杂性增加时,这些支持性子域可能会转移到新的团队。如果已经确定了新的核心领域,其他团队需要负责。
地理位置
关于康威定律的最后一个要点是团队的物理位置。这可能令人惊讶,但这一方面非常重要。如今,越来越多的公司雇佣远程工作者,他们在家工作,并在地球的不同部分保持开放式办公室。请记住,尽管这种劳动力分布可以更广泛地获取人才,但也带来了与本地团队和分布式团队完全不同的沟通负担。
如果一个边界上下文被分配给一个包含不同国家成员的团队,或者更糟糕的是,在完全不同的时区,将涉及很高的风险。如果这个团队的工程师是经验丰富的远程工作者,可能根本不是问题。但如果这个团队的人习惯在办公室工作,突然被要求与一个在他们计划回家时醒来的人一起工作,可能根本行不通。因此,如果你的公司没有太多与远程员工一起工作的经验,保持地理集中化是一个好主意。
这不应该被视为雇佣其他国家人员并让他们远程工作的障碍。但你可能希望有几个人能坐在同一个地方,或者至少住在同一个城镇,这样他们可以高效沟通,定期会面,甚至共享一个办公室,并将他们称为一个团队。这样的团队可以轻松地承担你软件的一个或多个边界上下文的所有权。协调负担的减轻和高度自主性——这些明确定义的边界上下文的益处无疑将使远程团队更加成功和高效。
事实上,我相信我们听到的许多失败,当公司在开设远程办公室一段时间后宣布这次经历是失败时,这与这些公司无法将分配给远程团队的工作明确为边界上下文有关。如果团队在一个共享的代码库上工作,他们必须协调。他们可能会做出相互冲突的更改,而当团队地理上分散时,所有这些负担和挫败感都会增加 10 倍。
摘要
在本章中,我们终于开始使用“边界上下文”这个术语。对于刚开始学习领域驱动设计(DDD)的人来说,这往往被忽视。在埃里克·埃文斯(Eric Evans)的《边界上下文》一书中,它被解释在战略设计部分,这部分在书中出现得相当晚。在介绍边界上下文概念之前,这本书介绍了许多有用的模式。自然地,人们开始使用他们所知道的东西,有时发现这已经足够了。
但不要误解,DDD(领域驱动设计)的力量并不在于聚合(aggregates)和仓储(repositories)。如果你有一个针对大型、复杂软件系统的单一模型,拥有聚合和仓储并不能帮助你。当大量开发者使用单一模型时,他们会面临广泛的协调需求、冲突的变更、回归错误、认知过载和持续的上下文切换问题。在这样一个系统中,上下文没有得到适当的阐述,但这并不意味着它们不存在。只要业务中有人专门执行不同的业务功能,这些上下文就会存在。上下文隐藏在大量的代码行、众多类和包含关于发生的一切信息的数据库表中。因此,上下文切换是存在的,无论你是否喜欢。
正如我第一次从 Vaughn Vernon 那里听说,DDD(领域驱动设计)是在边界上下文中开发的一种通用语言。我非常喜欢这个定义。它将 DDD 的两个最重要的原则带到了聚光灯下。没有什么比正确使用语言更重要,然后为具有相同但含义不同的词汇找到语言边界。这将是我们找到上下文边界的第一个明显步骤。
康威定律(Conway's law)是你不能忽视的东西。如果团队不是按照业务能力和功能来组织,而是按照技术责任来专业化,即使完美的边界上下文也无法帮助你。只有围绕系统功能方面组织、负责一个或多个边界上下文、能够有效且成功工作的跨职能团队才能做到这一点。这是因为定义良好的上下文边界为团队带来了最高的自主权,只要不超过一个团队拥有一个边界上下文。这并不意味着你的组织必须有与已识别的边界上下文数量一样多的团队。一个团队可能可以处理多个上下文,但反之则不然。高一致性和松散耦合不仅适用于类和服务;这些原则对于构建能够交付成功的团队是基本的。
不要忘记你同事的位置。分布式团队如果由有远程工作经验的人组成,是可以工作的。但远程团队可以负责一个或多个边界上下文,因为它们不需要与其他团队进行大量的协调。


浙公网安备 33010602011771号