Java-领域驱动开发-全-
Java 领域驱动开发(全)
原文:
zh.annas-archive.org/md5/1c486fa482e8d23299d9403a0ce535b5译者:飞龙
前言
领域驱动设计(DDD)提供了一套原则、模式和技巧,让领域专家、架构师、开发人员和其他团队成员可以采用这些原则共同工作,并将复杂系统分解成结构良好、协作和松散耦合的子系统。当埃里克·埃文斯在 21 世纪初引入这些概念时,从很多方面来看,这些原则远远超出了它们的时代。我们正处于单体架构的时代,面向服务的架构(SOA)作为一个概念刚开始生根发芽,而云、微服务、持续交付等甚至还未出现!虽然采用其战术方面相对容易,但 DDD 的战略方面在很大程度上仍被视为不必要的额外开销。
快进到今天,我们正在构建我们迄今为止最复杂的软件解决方案,同时需要应对更加复杂的组织和团队结构。此外,公共云的使用几乎成为必然。这导致了一种情况,即分布式团队和应用程序几乎成为常态。同时,我们也处于一个需要将上一代应用程序现代化的时代。所有这些都使得领域驱动设计(DDD)的原则,特别是战略元素,获得了很高的关注度。
我们一直是这些概念的实践者,并从我们的经验中获得了宝贵的见解。多年来,我们看到了许多进步,使得在更大范围内采用 DDD 成为可行的选择。本书是我们所有集体经验的结晶。虽然我们从该主题的早期作品中汲取了许多灵感,但我们非常注重以实践者的心态来应用这些概念,以便降低那些希望在其构建复杂、分布式软件的旅程中持续发展和繁荣的团队所面临的门槛。
本书面向的对象
本书是为具有多种角色和技能的读者群体所撰写的。虽然 DDD 的概念已经存在很长时间,但实际应用和扩展一直是一个挑战,这在很大程度上是由于缺乏将所有这些概念作为一个整体结合起来的实用技术、工具和真实世界案例。成功应用这些原则需要组织内部不同角色和学科之间的紧密合作,包括高管、业务专家、产品负责人、业务分析师、架构师、开发人员、测试人员和运维人员。
这里是对读者角色和他们在阅读本书中将获得的内容的简要总结:
高管和业务专家应该阅读本书,以便他们能够清晰地表达自己的愿景和证明解决方案必要性的核心概念。技术将使他们能够迅速地做到这一点,并增进对快速可靠地实施变革所需付出的努力的同情。
产品负责人应该阅读这本书,以便在沟通业务和技术团队成员时能够作为有效的促进者,确保没有翻译上的损失。
架构师应该阅读这本书,以便他们能够理解在思考解决方案之前理解问题的重要性。他们还将欣赏到各种架构模式以及它们如何与 DDD 原则协同作用。
开发人员和测试人员将能够利用这本实用指南将他们的知识付诸实践,创建出既易于使用又令人愉悦的优雅软件设计,并便于推理。
本书提供了一种动手方法来有效地收集需求,促进团队成员之间的共同理解,以便实施能够经受动态演变商业生态系统考验的解决方案。
本书涵盖的内容
第一章,领域驱动设计(DDD)的原理,探讨了 DDD 实践提供了一套指南和技术,以提高我们成功的概率。我们将探讨埃里克·埃文斯(Eric Evans)在 2003 年出版的关于该主题的经典书籍至今仍极具相关性。我们还将介绍战略和战术 DDD 的要素。
第二章,领域驱动设计(DDD)的适用位置和方式,探讨了 DDD 与几种架构风格相比的情况,以及它在构建软件解决方案的整体方案中的适用位置和方式。
第三章,理解领域,在虚构的 KP 银行中介绍了示例领域(国际贸易)。我们还探讨了如何使用商业模式画布、影响图和沃德利图等技术开始战略设计。
第四章,领域分析和建模,继续使用领域叙事和事件风暴等技术对示例问题领域——信用证(LC)应用进行分析和建模,以达成对问题的共同理解,并激发想法以找到解决方案。
第五章,实现领域逻辑,实现了示例应用的命令端 API。我们将探讨如何采用事件驱动架构来构建松散耦合的组件。我们还将探讨如何通过对比状态存储和事件源聚合来实现结构和业务验证以及持久化选项。
第六章,实现用户界面——基于任务的,为示例应用设计了用户界面(UI)。我们还将向服务实现表达对 UI 的期望。
第七章,实现查询,深入探讨了如何通过监听领域事件来构建数据读取优化的表示。我们还将探讨这些读取模型的持久化选项。
第八章,实现长时间运行的工作流程,探讨了实现长时间运行的用户操作(叙事)和截止日期。我们还将探讨如何通过日志聚合和分布式跟踪来跟踪整体流程。最后,我们将讨论何时/是否选择显式编排组件或隐式编排。
第九章,与外部系统集成,探讨了与其他系统和边界上下文集成的各种风格及其选择每种风格的影响。
第十章,开始分解之旅,将示例边界上下文的命令和查询方面分解为不同的组件。我们将探讨在这些选择中涉及的权衡。
第十一章,分解为更细粒度的组件,探讨了细粒度分解及其涉及的技术影响之外的权衡。我们将把我们的应用程序分解为不同的功能,并讨论在哪里划线可能更合适。
第十二章,超越功能需求,探讨了在应用程序分解中起重要作用的业务需求之外的因素。具体来说,我们将研究在应用领域驱动设计(DDD)时,跨职能需求产生的影响。
为了充分利用本书
本书面向广泛的软件开发团队成员角色。假设读者有一定的软件开发解决方案构建经验。本书中的代码示例使用 Java 编程语言。熟悉面向对象和 Spring 等框架将非常有帮助。

如果您正在使用本书的数字版,我们建议您亲自输入代码或从本书的 GitHub 仓库(下一节中提供链接)获取代码。这样做将帮助您避免与代码复制粘贴相关的任何潜在错误。
下载示例代码文件
您可以从 GitHub 下载本书的示例代码文件:github.com/PacktPublishing/Domain-Driven-Design-with-Java-A-Practitioner-s-Guide。如果代码有更新,它将在 GitHub 仓库中更新。
我们还有其他来自我们丰富的书籍和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们!
下载彩色图像
我们还提供了一份包含本书中使用的截图和图表彩色图像的 PDF 文件。你可以从这里下载:
使用的约定
本书使用了多种文本约定。
文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“如图所示的事件风暴工件中,LC 应用程序聚合器能够处理 ApproveLCApplicationCommand,这导致 LCApplicationApprovedEvent。”
代码块设置如下:

小贴士或重要提示
看起来像这样。
联系我们
我们始终欢迎读者的反馈。
一般反馈: 如果你对此书的任何方面有疑问,请通过 customercare@packtpub.com 给我们发邮件,并在邮件主题中提及书名。
勘误: 尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果你在这本书中发现了错误,我们将不胜感激,如果你能向我们报告这一点。请访问www.packtpub.com/support/errata并填写表格。
盗版: 如果你在互联网上以任何形式遇到我们作品的非法副本,如果你能提供位置地址或网站名称,我们将不胜感激。请通过版权@packt.com 与我们联系,并提供材料的链接。
如果你有兴趣成为作者:如果你在某个领域有专业知识,并且你感兴趣的是撰写或为书籍做出贡献,请访问authors.packtpub.com。
分享你的想法
一旦你阅读了《使用 Java 的领域驱动设计 - 实践者指南》,我们很乐意听听你的想法!请点击此处直接进入此书的亚马逊评论页面并分享你的反馈。
你的评论对我们和科技社区都很重要,并将帮助我们确保我们提供高质量的内容。
第一部分:基础
虽然 IT 行业自豪地认为自己处于技术最前沿,但它也监管着相当比例的项目,这些项目要么完全失败,要么由于各种原因未能实现最初设定的目标。在第一部分中,我们将探讨软件项目未能实现预期目标的原因,以及实践领域驱动设计(DDD)如何显著提高成功的几率。我们将快速浏览埃里克·埃文斯在其同名奠基性著作中阐述的主要概念,并探讨为什么/如何它在分布式系统时代极为相关。我们还将探讨几种流行的架构风格和编程范式,并探索 DDD 如何融入整个体系。
本部分包含以下章节:
-
第一章**,领域驱动设计的原理
-
第二章**,DDD 如何适应?
第一章:领域驱动设计的理由
任何服从任何权威而不是理性的人都不能被称为理性或道德的。
——玛丽·沃斯通克拉夫特
根据 2020 年 2 月发布的项目管理协会(PMI)的《职业脉搏》报告,只有 77%的所有项目达到预期目标——即使在最成熟的组织中也是如此。对于不太成熟的组织,这个数字下降到仅为 56%;也就是说,大约每两个项目中就有一个没有达到预期目标。此外,大约每五个项目中就有一个被宣布为彻底失败。与此同时,我们似乎也在着手进行我们最雄心勃勃和最复杂的项目。
在本章中,我们将探讨项目失败的主要原因,并查看应用领域驱动设计(DDD)如何提供一套指南和技术,以提高我们成功的几率。虽然埃里克·埃文斯在 2003 年就写下了关于这个主题的经典书籍,但我们来看为什么这项工作在今天仍然极其相关。
在本章中,我们将涵盖以下主题:
-
理解软件项目失败的原因
-
现代系统的特征和应对复杂性
-
领域驱动设计简介
-
回顾为什么 DDD 今天仍然相关
到本章结束时,你将获得对 DDD 的基本理解,以及为什么在架构/实现现代软件应用时,你应该强烈考虑应用 DDD 的原则,特别是对于更复杂的应用。
为什么软件项目会失败?
失败仅仅是开始再次尝试的机会,这次更加明智。
——亨利·福特
根据 PMI 的《项目管理杂志》发布的项目成功报告,以下六个因素必须真实存在,一个项目才能被认为是成功的:

表 1.1 – 项目成功因素
在应用所有这些标准来评估项目成功的情况下,大量项目由于各种原因而失败。让我们更详细地考察一些主要原因。
不准确的需求
PMI 的《职业脉搏》报告(2017 年)强调了一个非常明显的事实——绝大多数项目失败是由于不准确或误解的需求。因此,如果建成了错误的东西,那么就不可能建成客户可以使用、满意并且使他们工作更有效率的东西——更不用说项目能否按时并在预算内完成。
IT 团队,尤其是在大型组织中,由单一技能角色组成,如 UX 设计师、开发者、测试员、架构师、业务分析师、项目经理、产品所有者和业务赞助人。在许多情况下,这些人属于不同的组织单位/部门——每个单位/部门都有自己的优先级和动机。更糟糕的是,这些人之间的地理分隔还在不断加大。为了降低成本和最近的 COVID-19 生态系统也不利于解决这个问题。
![Figure 1.1 – 隔离思维和信息保真度损失]

图 1.1 – 隔离思维和信息保真度损失
所有这些都导致在装配线每个阶段的信息保真度下降,进而导致误解、不准确、延误,最终失败!
过多的架构
编写复杂的软件是一项相当艰巨的任务。你不能只是坐下来开始敲代码——尽管这种方法在某些简单情况下可能有效。在将业务理念转化为可工作的软件之前,对当前问题的彻底理解是必要的。例如,如果不了解信用卡是如何工作的,就不可能(或者至少非常困难)构建信用卡软件。为了传达你对问题的理解,在编写代码之前创建软件模型来表示问题及其解决方案的架构是很常见的。
努力创建一个完美的问题模型——在非常广泛的背景下都是准确的——并不亚于传说中的圣杯之旅。负责产生架构的人可能会陷入分析瘫痪和/或前期大设计,产生出过于高级、充满幻想、镀金、口号驱动或脱离现实世界的工件——而未能解决任何真正的业务问题。这种锁定在项目早期阶段,当团队成员的知识水平还在上升时,可能会特别有害。不用说,采用这种方法的项目的成功往往难以持续。
小贴士
要获取建模反模式的更全面列表,请参考 Scott W. Ambler 的网站(agilemodeling.com/essays/enterpriseModelingAntiPatterns.htm)和书籍,《敏捷建模:极限编程和统一过程的实用方法》,该书专门论述了这一主题。
架构过少
敏捷软件开发方法在 20 世纪 90 年代末和 21 世纪初显现出来,是对被称为“瀑布”的重量级流程的反应。这些流程似乎更倾向于前期的大规模设计和基于愿望、理想世界场景的抽象象牙塔思维。这是基于这样一个前提:提前深思熟虑可以避免在项目进展过程中出现严重的开发问题。
相比之下,敏捷方法似乎更倾向于一种更加灵活和迭代的软件开发方法,高度关注可工作的软件而不是其他工件,如文档。如今,大多数团队都声称在实践某种形式的迭代软件开发。然而,这种对声称符合特定敏捷方法论家族而不是基本原理的执着,导致许多团队误解了“足够的架构”与“没有明显架构”之间的区别。这导致了一种情况,即添加新功能或增强现有功能所需的时间比以前更长——这进而加速了解决方案的退化,变成了令人恐惧的“大泥球”(www.laputan.org/mud/mud.html#BigBallOfMud)。
过度的偶然复杂性
迈克·科恩(Mike Cohn)普及了测试金字塔的概念,他在其中谈到,大量的单元测试应该构成一个健全测试策略的基础——随着你向上移动金字塔,数字会显著减少。这里的逻辑是,随着你向上移动金字塔,维护成本急剧上升,而执行速度却大幅下降。然而,在现实中,许多团队似乎采用了与此完全相反的策略——被称为测试冰淇淋锥形,如图所示:

图 1.2 – 测试策略:期望与现实
测试冰淇淋锥形是弗雷德·布鲁克斯在其经典论文《没有银弹——软件工程中的本质与偶然》中提到的偶然复杂性的一个典型案例(worrydream.com/refs/Brooks-NoSilverBullet.pdf)。所有软件都有一定程度的本质复杂性,这是解决问题的固有属性。这在为非平凡问题创建解决方案时尤其如此。然而,偶然或意外的复杂性并不是直接归因于问题本身——而是由涉及人员的局限性、他们的技能水平、工具和/或使用的抽象造成的。不关注偶然复杂性会导致团队偏离关注真正的问题,解决这些问题可以提供最大的价值。因此,这样的团队成功的机会显著降低。
无法控制的债务
财务债务是指从外部借款以快速资助企业的运营——承诺及时偿还本金加上约定的利率。在适当的条件下,这可以显著加速企业的增长,同时允许所有者保留所有权、降低税收和较低的利率。另一方面,如果不能按时偿还这笔债务,可能会对信用评级产生不利影响,导致利率上升、现金流困难和其他限制。
技术债务是当开发团队采取可能不是最佳的行动来加速一组功能或项目的交付时产生的。在一段时间内,就像借款允许你比其他方式更快地做事一样,技术债务可以带来短期速度。然而,从长远来看,软件团队将不得不投入更多的时间和精力来简单地管理复杂性,而不是思考产生架构上合理的解决方案。这可能导致以下图中所示的恶性负面循环:

图 1.3 – 技术债务:影响
在最近麦肯锡公司对 CIOs 进行的一项调查(www.mckinsey.com/business-functions/mckinsey-digital/our-insights/tech-debt-reclaiming-tech-equity)中,大约 60%的受访者表示,过去 3 年中技术债务的数量有所增加。与此同时,超过 90%的 CIOs 将不到五分之一的科技预算用于偿还债务。马丁·福勒探讨了(martinfowler.com/articles/is-quality-worth-cost.html#WeAreUsedToATrade-offBetweenQualityAndCost)高软件质量(或缺乏质量)与增强软件的可预测性之间的深层相关性。虽然携带一定量的技术债务是不可避免的,也是商业活动的一部分,但没有计划系统地偿还这些债务可能会对团队生产力和交付价值的能力产生严重影响。
忽略非功能性需求
利益相关者通常希望软件开发团队将大部分(如果不是全部)时间用于开发提供增强功能的功能。考虑到这些功能提供了最高的投资回报率,这是可以理解的。这些功能被称为功能需求。
非功能性需求(有时也称为跨功能性需求),另一方面,是指那些不直接影响功能但会对使用和维护这些系统的人的效率产生深远影响的系统方面。有许多种类的 NFR。以下图中展示了常见 NFR 的部分列表:


图 1.4 – 非功能性需求(NFRs)
用户很少会明确要求非功能性需求(NFRs),但他们几乎总是期望这些功能成为他们使用的任何系统的一部分。很多时候,系统可能在没有满足非功能性需求的情况下继续运行,但这会对用户体验的“质量”产生不利影响。例如,在低负载下加载时间不到 1 秒,而在高负载下加载时间超过 30 秒的网站主页,在压力时期可能无法使用。不用说,如果不以与显式、增值的功能特性相同的标准来对待非功能性需求,可能会导致无法使用的系统——进而导致失败。
在本节中,我们探讨了导致软件项目失败的一些常见原因。我们能否提高我们的胜算?在我们这样做之前,让我们看看现代软件系统的本质以及我们如何应对随之而来的复杂性。
现代系统和处理复杂性
我们不能用创造问题的同一层次的思维来解决我们的问题。
—— 阿尔伯特·爱因斯坦
正如我们在上一节中看到的,软件项目失败有几个原因。在本节中,我们将尝试理解软件是如何被构建的,目前存在的现实情况是什么,以及我们需要做出哪些调整来应对。
软件是如何被构建的
构建成功的软件是一个不断精炼知识和以模型形式表达的过程。我们试图在这里从高层次上捕捉这一过程的精髓:


图 1.5 – 开发软件是一个持续的知识和模型精炼过程
在我们将解决方案以工作代码的形式表达出来之前,有必要理解问题所包含的“什么”,为什么这个问题需要解决,以及最后“如何”解决它。无论使用的方法论(瀑布、敏捷,以及两者之间的任何方法),构建软件的过程都是一个需要我们不断运用知识来精炼心理/概念模型,以便能够创造有价值的解决方案的过程。
复杂性是不可避免的
我们发现自己正处于第四次工业革命之中,世界正变得越来越数字化——技术成为企业价值的重要驱动力。正如摩尔定律所展示的,计算技术已经取得了指数级的进步:


图 1.6 – 摩尔定律
这也与互联网的兴起相吻合。

图 1.7 – 全球互联网流量
这意味着公司需要比以往任何时候都要更快地现代化他们的软件系统。伴随着所有这些,商品计算服务的出现,如公共云,导致了从昂贵的集中式计算系统转向更分散的计算生态系统。当我们试图构建最复杂的解决方案时,单体正在被分布式、协作的微服务环境所取代。现代哲学和实践,如自动化测试、架构适应性函数、持续集成、持续交付、DevOps、安全自动化和基础设施即代码等,正在颠覆我们交付软件解决方案的方式。
所有这些进步都引入了自己的复杂性。而不是试图控制复杂性的数量,我们需要接受并应对它。
优化反馈循环
随着我们进入遇到最复杂商业问题的时代,我们需要拥抱新的思维方式、发展哲学和一系列技术,以迭代地进化成熟的软件解决方案,这些解决方案将经受住时间的考验。我们需要更好的沟通方式、分析问题、达成共识、创建和建模抽象,然后实施和增强解决方案。
明白地说——我们都在用看似绝妙的商业理念一边构建软件,另一边则是我们不断要求苛刻的客户,正如这里所示:
![图 1.8 – 软件交付连续体
![图片/B16716_Figure_1.8.jpg]
图 1.8 – 软件交付连续体
在此过程中,我们需要跨越两个鸿沟——交付管道和反馈管道。交付管道使我们能够将软件交到客户手中,而反馈管道则允许我们调整和适应。正如我们所见,这是一个连续体。如果我们想要构建更好、更有价值的软件,这个连续体,这个可能无限循环的过程,必须得到优化!
为了优化这个循环,我们需要三个特征同时存在:我们需要快速、我们需要可靠,并且我们需要不断地重复这样做。换句话说,我们需要快速、可靠和可重复——同时!去掉任何一个,它都无法持续。
领域驱动设计(DDD)承诺以系统化的方式提供答案。在接下来的章节中,以及本书的其余部分,我们将探讨领域驱动设计(DDD)是什么,以及为什么在为当今大规模分布式团队和应用程序中的非平凡问题提供解决方案时,它是不可或缺的。
什么是领域驱动设计(DDD)?
生活其实很简单,但我们却坚持让它变得复杂。
—— 孔子
在上一节中,我们看到了众多原因以及系统复杂性是如何阻碍软件项目成功的。DDD(领域驱动设计)的概念,最初由埃里克·埃文斯在其 2003 年的著作中提出,是一种专注于以模型的形式表达软件解决方案的软件开发方法,该模型紧密体现了所解决问题的核心。它提供了一套原则和系统化的技术,以分析、设计和实现软件解决方案,从而提高成功的可能性。
虽然埃文斯的工作确实是开创性的、突破性的,并且远远领先于其时代,但它并不具有规范性。这实际上是一种优势,因为它使得 DDD 的演变超越了埃文斯当时所构想的。另一方面,这也使得定义 DDD 实际上包含的内容变得极其困难,使得实际应用成为一个挑战。在本节中,我们将探讨 DDD 背后的某些基础术语和概念。这些概念的详细阐述和实际应用将在本书的后续章节中展开。
当遇到复杂的企业问题时,DDD 建议采取以下措施:
-
理解问题:为了对问题有一个深入、共享的理解,商业和技术专家需要紧密合作。在这里,我们共同理解问题的本质以及为什么解决问题是有价值的。这被称为问题的领域。
-
将问题分解为更易管理的部分:为了保持复杂性在可管理的水平,将复杂问题分解为更小、可独立解决的组成部分。这些部分被称为子领域。如果子领域仍然过于复杂,可能还需要进一步分解子领域。为每个子领域分配明确的边界以限制其功能。这个边界被称为该子领域的边界上下文。也可能方便地将子领域视为对领域专家(在问题空间中)更有意义的概念,而边界上下文是对技术专家(在解决方案空间中)更有意义的概念。
-
对于这些边界上下文中的每一个,都要做以下事情:
-
达成共识的共享语言:通过建立一个适用于子领域范围内的明确共享语言来正式化理解。这种共享语言被称为领域的通用语言。
-
在共享模型中表达理解:为了生成可工作的软件,将通用语言以共享模型的形式表达出来。这个模型被称为领域模型。可能存在多个这种模型的变体,每个变体旨在阐明解决方案的特定方面,例如,流程模型、序列图、工作代码和部署拓扑。
-
拥抱问题的偶然复杂性:需要注意的是,无法回避给定问题的本质复杂性。通过将问题分解为子域和边界上下文,我们试图将其(或多或少)均匀地分布在更易于管理的部分。
-
持续进化以获得更深入的洞察:重要的是要理解,之前的步骤并不是一次性的活动。企业、技术、流程以及我们对这些的理解都在不断进化,因此,我们的共同理解需要通过持续的重构与这些模型保持同步。
-
这里展示了 DDD 本质的图示表示:

图 1.9 – DDD 的本质
我们认识到,这只是一个关于领域驱动设计(DDD)主题的快速介绍。
使用战略设计理解问题
在本节中,让我们揭开在使用 DDD 时一些常用概念和术语的神秘面纱。首先,我们需要理解我们所说的第一个 D —— 领域。
什么是领域?
在使用领域驱动设计(DDD)时,基础概念是领域这一概念。但领域究竟是什么?这个词“领域”起源于 17 世纪,源自古老的法语单词 domaine(权力)和拉丁语单词 dominium(财产、所有权),是一个相当令人困惑的词。根据谁、何时、何地以及如何使用,它可以有不同的含义。

图 1.10 – 领域的含义随上下文而变化
然而,在商业的背景下,这个词“领域”涵盖了其主要活动的整体范围——它向客户提供的服务。这也被称为问题域。例如,特斯拉在电动汽车领域运营,Netflix 提供在线电影和电视节目,麦当劳提供快餐。一些公司,如亚马逊,在多个领域提供服务——在线零售和云计算等。一个企业的领域(至少是成功的那些)几乎总是包含相当复杂和抽象的概念。为了应对这种复杂性,通常将这些领域分解成更易于管理的部分,称为子域。接下来,让我们更详细地了解子域。
什么是子域?
在本质上,DDD 提供了解决复杂性的方法。工程师通过将复杂问题分解为更易于管理的子域来实现这一点。这有助于更好地理解,并使找到解决方案变得更加容易。例如,在线零售领域可以划分为如产品、库存、奖励、购物车、订单管理、支付和运输等子域,如下面的图所示:

图 1.11 – 零售中的子域
在某些商业活动中,子域本身可能变得非常复杂,可能需要进一步的分解。例如,在零售的例子中,可能需要将产品子域进一步分解成更基本的子域,如目录、搜索、推荐和评论,如图所示:
![图 1.12 – 产品子域中的子域
![图片 B16716_Figure_1.12.jpg]
图 1.12 – 产品子域中的子域
可能需要进一步分解子域,直到达到可管理的复杂度水平。领域分解是领域驱动设计(DDD)的一个重要方面。让我们看看子域的类型,以便更好地理解这一点。
重要提示
“域”和“子域”这两个术语往往被交替使用,这可能会让旁观者感到困惑。鉴于子域往往非常复杂且具有层次结构,一个子域本身就可以成为一个域。
子域类型
将一个复杂的领域分解成更易于管理的子域是一件好事。然而,并非所有子域都是平等的。在任何一个商业活动中,你可能会遇到以下三种类型的子域:
-
核心:商业的主要关注领域。这是提供最大差异化和价值的地方。因此,自然地,人们会希望将最多的关注放在核心子域上。在零售的例子中,购物车和订单可能是最大的差异化因素——因此可能形成该商业冒险的核心子域。鉴于这是企业希望拥有最大控制权的地方,内部实施核心子域是明智的。在在线零售的例子中,企业可能希望专注于提供在线订单的丰富体验。这将使在线订单和购物车成为核心子域的一部分。
-
支持性:就像每部伟大的电影都需要一个坚实的配角阵容才能成为杰作一样,支持性或辅助性子域也是如此。支持性子域通常非常重要且非常必要,但可能不是运营业务的主要焦点。尽管这些支持性子域对于运营业务是必要的,但通常不会提供显著的竞争优势。因此,甚至可以完全外包这项工作或使用现成的解决方案,或者进行一些小的调整。以零售为例,假设在线订购是这个业务的主要焦点,目录管理可能就是一个支持性子域。
-
通用:在与商业应用打交道时,你需要提供一组与正在解决的问题不直接相关的能力。因此,仅仅使用现成的解决方案可能就足够了。以零售为例,身份验证、审计和活动跟踪子域可能就属于这一类别。
重要提示
重要的是要注意,核心、支持性或通用子领域这一概念非常具体。对一家企业来说是核心的,对另一家企业来说可能是支持性或通用性。识别和提炼核心领域需要深入了解和经验,了解正在尝试解决的问题。
由于核心子领域建立了大部分业务差异化,因此明智的做法是将最多的精力投入到维护这种差异化上。这如图中的核心领域图所示:
![图 1.13 – 子领域的重要性
![图片 B16716_Figure_1.13.jpg]
图 1.13 – 子领域的重要性
随着时间的推移,竞争对手尝试模仿您的成功是自然而然的事情。新的、更高效的方法将会出现,降低涉及的复杂性并扰乱您的核心。这可能会导致目前核心的概念发生转变,成为支持性或通用能力,如图所示:
![图 1.14 – 核心领域侵蚀
![图片 B16716_Figure_1.14.jpg]
图 1.14 – 核心领域侵蚀
为了继续运营一个成功的业务,需要在核心上不断进行创新。例如,当 AWS 开始云计算业务时,它只提供简单的基础设施(IaaS)解决方案。然而,随着微软和谷歌等竞争对手开始迎头赶上,AWS 不得不提供几个额外的增值服务(例如,PaaS 和 SaaS)。
如所示,这不仅仅是一个工程问题。它需要深入了解潜在的业务。这就是领域专家可以发挥重要作用的地方。
领域和技术专家
任何现代软件团队都需要至少在两个领域拥有专业知识——领域的功能以及将其转化为高质量软件的艺术。在大多数组织中,这些至少存在为两个不同的群体:
-
领域专家:那些对领域有深入和亲密理解的人。领域专家是领域专家(SMEs),他们对业务有非常强的掌握。领域专家可能具有不同程度的专长。一些 SMEs 可能选择在特定子领域专业化,而其他人可能对整个业务如何运作有更广泛的理解。
-
技术专家:另一方面,喜欢解决具体、可量化的计算机科学问题。通常,技术专家并不觉得了解他们所在业务的环境值得他们投入精力。相反,他们似乎过于渴望只提升他们在学术界学习延续的技术技能。
当领域专家指定“为什么”和“是什么”时,技术专家(软件工程师)主要帮助实现“如何”。两组之间的强大合作和协同作用对于确保持续的高性能和成功至关重要。
来自语言的分歧
虽然这些团队之间的紧密合作是必要的,但重要的是要认识到,这些人似乎有截然不同的动机和思维方式。表面上,这似乎仅限于他们日常语言中的差异。然而,更深入的分析通常揭示出在目标、动机等方面存在更大的分歧。这一点在本图中有体现:

图 1.15 – 语言起源的划分
但这本书主要关注技术专家。我们的观点是,仅仅通过解决技术难题而不对基础商业环境有深入理解,是不可能取得成功的。
我们对组织的每一个决策,无论是需求、架构还是代码,都会产生商业和用户后果。为了有效地构思、设计、构建和演进软件,我们的决策需要帮助创造最佳的商业影响。正如之前提到的,这只能在我们对要解决的问题有清晰理解的情况下实现。这使我们认识到,在解决问题的解决方案中存在两个截然不同的域。
注意
在这个上下文中,使用“域”一词是抽象意义上的,不要与之前介绍的商业域概念混淆。
问题域
这是一个术语,用于捕捉仅定义问题而有意避免任何解决方案细节的信息。它包括诸如为什么我们试图解决问题、我们试图实现什么以及如何解决等细节。重要的是要注意,为什么、是什么和如何是从客户/利益相关者的角度出发,而不是从提供软件解决方案的工程师的角度出发。
考虑一个零售银行的例子,该银行已经为其客户提供支票账户功能。他们希望获得更多流动资金。他们需要鼓励客户保持更高的账户余额来实现这一点。他们正在寻求推出一个名为高级支票账户的新产品,该产品具有更高的利率、透支保护和免费 ATM 访问等附加功能。以下是以为什么、是什么和如何的形式表达的问题域:

表 1.2 – 问题域:为什么、是什么以及如何
现在我们已经定义了问题和与之相关的动机,让我们来看看它如何能指导解决方案。
解决方案域
术语,用于描述解决方案开发的环境。换句话说,将需求转化为工作软件的过程(这包括设计、开发、测试和部署)。在这里,重点是如何从软件实现的角度解决被解决的问题。然而,如果没有对“为什么”和“是什么”的理解,很难找到解决方案。
建立在之前的优质支票账户示例之上,这个问题的代码级解决方案可能看起来像这样:

这可能看起来是从问题域描述到问题的一个重大飞跃,确实如此。在达到这样的解决方案之前,可能需要存在多个级别的问题细化。这个过程通常是混乱的,可能会导致对问题的理解不准确,从而导致一个可能很好(例如,从工程、软件架构的角度来看是合理的)但不是解决当前问题的解决方案。让我们看看我们如何通过缩小问题和解决方案域之间的差距来不断细化我们的理解。
使用通用语言促进共同理解
以前,我们看到了组织壁垒如何导致有价值的信息被稀释。在我曾经工作的一家信用卡公司,塑料、支付工具、账户、PAN(主要账户号码)、BIN(银行识别号码)和卡片这些词都被不同的团队成员用来指代同一个东西——信用卡——当他们在应用的同区域内工作时。另一方面,像用户这样的术语有时会被用来指代客户、关系经理或技术客户支持员工。更糟糕的是,很多这些混乱的术语也被应用到代码中。虽然这可能看起来是一件微不足道的事情,但它有着深远的影响。产品专家、架构师和开发者来了又去,每个人都逐渐增加了更多的混乱、混乱的设计、实施和技术债务,加速了走向令人恐惧的、难以维护的大泥球(www.laputan.org/mud/)的过程。
DDD 倡导打破这些人为的障碍,通过共同努力创建 DDD 所说的通用语言——一个共享的术语、单词和短语词汇,以不断增进整个团队的理解。这种说法随后被积极用于解决方案的各个方面:日常词汇、设计、代码——简而言之,由每个人和每个地方使用。一致地使用这种常见的通用语言有助于加强共同的理解,并产生更好地反映领域专家心智模型的解决方案。
领域模型和解决方案的演变
通用语言有助于在团队成员之间建立一致、尽管是非正式的术语。为了提高理解,这可以进一步细化为一套正式的抽象——一个领域模型,用于在软件中表示解决方案。当我们面临问题时,我们无意识地试图形成潜在解决方案的心理表征。此外,这些表征(模型)的类型和性质可能因我们的问题理解、背景和经验等因素而大相径庭。这意味着这些模型的不同是自然的。例如,同一问题可能由不同的团队成员以不同的方式思考,如下所示:

图 1.16 – 多个模型表示解决方案
如此所示,业务专家可能会考虑流程模型,而测试工程师可能会考虑异常和边界条件,以制定测试策略等。
备注
图 1.16 描述了多个模型的存在。可能还有其他视角,例如客户体验模型和信息安全模型,这些并未在图中展示。
应当始终注意专注于解决当前的业务问题。如果团队在建模业务逻辑和技术解决方案方面投入相同数量的努力,那么他们将得到更好的服务。为了控制偶然的复杂性,最好将解决方案的基础设施方面与该模型隔离开来。这些模型可以采取多种形式,包括对话、白板会议、文档、图表、测试以及其他形式的架构健康函数。还应注意,这不是一次性的活动。随着业务的演变,领域模型和解决方案需要保持同步。这只能通过领域专家和开发者之间的紧密合作来实现。
领域模型和边界上下文的范围
在创建领域模型时,一个常见的困境在于决定如何限制这些模型的范围。你可以尝试创建一个单一的领域模型,作为整个问题的解决方案。另一方面,我们可能选择创建极其细粒度的模型,这些模型在没有对其他模型有强烈依赖的情况下无法有意义地存在。每种方法都有其优缺点。无论情况如何,每个解决方案都有一个范围——它被限制在一定的边界内。这个边界被称为边界上下文。
在子域和有界上下文这两个术语之间似乎存在很多混淆。它们之间的区别是什么?结果是,子域是问题空间的概念,而有界上下文是解决方案空间的概念。这最好通过一个例子来解释。让我们考虑一个虚构的 Acme 银行,它提供两种产品:信用卡和零售银行。这可能分解为以下子域,如图所示:

](https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_Figure_1.17.jpg)
图 1.17 – Acme 银行的银行子域
在为问题创建解决方案时,存在许多可能的解决方案选项。我们在这里展示了一些选项:

](https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_Figure_1.18.jpg)
图 1.18 – Acme 银行的有界上下文选项
这些只是创建有界上下文的分解模式的几个示例。你可能会选择的特定模式集合可能因当前的现实情况而异,例如以下情况:
-
当前组织结构
-
领域专家的职责
-
关键活动和关键事件
-
现有应用程序
备注
康威定律断言,组织被限制在产生与其沟通结构相复制的应用设计。你当前的组织结构可能并不最优地与你的期望解决方案方法对齐。逆向康威机动可能被应用以达到与业务架构的同构。无论使用何种方法将问题分解为一系列有界上下文,都应小心确保它们之间的耦合尽可能低。
虽然有界上下文理想上需要尽可能独立,但它们仍然可能需要相互通信。当使用 DDD 时,整个系统可以表示为一组相互关联的有界上下文。这些关系定义了这些有界上下文如何相互集成,并被称为上下文图。这里展示了一个示例上下文图:

](https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_Figure_1.19.jpg)
图 1.19 – Acme 银行的示例上下文图
上下文图显示了有界上下文及其之间的关系。这些关系可能比这里展示的更为复杂。我们将在第九章“与外部系统集成”中讨论上下文图和通信模式。
我们现在已经介绍了一系列对领域驱动设计(DDD)战略设计原则至关重要的概念。让我们看看一些可以帮助加速这一过程的工具。
在随后的章节中,我们将更详细地巩固这里介绍的所有概念。
在下一节中,我们将探讨为什么那些多年前引入的领域驱动设计(DDD)理念至今仍然非常相关。我们将看到,如果有什么不同的话,它们现在甚至比以往任何时候都更加相关。
使用战术设计实施解决方案
在上一节中,我们看到了如何使用战略设计工具达成对问题的共同理解。我们需要利用这种理解来创建解决方案。DDD 的战术设计方面、工具和技术帮助将这种理解转化为可工作的软件。让我们详细看看这些方面。在本书的第二部分中,我们将将其应用于解决一个现实世界的问题。
可以将战术设计方面考虑进去,如图所示:
![图 1.20 – DDD 战术设计的元素
![图片 B16716_Figure_1.20.jpg]
图 1.20 – DDD 战术设计的元素
让我们来看看这些元素的定义。
值对象
值对象是不可变对象,封装了一个或多个相关属性的数据和行为。可以方便地将值对象视为命名的原始数据类型。例如,考虑一个MonetaryAmount值对象。一个简单的实现可以包含两个属性——金额和货币代码。这允许封装行为,例如安全地添加两个MonetaryAmount对象,如下所示:
![图 1.21 – 简单的 MonetaryAmount 值对象
![图片 B16716_Figure_1.21.jpg]
图 1.21 – 简单的 MonetaryAmount 值对象
有效地使用值对象有助于防止对反模式的原始执着,同时增加清晰度。它还允许使用一个或多个值对象来组合更高层次的抽象。需要注意的是,值对象没有身份的概念。也就是说,具有相同值的两个值被视为相等。因此,两个具有相同金额和货币代码的MonetaryAmount对象将被视为相等。此外,重要的是要使值对象不可变。如果需要更改任何属性,应导致创建一个新的属性。
容易将值对象视为一种简单的工程技术,但(不)使用它们的后果可能非常深远。在MonetaryAmount示例中,金额和货币代码可以独立作为属性存在。然而,使用MonetaryAmount强制了通用语言的概念。因此,我们建议将值对象作为默认选项,而不是使用原始数据类型。
批评者可能会迅速指出诸如类爆炸和性能问题等问题。但根据我们的经验,好处通常大于成本。但如果出现问题,可能需要重新审视这种方法。
实体
实体是一个具有唯一标识的对象,并封装了其属性的数据和行为。可能将实体视为需要组合在一起的其它实体和值对象的集合。这里展示了一个非常简单的实体示例:
![图 1.22 – 交易实体的简单表示
![图片 B16716_Figure_1.22.jpg]
图 1.22 – 交易实体的简单表示
与值对象不同,实体具有唯一标识符的概念。这意味着两个具有相同基础值但不同标识符(id)值的Transaction实体将被视为不同。另一方面,具有相同标识符值的两个实体实例被视为相等。此外,与值对象不同,实体是可变的。也就是说,它们的属性可以并且会随时间变化。
值对象和实体的概念取决于它们被使用的上下文。在一个订单管理系统内,地址可能在电子商务边界上下文中实现为一个值对象,而在订单履行边界上下文中可能需要实现为一个实体。
重要提示
通常将实体和值对象统称为领域对象。
聚合
如前所述,实体是分层的,因为它们可以由一个或多个子实体组成。本质上,聚合具有以下特性:
-
实体通常由其他子实体和值对象组成
-
通过暴露行为(通常称为命令)来封装对子实体的访问
-
是一个用于一致性地强制执行业务不变性(规则)的边界
-
是在边界上下文中完成工作的入口点
考虑以下CheckingAccount聚合的例子:


图 1.23 – CheckingAccount 聚合的简单表示
注意CheckingAccount是如何由AccountHolder和Transaction实体以及其他事物组成的。在这个例子中,让我们假设透支功能(保持负账户余额的能力)仅适用于currentBalance需要以唯一的Transaction形式出现——无论其结果如何。因此,CheckingAccount聚合使用Transaction实体。尽管Transaction具有作为其接口一部分的approve和reject方法,但只有聚合可以访问这些方法。通过这种方式,聚合强制执行业务不变性,同时保持高度的封装。tryWithdraw方法的一个潜在实现如下所示:

-
CheckingAccount聚合由子实体和值对象组成。 -
tryWithdraw方法充当操作的连续性边界。无论结果如何(批准或拒绝),系统都将保持一致状态。换句话说,currentBalance只能在CheckingAccount聚合的范围内变化。 -
聚合强制执行适当的业务不变性(规则),仅允许 HNIs 透支。
重要提示
聚合也被称为聚合根,即实体层次结构中的根对象。在这本书中,我们使用这些术语同义。
领域事件
如前所述,聚合体决定了状态变化何时以及如何发生。系统的其他部分可能对了解对业务有重要意义的变更感兴趣,例如,下订单或收到付款。领域事件是传达对业务有重要意义的变更的手段。区分系统事件和领域事件很重要。例如,在零售银行的情况下,数据库中行被保存或服务器磁盘空间不足可能被归类为系统事件,而存款到支票账户和交易中检测到欺诈活动可能被归类为领域事件。换句话说,领域事件是领域专家关心的事情。
可能明智的做法是利用领域事件来减少边界上下文之间的耦合,使其成为领域驱动设计(DDD)的一个关键构建块。
仓储
大多数业务都需要数据的持久性。因此,聚合体的状态需要在需要时持久化和检索。仓储是使聚合体实例能够持久化和加载的对象。这在马丁·福勒的《企业应用架构模式》一书中作为仓储(martinfowler.com/eaaCatalog/repository.html)模式的一部分有很好的记录。值得注意的是,我们在这里指的是聚合仓储,而不仅仅是任何实体仓储。这个仓储的单一目的是使用其标识符加载聚合体的单个实例。需要注意的是,这个仓储不支持使用任何其他方式查找聚合体实例。这是因为业务操作是作为在边界上下文中操作聚合体的单个实例的一部分发生的。
工厂
为了与聚合体和值对象一起工作,需要构建这些实例。在简单的情况下,可能只需要使用构造函数来做到这一点。然而,根据封装的状态量,聚合体和值对象实例可能会变得相当复杂。在这种情况下,考虑将对象构建责任委托给聚合体/值对象外部的工厂可能是明智的。我们在日常工作中非常常用静态工厂方法、构建器和依赖注入。约书亚·布洛奇在第二章,“DDD 适合在哪里以及如何?”中讨论了这种模式的几种变体。
服务
当在一个单一的有界上下文中工作时,聚合的公共接口(命令)提供了一个自然的 API。然而,更复杂的业务操作可能需要与多个有界上下文和聚合进行交互。换句话说,我们可能会发现自己处于某些业务操作与任何单个聚合都不自然匹配的情况。即使交互仅限于单一的有界上下文,也可能需要以实现无关的方式公开该功能。在这种情况下,您可以考虑使用称为服务的对象。服务至少有三种类型:
-
领域服务:为了协调多个聚合之间的操作——例如,在零售银行之间转账两个支票账户。
-
基础设施服务:为了与业务非核心的实用程序进行交互——例如,在零售银行进行日志记录和发送电子邮件。
-
应用服务:为了协调领域服务、基础设施服务和其它应用服务之间的操作——例如,在成功完成账户间转账后发送电子邮件通知。
服务也可以是有状态的或无状态的。最好让聚合管理状态,利用存储库,同时允许服务协调和/或编排业务流程。在复杂情况下,可能需要管理流程本身的状态。我们将在本书的第二部分中查看更多具体的例子。
可能会诱使人们几乎完全使用服务来实现业务逻辑——无意中导致贫血领域模型反模式(martinfowler.com/bliki/AnemicDomainModel.html)。努力将业务逻辑封装在聚合的范围内作为默认做法是值得的。
为什么 DDD 是相关的?为什么现在?
有一个为什么而活的人可以忍受几乎任何如何。
——弗里德里希·尼采
在很多方面,当埃里克·埃文斯在 2003 年引入这些概念和原则时,领域驱动设计(DDD)就已经远远领先于其时代。DDD 似乎一直都在不断壮大。在本节中,我们将探讨为什么 DDD 在埃里克·埃文斯在 2003 年撰写关于该主题的书籍时就已经非常相关,而现在更是如此。
开源软件的兴起
Eric Evans 在 2017 年探索 DDD 会议的开幕式上,哀叹他的书发布后,即使是实现最简单的概念,如值对象的不变性,也是多么困难。然而,如今,这仅仅是一个导入成熟、文档齐全、经过测试的库的问题,例如 Project Lombok(projectlombok.org)或 Immutables(immutables.github.io),以在几分钟内变得高效。说开源软件彻底改变了软件行业,这还是低估了它!在撰写本文时,公共 Maven 仓库(mvnrepository.com)索引了令人震惊的1830 万个工件,涵盖了从数据库和语言运行时到测试框架等众多流行类别,如图所示:
![图 1.24 – 多年来开源 Java 的发展情况(来源:https://mvnrepository.com/)]
![图 1.24 – 多年来开源 Java 的发展情况(来源:mvnrepository.com/)]
图 1.24 – 多年来开源 Java 的发展情况(来源:mvnrepository.com)
Java 的常青树,如 Spring 框架,以及更近期的创新,如 Spring Boot 和 Quarkus,使得在几分钟内就能创建出生产级别的应用程序变得易如反掌。此外,Axon 和 Lagom 等框架使得实现高级架构模式,如 CQRS 和事件溯源,变得相对简单,这对于实现基于 DDD 的解决方案非常有益。
技术进步
DDD 绝非仅仅是关于技术的;它不可能对当时可用的选择完全无动于衷。2003 年是重量级和仪式感重的框架的鼎盛时期,例如Java 2 Enterprise Edition(J2EE)、Enterprise JavaBeans(EJB)、SQL 数据库和对象关系映射(ORMs)——在公共领域,当涉及到构建复杂软件的企业工具和模式时,选择并不多。软件世界已经发展,并从那时起取得了很大的进步。事实上,现代的颠覆性技术,如 Ruby on Rails 和公共云,才刚刚发布。然而,相比之下,我们现在不缺少应用程序框架、NoSQL 数据库和用于创建基础设施组件的程序化 API,这些组件以单调的规律不断发布。
所有这些创新都允许快速实验、持续学习和快速迭代。这些改变游戏规则的技术进步也与互联网和电子商务作为成功开展业务的可行手段的指数级增长相吻合。事实上,互联网的影响如此广泛,以至于几乎无法想象没有数字组件作为核心组成部分来启动业务。最后,智能手机、物联网设备和社交媒体的消费者化和广泛渗透意味着数据的生产速度已经达到了十年前难以想象的程度。这意味着我们正在通过几个数量级来构建和解决最复杂的问题。
分布式计算的兴起
曾经有一段时间,构建大型单一实体是默认的做法。但计算技术的指数级增长、公共云(IaaS、PaaS、SaaS 和 FaaS)、大数据存储和处理量的增长,与继续创建更快 CPU 的能力的相对放缓相吻合,这意味着转向更多去中心化的解决问题方法。

图 1.25 – 全球信息存储容量
DDD(领域驱动设计),通过将难以驾驭的单一实体分解为更易于管理的子域和边界上下文的形式来处理复杂性,自然地融入了这种编程风格。因此,当我们在构建现代解决方案时,对采用 DDD 原则和技术的兴趣重新焕发并不令人惊讶。正如埃里克·埃文斯所说,DDD 现在比最初构想时更加相关!
摘要
在本章中,我们探讨了软件项目失败的一些常见原因。我们看到了不准确或误解的需求、架构(或缺乏架构)以及过度的技术债务是如何阻碍实现商业目标和成功的。
我们探讨了 DDD 的基本构建块,如领域、子域、通用语言、领域模型、边界上下文和上下文图。我们还考察了为什么 DDD 的原则和技术在现代微服务和无服务器时代仍然非常相关。你现在应该能够欣赏 DDD 的基本术语,并理解为什么它在当今的背景下很重要。
在下一章中,我们将更深入地探讨 DDD 的实际运作机制。我们将深入研究 DDD 的战略和战术设计元素,并探讨如何使用这些元素来帮助形成更好的沟通和更稳健的设计基础。
进一步阅读


第二章:DDD 在哪里和如何适用?
“如果我们被目的所吸引,我们就不会被比较所分散。”
——鲍勃·戈夫
软件架构指的是软件系统的基本结构以及创建这些结构和系统的学科。多年来,我们积累了一系列架构风格和编程范式,以帮助我们处理系统复杂性。
在本章中,我们将探讨如何将领域驱动设计(DDD)应用于与这些架构风格和编程范式相辅相成的模式。我们还将探讨在构建软件解决方案时,它如何/在哪里融入整体方案。
在本章中,我们将涵盖以下主题:
-
架构风格
-
编程范式
-
应该选择哪种范式?
到本章结束时,你将能够欣赏到各种架构风格和编程范式的优点,以及在使用它们时需要注意的一些陷阱。你还将了解领域驱动设计(DDD)在增强这些架构中的作用。
架构风格
领域驱动设计以战略和战术设计元素的形式提供了一套架构原则。这使得你能够将大型、可能难以管理的业务子域分解成良好设计的、独立的边界上下文。
DDD 的一个巨大优势是它不要求使用任何特定的架构。然而,在过去几年中,软件行业已经使用了大量的架构风格。让我们看看 DDD 如何与一系列流行的架构风格结合使用,以得出更好的解决方案。
分层架构
分层架构是最常见的架构风格之一,解决方案通常组织为四个广泛的类别:表示层、应用层、领域层和持久层。每一层都为其代表的特定关注点提供了解决方案,如图所示:

图 2.1 – 分层架构的本质
分层架构背后的主要思想是关注点的分离——层与层之间的依赖关系是单向的(从上到下)。例如,领域层可以依赖于持久层,但不能反过来。此外,任何给定的层通常只访问其下方的层,而不会绕过中间的层。例如,表示层可能只能通过应用层访问领域层。
这种结构使得层与层之间的耦合更加松散,并允许它们相互独立地发展。分层架构的理念与 DDD 的战略和战术设计元素非常契合,如图所示:

图 2.2 – 分层架构映射到 DDD 的战术设计元素
DDD 积极推广使用分层架构,主要是因为它使得可以独立于其他关注点(如信息如何显示、端到端流程如何管理以及数据如何存储和检索)专注于领域层。从这个角度来看,自然应用 DDD 的解决方案往往也是分层的。
显著的变体
分层架构的一种变体是由 Alistair Cockburn 发明的,他最初称之为六边形架构(alistair.cockburn.us/hexagonal-architecture/),也称为端口和适配器架构。这种风格背后的想法是避免层与层之间(在分层架构中可能会发生)的不自觉依赖,特别是系统核心与外围层之间的依赖。
这里的主要思想是在核心中仅使用接口(端口)来启用现代驱动程序,例如测试和松散耦合。这使得核心可以独立于非核心部分和外部依赖进行开发和演进。通过端口的具体实现(适配器)与数据库、文件系统、网络服务等现实世界组件的集成得以实现。在核心中使用接口使得在隔离系统其他部分的情况下对核心进行测试变得容易得多,可以使用模拟和存根。在端到端环境中与真实系统一起工作时,也常见使用依赖注入框架动态替换这些接口的实现。这里展示了六边形架构的视觉表示:

图 2.3 – 六边形架构
结果表明,在这个上下文中使用“六边形”一词纯粹是为了视觉目的——并不是要将系统限制为恰好六种类型的端口。
与六边形架构类似,洋葱架构(jeffreypalermo.com/2008/07/the-onion-architecture-part-1/),由 Jeffrey Palermo 构想,其基础是在核心中创建一个基于独立对象模型的应用程序,它可以独立于外部层进行编译和运行。这是通过在核心中定义接口(六边形架构中的端口)并在外部层实现它们(六边形架构中的适配器)来实现的。从我们的角度来看,六边形和洋葱架构风格没有我们能够识别的明显差异。
这里展示了洋葱架构的视觉表示:

图 2.4 – 洋葱架构
另一种流行的分层架构变体,由罗伯特·C·马丁(亲切地称为 Uncle Bob)推广,是清洁架构。这是基于遵循 SOLID 原则(blog.cleancoder.com/uncle-bob/2020/10/18/Solid-Relevance.html),这也是他提出的。这里的基本信息(就像六边形和洋葱架构的情况一样)是避免核心(即包含业务逻辑的核心)与其他易变层(如框架、第三方库、UI 和数据库)之间的依赖关系。

图 2.5 – 清洁架构
所有这些架构风格都与 DDD(领域驱动设计)为核心子域(以及由此扩展的边界上下文)独立于系统其余部分开发领域模型的想法相辅相成。
虽然每种架构风格都在如何构建分层架构方面提供了额外的指导,但我们选择的任何架构方法都伴随着其自身的权衡和限制,您需要对此有所认识。我们将在下一小节中讨论一些这些考虑因素。
层蛋糕反模式
坚持一组固定的层提供了一定程度的隔离,但在更简单的情况下,它可能证明是过度杀鸡用牛刀,除了遵守约定的架构指南外,没有增加任何可感知的好处。在层蛋糕反模式中,每个层只是代理对下层层的调用,而没有增加任何价值。以下示例说明了这种相当常见的场景:


图 2.6 – 通过 ID 查找实体表示的层蛋糕反模式示例
在这里,findById方法在每个层都被复制,并简单地调用下一层中相同名称的方法,没有任何额外的逻辑。这给解决方案引入了意外复杂性。为了标准化目的,层中的某些冗余可能是不可避免的。如果代码库中“层蛋糕”出现明显,最好重新审视分层指南。
贫弱转换
我们常见的一种层蛋糕变体是,层拒绝在更高隔离和更松耦合的名义下共享输入和输出类型。这使得在每个层的边界执行转换成为必要。如果被转换的对象在结构上大致相同,我们就有了一个之前讨论过的findById示例。

图 2.7 – 贫弱转换反模式示例
在这种情况下,每一层定义了自己的实体类型,需要在每一层之间进行类型转换。更糟糕的是,实体类型的结构可能会有看似微小的变化(例如,lastName被称为surname)。虽然这种转换在有限范围内可能是必要的,但团队应努力避免在单个有限范围内同一概念名称和结构的变化。有意使用通用语言有助于避免此类情况。
层绕过
当与分层架构一起工作时,从严格限制层只与直接下方的层交互开始是合理的。正如我们之前看到的,这种严格的执行可能导致无法容忍的意外复杂性,尤其是在将它们普遍应用于大量用例时。在这种情况下,有意识地允许一个或多个层被绕过可能是有价值的。
例如,控制器层可能被允许直接与仓储层工作而不使用服务层。在许多情况下,我们发现使用一套独立的规则来区分命令和查询作为起点是有用的。
这可能是一个滑稽的斜坡。为了继续维持一定的理智,团队应考虑使用轻量级的架构治理工具,如ArchUnit (www.archunit.org/),以使协议明确并提供快速反馈。这里展示了如何使用 ArchUnit 实现此目的的简单示例:

仓储层可以被服务和控制器层访问 – 有效地允许控制器绕过使用服务层。
垂直切片架构
分层架构及其变体为如何构建复杂应用程序提供了合理的指导。由 Jimmy Boggard 倡导的垂直切片架构认识到,在整个应用程序的所有用例中采用标准的分层策略可能过于僵化。
此外,值得注意的是,不能通过单独实现这些水平层来获得业务价值。这样做只会导致无法使用的库存和大量的不必要的上下文切换,直到所有这些层都连接起来。因此,垂直切片架构建议最小化切片之间的耦合,并最大化切片内的耦合 (jimmybogard.com/vertical-slice-architecture/),如图所示:

图 2.8 – 垂直切片架构
在此示例中,下单可能需要我们通过应用层与其他组件进行协调,并在 ACID 事务的范围内应用复杂业务不变性。同样,取消订单可能需要在 ACID 事务内应用业务不变性,而不需要任何额外的协调——在这种情况下,无需应用层。然而,搜索订单可能只需要我们从查询优化的视图中检索现有数据。这种风格利用了“按需分配”的方法来分层,这可能在实现纯分层架构时帮助缓解之前列出的某些反模式。
考虑事项
垂直切片架构在实现解决方案时提供了很大的灵活性——考虑到正在实施的使用案例的具体需求。然而,如果没有一定程度的治理,这可能会迅速演变成一团糟,分层决策似乎是基于个人偏好和经验(或缺乏经验)任意做出的。作为一个合理的默认选项,您可能希望考虑为命令和查询使用不同的分层策略。除此之外,非功能性需求可能规定了您可能需要如何偏离这里。例如,您可能需要绕过某些层以满足某些用例的性能服务级别协议(SLA)。
当实用地使用垂直切片架构时,它确实使您能够在每个或一组相关的垂直切片中非常有效地应用领域驱动设计(DDD)——允许它们被视为边界上下文。以下展示了使用下单和取消订单示例的两个可能性:

图 2.9 – 用于演进边界上下文的垂直切片
在前面图表中的 (i) 示例中,下单和取消订单各自使用不同的领域模型,而在 (ii) 示例中,这两个用例共享一个共同的领域模型,并且由此扩展,成为同一个边界上下文的一部分。这确实为在用例边界采用无服务器架构时切片功能铺平了道路。
面向服务架构(SOA)
面向服务架构(SOA)是一种架构风格,其中软件组件通过标准化的接口(例如 SOAP、REST 和 gRPC 等)暴露(可能)可重用的功能。使用标准化接口(如 SOAP、REST 和 gRPC 等)可以在集成异构解决方案时实现更简单的互操作性,如下所示:

图 2.10 – SOA – 在标准接口上暴露可重用功能
以前,使用非标准、专有接口使得这种集成变得更加具有挑战性。例如,一家零售银行可能会以 SOAP Web 服务的形式公开账户间转账功能。虽然 SOA 规定通过标准化接口公开功能,但重点更多地在于集成异构应用程序,而不是实现它们。
考虑事项
在我们曾经工作的一家银行中,我们通过 SOAP 暴露了超过 500 个服务接口。在底层,我们使用 EJB 2.x(无状态会话豆和无状态消息驱动豆的组合)实现了这些服务,这些服务托管在商业 J2EE 应用服务器上,该服务器还充当了企业服务总线(ESB)。这些服务将大部分,如果不是全部,逻辑委托给单个单体 Oracle 数据库中的底层存储过程,使用整个企业的规范数据模型!对于外界来说,这些服务是位置透明的、无状态的、可组合的和可发现的。事实上,我们将这种实现宣传为 SOA 的例子,很难反驳这一点。
这套服务在多年中自然发展,没有明确的边界,组织各部分的概念和几代人的人们混合在一起,每个人都添加了自己对业务功能如何实现的理解。本质上,实现类似于令人讨厌的大泥球,这非常难以增强和维护。
SOA 背后的意图是崇高的。然而,由于缺乏关于组件粒度的具体实施指导,重用和松耦合的承诺在实践中很难实现。同样,SOA 对不同的人来说意味着很多不同的东西(martinfowler.com/bliki/ServiceOrientedAmbiguity.html)。这种歧义导致大多数 SOA 实现变得复杂、难以维护的单体,围绕技术组件如服务总线、持久化存储或两者展开。这就是使用 DDD 通过将问题分解为子域和边界上下文来解决复杂问题的价值所在。
微服务架构
在过去十年左右的时间里,微服务获得了相当多的流行度,许多组织都希望采用这种架构风格。在许多方面,微服务是 SOA 的扩展 – 其中重点在于创建专注于执行有限数量事情并正确执行它们的组件。《构建微服务》一书的作者山姆·纽曼将微服务定义为小型化、独立部署的组件,它们维护自己的状态,并且围绕业务领域建模。这提供了采用针对特定解决方案的“按需定制”方法、限制爆炸半径、提高生产力和速度、自主跨职能团队等好处。
微服务通常作为一个整体存在,共同协作以实现预期的业务成果,如图所示:

图 2.11 – 微服务生态系统
如我们所见,从消费者的角度来看,SOA 和微服务非常相似,因为它们通过一组标准化接口访问功能。微服务方法是 SOA 的演变,现在的重点是构建更小、自给自足、独立部署的组件,目的是避免单点故障(如企业数据库或服务总线),这在许多基于 SOA 的实现中相当普遍。
考虑事项
尽管微服务确实有所帮助,但在回答一个微服务应该有多大或多小的问题上,仍然存在相当多的模糊性(martinfowler.com/articles/microservices.html#HowBigIsAMicroservice)。实际上,许多团队似乎都难以找到这个平衡点,导致出现了分布式单体(www.infoq.com/news/2016/02/services-distributed-monolith/),这在很多方面可能比 SOA 时代的单进程单体还要糟糕。再次强调,应用 DDD 的战略设计概念可以帮助创建独立、松散耦合的组件,使其成为微服务架构风格的理想伴侣。
事件驱动架构(EDA)
不论组件的粒度(单体、微服务或介于两者之间),大多数非平凡解决方案都有一个边界,超出这个边界可能需要与外部系统(s)进行通信。这种通信通常是通过系统之间的消息交换来实现的,导致它们相互耦合。耦合有两种广泛的形式:传入 – 依赖于你的人,和传出 – 你所依赖的人。过量的传出耦合会使系统变得非常脆弱且难以操作。
事件驱动系统通过在达到某种状态时发出事件,而不关心谁消费这些事件,从而能够编写具有相对较低输出耦合的解决方案。在这方面,区分消息驱动系统和事件驱动系统非常重要,正如反应宣言中提到的:
“消息是发送到特定目的地的一项数据。事件是组件达到给定状态时发出的信号。在消息驱动系统中,可寻址的接收者等待消息的到来并对它们做出反应,否则处于休眠状态。在事件驱动系统中,通知监听器被附加到事件源上,以便在事件发出时被调用。这意味着事件驱动系统关注可寻址的事件源,而消息驱动系统则专注于可寻址的接收者。”
– 反应宣言
用更简单的术语来说,事件驱动系统并不关心下游消费者是谁,而在消息驱动系统中,这未必是真实的。当我们在这本书的上下文中提到事件驱动时,我们指的是前者。
通常,事件驱动系统通过使用中介基础设施组件(通常称为消息代理、事件总线等)来消除与最终消费者之间的点对点消息需求。这有效地将来自 n 个消费者的输出耦合减少到 1。事件驱动系统可以实现的变体有几个。在发布事件的上下文中,马丁·福勒在他的“你说的‘事件驱动’是什么意思?”文章中谈到了两种广泛风格(以及其他事项),即事件通知和事件携带状态传输(martinfowler.com/articles/201701-event-driven.html)。
考虑事项
在构建事件驱动系统时,主要权衡之一是决定每个事件中应嵌入多少状态(有效载荷)。可能明智的做法是只嵌入足够的状态来指示由发出的事件引起的更改,以保持各种对立力量,如生产者扩展、封装、消费者复杂性和弹性。当我们讨论在第五章 实现领域逻辑中实现事件的相关影响时,我们将更详细地讨论这些问题。
领域驱动设计(DDD)的全部内容是通过创建这些独立的边界上下文来控制复杂性。然而,“独立”并不意味着“隔离”。边界上下文可能仍然需要相互通信。实现这一目标的一种方法是通过使用 DDD 的基本构建块——领域事件。因此,事件驱动架构和 DDD 是相辅相成的。通常,利用事件驱动架构允许边界上下文进行通信,同时继续相互松散耦合。
命令查询责任分离(CQRS)
在传统应用程序中,单一领域的数据/持久化模型用于处理所有类型的操作。在 CQRS 中,我们创建不同的模型来处理更新(命令)和查询。这将在以下图中展示:

图 2.12 – 传统与 CQRS 架构对比
注意
我们在上一个图中展示了多个查询模型,因为根据需要支持的各种查询用例,创建多个查询模型是可能的(但不是必需的)。
为了使这个过程能够可预测地工作,查询模型(们)需要与写入模型保持同步(我们将在稍后详细探讨一些实现这一点的技术)。
考虑因素
传统的单一模型方法对于简单的 CRUD 风格的应用程序来说效果很好,但对于更复杂的场景开始变得难以控制。我们将在下一小节中讨论一些这些场景。
读写之间的量不平衡
在大多数系统中,读操作通常比写操作多出显著的数量级。例如,考虑交易员检查股票价格次数与实际交易(买卖股票交易)次数之间的差异。通常,写操作是创造企业收入的活动。在以读操作为主的系统中,使用单一模型进行读写可能会使系统超负荷,从而影响写性能。
需要多个读表示
在处理相对复杂系统时,需要同一数据的多个表示形式并不罕见。例如,在查看个人健康数据时,你可能想查看每日、每周或每月的视图。虽然这些视图可以从 原始 数据实时计算得出,但每次转换(聚合、总结等)都会增加系统上的认知负荷。通常,无法提前预测这些需求的具体性质。因此,设计一个能够满足所有这些需求的单一规范模型是不切实际的。创建专门针对一组特定需求设计的领域模型可能会容易得多。
不同的安全需求
当使用单一模型工作时,管理数据/API 的授权和访问需求可能会变得繁琐。例如,与余额查询相比,借记操作可能需要更高的安全级别。拥有不同的模型可以大大简化设计细粒度授权控制复杂性。
更均匀的复杂性分布
专门用于仅服务于命令端用例的模型意味着现在它们可以专注于解决单个问题。对于查询端用例,我们根据需要创建与命令端模型不同的模型。这有助于更均匀地将复杂性分散到更大的表面上——而不是增加用于服务所有用例的单个模型的复杂性。值得注意的是,DDD 的本质主要是有效地与复杂的软件系统协同工作,而 CQRS 与这一思路非常契合。
注意
当使用基于 CQRS 的架构工作时,为命令端选择持久化机制是一个关键决策。当与事件驱动架构结合使用时,你可以选择将聚合体作为一系列事件(按其发生顺序排序)进行持久化。这种持久化方式被称为事件溯源。我们将在第五章“实现领域逻辑”部分中更详细地介绍这一点。
无服务器架构
无服务器架构是一种软件设计方法,允许开发者在无需管理底层基础设施的情况下构建和运行服务。AWS Lambda 服务的推出使得这种架构风格变得流行,尽管在 Lambda 推出之前就已经存在了其他一些服务(例如用于持久化的 S3 和 DynamoDB、用于通知的 SNS 以及用于消息队列的 SQS)。虽然 AWS Lambda 以函数即服务(FaaS)的形式提供计算解决方案,但这些其他服务对于从无服务器范式中获益同样重要,甚至更为重要。
在传统的 DDD 中,边界上下文是通过围绕聚合体分组相关操作来形成的,这随后会告知解决方案作为单元的部署方式——通常是在单个进程的范围内。在无服务器范式中,每个操作(任务)都预期作为其自身的独立单元进行部署。这要求我们以不同的方式来考虑如何建模聚合体和边界上下文——现在是以单个任务或函数为中心,而不是以相关任务组为中心。
这是否意味着到达解决方案的 DDD 原则不再适用?虽然无服务器范式引入了必须将细粒度可部署单元视为建模过程中的第一公民的额外维度,但应用 DDD 的战略和战术设计的过程仍然适用。我们将在第十一章“分解为更细粒度的组件”中更详细地探讨这一点,届时我们将重构本书中构建的解决方案以采用无服务器方法。
大泥球
到目前为止,我们已经考察了一系列命名的架构风格及其陷阱,以及如何应用 DDD 来帮助缓解这些问题。在另一个极端,我们可能会遇到缺乏可感知架构的解决方案,这臭名昭著地被称为“大泥球”:
“一个‘大泥球’结构混乱,庞大,杂乱无章,像胶带和草绳一样, spaghetti 代码丛林。我们都见过。这些系统显示出不受控制的增长和不重复、应急修复的明显迹象。信息在系统遥远元素之间随意共享,常常达到几乎所有重要信息都变成全局或重复的程度。系统的整体结构可能从未得到很好的定义。如果曾经有,它可能已经侵蚀到无法辨认。具有一丝架构感的程序员会避开这些泥潭。只有那些对架构不关心的人,也许,对日常修补这些失败堤坝的惰性感到舒适的人,才会满足于在这样的系统上工作。”
– 布赖恩·福特和约瑟夫·约德
尽管福特和约德建议不惜一切代价避免这种架构风格,但类似于“大泥球”的软件系统仍然是我们很多人日常生活中的不可避免。领域驱动设计(DDD)的战略和战术设计元素提供了一套技术,帮助我们以实用主义的方式处理和恢复这些近乎绝望的情况,而无需可能地采用大爆炸方法。实际上,本书的重点是将这些原则应用于防止或至少推迟进一步退化成“大泥球”。
你应该使用哪种架构风格?
正如我们所见,在构建软件解决方案时,你可以依赖各种架构风格。其中许多架构风格共享一些共同的原则。遵循任何单一的架构风格都可能变得困难。领域驱动设计(DDD)通过强调将复杂业务问题分解为子域和边界上下文,使得在边界上下文中使用多种方法成为可能。我们特别提一下垂直切片架构,因为它强调将功能划分为特定的业务成果,因此更自然地遵循 DDD 的子域和边界上下文理念。实际上,你可能需要扩展甚至偏离架构风格的严格定义,以满足现实世界的需求。但当我们做出这样的妥协时,重要的是要故意为之,并明确说明我们做出这种决定的原因(最好使用一些轻量级机制,例如ADRs (www.thoughtworks.com/de-de/radar/techniques/lightweight-architecture-decision-records)). 这很重要,因为当我们未来回顾时,可能很难向他人甚至我们自己解释这种决定。
在本节中,我们考察了流行的架构风格以及如何在使用 DDD 时增强其有效性。现在,让我们看看 DDD 如何补充现有编程范式的使用。
编程范式
DDD 的策略元素在解决问题时引入了特定的词汇(聚合、实体、值对象、存储库、服务、工厂、领域事件等)。最终,我们需要将这些概念转化为运行中的软件。多年来,我们已经采用了各种编程范式,包括过程式、面向对象、函数式和面向方面。将 DDD 与这些范式之一或多个结合使用是否可能?在本节中,我们将探讨一些常见的编程范式和技术如何帮助我们用代码表达策略设计元素。
面向对象编程
从表面上看,DDD 似乎只是复制了一套面向对象的术语,并赋予它们不同的名称。例如,战术 DDD 的核心概念,如聚合、实体和值对象,在面向对象术语中可以简单地称为对象。其他如服务可能没有直接的面向对象对应物。那么,如何在面向对象的世界中应用 DDD 呢?让我们看一个简单的例子:

面向对象纯粹主义者会迅速指出PasswordService是过程式的,可能需要一个Password类来封装相关行为。同样,DDD 爱好者可能会指出这是一个贫血的领域模型实现。一个可能更好的面向对象版本可能看起来像以下这样:

在这种情况下,Password 类停止暴露其内部结构,并以行为(isStrong 和 isWeak 方法)的形式暴露了强密码或弱密码的概念。从面向对象的角度来看,第二种实现可能更优越。如果是这样,我们是否应该始终使用面向对象的版本?实际上,答案是有细微差别的,这取决于消费者在特定情境下的需求以及普遍使用的语言。如果 Password 的概念在领域内广泛使用,那么在实现中引入这样的概念可能是合理的。如果不是,第一种解决方案可能就足够了,即使它似乎违反了面向对象的封装原则。
我们默认的立场是将良好的面向对象实践作为起点。然而,更重要的是要反映领域语言,而不是教条地应用面向对象。因此,如果在这种情况下这样做显得不自然,我们愿意在面向对象的纯粹性上做出妥协。如前所述,清楚地传达做出此类决策的理由可以走很长的路。
函数式编程
函数是代码组织的基本构建块,存在于所有高级编程语言中。函数式编程是一种编程范式,其中程序通过应用和组合函数来构建。这与使用语句来改变程序状态的命令式编程形成对比。最显著的区别源于函数式编程避免了命令式编程中常见的副作用。纯函数式编程完全防止副作用并强制不可变性。在设计领域模型时采用函数式风格,使其更具声明性,可以更清晰地表达意图,同时保持简洁。它还使我们能够通过使用更简单的概念来组合更复杂的概念,从而控制复杂性。函数式实现使我们能够使用更接近问题域的语言,同时也有简洁的附加好处。考虑一个简单的例子,我们需要使用函数式风格在所有仓库中找到库存最少的物品,如下所示:

这里展示的命令式风格确实完成了工作,但可能更加冗长且难以理解,有时甚至对技术团队成员来说也是如此!
这里有一个命令式的例子:

从领域驱动设计(DDD)的角度来看,这带来了一些好处:
-
与领域专家的协作增加,因为声明式风格允许更多地关注“是什么”,而不是“怎么做”。这使得无论是技术还是非技术利益相关者都能在持续合作中感到不那么令人畏惧。
-
更好的可测试性,因为纯函数(那些无副作用的函数)的使用使得创建数据驱动测试变得更容易。这也为我们提供了额外的优势,即减少模拟/存根的使用。这些特性使得测试更容易维护和合理化。这也有利于让技术团队成员在早期阶段就能可视化边缘情况。
你应该选择哪种范式?
DDD 简单地说,你应该围绕代表软件试图解决的实际问题的领域模型来构建你的软件。当遇到复杂的生活问题时,我们常常发现很难在整个范围内遵循任何单一范式。寻求一种一刀切的方法可能会对我们产生不利影响。我们的经验表明,我们需要利用各种技术来优雅地解决问题。Java 本质上是一种面向对象的编程语言,但随着 Java 8 的推出,它开始拥抱各种函数式构造。这使我们能够利用多种技术来创建优雅的解决方案。最重要的是,要就通用的语言达成一致,并允许它指导采取的方法。这也很大程度上取决于你拥有的才能和经验。使用大多数团队成员都不熟悉的风格可能会适得其反。尽管我们在这里的章节中没有涵盖过程范式,但在某些情况下,它可能是最佳解决方案。只要我们有意于偏离特定编程范式的公认规范,我们就应该处于一个相当好的位置。
摘要
在本章中,我们介绍了一系列常用的架构模式,以及我们在使用它们时如何实践 DDD。我们还探讨了在使用这些架构时可能需要留意的常见陷阱和问题。我们还探讨了流行的编程范式及其对 DDD 战术元素的影响。
此外,你应该欣赏在构思解决方案时需要采用的多种架构风格。此外,你应该了解 DDD 可以在你选择采用哪种架构风格时扮演的角色。
在下一节中,我们将把在本章和之前章节中学到的所有知识应用到现实世界的商业案例中。我们将应用 DDD 的战略和战术模式,将复杂的领域分解为子领域和边界上下文,并迭代地构建解决方案,使用基于 Java 编程语言的技术。
第二部分:现实世界的领域驱动设计(DDD)
在本书的第一部分,我们探讨了领域驱动设计(DDD)的词汇以及它如何适应常用架构风格和编程范式。在本节中,我们将实现一个现实世界的应用,从业务动机和需求开始,并采用一系列技术和实践,使我们能够应用 DDD 的战略和战术设计原则。
本部分包含以下章节:
-
第三章**,理解领域
-
第四章**,领域分析与建模
-
第五章**,实现领域逻辑
-
第六章**,实现用户界面 – 基于任务的
-
第七章**,实现查询
-
第八章**,实现长时间运行的工作流程
-
第九章**,与外部系统集成
第三章:理解领域
“勺子不知道汤的味道,学问浅薄的人不知道智慧的滋味。”
– 威尔士谚语
在本章中,我们将介绍一个名为科索莫普瑞马(KP)银行的虚构组织,该组织正寻求现代化其在国际贸易业务中的产品提供。为了建立一个能够使其在中长期内持续成功的商业战略,我们将采用一系列技术和实践来帮助其从战略到执行的路径加速。
在我们深入探讨之前,让我们先对 KP 银行的业务领域有一个高层次的理解。
在本章中,我们将涵盖以下主题:
-
国际贸易领域
-
KP 银行的国际贸易
-
理解 KP 银行的国际贸易战略
-
KP 银行的国际贸易产品和服务
在本章结束之际,你将学会如何运用商业价值画布和精益画布等技术,以建立对商业战略的深刻理解。此外,我们将探讨如何绘制影响图,使我们能够将业务成果与目标相关联。最后,沃德尔映射练习将确立我们的业务决策在我们竞争格局中的重要性。
国际贸易领域
在许多国家,国际贸易占国内生产总值(GDP)的很大一部分,使得在全球范围内进行资本、商品和服务的交换成为必要。虽然像世界贸易组织(WTO)这样的经济组织专门成立是为了简化这一过程,但经济政策、贸易法律和货币等方面的差异确保了进行国际贸易可能是一个复杂的过程,涉及多个国家的多个实体。信用证的存在就是为了简化这一过程。让我们看看它是如何工作的。
KP 银行的国际贸易
KP 银行已经营业多年,一直专注于提供各种银行解决方案,如零售、企业、证券和其他产品。他们一直在稳步扩大到其他国家和大洲的业务。这使他们在过去十年中显著扩大了国际贸易业务。虽然他们一直是这一领域的领导者之一,但最近出现的新的数字原生竞争对手已经开始侵蚀他们的业务,并对其收入线产生不利影响。客户抱怨流程过于繁琐、耗时,最近还不可靠。此外,由于目前实施的非常低效的手动流程,KP 银行发现很难控制成本。仅在过去的 3 年里,他们不得不将交易处理成本增加约 50%!不出所料,这恰好与客户满意度急剧下降相吻合,这一点可以从客户服务数量在间隔时间内保持平稳的事实中得到证明。
CIO 已经认识到,有必要重新审视这个问题,并提出一种策略,使 KP 银行在未来几年内能够持续成功,并重新成为国际贸易领域的领导者之一。
了解 KP 银行的国际贸易战略
为了达到最佳解决方案,了解公司的商业目标和它们如何支持解决方案用户的需要非常重要。我们将介绍一套我们认为有用的工具和技术。
备注
有必要指出,这些工具是独立构思的,但当你与其他 DDD 技术结合使用时,它们可以增强整个过程和解决方案的有效性。使用这些工具应被视为您 DDD 旅程的补充。
让我们看看我们采用的一些最受欢迎的技术,这些技术可以帮助我们快速理解商业问题并提出解决方案。
商业模型画布
正如我们多次提到的,在尝试解决问题之前确保我们解决的是正确的问题非常重要。商业模型画布是由瑞士顾问亚历山大·奥斯特瓦尔德在其博士论文中构思的,它是一种快速且简单的方法,可以确保我们在一个单一的可视化中解决一个有价值的问题,这个可视化捕捉了您业务的九个要素:
-
价值主张:你做什么?
-
关键活动:你是如何做的?
-
关键资源:你需要什么?
-
关键合作伙伴:谁会帮助你?
-
成本结构:这会花费多少?
-
收入来源:你会赚多少钱?
-
客户细分:你为谁创造价值?
-
客户关系:你与谁互动?
-
渠道:你是如何接触你的客户的?
商业模式画布有助于在包括商业利益相关者、领域专家、产品所有者、架构师和开发者在内的不同群体中建立对整体图景的共同理解。我们发现,在开始绿地和棕地项目时,它非常有用。以下是我们为 KP 银行国际贸易业务创建商业模式画布的尝试:

图 3.1 – 商业模式画布
使用这个画布可以让我们了解我们打算在银行服务的客户,通过什么渠道提供什么价值主张,以及我们如何赚钱。在开发商业模式画布时,建议我们遵循上一幅图中显示的编号顺序,以便更好地理解以下内容:
-
商业的吸引力(我们的客户是谁以及他们想要什么)
-
商业的可行性(我们如何运营和交付它)
-
商业的经济可行性(我们如何识别成本和获取利润)
如果你还没有现有的产品,创建商业模式画布可能会很有挑战性,这在初创企业或现有企业拓展新业务领域时通常是真实的情况。在这种情况下,探索精益画布的变体形式是值得的。
精益画布
精益画布是 Ash Maurya 为精益初创企业构思的一种商业模式画布的变体。与商业模式画布相比,这里的重点首先是详细阐述需要解决的问题,并探索潜在解决方案。为了使画布具有可操作性,想法是捕捉最不确定和/或风险最大的项目。这对于在高不确定性下运营的企业(通常适用于初创企业)是相关的。类似于领域驱动设计(DDD),它鼓励你将问题作为建立企业的起点。
结构上,它与商业模式画布相似,但有以下不同之处:
-
问题而不是关键合作伙伴:企业常常因为误解他们正在解决的问题而失败。替换关键合作伙伴块的理由是,当你是一个寻求建立未经证实的产品的不知名实体时,追求关键合作伙伴可能为时尚早。
-
解决方案而不是关键活动:尝试多个解决方案并响应反馈是很重要的。关键活动被移除,因为它们通常是解决方案的副产品。
-
关键指标而非关键资源:了解我们是否朝着正确的方向前进非常重要。建议关注少数几个关键指标,以便在需要时能够快速调整。关键资源随着云的出现和成熟框架的可用性而变得相对容易()。此外,它们可能出现在不公平优势框中,我们将在下一节讨论。
-
不公平优势而非客户关系:这明确地确立了我们的差异化优势,这些优势难以复制。这与我们在第一章,“领域驱动设计的理由”中讨论的核心子域概念紧密相关,并为我们提供了一个清晰的画面,说明我们在一开始需要集中精力关注什么。
我们为 KP 银行举办的精益画布工作坊的结果如下所示:

图 3.2 – 国际贸易业务的精益画布
填写精益画布的确切顺序可能有所不同。在他的博客上,Ash Maurya 建议可能没有规定性的执行顺序来做这个练习(blog.leanstack.com/what-is-the-right-fill-order-for-a-lean-canvas/)。就个人而言,我们喜欢先详细阐述问题,然后再转向画布的其他方面。商业模式画布和精益画布都提供了对商业模式、高优先级问题和潜在解决方案的高级视图。接下来,让我们看看影响图,这是一种基于思维导图的轻量级规划技术,旨在制定以结果为导向的计划。
影响图
影响图是一种可视化和战略规划工具,它使您能够理解范围和潜在假设。它是由高级技术和业务人员通过考虑以下四个方面共同创建的,以思维导图的形式:
-
目标:为什么我们要做这件事?
-
参与者:我们的产品消费者或用户是谁?换句话说,谁会受到它的影响?
-
影响:消费者行为的变化如何帮助我们实现目标?换句话说,就是我们试图创造的影响?
-
交付成果:作为组织或交付团队,我们能做什么来支持所需的影响?换句话说,作为解决方案的一部分需要实现哪些软件功能或流程变更?

图 3.3 – 一个简单的影响图
影响图提供了一种易于理解的视觉表示,展示了目标、用户和交付成果之间的影响关系。接下来,让我们来探讨沃德利图,它使我们能够更深入地了解我们的目的,并确定哪些业务部分提供了最大的价值。
沃德利图
商业模式画布和精益画布可以帮助在较高层次上建立目的的清晰性。沃德利图是另一个帮助构建商业策略和建立目的的工具。它提供了一个系统为谁而建的草图,然后是系统为他们提供的利益,以及提供这些利益所需的需求链(称为价值链)。接下来,价值链沿着一个进化轴进行绘制,该轴的范围从未探索和不确定到高度标准化。构建沃德利图可以通过六个步骤完成:
-
目的:你的目的是什么?为什么组织或项目存在?
-
范围:地图(范围)包括什么(以及不包括什么)?
-
用户:谁使用或与你所绘制的物品互动?
-
用户需求:你的用户需要从你所绘制的物品中获得什么?
-
价值链:为了满足之前捕获的需求,我们需要做什么?这些需求根据其依赖关系排列,从而创建了一个价值链,将用户需求映射到一系列按用户可见性顺序排列的活动(从最明显到最不明显)。
-
绘制地图:最后,使用进化特征绘制地图,以决定将每个组件放置在水平轴的哪个位置。
我们在 KP 银行进行了沃德利图绘制练习,以展示他们的国际贸易业务,如下所示:

图 3.4 – KP 银行国际贸易业务的沃德利图
注意
在这个画布上,我们为了简洁起见,只详细阐述了某一类用户(进口商和出口商)的需求。在现实世界的场景中,我们不得不为所有类型的用户重复步骤 4、5 和 6。
沃德利图使得理解我们解决方案提供的功能、它们的依赖关系以及价值是如何产生的变得容易。它还帮助描绘了这些功能与竞争对手提供的功能相比的表现,使你能够适当地优先考虑关注点并做出构建或购买的决定。
我们已经检查了多种轻量级和协作技术,以快速了解问题空间以及我们对我们用户和业务可能产生的影响。这些技术中的每一种都相当轻量,可以在几个小时内完成。每一种都能让我们专注于最有影响力的业务领域,并最大化投资回报率。根据我们的经验,尝试这些练习中的多个(甚至所有)都是值得的,因为每个练习都可以突出业务/用户需求的不同方面。
国际贸易产品和服务的
国际贸易充满风险,这导致卖方(出口商)和买方(进口商)之间支付时间的确定性程度较高,尤其是在涉及各方之间缺乏信任的情况下。对于出口商来说,在收到付款之前,所有销售都是礼物。因此,出口商更喜欢在订单下单后或至少在货物发货前收到付款。对于进口商来说,在收到货物之前,所有为购买支付的款项都是捐赠。因此,进口商更喜欢尽快收到货物,并推迟付款,直到货物转售以筹集足够的资金来支付卖方。
这种情况为可信赖的中间机构(如 KP 银行)提供了一个机会,在安全的方式下在国际贸易交易中发挥重要作用。KP 银行提供了一系列产品来促进国际贸易支付,如下所示:
-
信用证(LC)
-
跟单托收(DC)
-
赊账
-
预付款
-
寄售
下面的图表显示了从出口商和进口商的角度来看,这些支付方式各自的风险特征:

图 3.5 – 国际贸易支付方式的风险特征
如此明显,直流和交流产品在提供解决方案时,从双方的角度来看都提供了相对安全的选择。需要涉及一个可信赖的中间机构,如 KP 银行,来参与履行过程,这使得这些支付方式对双方来说风险较低。从银行的角度来看,将这些产品的流程简化作为优先事项,与其他产品相比,也提供了更大的商业机会。在这两者中,信用证产品满足大多数标准,这些标准是在我们之前详细阐述的最近结束的商业策略会议中针对用户需求制定的。因此,KP 银行的利益相关者决定一开始就大力投资信用证产品。
在下一章以及本书的其余部分,我们将详细阐述如何通过利用与 DDD 原则紧密一致的原则来改进信用证的应用、发行和相关流程。
摘要
在本章中,我们探讨了各种技术,这些技术有助于确定特定问题是否是正确的问题需要解决。具体来说,我们研究了商业价值画布和精益画布,以阐明初创企业和成熟企业两者的商业策略。然后,我们探讨了影响图,它使您能够明确地将业务目标与用户影响以及创造这些影响所需的交付成果相关联。最后,我们研究了沃德利图,以进一步深入关注重要领域,包括建立构建与购买决策、与竞争对手相关的商业策略的重要性,以及进入未知领域时涉及的相关风险。
在下一章中,我们将探讨进一步深入挖掘的技术和实践,以便我们了解 LC 业务,从而开始构建领域模型(s),使我们能够得出适当的解决方案。
进一步阅读
在blog.leanstack.com/what-is-the-right-fill-order-for-a-lean-canvas/了解更多关于精益画布的信息。
第四章:领域分析和建模
“问问题的人五分钟内还是傻瓜。不问的人永远都是傻瓜。”
– 中国谚语
正如我们在上一章所看到的,误解的需求可能导致相当一部分软件项目失败。达成共识并创建有用的领域模型需要领域专家之间高度的合作。在本章中,我们将介绍本书中将要使用的示例应用程序,并探讨建模技术,如领域故事讲述和事件风暴,以可靠和结构化的方式增强我们对问题的集体理解。
本章将涵盖以下主题:
-
介绍示例应用程序(信用证)
-
增强共识
-
领域故事讲述
-
事件风暴
本章将帮助开发人员和架构师学习如何在现实生活中的场景中应用这些技术,以产生优雅的软件解决方案,这些解决方案反映了需要解决的领域问题。同样,非技术领域的专家将了解如何传达他们的想法,并有效地与技术团队成员合作,以加速达成共识的过程。
技术要求
本章没有具体的技术要求。然而,鉴于可能需要远程协作而不是在同一房间内使用白板,以下资源将非常有用:
-
数字白板(例如
www.mural.co/或miro.com/) -
在线领域故事讲述模型器(例如
www.wps.de/modeler/)
理解信用证
信用证(LC)是银行作为进口商(或买方)和出口商(或卖方)之间的合同而发行的一种金融工具。该合同规定了交易的条件和条款,根据这些条件,进口商承诺支付出口商提供的商品或服务。信用证交易通常涉及多个当事人。以下是对涉及当事人的简化总结:
-
进口商:商品或服务的买方。
-
出口商:商品或服务的卖方。
-
货运代理人:代表出口商处理货物运输的代理机构。这仅适用于涉及实物商品交换的情况。
-
开证行:进口商请求发行信用证申请的银行。通常,进口商与该银行有既定的关系。
-
通知行:通知出口商关于信用证发行情况的银行。这通常是在出口商所在国的本地银行。
-
议付行:出口商提交货物运输或提供服务所需文件的银行,通常出口商与该银行已有预存关系。
-
偿付行:在开证行的要求下,向议付行支付资金的银行。
备注
在一项特定的交易中,一家银行可以扮演多个角色。在最复杂的情况下,可能有四个不同的银行参与交易(有时甚至更多,但为了简洁起见,我们将跳过这些情况)。
一份信用证发行申请
如前一章所述,Kosmo Primo Bank 需要我们关注简化信用证申请和发行流程。在本章以及本书的其余部分,我们将努力理解、演进、设计和构建一个软件解决方案,通过用更简化的流程取代大量手动且易出错的流程,以使流程更加高效。
我们理解,除非你是处理国际贸易的专家,否则你不太可能对诸如信用证(LCs)等概念有深入了解。在接下来的章节中,我们将探讨如何揭开信用证的神秘面纱以及如何与之合作。
增强共识
当处理一个领域概念不明确的问题时,需要在关键团队成员(既有好主意的人——业务/产品人员,也有将这些想法转化为工作软件的人——软件开发人员)之间达成共识。为了使这个过程有效,我们倾向于寻找以下方法:
-
快速、非正式且有效
-
协作性 – 对于非技术性和技术性团队成员来说都易于学习和采用
-
图形化,因为一张图片可能胜过千言万语
-
适用于粗粒度和细粒度场景
有几种方法可以达到这种共识。以下是一些常用的方法:
-
UML
-
BPMN
-
用例
-
用户故事映射
-
CRC 模型
-
数据流图
这些建模技术试图将知识形式化,并以图表或文本的形式表达出来,以帮助将业务需求作为软件产品交付。然而,这种尝试并没有缩小而是扩大了业务与软件系统之间的差距。虽然这些方法对于技术受众来说往往效果良好,但它们通常对非技术用户不太有吸引力。
为了恢复平衡并推广对双方都适用的技术,我们将使用领域叙事和事件风暴法作为我们的手段,从领域专家那里捕捉业务知识,供开发人员、业务分析师等使用。
领域叙事
科学研究现在已经证明,使用视听辅助工具的学习方法能够非常有效地帮助教师和学生保留和内化概念。此外,教授我们所学的知识有助于加强想法并激发新想法的形成。
领域故事是一种协作建模技术,它结合了图形语言、现实世界示例和工作坊格式,作为一种非常简单、快捷且有效的技术在团队成员之间分享知识的方法。领域故事是一种由斯蒂芬·霍弗和亨宁·施文特纳发明并普及的技术,基于汉堡大学进行的一些相关研究,称为合作图。
该技术的图形符号在以下图表中展示:
![图 4.1 – 领域故事概述]
![图片 B16716_04_01.jpg]
图 4.1 – 领域故事概述
领域故事是通过以下属性传达的:
-
参与者:故事是从一个参与者的角度(名词)进行传达的——例如,发行银行,在特定故事情境中扮演着积极角色。使用特定领域的通用语言是一种良好的实践。
-
工作对象:参与者对某些对象进行操作——例如,申请信用证。同样,这将是领域内常用的一个术语(名词)。
-
活动:参与者对一个工作对象执行的动作(动词)。通过一个连接参与者和工作对象的带标签的箭头来表示。
-
注释:用于捕捉故事中的额外信息,通常以几句话的形式表示。
-
序列号:通常,故事是一句接一句地讲述的。序列号有助于捕捉故事中活动的顺序。
-
组别:用于表示相关概念的集合的概要,范围从重复/可选活动到子领域/组织边界。
使用 DST 进行信用证申请
KP 银行有一个允许处理信用证的过程。然而,这个过程非常陈旧,基于纸张,且手工操作密集。银行中很少有人完全理解整个过程,自然损耗意味着这个过程过于复杂,而没有任何合理的理由。因此,他们正在寻求数字化和简化这个过程。DST 本身只是一种可以独立完成的图形符号。然而,通常不会单独进行,而是采用工作坊风格,让领域专家和软件专家共同协作。
在本节中,我们将使用 DST 工作坊来捕捉当前的业务流程。以下是一个这样的对话摘录,对话双方是凯蒂,领域专家,和帕特里克,软件开发者:
帕特里克:你能给我一个典型的信用证流程的概述吗?
凯蒂:当然,一切始于进口商和出口商签订购买商品或服务的合同。
帕特里克:这份合同的形式是什么?是正式文件条款吗?还是这只是个对话?
凯蒂:这只是一个对话。
帕特里克:哦,明白了。对话涵盖了什么内容?
凯蒂:有几个方面——商品的性质和数量、定价细节、付款条款、运输成本和时间表、保险和保修等。这些细节可以包含在采购订单中——这是一个简单的文件条款,详细说明了上述内容。
此时,帕特里克绘制了进口商和出口商之间的互动部分。这个图形在以下图表中展示:

图 4.2 – 进口商和出口商之间的互动
帕特里克:这似乎很简单,那么银行在其中的作用是什么?
凯蒂:这是一项国际贸易,进口商和出口商都需要减轻这种商业交易中涉及到的财务风险。因此,他们需要一家银行作为可信赖的中介。
帕特里克:这是什么类型的银行?
凯蒂:通常涉及多个银行。但一切始于一个 开证行 *。
帕特里克:开证行是什么?
凯蒂:任何被授权调解国际贸易交易的银行。这必须是进口国的一家银行。
帕特里克:进口商需要与这家银行有现有关系吗?
凯蒂:不一定。进口商可能与其他银行有关系——这些银行反过来会代表进口商与开证行联络。但为了简单起见,让我们假设进口商与开证行有现有关系——在这种情况下,就是我们的银行。
帕特里克:进口商需要向开证行提供采购订单的详情以开始吗?
凯蒂:是的。进口商通过提交 信用证申请 来提供交易详情。

图 4.3 – 介绍信用证和开证行
帕特里克:开证行在收到这份信用证申请时会做什么?
凯蒂:主要有两点——核实进口商的财务状况和进口货物的合法性。
帕特里克:好的。如果一切都检查无误,会发生什么?
凯蒂:开证行批准信用证并通知进口商。

图 4.4 – 通知进口商信用证批准
帕特里克:接下来会发生什么?开证行现在会联系出口商吗?
凯蒂:还没有。这并不简单。开证行只能与出口国的一家对应银行交易。这家银行被称为 通知行 。

图 4.5 – 介绍通知行
帕特里克:通知行做了什么?
凯蒂:通知行通知出口商关于信用证的信息。
帕特里克:进口商不需要知道信用证已通知吗?
凯蒂:是的。开证行通知进口商,信用证已通知出口商。

图 4.6 – 建议通知给进口商
帕特里克:出口商如何知道如何进行操作?
凯蒂:通过通知行——他们通知出口商信用证已发行。

图 4.7 – 将建议发送给出口商
帕特里克:出口商现在就开始发货,他们如何收款?
凯蒂:通过通知行——他们通知出口商信用证已发行,这触发了流程中的下一步——这个过程称为结算。但让我们现在专注于发行。我们将在稍后讨论结算。
我们现在已经查看了一个典型的 DST 工作坊的摘录。它提供了一个相当好的对高级业务流程的理解。请注意,在过程中我们没有引用任何技术工件。
为了细化这个流程并将其转换为可用于设计软件解决方案的形式,我们需要进一步改进这个视图。在下一节中,我们将使用EventStorming作为一种结构化的方法来实现这一点。
EventStorming
“驳斥胡说八道的能量比产生它的能量大一个数量级。”
– 阿尔贝托·布兰多利尼
介绍 EventStorming
在上一节中,我们获得了对信用证发行流程的高级理解。为了能够构建一个现实世界的应用,使用一种深入到下一级细节的方法是有帮助的。EventStorming,最初由阿尔贝托·布兰多利尼构想,就是这样一种用于协作探索复杂领域的方法。
在这种方法中,你只需从墙上或白板上按大致时间顺序列出对业务领域有重要意义的所有事件,使用一堆彩色便利贴。每种便签类型(用不同颜色表示)都有特定的用途,如下所述:
-
领域事件:对业务流程有重要意义的事件——用过去时态表达。
-
命令:可能引起一个或多个领域事件发生的动作或活动。这是由用户发起或系统发起的,作为对领域事件的响应。
-
用户:执行业务动作/活动的人。
-
策略:一组需要遵守的业务不变量(规则),以便成功执行动作/活动。
-
查询/读取模型:执行动作/活动所需的信息。
-
外部系统:对业务流程有重要意义但在当前上下文中不在范围内的系统。
-
热点:系统内部的一个争议点,可能对团队的小部分人来说既令人困惑又令人费解。
-
聚合:一个状态变化一致且原子化的对象图。这与我们在第二章中看到的聚合定义一致,DDD 如何适应?。
这里展示了我们的事件风暴研讨会中贴纸的描绘:

图 4.8 – 事件风暴图例
为什么是领域事件?
当试图理解一个业务流程时,解释该背景下的重要事实或事物是很方便的。这种做法也可以是非正式的,并且对未经介绍的人群来说很容易理解。这提供了一个易于消化的领域复杂性的视觉表示。
使用事件风暴法处理 LC 发行申请
现在我们已经通过领域讲故事研讨会对当前业务流程有了高级理解,让我们看看我们如何使用事件风暴法深入挖掘。以下是从同一应用程序的事件风暴研讨会阶段摘录的内容:
- 概述事件时间线:在这个练习中,我们回忆系统中的重要领域事件(使用橙色贴纸),并将它们贴在白板上,如图所示。我们确保事件贴纸按发生的大致时间顺序粘贴。由于时间线的实施,业务流程将开始显现:

图 4.10 – 处理被拒绝申请的新事件
- 识别触发活动和外部系统:在达到对事件时间线的高级理解之后,下一步是使用蓝色贴纸添加导致这些事件发生的活动/动作,以及与外部系统的交互(使用粉色贴纸):

图 4.11 – 活动和外部系统
- 捕获用户、上下文和政策:下一步是捕获执行这些活动的用户以及他们的功能上下文(使用黄色贴纸)和政策(使用紫色贴纸)。
为了解决这个问题,我们添加了一个新的领域事件,明确表示申请已被拒绝,如图所示:


- 概述查询模型:每个活动都需要一组数据。用户需要查看他们需要采取行动的带外数据,并看到他们行动的结果。这些数据集以 查询模型(使用绿色便签)表示:

图 4.13 – 一个大型的 EventStorming 实作工作板
重要提示
对于领域故事讲述和 EventStorming 实作,当有大约六到八人参与,并且有合适的领域和技术专家混合时,效果最佳。
这标志着 EventStorming 实作结束,以获得对 LC 应用和发行流程的合理详细理解。这意味着我们已经结束了领域需求收集过程吗?绝非如此——虽然我们在理解领域方面取得了重大进展,但仍有一段很长的路要走。阐述领域需求的过程是持续的。我们在这一连续体中处于什么位置?以下图表试图阐明:

图 4.14 – 阐述领域需求连续体
在随后的章节中,我们将更详细地探讨其他技术。
摘要
在本章中,我们探讨了两种使用轻量级建模技术——领域故事讲述和 EventStorming——来增强我们对问题领域集体理解的方法。
领域故事讲述使用简单的图形符号在领域专家和技术团队成员之间共享业务知识。另一方面,EventStorming 使用业务流程中发生的领域事件的时序顺序来获得相同的共享理解。
领域故事讲述可以用作一种入门技术,以建立对问题空间的高级理解,而 EventStorming 可以用来指导解决方案空间详细设计决策。
带着这些知识,我们应该能够深入到解决方案实施的技术细节。在下一章中,我们将开始实施业务逻辑,并建模我们的聚合,包括命令和领域事件。
进一步阅读

第五章:实现领域逻辑
为了有效地沟通,代码必须基于编写需求所使用的相同语言——开发人员彼此之间以及与领域专家交流的语言。
– 埃里克·埃文斯
在本书的命令查询责任分离(CQRS)部分,我们描述了领域驱动设计(DDD)和 CQRS 如何相互补充,以及命令端(写请求)是业务逻辑的家园。在本章中,我们将使用 Spring Boot、Axon Framework、JSR-303 Bean 验证和持久化选项,通过对比状态存储和事件源聚合来实现信用证(LC)应用的命令端 API。以下是将要涵盖的主题列表:
-
识别聚合
-
处理命令和发布事件
-
测试驱动应用程序
-
持续聚合
-
执行验证
到本章结束时,您将学会如何以稳健、封装良好的方式实现系统的核心(领域逻辑)。您还将学会如何将领域模型从持久化关注点中解耦。最后,您将能够欣赏如何使用服务、存储库、聚合、实体和值对象执行 DDD 的战略设计。
技术要求
要跟随本章的示例,您需要访问以下内容:
-
JDK 1.8+(我们使用 Java 16 编译示例源代码)
-
Maven 3.x
-
Spring Boot 2.4.x
-
JUnit 5.7.x(包含在 Spring Boot 中)
-
Axon Framework 4.4.7(DDD 和 CQRS 框架)
-
Project Lombok(用于减少冗余)
-
Moneta 1.4.x(货币和货币参考实现——JSR 354)
继续我们的设计之旅
在上一章中,我们讨论了事件风暴作为一种轻量级的方法来阐明业务流程。作为提醒,这是我们的事件风暴会议产生的输出:

图 5.1 – 事件风暴会议回顾
如前所述,此图中的蓝色便签代表命令。我们将使用命令查询责任分离(CQRS)模式作为高级架构方法来实现我们的 LC 发行应用的领域逻辑。让我们来探讨使用 CQRS 的机制以及它如何导致优雅的解决方案。有关 CQRS 是什么以及何时适用此模式的概述,请参阅第二章中的何时使用 CQRS部分,“DDD 如何适应?*”。
重要提示
CQRS 绝对不是万能的银弹。尽管它足够通用,可以在各种场景中使用,但它对主流软件问题来说是一种范式转变。像任何其他架构决策一样,在决定采用 CQRS 时,您应该进行适当的尽职调查。
让我们通过使用 Spring 和 Axon 框架实现 LC 应用程序命令侧的一个代表性片段来实际看看它是如何工作的。
实现命令侧
在本节中,我们将专注于实现应用程序的命令侧。这是我们预期应用程序的所有业务逻辑都将得到实现的地方。从逻辑上看,它看起来像以下图示:
![图 5.2 – 传统与 CQRS 架构对比
![img/B16716_05_01.jpg]
图 5.2 – 传统与 CQRS 架构对比
命令侧的高级序列在此描述:
-
接收到一个请求来修改状态(命令)。
-
在事件源系统中,通过回放该实例已发生的事件来构建命令模型。在状态存储系统中,我们只需从持久化存储中读取状态来恢复状态。
-
如果业务不变量(验证)得到满足,一个或多个领域事件将被准备好以供发布。
-
在事件源系统中,领域事件在命令侧被持久化。在状态存储系统中,我们会在持久化存储中更新实例的状态。
-
通过将这些领域事件发布到事件总线上来通知外部世界。事件总线是一个基础设施组件,事件被发布到该组件上。
让我们看看我们如何在 LC 发行应用程序的上下文中实现这一点。
重要提示
我们描绘了多个读取模型,因为根据需要支持的查询用例类型,可能(但不一定)需要创建多个读取模型。
为了使这个过程能够可预测地工作,读取模型(们)需要与写入模型保持同步(我们将在稍后详细探讨一些实现这一点的技术)。
工具选择
实现 CQRS 不需要使用任何框架。被认为是 CQRS 模式之父的 Greg Young 在ordina-jworks.github.io/domain-driven%20design/2016/02/02/A-Decade-Of-DDD-CQRS-And-Event-Sourcing.html这篇论文中建议不要自己构建 CQRS 框架,这篇论文值得一读。使用一个好的框架可以帮助提高开发者的效率并加速业务功能的交付,同时抽象出底层管道和非功能性需求,而不限制灵活性。在本书中,我们将使用 Axon 框架(axonframework.org/)来实现应用程序功能,因为我们有在大型企业开发中使用它的实际经验。还有其他一些工作得相当好的框架,如 Lagom 框架(www.lagomframework.com/)和 Eventuate(eventuate.io/),也值得探索。
启动应用程序
要开始,让我们创建一个简单的 Spring Boot 应用程序。有几种方法可以做到这一点。您始终可以使用start.spring.io上的 Spring 启动应用程序来创建此应用程序。在这里,我们将使用 Spring CLI 来启动应用程序。
重要提示
要为您的平台安装 Spring CLI,请参阅docs.spring.io/spring-boot/docs/current/reference/html/getting-started.html#getting-started.installing中的详细说明。
要启动应用程序,请使用以下命令:

这应该在当前目录中创建一个名为lc-issuance-api.zip的文件。将此文件解压缩到您选择的位置,并在pom.xml文件的dependencies部分添加 Axon 框架的依赖项:

- 您可能需要更改版本。本书编写时,我们处于 4.5.3 版本。
此外,添加以下对axon-test库的依赖项以启用聚合的单元测试:

使用前面的设置,您应该能够运行应用程序并开始实现 LC 发行功能。
让我们看看如何使用 Axon 框架实现这些命令。
识别命令
从上一章的事件风暴会话中,我们有以下命令作为起点:

图 5.3 – 识别出的命令
命令总是针对聚合(根实体)进行处理(处理)。这意味着我们需要解决这些命令,以便由聚合处理。虽然命令的发送者不关心系统中的哪个组件处理它,但我们需要决定哪个聚合将处理每个命令。还应注意,任何给定的命令只能由系统中的单个聚合处理。让我们看看如何对这些命令进行分组并将它们分配给聚合。为了能够做到这一点,我们首先需要识别系统中的聚合。
识别聚合
查看我们 LC 应用的 eventstorming 会话的输出,一个潜在的分组可以如下所示:

图 5.4 – 对聚合设计的第一次尝试
这些实体中的某些或所有可能是聚合(有关聚合和实体的区别的更详细解释,请参阅第一章,领域驱动设计的原理)。乍一看,我们似乎有四个潜在的实体来处理这些命令:

图 5.5 – 初次观察到的潜在聚合
初看起来,这些实体中的每一个都可能被分类为我们解决方案中的聚合。在这里,鉴于我们正在构建一个用于管理 LC 应用的解决方案,LC 应用程序 似乎是一个相当合适的聚合选择。然而,其他实体是否也适合被分类为聚合呢?产品 和 申请人 看起来像潜在的实体,但我们需要问自己是否需要在 LC 应用程序 的范围之外对这些实体进行操作。如果答案是 是,那么 产品 和 申请人 可能 被分类为聚合。但 产品 和 申请人 似乎不需要在没有包含在边界上下文中的 LC 应用程序 聚合的情况下进行操作。感觉是这样,因为产品和申请人的详细信息都需要作为 LC 申请过程的一部分提供。至少从我们目前所了解的过程来看,这似乎是正确的。这意味着我们剩下两个潜在的聚合 – LC 和 LC 应用程序:
![图 5.6 – 边界上下文之间的关系]
![图片 B16716_05_06.jpg]
![图 5.6 – 边界上下文之间的关系]
当我们查看我们的事件风暴会议输出时,LC 应用程序 聚合在生命周期中较晚转变为 LC 聚合。现在让我们专注于 LC 应用程序,并将对 LC 聚合需求的进一步分析推迟到以后。
重要提示
通俗地说,术语聚合和聚合根有时可以互换使用,意思相同。聚合可以是分层的,并且聚合可以包含子聚合。虽然聚合和聚合根都处理命令,但在给定上下文中只能有一个聚合作为根,它封装了对其子聚合、实体和值对象的访问。
需要注意的是,实体可能需要在不同的边界上下文中被视为聚合,这种处理完全取决于上下文。
当我们查看我们的事件风暴会议输出时,LC 应用程序在发行上下文中在生命周期中较晚转变为 LC。我们现在的重点是优化和自动化整个发行流程的 LC 应用程序流程。既然我们已经决定与 LC 应用程序聚合(根)合作,让我们开始编写我们的第一个命令,看看它在代码中是如何体现的。
测试驱动系统
尽管我们对系统有一个相当好的概念性理解,但我们仍在不断细化这种理解。通过测试驱动系统,我们可以通过充当我们正在生产的解决方案的第一个客户来锻炼我们的理解。
重要提示
测试驱动系统的实践在畅销书《由测试引导的面向对象软件开发》中得到了很好的阐述,作者是 Nat Price 和 Steve Freeman。阅读这本书以深入了解这一实践是值得的。
因此,让我们从第一个测试开始。对于外部世界来说,一个事件驱动系统通常以以下图示的方式进行工作:

图 5.7 – 事件驱动系统
这个图示可以这样解释:
-
可能已经发生了一组可选的领域事件。
-
系统接收到一个命令(由用户手动发起或由系统的一部分自动发起),它充当刺激。
-
命令由一个聚合体处理,然后继续验证接收到的命令以强制执行不变性(结构性和领域验证)。
-
系统随后以两种方式之一做出反应:
-
发射一个或多个事件。
-
抛出异常。
-
重要提示
本章中展示的代码片段是为了突出显著的概念和技术。对于完整的示例,请参阅本章附带源代码(包含在 ch05 目录中)。
Axon 框架允许我们以下形式表达测试:

-
FixtureConfiguration是 Axon 框架的一个实用工具,用于帮助使用 BDD 风格的给定-当-然后语法测试聚合行为。 -
AggregateTestFixture是FixtureConfiguration的具体实现,其中您需要注册您的聚合类 – 在我们的情况下,LCApplication是处理指向我们解决方案的命令的候选者。 -
由于这是业务流程的开始,到目前为止还没有发生任何事件。这一点通过我们没有向给定方法传递任何参数来表示。在稍后讨论的示例中,可能会在接收到此命令之前已经发生了一些事件。
-
这是我们实例化命令对象的新实例的地方。命令对象通常类似于数据传输对象,携带一组信息。这个命令将被路由到我们的聚合体进行处理。我们将在稍后详细查看这是如何工作的。
-
在这里,我们声明我们期望匹配确切序列的事件。
-
在这里,我们期望在成功处理命令后发射一个
LCApplicationCreated类型的事件。 -
我们最终表示我们不期望有更多的事件,这意味着我们期望恰好发射一个事件。
实现命令
在前面的简单示例中,CreateLCApplicationCommand 不携带任何状态。现实中,命令可能看起来像以下所示:

-
这是命令类。在命名命令时,我们通常使用祈使句风格;也就是说,它们通常以一个表示所需动作的动词开头。请注意,这是一个数据传输对象。换句话说,它只是一个数据属性袋。同时请注意,它没有任何逻辑(至少目前是这样)。
-
这是 LC 应用程序的标识符。在这种情况下,我们假设使用客户端生成的标识符。关于使用服务器生成标识符与客户端生成标识符的主题超出了本书的范围。你可以根据你的上下文优势选择使用其中之一。此外,请注意,我们使用强类型
LCApplicationId标识符,而不是原始类型,如数值或字符串值。在某些情况下,使用 UUID 作为标识符也很常见。然而,我们更喜欢使用强类型来区分标识符类型。注意我们是如何使用ClientId类型来表示应用程序的创建者的。 -
Party和AdvisingBank类型是我们解决方案中代表这些概念的复杂类型。应小心使用与问题(业务)领域相关的名称,而不是使用仅在解决方案(技术)领域有意义的名称。注意在两种情况下都尝试使用领域专家的通用语言。这是我们命名系统中的事物时应该始终意识到的实践。
值得注意的是,merchandiseDescription被保留为原始的String类型。这可能与之前我们提出的评论相矛盾。我们将在接下来的结构验证部分解决这个问题。
现在,让我们看看成功处理命令后我们将发出的事件将是什么样子。
实现事件
在事件驱动系统中,通过成功处理命令来改变系统状态通常会导致域事件被发出,以向系统的其余部分信号状态的变化。这里展示了现实世界中的LCApplicationCreatedEvent事件的简化表示:

- 当命名事件时,我们通常使用过去时态的名称来表示已经发生并且无条件接受为无法改变的经验事实。
你可能会注意到,事件的结构目前与命令的结构完全相同。虽然在这个案例中这是正确的,但并不总是这样。我们在事件中选择的披露信息量是上下文相关的。在将信息作为事件的一部分发布时,与领域专家进行咨询非常重要。你可能会选择在事件有效负载中保留某些信息。例如,考虑ChangePasswordCommand,它包含新更改的密码。可能明智的做法是不在结果PasswordChangedEvent中包含更改后的密码。
在之前的测试中,我们已经看到了命令和结果事件。让我们通过查看聚合实现来了解这是如何实现的。
设计聚合
聚合是处理命令和发出事件的场所。我们编写的测试的好处是它以一种隐藏实现细节的方式表达。但让我们看看实现,以便能够欣赏我们如何让测试通过并满足业务需求:

-
这是
LCApplication聚合体的聚合标识符。对于聚合体,标识符唯一地标识了一个实例与另一个实例的不同。因此,所有聚合体都需要声明一个标识符,并使用框架提供的@AggregateIdentifier注解将其标记为使用。 -
处理命令的方法需要使用
@CommandHandler注解进行标注。在这种情况下,命令处理器恰好是这个类的构造函数,因为这是这个聚合体可以接收的第一个命令。我们将在本章后面看到后续命令由其他方法处理的示例。 -
@CommandHandler注解将一个方法标记为命令处理器。这个方法可以处理的确切命令需要作为参数传递给方法。请注意,对于任何给定的命令,整个系统中只能有一个命令处理器。 -
在这里,我们使用框架提供的
AggregateLifecycle实用工具发出LCApplicationCreatedEvent。在这个非常简单的例子中,我们在收到命令后无条件地发出一个事件。在现实世界的场景中,在决定发出一个或多个事件或用异常失败命令之前,可能需要执行一系列验证。我们将在本章后面查看更实际的示例。 -
在这个阶段,
@EventSourcingHandler的需求及其作用可能非常不清楚。我们将在本章的后续部分详细解释这一需求。
这是对一个简单事件驱动系统的快速介绍。我们仍然需要理解@EventSourcingHandler的作用。为了理解这一点,我们需要欣赏聚合持久化的工作方式及其对我们整体设计的影响。
聚合持久化
当与任何具有适度复杂性的系统一起工作时,我们需要确保交互持久化;也就是说,交互需要超越系统重启、崩溃等情况。因此,持久化的需求是既定的。虽然我们应该始终努力将持久化关注点从系统的其余部分抽象出来,但我们的持久化技术选择可能会对我们整体解决方案的架构产生重大影响。在如何选择持久化聚合状态方面,我们有几个选择值得提及:
-
状态存储
-
事件源
让我们在接下来的几节中更详细地检查这些技术。
状态存储聚合
保存实体的当前值到目前为止仍然是持久化状态的最流行方式——得益于关系数据库和 LCApplication 的巨大普及,我们可以设想使用一个结构类似于以下的关系数据库:

图 5.8 – 典型的实体关系模型
无论我们选择使用关系数据库还是更现代的 NoSQL 存储——例如,文档存储、键值存储、列族存储等等——我们用来持久化信息的风格基本上保持不变,即存储该聚合/实体的属性当前值。当属性值发生变化时,我们只需用新的值覆盖旧值;也就是说,我们存储聚合和实体的当前状态——因此得名 状态存储。这种技术在过去的几年里一直为我们服务得很好,但至少还有另一种机制我们可以用来持久化信息。我们将在下一节更详细地探讨这一点。
事件源聚合
开发者已经非常长时间以来一直在依赖日志来完成各种诊断目的。同样,关系数据库几乎从诞生之初就开始使用提交日志来持久化存储信息。然而,在主流系统中,开发者将日志作为结构化信息的首选持久化解决方案的使用仍然极为罕见。
日志是一个极其简单、只追加且不可变的按时间顺序排列的记录序列。这里的图展示了记录按顺序写入的日志结构。本质上,日志是一个只追加的数据结构,如图所示:

图 5.9 – 日志数据结构
与比表等更复杂的数据结构相比,写入日志是一个相对简单且快速的操作,并且可以处理极大量的数据,同时提供可预测的性能。确实,现代事件流平台如 Apache Kafka 就利用这种模式来扩展以支持极大量的数据。我们确实觉得这可以应用于在主流系统中处理命令时作为持久化存储,因为这具有超出之前列出的技术优势。考虑以下这里展示的在线订单流程示例:

如您所见,在事件存储中,我们继续对所有用户执行的操作保持全面可见。这使我们能够更全面地推理这些行为。在传统存储中,我们失去了用户将白面包替换为小麦面包的信息。虽然这本身不影响订单,但我们失去了从这一用户行为中获取洞察的机会。我们认识到,这种信息可以通过其他方式使用专门的分析解决方案来捕获;然而,事件日志机制提供了一种无需额外努力的自然方式来完成这项工作。它还充当审计日志,提供迄今为止发生的所有事件的完整历史。这与领域驱动设计的本质非常吻合,我们不断探索减少复杂性的方法。
然而,以简单事件日志的形式持久化数据有一些影响。在处理任何命令之前,我们需要按照发生顺序恢复过去的事件,并重建聚合状态,以便我们能够执行验证。例如,在确认结账时,仅仅有已过期的事件集合是不够的。我们仍然需要在允许下单之前计算出购物车中确切的商品。在处理该命令之前,这种事件回放以恢复聚合状态(至少是那些需要验证该命令的属性)是必要的。例如,在处理RemoveItemFromCartCommand之前,我们需要知道当前购物车中包含哪些商品。这在下表中得到了说明:

整个场景的对应源代码在以下代码片段中展示:

-
在处理任何命令之前,聚合加载过程首先通过调用无参构造函数开始。因此,我们需要无参构造函数是
状态。状态的恢复必须只发生在触发事件回放的那些方法中。在 Axon 框架的情况下,这相当于带有@EventSourcingHandler注解的方法。 -
重要的是要注意,在之前的代码中,在发出
CartCreatedEvent和ItemAddedEvent的地方发出AddItemCommand是可能的(但不是必需的)。命令处理器不会改变聚合的状态。它们只利用现有的聚合状态来强制执行不变性(验证)并在这些不变性为true时发出事件。 -
加载过程通过调用事件源处理器方法按发生顺序继续进行,这些方法针对该聚合实例。事件源处理器仅需要根据过去的事件来恢复聚合状态。这意味着它们通常不包含任何业务(条件)逻辑。不言而喻,这些方法不会发出任何事件。事件发射仅限于在强制执行不变性成功时在命令处理器中发生。
当与事件源聚合一起工作时,非常重要的一点是要对可以编写的代码类型保持纪律性:

如果有大量历史事件需要恢复状态,聚合加载过程可能会变得耗时——直接与该聚合已过事件的数量成正比。我们可以采用一些技术(如事件快照)来克服这一点。
持久化技术选择
如果你使用状态存储来持久化你的聚合,使用你通常的评估过程来选择你的持久化技术应该足够。然而,如果你正在查看事件源聚合,决策可能会更加微妙。根据我们的经验,即使是简单的关系型数据库也能做到这一点。事实上,我们曾经使用关系型数据库作为具有数十亿事件的超大规模事务性应用的事件存储。这种设置对我们来说效果很好。值得注意的是,我们只使用了事件存储来插入新事件和按顺序加载给定聚合的事件。然而,有许多专门构建来作为事件存储的技术,它们支持其他增值功能,如时间旅行、完整事件回放、事件负载内省等。如果你有这样的需求,考虑其他选项可能值得,例如 NoSQL 数据库(如 MongoDB 这样的文档存储或如 Cassandra 这样的列族存储)或专门构建的商业产品,如 EventStoreDB 和 Axon Server,以评估在你的环境中是否可行。
我们应该选择哪种持久化机制?
现在我们对两种聚合持久化机制(状态存储和事件源)有了相当好的理解,这就引出了我们应该选择哪一种的问题。我们在此列出使用事件源的一些好处:
-
我们可以在高合规性场景中将事件用作自然审计日志。
-
它提供了在细粒度事件数据的基础上执行更深入的分析的能力。
-
当我们与基于不可变事件的系统一起工作时,这可能会产生更灵活的设计,因为持久化模型的复杂性受到限制。此外,我们无需处理复杂的 ORM 阻抗不匹配问题。
-
领域模型与持久化模型之间的耦合更加松散,使其能够主要独立于持久化模型而演进。
-
它使得能够回到过去,以便能够创建临时视图和报告,而无需处理前置复杂性。
反过来,在实施基于事件源解决方案时,你可能需要考虑以下一些挑战:
-
事件源需要范式转变,这意味着开发和业务团队将不得不花费时间和精力去理解它是如何工作的。
-
持久化模型不直接存储状态。这意味着在持久化模型上直接进行临时查询可能会更加具有挑战性。这可以通过实现新的视图来缓解;然而,这样做会增加复杂性。
-
事件源通常在结合 CQRS 实现时工作得非常好,这可能会给应用程序增加更多的复杂性。它还要求应用程序更加关注强一致性 versus 最终一致性的问题。
我们的经验表明,事件源系统在现代事件驱动系统中带来了许多好处。然而,在做出持久化选择时,你需要意识到在你自己的生态系统中所提出的先前考虑。
强制执行策略
在处理命令时,我们需要强制执行策略或规则。策略分为两大类:
-
结构性规则 – 那些强制执行已发送命令的语法有效性的规则
-
领域规则 – 那些强制执行业务规则得到遵守的规则
在系统的不同层执行这些验证也可能是明智的。而且,在系统的多个层中重复执行这些策略强制也是常见的。然而,重要的是要注意,在命令成功处理之前,所有这些策略强制都是统一应用的。让我们在接下来的部分中看看这些示例。
结构性验证
目前,要创建 LC 应用程序,你需要发送CreateLCApplicationCommand。虽然命令规定了结构,但目前没有任何强制执行。让我们纠正这一点。
为了能够声明式地启用验证,我们将使用 JSR-303 Bean 验证库。我们可以通过在pom.xml文件中使用spring-boot-starter-validation依赖轻松地添加它,如图所示:

现在,我们可以使用 JSR-303 注解向命令对象添加验证,如图所示:

大多数结构性验证可以使用内置的验证注解来完成。也有可能为单个字段创建自定义验证器,或者验证整个对象(例如,验证相互依赖的属性)。有关如何操作的更多详细信息,请参阅beanvalidation.org/2.0/的 Bean 验证规范和hibernate.org/validator/的参考实现。
业务规则强制
结构性验证可以使用命令中已经存在的信息来完成。然而,还有另一类验证需要的信息并不在传入的命令本身中。这类信息可能存在于两个地方之一 – 在我们正在操作的聚合内,或者不在聚合本身内,但可以在有限范围内提供。
让我们看看一个需要聚合内存在状态验证的验证示例。考虑提交 LC 的例子。当 LC 处于草稿状态时,我们可以对其进行多次编辑,但在提交后就不能再进行任何更改。这意味着我们只能提交一次 LC。通过发出SubmitLCApplicationCommand来实现提交 LC 的行为,如事件风暴会议中的工件所示:

图 5.10 – 提交 LC 应用程序过程中的验证
让我们从编写一个测试来表示我们的意图开始:

-
已知
LCApplicationCreatedEvent已经发生 – 换句话说,LC 申请已经创建。 -
这是我们尝试通过发出针对同一应用的
SubmitLCApplicationCommand来提交应用程序的时候。 -
我们期望会发出
LCApplicationSubmittedEvent事件。
相应的实现将类似于以下内容:

上述实现允许我们无条件地提交 LC 应用程序 – 超过一次。然而,我们希望限制用户只能提交一次。为了能够做到这一点,我们需要记住 LC 应用程序已经被提交。我们可以在相应事件的@EventSourcingHandler处理程序中做到这一点,如下所示:

- 当
LCApplicationSubmittedEvent被重放时,我们将 LC 应用程序的状态设置为SUBMITTED。
虽然我们已经记住应用程序已变为SUBMITTED状态,但我们仍然没有阻止多次提交尝试。我们可以通过编写一个测试来修复这个问题,如下所示:

-
LCApplicationCreatedEvent和LCApplicationSubmittedEvent已经发生,这意味着LCApplication已经被提交过一次。 -
我们现在向系统发送另一个
SubmitLCApplicationCommand命令。 -
我们期望会抛出
AlreadySubmittedException异常。 -
我们也期望不会发出任何事件。
使其工作的命令处理程序的实现如下所示:

- 注意我们是如何使用
LCApplication聚合的状态属性来执行验证的。如果应用程序不在DRAFT状态,我们将通过AlreadySubmittedException域异常失败。
让我们再看看一个例子,其中执行验证所需的信息既不是命令的一部分,也不是聚合的一部分。让我们考虑这样一个场景,即国家法规禁止与所谓的受制裁国家进行交易。这个国家列表的变化可能受到外部因素的影响。因此,将这个受制裁国家的列表作为命令有效负载的一部分是没有意义的。同样,将其作为每个单个聚合状态的一部分来维护也没有意义——鉴于它可以改变(尽管非常不频繁)。在这种情况下,我们可能想要考虑使用一个位于聚合类之外的命令处理器。到目前为止,我们只看到了聚合内部@CommandHandler方法的示例。但是,@CommandHandler注解可以出现在任何其他外部于聚合的类上。然而,在这种情况下,我们需要自己加载聚合。Axon 框架提供了一个org.axonframework.modelling.command.Repository接口,允许我们这样做。重要的是要注意,这个仓库与 Spring 数据库中作为其一部分的 Spring 框架接口是不同的。这里展示了如何工作的一个示例:

-
我们正在注入 Axon
Repository接口以允许我们加载聚合。之前这并不是必需的,因为@CommandHandler注解直接出现在聚合方法上。 -
我们正在使用
Repository接口来加载聚合并与之交互。Repository接口支持其他方便的方法来处理聚合。请参阅 Axon 框架文档以获取更多使用示例。
回到受制裁国家的例子,让我们看看我们需要如何稍微不同地设置测试:

-
我们像往常一样创建一个新的聚合夹具。
-
我们正在使用夹具来获取 Axon
Repository接口的一个实例。 -
我们实例化了一个自定义命令处理器,传递了
Repository实例。同时,请注意我们如何通过简单的依赖注入将受制裁国家的集合注入到处理器中。在现实生活中,这组受制裁国家很可能会从外部配置中获得。 -
我们最终需要将命令处理器注册到夹具中,以便它可以路由命令到这个处理器。
对于这个测试,看起来相当直接:

-
为了测试的目的,我们将国家
SOKOVIA标记为受制裁国家。在更现实的场景中,这很可能是来自某种形式的外部配置(例如,查找表或某种形式的外部配置)。然而,这对于我们的单元测试是合适的。 -
然后将这组受制裁国家注入到命令处理器中。
-
当为受制裁国家创建 LC 应用程序时,我们预计不会发出任何事件,并且还会抛出
CannotTradeWithSanctionedCountryException异常。 -
最后,当受益人属于非制裁国家时,我们发出
LCApplicationCreatedEvent事件。
命令处理器的实现如下所示:

-
我们将类标记为
@Service以标记它为一个没有封装状态的组件,并在使用基于注解的配置或类路径扫描时启用自动发现。因此,它可以用于执行任何“管道”活动。 -
请注意,受益人所在国家是否被制裁的验证也可以在行 18 进行。有些人可能会认为这是理想的,因为我们如果那样做可以避免调用 Axon
Repository方法的潜在不必要的调用。然而,我们更喜欢尽可能地将业务验证封装在聚合的范围内,这样我们就不会遭受创建贫血领域模型的问题。 -
我们使用聚合存储库作为工厂来创建
LCApplication领域对象的新的实例。
最后,这里展示了聚合实现以及验证:

- 验证本身相当直接。当验证失败时,我们抛出
CannotTradeWithSanctionedCountryException异常。
通过这些示例,我们探讨了在聚合边界内实现策略执行的不同方法。
摘要
在本章中,我们使用了事件风暴会议的输出,并将其用作创建我们边界上下文领域模型的主要辅助工具。我们探讨了如何使用 CQRS 架构模式来实现这一点。我们探讨了持久化选项以及使用基于事件的聚合与存储状态的聚合的后果。最后,我们通过一系列代码示例来查看执行业务验证的多种方式。我们通过 Spring Boot 和 Axon 框架的代码示例来查看所有这些。有了这些知识,我们应该能够实现健壮、封装良好、事件驱动的领域模型。
在下一章中,我们将探讨实现这些领域能力的用户界面,并考察一些选项,例如基于 CRUD 的 UI 与基于任务的 UI。
进一步阅读

第六章:基于任务的用户界面实现
要完成一项艰巨的任务,首先必须使其变得简单。
– 马蒂·鲁宾
领域驱动设计(DDD)的精髓在于捕捉业务流程和用户意图。在前一章中,我们设计了一套 API,并没有过多关注这些 API 最终用户将如何使用它们。在这一章中,我们将使用 JavaFX 框架为 LC 应用程序设计 GUI。作为其中的一部分,我们将检查这种独立设计 API 的方法如何导致生产者和消费者之间的阻抗不匹配。我们将检查这种阻抗不匹配的后果以及基于任务的 UI 如何帮助应对这种不匹配。
在本章中,我们将涵盖以下主题:
-
API 样式
-
启动 UI
-
实现 UI
到本章结束时,你将了解如何运用 DDD 原则来帮助你构建简单直观的健壮用户体验。你还将了解为什么从消费者的角度设计你的后端接口(API)可能是明智的。
技术要求
你将需要访问以下内容:
-
JDK 1.8+(我们使用了 Java 16 来编译示例源代码)
-
JavaFX SDK 16 和 Scene Builder
-
Maven 3.x
-
Spring Boot 2.4.x
-
mvvmFX 1.8 (
sialcasa.github.io/mvvmFX/) -
JUnit 5.7.x(包含在 Spring Boot 中)
-
TestFX(用于 UI 测试)
-
OpenJFX Monocle(用于无头 UI 测试)
-
Project Lombok(用于减少冗余)
在我们深入构建 GUI 解决方案之前,让我们快速回顾一下我们之前留下的 API 状态。
API 样式
如果你还记得第五章中的实现领域逻辑,我们创建了以下命令:

图 6.1 – 事件风暴会议中的命令
如果你仔细观察,似乎有两个粒度的命令。创建 LC 应用程序和更新 LC 应用程序是粗粒度的,而其他的则更专注于它们的意图。粗粒度命令的可能分解方式如下所示:

图 6.2 – 分解的命令
除了比前一个迭代的命令更细粒度之外,修订后的命令似乎更好地捕捉了用户的意图。这可能感觉像是一个微小的语义变化,但可能会对我们解决方案的最终用户使用方式产生巨大影响。那么问题来了,我们是否应该始终偏好细粒度 API 而不是粗粒度 API。答案可能更加复杂。在设计 API 和体验时,我们看到两种主要风格被采用:
-
基于 CRUD
-
基于任务
让我们更详细地看看这些。
基于 CRUD 的 API
Insert、Select、Update 和 Delete。同样,HTTP 协议有 POST、GET、PUT 和 DELETE 作为动词来表示这些 CRUD 操作。这种方法已经扩展到我们 API 的设计中。这导致了基于 CRUD 的 API 和用户体验的激增。看看来自 第五章 的 CreateLCApplicationCommand,实现领域逻辑:

沿着类似的思路,创建相应的 UpdateLCApplicationCommand 并不罕见,如下所示:

虽然这非常常见且很容易理解,但并非没有问题。以下是一些采取这种方法会引发的问题:
-
我们是否允许更改
update命令中列出的所有内容? -
假设一切都可以改变,它们是否同时改变?
-
我们如何知道确切发生了什么变化?我们应该进行差异比较吗?
-
如果上述所有属性都没有包含在
update命令中怎么办? -
如果未来需要添加属性怎么办?
-
用户想要完成的事务的业务意图是否被捕捉到了?
在一个简单的系统中,这些问题的答案可能并不那么重要。然而,随着系统复杂性的增加,这种方法是否仍然能够适应变化?我们认为,有必要考虑另一种方法,即基于任务的 API,以便能够回答这些问题。
基于任务的 API
在一个典型的组织中,个人执行与其专业相关的任务。组织越大,专业化的程度越高。根据个人专业来隔离任务的方法是有道理的,因为它减少了相互干扰的可能性,尤其是在完成复杂工作的时候。例如,在 LC 申请流程中,需要确定产品的价值/合法性,同时也要确定申请人的信用度。这些任务通常由不同部门的个人执行是有意义的,这也意味着这些任务可以独立于其他任务执行。
在业务流程方面,如果我们有一个名为 CreateLCApplicationCommand 的命令先于这些操作,那么两个部门的个人首先必须等待整个申请表填写完毕,然后才能开始他们的工作。其次,如果通过单个 UpdateLCApplicationCommand 命令更新任何信息,那么不清楚具体发生了什么变化。这种流程中的不明确性可能导致至少一个部门收到虚假通知。
由于大部分工作都是以特定任务的形式进行的,如果我们的流程和 API 能够反映这些行为,这将对我们有利。
考虑到这一点,让我们重新审视我们修订后的 LC 申请流程 API:

图 6.3 – 修订后的命令
虽然之前可能看起来我们只是将粗粒度的 API 转换为更细粒度,但实际上,这更好地代表了用户意图要执行的任务。因此,本质上,基于任务的 API 是以与用户意图更紧密对齐的方式对工作进行分解。使用我们新的 API,产品验证可以在ChangeMerchandise发生时立即开始。此外,用户的行为以及对此行为需要做出何种反应都变得明确无误。这随即引发了一个问题:我们是否应该始终使用基于任务的 API。让我们更详细地看看其影响。
基于任务还是基于 CRUD?
基于 CRUD 的 API 似乎在聚合级别上运行。在我们的例子中,我们有 LC 聚合。在最简单的情况下,这本质上等同于与每个 CRUD 动词对齐的四个操作。然而,正如我们所看到的,即使在我们的简化版本中,LC 正成为一个相当复杂的概念。在 LC 级别上仅需要处理四个操作,从认知上来说是复杂的。随着需求的增加,这种复杂性只会继续增加。例如,考虑一种情况,业务表达了对捕获更多关于“商品”信息的需要,而今天,这仅仅以自由文本的形式被捕获。这里展示了商品信息的更详细版本:

在我们当前的设计中,这种变化的含义对提供者和消费者(们)都产生了深远的影响。让我们更详细地看看一些后果:

在我们当前的设计中,这种变化的含义对提供者和消费者(们)都产生了深远的影响。让我们更详细地看看一些后果。
如我们所见,基于 CRUD 和基于任务的接口之间的选择是微妙的。我们并不是建议你应该选择其中之一。你使用哪种风格将取决于你的具体需求和上下文。根据我们的经验,基于任务的接口将用户意图视为一等公民,并以非常优雅的方式延续了 DDD 的通用语言精神。我们更倾向于尽可能地将接口设计为基于任务的,因为它们会产生更直观的界面,更好地表达问题域。
随着系统的演变,它们开始支持更丰富的用户体验和多个渠道,基于 CRUD 的接口似乎需要额外的翻译层来满足用户体验需求。下面的图示描绘了一个支持多用户体验渠道的解决方案的典型分层架构:

图 6.4 – 支持多用户体验渠道的分层架构
这种设置通常由以下内容组成:
-
由基于 CRUD 的服务组成的领域层,这些服务简单地映射到数据库实体
-
由跨越多个核心服务的企业能力组成的复合层
-
由特定通道 API 组成的前端后端(BFF)层
注意,复合层和 BFF 层主要作为将后端能力映射到用户意图的手段。在一个理想的世界里,如果后端 API 紧密反映用户意图,那么翻译的需求应该是最小的(如果有的话)。我们的经验表明,这种设置会导致业务逻辑被推向用户通道,而不是封装在精心设计的业务服务中。此外,这些层会导致同一功能在不同通道上产生不一致的体验,因为现代团队是按照层边界结构化的。
重要提示
我们并不反对使用分层架构。我们认识到分层架构可以带来模块化、关注点分离和其他相关好处。然而,我们反对仅仅为了补偿核心领域 API 设计不当而创建额外的层级。
一个精心设计的 API 层可以对构建出色的用户体验产生深远的影响。然而,这是一章关于实现用户界面的内容。让我们回到为 LC 应用程序创建用户界面的任务。
启动 UI
我们将简单地为我们创建的 LC 应用程序构建 UI,该应用程序在第五章中实现,实现领域逻辑。有关详细说明,请参阅启动应用程序部分。此外,我们还需要将以下依赖项添加到项目根目录下的 Maven pom.xml文件的dependencies部分:

要运行 UI 测试,你需要添加以下依赖项:

要能够从命令行运行应用程序,你需要在pom.xml文件的plugins部分添加javafx-maven-plugin,如下所示:

要从命令行运行应用程序,请使用以下命令:
mvn javafx:run
重要提示
如果你使用的是版本大于 1.8 的 JDK,JavaFX 库可能不会与 JDK 本身捆绑。当你从你的 IDE 运行应用程序时,你可能会需要添加以下内容:
--module-path=<path-to-javafx-sdk>/lib/ \
--add-modules=javafx.controls,javafx.graphics,
javafx.fxml,javafx.media
我们正在使用 mvvmFX 框架来组装 UI。为了与 Spring Boot 兼容,应用程序启动器看起来如下所示:

重要提示
我们需要从MvvmfxSpringApplication mvvmFX 框架类扩展。
请参考附带源代码仓库中的ch06目录以获取完整示例。
实现 UI
当与用户界面一起工作时,使用以下这些表示模式是相当常见的:
-
模型-视图-控制器(MVC)
-
模型-视图-演示者(MVP)
-
模型-视图-视图模型(MVVM)
MVC 模式存在时间最长。在协作的模型、视图和控制器对象之间分离关注点的想法是合理的。然而,在实际实现中,这些对象的定义似乎有很大的差异——在许多情况下,控制器变得过于复杂。相比之下,MVP 和 MVVM,虽然都是 MVC 的衍生品,但似乎在协作对象之间带来了更好的关注点分离。特别是当与数据绑定构造结合使用时,MVVM 使得代码更加易于阅读、维护和测试。在这本书中,我们使用 MVVM,因为它使我们能够进行测试驱动开发,这是我们强烈偏好的。让我们快速了解一下 MVVM 入门,如 mvvmFX 框架中实现的那样。
MVVM 入门
现代 UI 框架开始采用声明性风格来表示视图。MVVM 旨在通过使用绑定表达式从视图中移除所有 GUI 代码(代码后置),从而实现风格与编程关注点之间的更清晰分离。这里展示了如何实现此模式的视觉高级概述:


图 6.5 – MVVM 设计模式
该模式包含以下组件:
-
模型:负责容纳业务逻辑和管理应用程序的状态。
-
视图:负责向用户展示数据,并通过视图代理通知视图模型关于用户交互。
-
视图代理:负责在用户或视图模型进行更改时保持视图和视图模型同步。它还负责将视图上执行的操作传输到视图模型。
-
视图模型:负责代表视图处理用户交互。视图模型使用观察者模式(通常是一向或双向数据绑定,使其更方便)与视图交互。视图模型与模型交互以进行更新和读取操作。
创建新的 LC
让我们考虑创建新的 LC 的例子。要开始创建新的 LC,我们只需要申请人提供一个友好的客户端引用。这是一个容易记住的文本字符串。这里展示了这个 UI 的简单版本:


图 6.7 – 开始 LC 创建屏幕
让我们更详细地检查每个组件的实现和目的。
声明性视图
当使用 JavaFX 时,视图可以使用 FXML 格式的声明性风格进行渲染。以下是从StartLCView.fxml文件中提取的重要片段,用于开始创建新的 LC:

-
StartLCView类作为 FXML 视图的视图代理,并使用根元素(在这种情况下为javafx.scene.layout.Pane)的fx:controller属性进行分配。 -
为了在视图代理中引用
client-reference输入字段,我们使用fx:id注解(在这种情况下为clientReference)。 -
同样,在视图代理中使用
"fx:id=startButton"来引用启动按钮。此外,视图代理中的start方法被分配来处理默认操作(javafx.scene.control.Button`的按钮按下事件)。
视图代理
接下来,让我们看看com.premonition.lc.issuance.ui.views.StartLCView视图代理的结构:

-
这是
StartLCView.fxml视图的视图代理类。 -
这是视图中的
clientReference文本框的 Java 绑定。成员的名称需要与视图中的fx:id属性值完全匹配。此外,它需要使用@javafx.fxml.FXML注解进行注解。如果视图代理中的成员是公共的并且与视图中的名称匹配,则使用@FXML注解是可选的。 -
同样,
startButton被绑定到视图中的相应按钮小部件。 -
这是当按下
startButton时的动作处理方法。
视图模型
这是StartLCView的StartLCViewModel视图模型类:

-
这是
StartLCView的视图模型类。请注意,我们必须实现 mvvmFX 框架提供的de.saxsys.mvvmfx.ViewModel接口。 -
我们正在使用 JavaFX 提供的
SimpleStringProperty初始化clientReference属性。还有其他几个属性类可以定义更复杂的数据类型。请参阅 JavaFX 文档以获取更多详细信息。 -
这是视图模型中
clientReference的值。我们很快将看看如何将其与视图中的clientReference文本框的值关联起来。请注意,我们正在使用 JavaFX 提供的StringProperty,它提供了对客户端引用底层字符串值的访问。 -
除了为底层值的标准获取器和设置器之外,JavaFX beans 还需要创建一个特殊的访问器来处理该属性本身。
将视图绑定到视图模型
接下来,让我们看看如何将视图与视图模型关联起来:

-
mvvmFX框架要求view delegate实现FXMLView<? extends ViewModelType>。在这种情况下,视图模型类型是StartLCViewModel。mvvmFX框架还支持其他视图类型。请参阅框架文档以获取更多详细信息。 -
框架提供了一个
@de.saxsys.mvvmfx.InjectViewModel注解,允许依赖注入,将视图模型注入到视图代理中。 -
框架将在初始化过程中调用所有带有
@de.saxsys.mvvmfx.Initialize注解的方法。如果方法名为initialize且声明为 public,则可以省略此注解。请参阅框架文档以获取更多详细信息。 -
现在,我们已经将视图代理中
clientReference文本框的文本属性绑定到视图模型中的相应属性。请注意,这是一个双向绑定,这意味着如果任一端发生变化,视图和视图模型中的值都将保持同步。 -
这是绑定操作的另一种变体,我们在这里使用的是单向绑定。在这里,我们将启动按钮的禁用属性绑定到视图模型上的相应属性。我们很快就会看到为什么我们需要这样做。
在 UI 中强制执行业务验证
我们有一个业务验证,即 LC 的客户端引用长度至少需要四个字符。这将在后端强制执行。然而,为了提供更丰富的用户体验,我们也将在这个 UI 上强制执行此验证。
重要提示
这可能与我们集中业务验证在后端的概念相悖。虽然这可能是一个崇高的尝试来实施不要重复自己(DRY)原则,但在现实中,它带来了许多实际的问题。分布式系统专家 Udi Dahan 对为什么这可能不是一个值得追求的善举有非常有趣的看法。Ted Neward 也在他的博客中谈到了这一点,标题为企业计算的谬误。
使用 MVVM 的优势在于,这个逻辑可以很容易地在视图模型的简单单元测试中进行测试。现在让我们看看它是如何工作的:

现在,让我们看看视图模型中这个功能的实现:

-
我们在视图模型中声明了一个
startDisabled属性来管理何时应禁用启动按钮。 -
有效客户端引用的最小长度被注入到视图模型中。可以想象,这个值可能是作为外部配置的一部分提供的,或者可能来自后端。
-
我们创建了一个绑定表达式来匹配业务要求。
-
我们将视图模型属性绑定到视图代理中启动按钮的禁用属性。
让我们再看看如何编写一个端到端、无头 UI 测试,如下所示:

-
我们编写了一个方便的
@UITest扩展,用于结合 Spring 框架和 TestFX 测试。请参阅书中附带的源代码以获取更多详细信息。 -
我们设置了 Spring 上下文,使其作为 mvvmFX 框架及其注入注解(例如,
@InjectViewModel)的依赖注入提供者。 -
我们正在使用 TestFX 框架提供的
@Start注解来启动 UI。 -
TestFX 框架注入了一个 FxRobot UI 辅助实例,我们可以使用它来访问 UI 元素。
-
我们使用 TestFX 框架提供的便利匹配器进行测试断言。
现在,当我们运行应用程序时,我们可以看到在输入有效的客户端引用时启动按钮是启用的:

图 6.8 – 使用有效的客户端引用启用启动按钮
现在我们已经正确地启用了启动按钮,让我们实现 LC 本身的实际创建,通过调用后端 API。
集成后端
LC 创建是一个复杂的过程,需要各种物品的信息,正如我们在分解 LC 创建过程时所证明的那样。在本节中,我们将集成 UI 与启动新 LC 创建的命令。这发生在我们按下StartNewLCApplicationCommand时,如下所示:

- 要启动一个新的 LC 应用程序,我们需要
applicantId和clientReference。
由于我们使用 MVVM 模式,调用后端服务的代码是视图模型的一部分。让我们驱动测试这个功能:

视图模型相应增强以注入BackendService的实例,如下所示:

现在,让我们测试以确保只有在输入有效的客户端引用时才调用后端:

-
我们设置已登录用户。
-
当客户端引用为空时,不应与后端服务有任何交互。
-
当输入有效的客户端引用值时,后端应该使用输入的值进行调用。
使此测试通过的实现看起来像这样:

-
我们在调用后端之前检查启动按钮是否启用。
-
这些是实际的带有适当值的后端调用。
现在,让我们看看如何从视图中集成后端调用。我们在 UI 测试中进行了此测试,如下所示:

-
我们注入后端服务的模拟实例。
-
我们模拟后端调用以成功返回。
-
我们输入有效的客户端引用值。
-
我们点击启动按钮。
-
我们验证服务确实使用正确的参数进行了调用。
-
我们验证我们已经移动到 UI 中的下一个屏幕(LC 详细信息屏幕)。
让我们看看当服务调用在另一个测试中失败时会发生什么:

-
我们模拟后端服务调用失败并抛出异常。
-
我们验证我们继续停留在
start-lc-screen。
此功能的视图实现如下所示:

-
JavaFX,像大多数前端框架一样,是单线程的,并且要求长时间运行的任务不要在 UI 线程上调用。为此,它提供了
javafx.concurrent.Service抽象,以在后台线程中优雅地处理此类交互。 -
通过视图模型实际调用后端发生在这里。
-
我们在这里显示下一个屏幕以输入更多的 LC 详细信息。
最后,服务实现本身如下所示:

-
我们注入由 Axon 框架提供的
org.axonframework.commandhandling.gateway.CommandGateway来调用命令。 -
实际上,使用
sendAndWait方法调用后端发生在这里。在这种情况下,我们会阻塞,直到后端调用完成。还有其他不需要这种阻塞的变体。请参阅 Axon 框架文档以获取更多详细信息。
我们现在已经看到了一个完整的示例,展示了如何实现 UI 和调用后端 API。
摘要
在本章中,我们探讨了 API 风格的细微差别,并阐明了设计能够紧密捕捉用户意图的 API 非常重要的原因。我们比较了基于 CRUD 和基于任务的 API 的区别。最后,我们利用 MVVM 设计模式实现了 UI,并展示了它是如何帮助测试驱动前端功能的。
现在我们已经实现了创建新的 LC,为了实现后续的命令,我们需要访问现有的 LC。在下一章中,我们将探讨如何实现查询端以及如何使其与命令端保持同步。
进一步阅读

第七章:实现查询
最美的风景总是在最艰难的攀登之后。
– 匿名
在第三章“理解领域”的命令查询责任分离(CQRS)部分,我们描述了领域驱动设计(DDD)和 CQRS 如何相互补充,以及查询端(读取模型)如何用于创建底层数据的单一或多个表示。在本章中,我们将深入探讨如何通过监听领域事件来构建数据的高效读取表示。我们还将探讨这些读取模型的持久化选项。
当与查询模型一起工作时,我们通过监听事件的发生来构建模型。我们将探讨如何处理以下情况:
-
随着时间的推移,新需求不断演变,需要我们构建新的查询模型。
-
我们在我们的查询模型中发现了一个需要我们从零开始重新创建模型的 bug。
为了做到这一点,本章的议程包括以下主题:
-
继续我们的设计之旅
-
实现查询端
-
历史事件回放
到本章结束时,你将学会欣赏如何通过监听领域事件来构建查询模型。你还将学会如何专门构建新的查询模型以满足特定的读取需求,而不是受限于为服务命令而选择的数据库模型。你最终将了解历史事件回放的工作原理以及如何使用它们来创建新的查询模型以满足新的需求。
技术要求
要跟随本章的示例,你需要访问以下内容:
-
JDK 1.8+(我们使用 Java 17 编译示例源代码)
-
Spring Boot 2.4.x
-
Axon Framework 4.5.3
-
JUnit 5.7.x(包含在 Spring Boot 中)
-
OpenJFX Monocle(用于无头 UI 测试)
-
Project Lombok(用于减少冗余)
-
Maven 3.x
请参考书籍配套源代码仓库中的Chapter07目录,以获取完整的示例代码。github.com/PacktPublishing/Domain-Driven-Design-with-Java-A-Practitioner-s-Guide/tree/master/Chapter07
继续我们的设计之旅
在第四章“领域分析和建模”中,我们讨论了事件风暴作为一种轻量级方法来阐明业务流程。作为提醒,这是我们事件风暴会议的输出:

图 7.1 – 事件风暴会议回顾
如前所述,我们正在使用 CQRS 架构模式来创建解决方案。关于为什么这是一个值得采用的方法的详细解释,您可以回顾第三章中的何时使用 CQRS部分,理解领域,我们已经对此进行了讨论。在前面的图中,绿色的便利贴代表读取/查询模型。当验证一个命令(例如,处理ValidateProduct命令时的有效产品标识符列表)或信息需要简单地展示给用户时(例如,申请人创建的 LC 列表),这些查询模型是必需的。让我们看看在实际应用中如何将 CQRS 应用于查询端。
实现查询端
在第五章中,实现领域逻辑,我们探讨了在命令成功处理时如何发布事件。现在,让我们看看我们如何通过监听这些领域事件来构建查询模型。从逻辑上讲,这看起来像以下图示:

图 7.2 – CQRS 应用 – 查询端
关于命令端是如何实现的详细解释,请参阅第五章中的实现领域逻辑部分。
查询端的高级序列在此描述:
-
一个事件监听组件监听在事件总线上发布的这些领域事件。
-
它构建一个专门用于满足特定查询用例的查询模型。
-
此查询模型持久化在针对读取操作优化的数据存储中。
-
然后将此查询模型以 API 的形式公开。
注意,可以存在多个查询端组件来处理相应的场景。
让我们逐一实施这些步骤,看看这对我们的 LC 发行申请是如何工作的。
工具选择
在 CQRS 应用中,命令端和查询端之间存在分离。目前,这种分离在我们的应用中是逻辑上的,因为命令端和查询端都在同一个应用进程中作为组件运行。为了说明这些概念,我们将使用 Axon 框架提供的便利性来实现本章的查询端。在第十章中,开始分解之旅,我们将探讨是否有必要使用专门的框架(如 Axon)来实现查询端。
当实现查询端时,我们有两个关注点需要解决,如下面的图示所示:

图 7.3 – 查询端剖析
这些关注点如下:
-
消费领域事件和持久化一个或多个查询模型
-
将查询模型公开为 API
在我们开始实现这些关注点之前,让我们确定我们需要为我们的 LC 发放应用程序实现的查询。
识别查询
从事件风暴会议中,我们开始有以下查询:

图 7.4 – 识别到的查询
在事件风暴会议的输出(如图 7.1 所示)中用绿色标记的查询都需要我们暴露各种状态的 LC 集合。为了表示这一点,我们可以创建一个 LCView 类,这是一个没有任何逻辑的极其简单的对象,如下所示:

这些查询模型是实现由业务需求决定的基本功能的绝对必要条件。但是,随着系统需求的发展,我们很可能还需要额外的查询模型。我们将根据需要增强我们的应用程序以支持这些查询。
创建查询模型
如第五章中所述,实现领域逻辑,当启动一个新的 LC 应用程序时,导入器发送 StartNewLCApplicationCommand,这将导致 LCApplicationStartedEvent 被触发,如下所示:

让我们编写一个事件处理组件,它将监听此事件并构建查询模型。在处理 Axon 框架时,我们可以通过用 @EventHandler 注解标注事件监听方法来方便地完成这项工作。
要使任何方法成为事件监听器,我们需要用 @EventHandler 注解来标注它:

-
要使任何方法成为事件监听器,我们需要用
@EventHandler注解来标注它。 -
处理方法需要指定我们打算监听的事件。事件处理器还支持其他一些参数。请参阅 Axon 框架文档以获取更多信息。
-
我们最终将查询模型保存在合适的查询存储中。在持久化这些数据时,我们应该考虑以优化数据访问的形式存储。换句话说,我们希望在查询这些数据时尽可能减少复杂性和认知负荷。
@EventHandler 注解不应与我们在第五章中查看的 @EventSourcingHandler 注解混淆,实现领域逻辑。@EventSourcingHandler 注解用于在命令端加载事件源聚合时重放事件并恢复聚合状态,而 @EventHandler 注解用于在聚合上下文之外监听事件。换句话说,@EventSourcingHandler 注解仅用于聚合内部,而 @EventHandler 注解可以在需要消费领域事件的地方使用。在这种情况下,我们正在使用它来构建查询模型。
查询端持久化选择
以这种方式隔离查询端使我们能够选择最适合在查询端解决问题的持久化技术。例如,如果极端性能和简单的过滤标准很重要,那么选择一个内存存储如 Redis 或 Memcached 可能是明智的。如果需要支持复杂的搜索/分析要求和大数据集,那么我们可能想考虑像 Elasticsearch 这样的东西。或者,我们甚至可以简单地选择坚持使用关系数据库。我们想强调的是,采用 CQRS 提供了一种以前我们没有的灵活性级别。
暴露查询 API
申请者喜欢查看他们创建的 LC,特别是处于草稿状态的 LC。让我们看看我们如何实现此功能。让我们首先定义一个简单的对象来捕获查询标准:

让我们使用 Spring 的仓库模式来实现查询,以检索这些标准的结果:

-
这是我们将用于查询数据库的动态 Spring 数据查找方法。
-
Axon 框架提供的
@QueryHandler注解将查询请求路由到相应的处理器。 -
最后,我们调用查找方法以返回结果。
在前面的示例中,为了简洁起见,我们在仓库本身中实现了QueryHandler方法。QueryHandler也可以放在其他地方。
为了将此与 UI 连接,我们在BackendService中添加了一个新方法(最初在第六章,*实现用户界面 - 基于任务的实现)中介绍)来调用查询,如下所示:

-
Axon 框架提供了
QueryGateway便利性,允许我们调用查询。有关如何使用QueryGateway的更多详细信息,请参阅 Axon 框架文档。 -
我们使用
MyDraftLCsQuery对象执行查询以返回结果。
我们之前查看的是一个非常简单的查询实现示例,其中我们有一个单个@QueryHandler注解来服务查询结果。此实现作为一次性检索返回结果。让我们看看更复杂的查询场景。
高级查询场景
我们目前关注的重点是活跃的 LC 应用。已签发的 LC 维护发生在系统的不同边界上下文中。考虑一个场景,我们需要提供当前活跃的 LC 应用和已签发的 LC 的统一视图。在这种情况下,有必要通过查询两个不同的来源(理想情况下并行)来获取这些信息,通常称为散点-聚合模式。请参阅 Axon 框架文档中关于散点-聚合查询的部分以获取更多详细信息。
在其他情况下,我们可能希望保持对动态变化数据的最新状态。例如,考虑一个实时股票行情应用跟踪价格变化。实现这一点的 一种方式是通过轮询价格变化。一个更有效的方法是在价格变化发生时推送价格变化——通常被称为发布-订阅模式。有关详细信息,请参阅 Axon 框架文档中的订阅查询部分。
历史事件重放
我们迄今为止看到的示例使我们能够监听事件的发生。考虑一个场景,我们需要从历史事件中构建一个新的查询来满足一个未预料到的新需求。这个新需求可能需要创建一个新的查询模型,或者在更极端的情况下,一个全新的边界上下文。另一种情况可能是当我们可能需要纠正我们构建现有查询模型的方式中的错误,现在需要从头开始重新创建它。鉴于我们在事件存储中记录了所有发生的事件,我们可以使用重放事件来使我们能够相对容易地构建新的和/或纠正现有的查询模型。
重要提示
我们在重新构建事件源聚合实例的状态的上下文中使用了术语事件重放(在第五章的事件源聚合部分讨论,实现领域逻辑)。这里提到的事件重放,尽管在概念上相似,但仍然非常不同。在领域对象事件重放的情况下,我们与单个聚合根实例一起工作,并且只为该实例加载事件。然而,在这种情况下,我们可能会处理跨越多个聚合的事件。
让我们看看不同类型的事件重放以及我们如何使用每种类型。
重放类型
在重放事件时,根据我们需要满足的要求,至少有两种类型的事件重放。让我们依次查看每种类型:
-
完整事件重放:这是指我们在事件存储中重放所有事件。这可以在我们需要支持一个完全新的、依赖于此子域的边界上下文的情况下使用。这也可以用于我们需要支持一个全新的查询模型或重建一个现有、错误构建的查询模型的情况。根据事件存储中的事件数量,这可能是一个相当长且复杂的过程。
-
部分/临时事件回放:这是我们需要在聚合实例的子集或所有聚合实例的事件子集上回放所有事件,或者两者的组合。当处理部分事件回放时,我们需要指定过滤标准来选择聚合实例和事件的子集。这意味着事件存储需要具有灵活性来支持这些用例。使用专业的事件存储解决方案(例如 Axon Server 和 EventStoreDB,仅举两例)可以非常有益。
事件回放考虑事项
能够回放事件和创建新的查询模型可能非常有价值。然而,就像其他所有事情一样,在处理回放时,我们需要注意一些考虑因素。让我们更详细地探讨其中的一些。
事件存储设计
如第五章中所述,实现领域逻辑,当与事件源聚合一起工作时,我们在持久化存储中持久化不可变事件。我们需要支持的主要用例如下:
-
当作为只读存储时,提供一致且可预测的写入性能。
-
当使用聚合标识符查询事件时,提供一致且可预测的读取性能。
然而,回放(尤其是部分/临时)需要事件存储支持更丰富的查询能力。考虑这样一个场景,我们发现了一个问题,即在某些时间段内仅对某些货币的已批准 LC 报告了错误的金额。为了修复这个问题,我们需要做以下几步:
-
从事件存储中识别受影响的 LC(逻辑组件)。
-
修复应用程序中的问题。
-
重置受影响聚合的查询存储。
-
对受影响聚合的子集事件进行回放并重建查询模型。
如果我们不支持允许我们内省事件负载的查询能力,从事件存储中识别受影响的聚合可能会很棘手。即使这种临时的查询能力得到支持,这些查询也可能对事件存储的命令处理性能产生不利影响。采用 CQRS 的主要原因之一就是利用查询端存储来解决这种复杂的读取场景。
事件回放似乎引入了一个“先有鸡还是先有蛋”的问题,即查询存储有一个问题,只能通过查询事件存储来纠正。这里讨论了一些缓解此问题的选项:
-
通用存储:选择一个提供两种场景(命令处理和回放查询)可预测性能的事件存储。
-
内置数据存储复制:利用读取副本进行事件回放查询。
-
不同的数据存储:使用两个不同的数据存储来解决每个问题(例如,使用关系数据库/键值存储来处理命令,以及用于事件回放查询的搜索优化文档存储)。
重要提示
请注意,用于回放的独立数据存储方法是为了满足操作问题,而不是本章前面讨论的查询端业务用例。可以说,它更复杂,因为命令端的技术团队必须配备维护多个数据库技术的能力。
事件设计
事件回放是必需的,以便从事件流中重建状态。在这篇文章《什么是事件驱动》(martinfowler.com/articles/201701-event-driven.html)中,马丁·福勒讨论了三种不同的事件风格。如果我们采用马丁文章中提到的事件携带状态转移方法来重建状态,那么可能只需要回放给定聚合的最新事件,而不是按发生顺序回放该聚合的所有事件。虽然这看起来很方便,但它也有其缺点:
-
所有事件现在可能都需要携带大量可能对该事件不相关的附加信息。在发布事件时组装所有这些信息可能会增加命令端的认知复杂性。
-
需要存储和通过网络传输的数据量可能会急剧增加。
-
在查询方面,当理解事件结构和处理它时,可能会增加认知复杂性。
在很多方面,这回到了在第 5 章 《实现领域逻辑》中讨论的基于 CRUD 与基于任务的 API 接口方法。我们的总体偏好是尽可能设计出负载最轻的事件。然而,你的经验可能因具体问题或情况而异。
应用程序可用性
在事件驱动系统中,随着时间的推移,即使在相对简单的应用程序中,也可能会积累大量的事件。回放大量事件可能会很耗时。让我们看看回放通常是如何工作的机制:
-
我们暂停收听新事件,为回放做准备。
-
清除受影响聚合的查询存储。
-
为受影响聚合启动事件回放。
-
在回放完成后,继续收听新事件。
根据上述列表,在回放运行时(步骤 3),我们可能无法提供受回放影响查询的可靠答案。这显然会影响应用程序的可用性。在使用事件回放时,需要小心确保服务级别目标(SLOs)继续得到满足。
具有副作用的事件处理器
在回放事件时,我们重新触发事件处理器,要么是为了修复之前错误的逻辑,要么是为了支持新的功能。调用大多数(如果不是所有)事件处理器通常会导致某种副作用(例如,更新查询存储)。这意味着某些事件处理器可能不是第一次运行。为了防止不希望的副作用,重要的是要撤销之前调用这些事件处理器的效果,或者以幂等的方式编写事件处理器(例如,使用upsert命令而不是简单的insert命令或update命令)。某些事件处理器的效果可能难以(如果不可能)撤销(例如,调用命令、发送电子邮件或短信)。在这种情况下,可能需要将这些事件处理器标记为在回放期间不可运行。在使用 Axon 框架时,这相当简单:

可以使用@DisallowReplay(或其对应项@AllowReplay)来明确标记事件处理器在回放期间不可运行。
事件作为 API
在一个事件源系统中,事件被持久化而不是领域状态,事件结构随时间演变是很自然的。考虑一个BeneficiaryInformationChangedEvent的例子,它在一段时间内发生了演变,如下所示:

图 7.5 – 事件演变
由于事件存储是不可变的,我们可以设想对于给定的 LC,我们可能有一组或多个这些事件版本。这可能会在我们执行事件回放时带来一系列需要做出的决策:
-
生产者可以简单地提供事件存储中存在的历史事件,并允许消费者处理事件的旧版本。
-
生产者可以在将事件暴露给消费者之前将旧版本的事件升级到最新版本。
-
允许消费者指定他们能够处理的事件的显式版本,并在将其暴露给消费者之前将其升级到该版本。
-
随着演变的进行,将事件存储中的事件迁移到最新版本。考虑到事件存储中事件的不可变性承诺,这可能不可行。
你选择哪种方法完全取决于你的具体环境和生产者/消费者生态系统的成熟度。Axon 框架为它们称为事件上溯的过程提供了规定,允许事件在消费前即时升级。请参阅 Axon 框架文档以获取更多详细信息。
在事件驱动系统中,事件是你的 API。这意味着在做出生命周期管理决策(例如,版本控制、弃用和向后兼容性)时,你需要应用与 API 相同的严谨性。
摘要
在本章中,我们研究了如何实现基于 CQRS 的系统的查询端。我们探讨了如何实时消费领域事件来构建可用来服务查询 API 的物化视图。我们研究了可以用来高效访问底层查询模型的不同查询类型。最后,我们探讨了查询端的持久化选项。
最后,我们探讨了历史事件回放及其如何被用于在事件驱动系统中纠正错误或引入新功能。
本章应使您对如何构建和演进基于 CQRS 的系统查询端以满足不断变化的企业需求,同时保留命令端的所有业务逻辑有一个良好的理解。
在本章中,我们探讨了如何以无状态的方式消费事件(即没有两个事件处理器知道彼此的存在)。在下一章中,我们将继续探讨如何消费事件,但这次是以有状态的方式进行,即通过长时间运行的用户事务(也称为叙事)。
进一步阅读

第八章:实现长期运行的工作流程
从长远来看,悲观者可能会被证明是对的,但乐观者在旅途中会有更好的时光。
— 丹尼尔·雷诺德
在前面的章节中,我们探讨了在单个聚合体上下文中处理命令和查询。迄今为止,我们所考虑的所有场景都局限于单一交互。然而,并非所有功能都可以以简单的请求-响应交互的形式实现,需要跨多个外部系统或以人为中心的操作进行协调,或者两者都需要。在其他情况下,可能需要响应非确定性的触发器(条件发生或不发生)和/或时间限制的触发器(基于截止日期)。这可能需要管理跨越多个边界上下文的业务事务,这些事务可能需要很长时间才能运行,同时继续维护一致性(叙事)。
至少有两种常见的模式来实现叙事模式:
-
显式编排:一个指定的组件作为中央协调器——系统依赖于协调器对领域事件做出反应以管理流程。
-
隐式协奏:不需要单个组件作为中央协调器——组件简单地对其他组件中的领域事件做出反应以管理流程。
本章我们将涵盖以下主要内容:
-
实现叙事
-
在编排和协奏之间做出决定
-
处理截止日期
到本章结束时,你将学会如何使用这两种技术实现叙事。你还将学会在没有系统内发生明确事件时如何处理截止日期。你最终将能够理解何时/是否选择显式编排器,或者简单地坚持隐式协奏,而不必求助于可能昂贵的分布式事务。
技术要求
要遵循本章的示例,你需要访问以下内容:
-
JDK 1.8+(我们使用 Java 17 编译示例源代码)
-
Spring Boot 2.4.x
-
Axon Framework 4.5.3
-
JUnit 5.7.x(包含在 Spring Boot 中)
-
项目 Lombok(以减少冗余)
-
Maven 3.x
请参阅本书配套源代码仓库中的 Chapter08 目录,GitHub 上的完整工作示例位于 github.com/PacktPublishing/Domain-Driven-Design-with-Java-A-Practitioner-s-Guide/tree/master/Chapter08。
继续我们的设计之旅
在 第四章 领域分析和建模 中,我们讨论了事件风暴作为一种轻量级方法来阐明业务流程。作为提醒,这是我们事件风暴会议的输出:

图 8.1 – 事件风暴会议回顾
如前图所示,信用证(LC)申请处理的一些方面发生在我们的当前边界上下文之外,在贸易融资经理决定批准或拒绝申请之前,如下列所示:
-
产品价值得到验证。
-
产品合法性得到验证。
-
申请人的信用度得到验证。
目前,最终批准是一个手动过程。值得注意的是,产品价值和合法性检查是产品分析部门工作的一部分,而申请人信用度检查发生在信贷分析部门。这两个部门都使用自己的系统来执行这些功能,并通过相应的事件通知我们。只有当每个这些检查都完成时,LC 申请才不准备被批准或拒绝。这些过程中的每一个主要都是独立于其他过程的,可能需要非确定性的时间(通常在几天左右)。在这些检查发生后,贸易融资经理手动审查申请并做出最终决定。
鉴于收到的 LC 申请数量不断增加,银行正在寻求引入一个流程优化,以自动批准金额低于一定阈值(目前为USD 10,000)的申请。业务认为前三个检查是足够的,并且在批准此类申请时不需要进一步的人工干预。
从整体系统角度来看,值得注意的是,产品分析师系统通过ProductValueValidatedEvent和ProductLegalityValidatedEvent事件通知我们,而信贷分析师系统则通过ApplicantCreditValidatedEvent事件进行同样的操作。这些事件中的每一个都可以,实际上也确实可以独立于其他事件发生。为了能够自动批准申请,我们的解决方案需要等待所有这些事件的发生。一旦这些事件发生,我们需要检查每个事件的输出结果,最终做出决定。
注意
在此上下文中,我们使用“长运行”一词来表示需要多个步骤才能完成的复杂业务流程。随着这些步骤的发生,流程从一个状态转换到另一个状态。换句话说,我们指的是一个状态机。这不应与计算密集型的长运行软件过程(例如,复杂的 SQL 查询或图像处理例程)混淆。
如前图所示,LC 自动批准功能是一个长运行业务流程的例子,其中我们的系统中需要跟踪这些独立事件发生的事实才能继续进行。这种功能可以使用 saga 模式实现。让我们看看我们如何做到这一点。
实现 sagas
在我们深入探讨如何实现此自动审批功能之前,让我们看看从逻辑角度来看它是如何工作的,如图所示:

图 8.2 – 自动审批流程—逻辑视图
如前图所示,有三个有界上下文在起作用:
-
LC 应用(我们迄今为止一直在实施的有界上下文)
-
申请人有界上下文
-
产品有界上下文
当 LC 申请提交时,流程被触发。这反过来又启动了三个独立的功能,以建立以下内容:
-
交易产品的价值
-
交易产品的合法性
-
申请人的信用度
只有在所有这些功能都完成后,LC 批准才能进行。此外,为了自动批准,所有这些检查都必须顺利通过,并且如前所述,LC 金额必须低于 USD 10,000 阈值。
如事件风暴工件所示,LC Application 聚合能够处理 ApproveLCApplicationCommand,这导致 LCApplicationApprovedEvent。为了自动批准,当所有提到的条件都满足时,此命令需要自动调用。我们正在构建一个事件驱动系统,我们可以看到,每当相应的动作完成时,这些验证都会产生事件。实现此功能至少有两种方法:
-
编排:在系统中,单个组件协调流程的状态并在必要时触发后续操作
-
舞蹈编排:在流程中,动作被触发,而不需要显式的协调组件
让我们更详细地检查这些方法。
编排
当使用编排组件实现 sagas 时,系统看起来类似于以下图所示:

图 8.3 – 使用编排器的 saga 实现
当 LC 申请提交时,编排器开始跟踪流程。然后它需要等待每个 ProductValueValidatedEvent、ProductLegalityValidatedEvent 和 ApplicantCreditValidatedEvent 事件发生,并决定是否触发 ApproveLCApplicationCommand。最后,当 LC 申请被批准时,saga 生命周期无条件结束。还有其他可能导致 saga 突然结束的条件。我们将在稍后详细检查这些场景。值得注意的是,对于每个提交的 LC 申请,将有一个独立的自动审批 saga 实例。让我们看看如何使用 Axon 框架实现此功能。像往常一样,让我们通过测试驱动来验证当 LC 申请提交时,会创建一个新的自动审批 saga 实例:

-
我们使用 Axon 提供的
FixtureConfiguration和SagaTestFixture,这允许我们测试编排功能。 -
假定在此之前没有发生任何活动(从编排的角度来看)。
-
当发布
LCApplicationSubmittedEvent时。 -
我们期望存在一个活动编排。
使此测试通过的实现如下:

-
当与 Axon 和 Spring 一起工作时,编排器被
@Saga注解标记为 Spring bean。为了跟踪每个提交的 LC 申请,@Saga注解是原型作用域的(与单例作用域相对),以允许创建多个编排实例。请参阅 Axon 和 Spring 文档以获取更多信息。 -
编排监听
LCApplicationSubmittedEvent以跟踪流程(如@SagaEventHandler注解所示)。从概念上讲,@SagaEventHandler注解与我们之前章节中讨论的@EventHandler注解非常相似。然而,@SagaEventHandler注解专门用于编排内部的事件监听器。@SagaEventHandler注解上的associationProperty属性使得此事件处理方法仅在事件负载中lcApplicationId属性具有匹配值的编排上被调用。此外,@SagaEventHandler是一个事务边界。每次此类方法成功完成时,Axon 框架都会提交一个事务,从而允许它跟踪存储在编排中的状态。我们将在稍后更详细地探讨这一点。 -
每个编排至少需要有一个同时被
@StartSaga注解标注的@SagaEventHandler方法,以表示编排的开始。
我们有一个要求,如果信用证的金额超过阈值(在我们的情况下为USD 10,000),则不能自动批准信用证。对此场景的测试看起来是这样的:

-
当信用证(LC)金额超过自动批准阈值时。
-
我们期望该 LC 不存在任何活动编排。
满足此条件实现的代码如下:

-
我们检查 LC 金额是否大于阈值金额的条件。
-
如果是这样,我们将使用框架提供的
SagaLifecycle.end()方法结束编排。在这里,我们以编程方式结束编排。当LCApplicationApprovedEvent发生时,也可以使用@EndSaga注解声明性地结束编排。有关更多信息,请参阅本章存储库中包含的完整代码示例。
如果ApplicantCreditValidatedEvent、ProductLegalityValidatedEvent和ProductValueValidatedEvent都成功发生,我们需要自动批准编排。验证此功能的测试如下所示:

-
假定 LC 申请已经提交,并且
ProductValueValidatedEvent和ProductLegalityValidatedEvent已经成功发生。 -
当发布
ApplicantCreditValidatedEvent时。 -
我们期望有一个活跃的 saga 实例和以下内容。
-
我们期望为该 LC 派发
ApproveLCApplicationCommand。
这个实现的看起来如下:

-
如前所述,sagas 可以维护状态。在这种情况下,我们维护了三个布尔变量,每个变量表示相应事件的触发。
-
我们已将 Axon 的
CommandGateway声明为瞬态成员,因为我们需要用它来派发命令,但不需要与其他 saga 状态一起持久化。 -
此事件处理器拦截特定 LC 申请的
ApplicantCreditValidatedEvent(如@SagaEventHandler注解中的associationProperty所示)。 -
如果
ApplicantCreditValidatedEvent的决策被拒绝,我们将立即结束 saga。 -
否则,我们记住申请人的信用已经得到验证。
-
我们接着检查产品的价值和合法性是否已经被验证。
-
如果是这样,我们将发布自动批准 LC 的命令。
注意
ProductValueValidatedEvent和ProductLegalityValidatedEvent中的逻辑与saga事件处理器中的ApplicantCreditValidatedEvent非常相似。为了简洁,这里省略了它。请参阅本章源代码以获取完整的示例以及测试。
最后,当我们收到此应用的LCApplicationApprovedEvent时,我们可以结束 saga:

-
由于 LC 已经提交并且所有验证都已完成成功。
-
当发布
LCApplicationApprovedEvent时。 -
我们期望没有活跃的 saga 正在运行。
-
我们也期望不会派发任何命令。
现在我们已经了解了如何使用协调器实现 sagas,让我们来探讨一些在设计时可能需要考虑的设计决策。
这里是编排的优点:
-
复杂的工作流:在处理涉及多个参与者和许多条件的工作流时,拥有一个显式的协调器非常有帮助,因为协调器可以以细粒度的方式跟踪整体进度。
-
测试:正如我们在前面的实现中看到的,在隔离状态下测试流程逻辑相对简单。
-
调试:鉴于我们有一个单一的协调器,调试流程的当前状态可以相对容易。
-
处理异常:鉴于协调器对流程有细粒度的控制,从异常中优雅地恢复可以更容易。
-
系统知识:不同边界上下文中的组件不需要了解彼此的内部(例如,命令和事件)以推进流程。
-
循环依赖:拥有一个中央协调器可以避免组件之间意外产生循环依赖。
这里是编排的缺点:
-
单点故障:从操作角度来看,编排器可能成为单点故障,因为它们是唯一了解流程的组件。这意味着与其他组件相比,这些组件需要表现出更高的弹性特征。
-
领域逻辑泄露:在一个理想的世界里,聚合将始终是所有领域逻辑的保管者。鉴于编排器也是有状态的,业务逻辑可能会无意中转移到编排器。应小心确保编排器只具有流程控制逻辑,而业务不变量仍然保持在聚合的范围内。
上述实现应该能让你对如何实现 Saga 编排器有一个很好的了解。现在让我们看看在不使用显式编排器的情况下如何实现这一点。
编排
Saga 编排器跟踪流程的当前状态,通常使用某种类型的数据存储。实现此功能的另一种方式是不使用任何有状态的组件。从逻辑上看,这就像这里图中所示的设置:
![Figure 8.4 – Saga implementation using choreography
![img/B16716_Figure_8.4.jpg]
图 8.4 – 使用编排的 Saga 实现
如您所见,没有单个组件跟踪 Saga 的生命周期。然而,为了做出自动批准的决定,每个这些无状态的事件处理器都需要了解相同的三种事件发生:
-
验证产品价值。
-
验证产品合法性。
-
验证申请人的信用度。
由于事件监听器本身是无状态的,至少有三种方式可以向它们提供此信息:
-
每个事件都在它们各自的负载中携带这些信息。
-
事件监听器查询源系统(在这种情况下,分别是产品和申请人边界上下文)。
-
LC 应用边界上下文维护一个查询模型以跟踪这些事件的发生。
就像在编排器示例中一样,当所有事件都已发生且 LC 金额低于指定阈值时,这些事件监听器可以发出ApproveLCApplicationCommand。
注意
我们将跳过介绍编排实现中的代码示例,因为这与我们在本章和前几章中介绍的内容没有不同。
现在我们已经了解了如何实现两种风格的 Saga,让我们来探讨一些在设计时可能需要考虑的设计决策。
编排实现的优点如下:
-
简单的流程:对于简单的流程,编排方法可以相对直接,因为它不需要额外协调组件的开销。
-
无单点故障:从操作角度来看,不再需要担心一个高弹性组件。
这些是编排实现的缺点:
-
工作流程跟踪:特别是对于涉及许多步骤和条件的复杂工作流程,跟踪和调试流程的当前状态可能变得具有挑战性。
-
循环依赖:当工作流程变得复杂时,可能会意外地在组件之间引入循环依赖。
Sagas 允许应用程序在需要多个有界上下文来完成业务功能而不必求助于使用分布式事务的情况下维护数据和事务一致性。然而,它确实给编程模型引入了一定的复杂性,尤其是在处理失败时。当我们在即将到来的章节中讨论与分布式系统一起工作时,我们将更详细地探讨异常处理。让我们通过查看截止日期的工作方式来了解在没有明确刺激的情况下如何推进流程。
处理截止日期
到目前为止,我们已经探讨了由人类(例如,申请人提交 LC 申请)或系统(例如,LC 申请的自动批准)行为引起的事件。然而,在事件驱动系统中,并非所有事件都是由于明确的人类或系统刺激而发生的。事件可能需要根据一段时间内的不活动或基于现有条件的重复调度来发出。
例如,让我们考察银行需要尽快决定已提交的 LC 申请的情况。当贸易融资经理在 10 个日历日内未对申请采取行动时,系统应发送提醒。
为了处理这种不活动,我们需要一种方法来触发基于时间流逝的系统行为(即:在截止日期到期时执行操作)。在理想情况下,我们期望用户或系统采取某些行动。在这种情况下,我们还需要考虑在截止日期到期时需要取消已安排触发的情况。让我们看看如何测试驱动这个功能:

-
当 LC 申请提交时。
-
我们期望安排一个提醒的截止日期。
这个实现的步骤相当直接:

-
为了允许与截止日期一起工作,Axon 框架提供了一个
DeadlineManager,允许与截止日期一起工作。这个管理器被注入到命令处理方法中。 -
我们使用
deadlineManager来安排一个名为的截止日期(在这种情况下为"LC_APPROVAL_REMINDER"),它将在 10 天后到期。 -
当截止日期满足时,它将导致
LCApprovalPendingNotification,这可以像命令一样处理。但是,在这种情况下,行为是由时间的流逝触发的。
如果 10 天内没有采取任何行动,这是我们期望的情况:

-
假设 LC 申请已经提交。
-
当 10 天的周期结束时。
-
应该满足截止日期。
-
应该发出
LCApprovalPendingEvent。
让我们看看如何实现这一点:

-
截止日期通过使用
@DeadlineHandler注解来处理。请注意,这里引用的是之前使用的相同截止日期名称。 -
这是截止日期处理方法,并使用在调度时传递的相同有效载荷。
-
当截止日期到期时,我们发出
LCApprovalPendingEvent。
截止日期处理逻辑只有在没有采取任何行动的情况下才会被触发。然而,如果在 10 天的时间内 LC(贷款承诺)被批准或拒绝,则不应触发以下任何行为:

-
假设 LC 申请已提交。
-
当它在 10 天(在这种情况下,几乎是立即)内被批准。
-
我们预期没有计划中的截止日期。
该实现的示例如下:

- 在此聚合的作用域内,我们取消所有名为
LC_APPROVAL_REMINDER(在这种情况下,我们只有一个具有该名称的截止日期)的截止日期。
摘要
在本章中,我们探讨了如何使用 sagas 和不同的实现风格来处理长时间运行的流程。我们还探讨了使用显式编排与隐式编舞的后果。最后,我们探讨了在没有用户发起的操作时如何处理截止日期。
你已经学会了如何在设计利用领域驱动设计原则的系统时,sagas 可以作为除了聚合之外的一等公民。
在下一章中,我们将探讨如何在尊重核心系统和外围系统之间的边界上下文中与外部系统进行交互。
进一步阅读

第九章:与外部系统集成
完整性并非通过割裂自己的某一部分来实现,而是通过整合对立面来达到。
– 卡尔·荣格
到目前为止,我们已经使用 DDD 为我们应用程序实现了一个健壮的核心。然而,大多数解决方案(通过扩展有限上下文)通常既有上游也有下游依赖,这些依赖通常以不同的速度变化,这与这些核心组件不同。为了保持敏捷性和可靠性,并实现松散耦合,重要的是以一种保护核心免受周围一切影响的方式来与外围系统集成。
在本章中,我们将探讨 LC 应用处理解决方案,并检查我们如何与其他生态系统中的组件进行集成的手段。你将学习如何识别组件之间的关系模式。
本章涵盖了以下主要内容:
-
继续我们的设计之旅
-
有限上下文关系
-
实现模式
在本章结束时,我们将通过查看与遗留应用集成的常见模式来结束讨论。让我们直接进入正题!
继续我们的设计之旅
从我们之前章节中的领域分析中,我们已经为我们的应用程序确定了四个有限上下文,如图所示:

图 9.1 – 有限上下文之间的关系
到目前为止,我们的关注点一直集中在实现LC 应用有限上下文的内部。虽然 LC 应用有限上下文与其他有限上下文是独立的,但它并不是完全与它们隔离。例如,在处理 LC 应用时,我们需要执行商品和申请人检查,这需要与合规性和客户入职有限上下文进行交互。这意味着这些有限上下文之间存在关系。这些关系是由在各自有限上下文上工作的团队之间的协作性质所驱动的。让我们考察这些团队动态如何影响有限上下文之间的集成机制,同时继续保持它们的个体完整性。
有限上下文关系
我们需要边界上下文尽可能独立。然而,这并不意味着边界上下文完全相互隔离。边界上下文需要与其他上下文协作以提供业务价值。每当需要两个边界上下文之间协作时,它们关系的性质不仅受它们各自的目标和优先级的影响,还受组织现实情况的影响。在一个高绩效的环境中,一个团队承担一个边界上下文的拥有权是相当常见的。拥有这些边界上下文的团队之间的关系在影响采用以到达解决方案的集成模式中起着重要作用。从高层次来看,有两种关系类型:
-
对称
-
非对称
让我们更详细地看看这些关系类型。
对称关系模式
当两个团队,比如团队 A 和团队 B,在决定解决方案的过程中拥有相等的影响力时,可以说它们之间存在对称关系。两个团队都处于能够,并且实际上确实,以几乎相等的方式对结果做出贡献的位置。以下是一个图示表示:

图 9.2 – 两个团队在影响解决方案方面拥有平等的话语权
对称关系有三种变体,我们将在接下来的小节中更详细地概述。
伙伴关系
在伙伴关系中,两个团队以临时方式集成。在需要完全集成工作时,没有分配固定的责任。每个团队根据需要随时接手工作,无需任何特定的仪式或喧哗。集成的性质通常是双向的,两个团队根据需要交换解决方案工件。这种关系需要极高程度的协作和对两个团队所做工作的理解。查看以下图示:

图 9.3 – 伙伴关系中的团队之间存在临时的相互依赖
示例
让我们以一个与构建前端 BFFs(philcalcado.com/2015/09/18/the_back_end_for_front_end_pattern_bff.html)的前端团队合作紧密的 Web 前端团队为例。BFF 团队创建的经验 API 旨在仅由前端使用。为了实现任何功能,前端团队需要 API 团队暴露的能力。另一方面,API 团队依赖于前端团队提供有关要构建哪些能力和按何种顺序构建它们的建议。两个团队都可以自由地使用对方的领域模型(例如,定义 API 的相同请求和响应对象集合)来实现功能。这种重用主要发生得任意,当 API 发生变化时,两个团队协调更改以保持一切正常工作。
何时使用
团队之间的合作关系需要高度的协作、信任和理解。当团队界限不正式时,团队往往会使用这种合作关系。如果这些团队是集中办公并且/或者有显著的工作时间重叠,这也会有所帮助。
可能的陷阱
团队之间的合作关系可能导致个人团队责任变得非常不明确,导致解决方案走向令人恐惧的大泥球。
共享内核
与合作关系不同,当使用共享内核时,团队对自己之间选择共享的解决方案工件和模型有清晰的理解。两个团队都承担着维护这些共享工件的责任。
示例
在我们的 LC 应用中,LC 应用程序处理和客户入职团队可能会选择使用一个共同模型来表示CustomerCreditValidatedEvent。对事件架构的任何增强或更改都可能影响两个团队。做出任何更改的责任由两个团队共同承担。有意地,这些团队除了这些共同同意的模型和工件之外,不共享任何其他内容。以下是团队之间共享内核关系的表示:

![img/B16716_Figure_9.04.jpg]
图 9.4 – 团队对共享模型有明确的理解
何时使用
如果在两个上下文中都需要以相同的方式消费共享工件,那么共享内核形式的协作效果很好。此外,对于多个团队来说,协调并继续共享,而不是在两个上下文中复制相同的模型,这很有吸引力。
可能的陷阱
对共享内核所做的更改会影响所有边界上下文。这意味着对共享内核所做的任何更改都需要与两个团队保持兼容。不用说,随着使用共享内核的团队数量的增加,协调的成本会成倍增加。
分离的方式
当两个团队选择不共享任何工件或模型时,他们会各自为政:
![图 9.5 – 团队分道扬镳,彼此之间不共享任何内容
![img/B16716_Figure_9.05.jpg]
图 9.5 – 团队分道扬镳,彼此之间不共享任何内容
示例
LC 应用程序处理和客户入职团队可能最初会共享他们服务的相同构建/部署脚本。随着时间的推移,部署需求可能会分歧到共享维护这些脚本的代价变得过高,导致这些团队分叉他们的部署以恢复对其他团队的独立性。
何时使用
在某些情况下,由于各种原因,两个团队可能无法合作,这些原因可能从个人团队需求的变化到组织政治。无论情况如何,这些团队可能会决定合作的成本太高,从而导致他们各自为政。
可能的陷阱
选择分道扬镳可能会导致受影响的边界上下文中出现重复工作。当在映射到核心子域的边界上下文中工作时,这可能会证明是适得其反的,因为它可能导致无意中产生不一致的行为。
在一段时间内,从一种关系类型过渡到另一种关系类型是可能的。根据我们的经验,从任何一种关系过渡可能并不简单。在需求一开始相对清晰的情况下,可能更容易从共享内核开始。相反,如果需求不明确,可能明智地开始以松散的伙伴关系或分道扬镳,直到需求变得明确。在任何这些场景中,重要的是要持续评估关系的性质,并根据我们对需求及其本身的更深入了解,过渡到更合适的关系类型。
在前面描述的每一种关系中,涉及的团队在关系演变和最终结果方面都有或多或少的发言权。然而,这并不总是如此。让我们看看一些案例,在这些案例中,一个团队可能在关系演变方面具有明显的优势。
非对称关系模式
当一个团队在决策过程中对解决方案有更强的影响力时,可以说两个团队之间存在非对称关系。换句话说,存在一个明确的客户-供应商(或上游-下游)关系,其中客户或供应商在影响解决方案设计方法方面扮演着主导角色。客户和供应商可能没有共同的目标。以下是客户和供应商之间非对称关系的表示:
![图 9.6 – 一个团队在影响解决方案方面具有主导权
![img/B16716_Figure_9.06.jpg]
图 9.6 – 其中一个团队在影响解决方案方面具有主导权
当团队处于非对称关系时,至少存在三种解决方案模式,我们将在以下小节中更详细地概述。
顺从者(CF)
供应商角色的一方在如何实施与一个或多个客户的关系方面拥有主导权并不罕见。此外,客户可能简单地选择接受供应商提供的解决方案,将其作为他们自己解决方案的一个组成部分。换句话说,供应商提供一系列模型,客户使用这些相同的模型来构建他们的解决方案。在这种情况下,客户被认为是顺从者:

图 9.7 – 客户接受对供应商模型的依赖
示例
当构建一个解决方案以验证 LC 申请者的美国邮政地址时,我们选择遵循 USPS Web Tools 地址验证 API 模式(www.usps.com/business/web-tools-apis/)。鉴于业务最初仅限于美国申请者,这样做是有意义的。这意味着我们边界上下文中对地址模型的任何引用都模仿了 USPS 规定的模式。此外,这也意味着我们需要随时关注 USPS API 发生的任何变化(无论这些变化是否需要用于我们自己的功能)。
何时使用
成为顺从者并不一定是件坏事。供应商的模型可能是被广泛接受的行业标准,或者它们可能只是满足我们需求的好方法。也可能是因为团队可能没有必要的技能、动机或立即的需求去做与供应商提供的内容不同的事情。这种方法还使团队能够快速取得进展,利用其他专家大部分已完成的工作。
潜在陷阱
过度使用顺从者模式可能会稀释我们自身边界上下文中的通用语言,导致供应商和客户概念之间没有明确的分离。也可能出现这样的情况,即对供应商上下文核心的概念泄漏到我们自己的上下文中,尽管这些概念在我们自己的上下文中几乎没有任何意义。这可能导致这些边界上下文之间非常紧密地耦合在一起。如果需要切换到另一个供应商或支持多个供应商,变更的成本可能会非常高昂。
反腐败层
可能存在客户需要与供应商合作但可能希望保护自己免受供应商通用语言和模型影响的场景。在这种情况下,在集成时重新定义这些冲突模型,使用翻译层,也称为反腐败层(ACL),可能是明智的。参见以下图示:

图 9.8 – 客户希望保护自己免受供应商模型的影响
示例
在一致者(CF)部分引用的地址验证示例中,LC 应用程序处理团队可能还需要支持加拿大申请人。在这种情况下,成为一个仅支持美国地址的系统的一致者可能会显得限制性甚至令人困惑。例如,美国的州相当于加拿大的省。同样,美国的ZIP 代码在加拿大被称为邮政编码。此外,美国的 ZIP 代码是数字的,而加拿大的邮政编码是字母数字的。最重要的是,我们目前在我们的地址模型中没有国家代码的概念,但现在我们需要引入这个概念来区分各自国家内的地址。以下是我们分别国家的地址模型:

图 9.9 – 不同国家的地址模型
虽然我们最初遵循了 USPS 模型,但我们现在已经发展到支持更多国家。例如,地区用于表示州/省的概念。此外,我们引入了国家值对象,这是之前缺失的。
何时使用
当客户模型是核心领域的一部分时,ACL 非常有用。ACL 可以保护客户免受供应商模型的变化,并有助于产生更松散耦合的集成。在试图从多个供应商整合类似概念时,这也可能是必要的。
可能的陷阱
在很多情况下,使用 ACL 可能很有吸引力。然而,当被整合的概念不经常变化或由一个众所周知的权威机构定义时,使用 ACL 可能不太有益。使用带有自定义语言的 ACL 可能会造成更多的混淆。创建 ACL 通常需要额外的翻译,从而可能增加客户边界上下文的总体复杂性,并且可能被认为是过早的优化。
开放主机服务
与一致者和 ACL 不同,在这些情况下,客户没有正式的方式与供应商接口,而在开放主机服务(OHS)中,供应商定义了一个清晰的接口来与其客户交互。这个接口可能以众所周知的发布语言的形式提供(例如,REST 接口或客户端 SDK):

图 9.10 – 使用已发布语言(PL)的 OHS
示例
LC 应用程序处理边界上下文可以为每个命令公开一个 HTTP 接口,如下所示:

作为此处所示 HTTP 接口的补充,我们甚至可以为客户使用的某些更流行的语言提供客户端 SDK。这有助于隐藏更多实现细节,例如 MIME 类型和版本,从而保护客户。
何时使用
当供应商想要隐藏其内部模型(通用语言)时,创建一个 OHS 使得供应商在提供稳定接口给客户的同时能够进行演变。从某种意义上说,OHS 模式是 ACL 模式的反转——不是客户,而是供应商实现了其内部模型的翻译。此外,当供应商希望为其客户提供更丰富的用户体验时,它也可以考虑提供 OHS。
潜在陷阱
虽然供应商通过为顾客提供 OHS 可能有良好的意图,但它可能会导致实现复杂性的增加(例如,可能需要支持 API 的多个版本,或者多种语言的客户端 SDK)。如果 OHS 没有考虑到客户的常见使用模式,它可能会导致客户可用性差,同时也可能降低供应商的性能。
重要的一点是,守成者和 ACL 是客户实施的模式,而 OHS 是供应商端的模式。例如,以下场景中供应商为一位是守成者的客户和另一位有 ACL 的客户提供OHS,如图所示:

图 9.11 – 与多个客户的非对称关系
现在我们已经看到了有界上下文如何相互集成的各种方式,以下是我们 LC 应用程序的一个可能的实现方案,以上下文图的形式展示:

图 9.12 – LC 应用程序的简化上下文图
到目前为止,我们已经探讨了团队动态影响集成机制的各种方式。虽然概念层面的清晰度有帮助,但让我们看看这些关系在实现层面是如何体现的。
实现模式
我们在设计层面上探讨了有界上下文之间的集成,但需要将这些概念转化为代码。在集成两个有界上下文时,可以采用三个广泛的类别:
-
基于数据
-
基于代码
-
基于 API
让我们更详细地看看每种方法。
基于数据
在这种集成风格中,相关有界上下文之间共享数据。如果关系是对称的,拥有这些有界上下文的团队可以选择共享整个数据库,允许自由地读取、写入和更改底层结构。相反,在不对称的关系中,供应商可能会根据关系的类型限制访问范围。
共享数据库
数据集成最简单的形式是使用共享数据库。在这种集成风格中,所有参与的有界上下文都可以无限制地访问模式和底层数据,如图所示:

图 9.13 – 使用共享数据库的集成
何时使用
共享数据库为希望快速启用新功能或增强现有功能的团队提供了非常低的进入门槛,通过提供对读取和/或写入用例数据的即时访问。更重要的是,它还允许使用本地数据库事务,这通常提供强一致性、较低复杂性和更好的性能(尤其是在与关系数据库一起工作时)。
潜在陷阱
然而,这种多个团队共享所有权的对称集成风格通常是不受欢迎的,因为它往往导致没有明确所有权的局面。此外,共享数据库可能成为紧密耦合的来源,加速走向令人恐惧的一团糟的道路。此外,共享数据库的用户可能会遭受嘈杂邻居效应,其中一个共同租户垄断资源会不利地影响所有其他租户。因此,团队最好谨慎选择这种集成风格。
复制数据
在非对称关系中,供应商可能不愿意直接提供其数据的访问权限。然而,他们可以选择使用基于数据共享的机制与客户集成。另一种集成形式是提供消费者所需数据的副本。这种实现方式有很多变体;我们在此展示更常见的方法:

图 9.14 – 使用数据复制的集成
-
数据库视图:在这种形式中,消费者通过查询或物化视图获取或被提供对数据子集的访问权限。在任一情况下,客户通常只有对数据的只读访问权限,供应商和客户继续共享相同的物理资源(通常是数据库引擎)。
-
完整读取副本:在这种形式中,客户可以访问供应商整个数据库的读取副本,通常在物理上分散的基础设施上。
-
部分读取副本:在这种形式中,客户可以访问供应商数据库子集的读取副本,再次在物理上分散的基础设施上。
何时使用
当供应商和客户之间存在非对称关系时,可能需要这种集成风格。与共享数据库类似,这种集成风格通常需要较少的前期努力来集成。这也适用于供应商打算只提供其数据子集的只读访问权限。当客户只需要读取供应商数据的一个子集时,使用数据复制也可能足够。
潜在的陷阱
如果我们选择使用数据库视图,我们可能会继续遭受嘈杂邻居效应。另一方面,如果我们选择创建物理上不同的副本,我们可能需要承担额外的操作复杂性成本。更重要的是,消费者仍然紧密耦合到供应商的领域模型和通用语言。
接下来,让我们看看一些充分利用基于数据集成的途径。
提高效率
当共享数据时,模式(数据库的结构)充当强制执行合同的手段,尤其是在使用需要指定正式结构的数据库时(例如,关系型数据库)。当涉及多方时,管理模式可能成为一个挑战。
为了减轻不希望的变化,共享数据的团队可能需要考虑使用模式迁移工具。关系型数据库与 Liquibase (www.liquibase.org/) 或 Flyway (flywaydb.org/) 等工具配合良好。当与不正式强制执行模式的数据库一起工作时,最好避免采用这种集成方式,尤其是在所有权不明确的对称关系中。
在任何情况下,如果使用共享数据集成风格之一是不可避免的,团队可能需要强烈考虑在重构数据库时采用上述一项或多项技术,以使其更易于管理。
基于代码
在这种集成方式中,团队通过共享代码工件进行协调,这些工件可以是源代码和/或二进制文件的形式。从高层次来看,有两种形式:
-
分享源代码
-
分享二进制文件
我们将在这里描述每个方面。
分享源代码
在组织内部,共享源代码以促进重用和标准化是一种相当常见的做法。这可能包括实用程序(如日志记录和身份验证)、构建/部署脚本和数据传输对象——换句话说,任何成本高于重用的源代码片段。
何时使用
根据关系类型(对称/非对称),共享代码的团队对共享工件演变的影响程度可能不同。在对称关系中,这种做法效果良好,因为两个团队都有权进行相互兼容的更改。同样,在非对称关系中,供应商可能接受来自客户的更改,同时保留对共享工件的所有权和控制权。这通常也适用于非核心、不经常更改的代码工件。共享源代码也使得共享工件内部具有更高的透明度和可见性(开源软件就是一个例子)。
潜在的陷阱
分享代码工件意味着各个团队需要承担确保将源代码转换为二进制可执行文件的过程统一且符合各方要求的责任。这可能包括代码约定、静态质量检查、测试(是否存在测试)、编译/构建标志和版本控制。当涉及相对大量的团队时,维护这种兼容性可能会变得繁重。
分享二进制工件
另一种相对常见的做法是在二进制级别共享工件。在这种情况下,消费者可能或可能没有直接访问源代码工件。例如,第三方库、客户端 SDK 和 API 文档。当协调各方的关系不对称时,这种集成形式相当常见。库的供应商对维护共享工件的生命周期有明确的拥有权。
何时使用
当供应商无法或不愿意共享源代码时,仅分享二进制工件可能是必要的,这可能是由于它们可能是专有的,也可能是供应商的知识产权的一部分。由于供应商负责构建过程,因此供应商有责任生产与大多数潜在消费者兼容的工件。因此,当供应商愿意这样做时,这种方法效果很好。另一方面,这也意味着在生成这些工件时,客户对供应商的软件供应链(www.thoughtworks.com/en-us/insights/podcasts/technology-podcasts/securing-software-supply-chain)和blog.sonatype.com/software-supply-chain-a-definition-and-introductory-guide)有很高的信任度。
可能的陷阱
通过使用二进制工件共享进行集成,减少了消费者对共享工件构建过程的可见性。如果消费者依赖于缓慢移动的供应商,这可能会变得不可行。例如,如果在共享的二进制文件中发现一个关键的安全漏洞,消费者将完全依赖于供应商来修复它。如果这种依赖性在解决方案的关键、业务区分性方面(特别是在核心子域中),这可能会带来巨大的风险。如果没有使用适当的 ACLs 和/或服务级别协议(SLAs),这种风险可能会加剧。
提高效率
当分享代码工件时,明确如何进行更改并继续保持高质量变得尤为重要——尤其是在涉及多个团队时。让我们更详细地考察一些这些技术:
-
静态分析:这可以简单到使用 Checkstyle 等工具遵循一组编码标准。更重要的是,这些工具可以用来遵循一组命名约定,以便在整个代码库中更坚定地使用通用语言。此外,SpotBugs 和 PMD/CPD 等工具可以用来静态分析代码,以检查是否存在错误和重复代码。
-
代码架构测试:虽然静态检查工具在操作单个编译单元的层面上非常有效,但运行时检查可以将这一层次提升一级,以识别包循环、依赖检查、继承树等,从而应用轻量级架构治理。使用 JDepend 和 ArchUnit 等工具可以在这方面提供帮助。
-
单元测试:当与共享代码库一起工作时,团队成员寻求安全可靠地进行更改。存在一套全面的快速运行的单元测试可以大大提高信心。我们强烈建议采用测试驱动设计,以进一步最大化创建一个设计良好且易于重构的代码库。
-
代码审查:虽然自动化可以走很长的路,但增加人工审查变更的过程可以因多种原因而非常有效。这可以采取离线审查(使用拉取请求)或主动的同行审查(使用结对编程)的形式。所有这些技术都有助于增强集体理解,从而在做出变更时降低风险。
-
文档:不用说,良好的结构化文档在做出贡献和消费二进制代码工件时非常有价值。团队将明智地通过努力在整个过程中编写自文档化的代码来推广使用通用语言,以最大化衍生出的好处。
-
依赖管理:在共享二进制代码工件时,由于存在过多的依赖项、长的依赖链、冲突/循环依赖等问题,管理依赖项可能会变得相当复杂。团队应努力尽可能减少输入(进入)耦合,以减轻之前描述的问题。
-
版本控制:除了最小化输入耦合的数量外,采用显式的版本控制策略可以在很大程度上简化依赖管理。我们强烈建议考虑使用诸如语义版本控制等技术来处理共享代码工件。
基于 IPC
在这种集成风格中,边界上下文通过某种形式的进程间通信(IPC)交换消息以相互交互。这可以是同步或异步通信。
同步消息
同步消息是一种通信风格,其中请求的发送者等待接收者的响应,这意味着发送者和接收者都需要处于活动状态才能使这种风格生效。通常,这种通信形式是点对点的。HTTP 是这种通信风格常用的协议之一。这种通信形式的视觉表示如下所示:

图 9.15 – 同步消息
注意
请查看 HTTP API,了解 LC 应用处理过程中使用的命令,这些命令包含在本章的代码示例中。
使用时机
当客户对供应商对请求的响应感兴趣时,使用这种集成形式。然后使用响应来确定请求是否成功。鉴于客户需要等待响应,建议在低延迟操作中使用这种消息风格。这种集成形式在通过互联网公开 API(例如,GitHub 的 REST API,您可以在 docs.github.com/en/rest 上了解更多信息)时很受欢迎。
可能的陷阱
在使用同步消息时,客户的扩展能力高度依赖于供应商来满足客户的需求。另一方面,请求频率过高的客户可能会损害供应商以可预测的方式服务客户的能力。如果有同步消息的链式,级联失败的概率会大大增加。
异步消息
异步消息是一种通信风格,其中发送者不等待接收者的明确响应。
注意
我们使用术语 发送者 和 接收者 而不是 客户 和 供应商,因为它们都可以扮演发送者或接收者的角色。
这通常是通过引入中间件(以消息通道的形式)来实现的。中间件的存在使得一对一和一对多通信模式成为可能。通常,中间件可以采用共享文件系统、数据库或队列系统的形式:

图 9.16 – 异步消息
注意
请查看 LC 应用处理过程中使用的命令的事件 API,这些命令包含在本章的代码示例中。
使用时机
当发送者不关心是否收到来自 合规性 和 客户注册 系统的 LCApplicationSubmittedEvent 时,使用这种集成形式。
可能的陷阱
介绍中间件组件会增加整体解决方案的复杂性。中间件的非功能性特性可能会对整个系统的弹性特性产生深远的影响。也可能会有诱惑在中间件中添加处理逻辑,从而将整个系统与该组件紧密耦合。为了确保发送者和接收者之间的可靠通信,中间件可能必须支持各种增强功能(如排序、生产者流控制、持久性和事务)。
提高有效性
当使用某种形式的 IPC 实现集成时,代码实现模式部分讨论的许多技术仍然适用。如前所述,API 文档在减少客户摩擦方面发挥着重要作用。此外,以下是一些在基于 IPC 的集成中特别适用的技术:
- 类型化协议:在与这种形式的集成工作时,最小化用于收集结构验证反馈所需的时间是非常重要的。考虑到供应商和客户可能处于持续独立的演变状态,这一点尤为重要。使用类型化协议,如 Protocol Buffers、Avro、Netflix 的 Falcor 和 GraphQL,可以使客户在与供应商互动的同时,保持一个轻量级的机制来验证请求是否正确。
注意
关键词是轻量级。值得注意的是,我们并不是反对使用基于 JSON 的 HTTP API(通常宣传为 RESTful),这些 API 不强制使用显式模式。我们也不是在推广使用(有争议的)传统协议,如 SOAP、WSDL 和 CORBA。尽管这些协议都是出于好意,但它们都存在相对较重的缺点。
-
自我发现:如前所述,当使用基于 IPC 的集成机制时,我们应该努力降低入门门槛。当使用 RESTful API 时,虽然对供应商来说实施 HATEOAS (
restfulapi.net/hateoas)可能比较困难,但它可以使客户更容易理解和消费 API。此外,利用服务注册表和/或模式注册表可以进一步减少消费摩擦。 -
契约测试:在快速失败和左移的精神下,契约测试和消费者驱动的契约的实践可以进一步提高集成的质量和速度。例如,Pact (https://pact.io/) 和 Spring Cloud Contract (
spring.io/projects/spring-cloud-contract) 等工具使得这些实践的采用相对简单。
到目前为止,我们已经讨论了实现模式,这些模式大致分为基于数据、基于代码和基于 IPC 的集成。希望这能帮助你通过考虑它们带来的好处和注意事项,有意识地选择适当的方法。
摘要
在本章中,我们探讨了不同类型的边界上下文关系。我们还检查了在实现这些边界上下文关系时可以使用的常见集成模式。
你已经学会了何时可以使用特定技术,了解了潜在的风险,以及在使用这些方法时如何提高效率的想法。
在下一章中,我们将探讨将这些边界上下文分配到独立可部署的组件中的方法(换句话说,采用基于微服务的架构)。
进一步阅读

第三部分:演化模式
在上一节中,我们从零开始构建了一个应用程序。然而,我们将所有组件打包成一个单一的部署单元,作为一个单体。在本部分中,我们将通过探索如何迭代地将我们在第二部分中构建的应用程序分解成更细粒度的组件的各种选项来扩展我们构建的应用程序。我们还将从功能和跨功能两个方面探讨分解的影响。
本部分包含以下章节:
-
第十章**,开始分解之旅
-
第十一章**,分解为更细粒度的组件
-
第十二章**,超越功能需求
第十章:开始分解之旅
一个分布式系统是这样的,一个你甚至不知道存在的计算机的故障可以使你的计算机无法使用。
—— 莱斯利·兰波特
到目前为止,我们已经有一个用于信用证(LC)应用处理的运行中的应用程序,它与其他组件捆绑在一起作为一个单一包。尽管我们已经讨论了子域和边界上下文的概念,但这些组件之间的分离是逻辑上的,而不是物理上的。此外,我们主要关注整体解决方案的LC 应用处理方面。
在本章中,我们将探讨如何将 LC 应用处理的边界上下文提取到一个物理上分离的组件中,从而使我们能够独立于整个解决方案进行部署。我们将讨论我们可用的各种选项,选择给定选项的理由,以及我们需要意识到的含义。
在本章中,我们将涵盖以下主题:
-
继续我们的设计之旅
-
分解我们的单体
-
前端交互的变化
-
数据库交互的变化
到本章结束时,你将了解到设计良好设计的 API 所需的因素——无论是远程过程调用还是基于事件的。对于基于事件的 API,你将了解可能需要创建健壮解决方案的各种保证。最后,你还将学习如何在使用多个数据存储时管理一致性。
继续我们的设计之旅
在前面的章节中,我们有一个 LC 应用处理的解决方案,它作为整体应用程序的进程内组件工作。从逻辑角度来看,我们对 LC 应用的实现类似于以下图表:

图 10.1 – LC 应用单体当前视图
尽管LC 应用处理组件与整个应用程序松散耦合,但我们仍然需要与其他几个团队协调以实现业务价值。这可能会阻碍我们以比生态系统中最慢的贡献者更快的速度进行创新。这是因为所有团队都需要准备好生产环境,才能进行部署。此外,由于各个团队可能在工程成熟度方面处于不同的水平,这可能会进一步加剧。让我们看看一些关于我们如何通过将我们的组件物理分解成明显可部署的工件来实现一定程度的独立性的选项。
分解我们的单体
首先也是最重要的,LC 应用处理组件在与其他组件交互时仅暴露进程内 API。这包括以下交互:
-
前端
-
发布/消费的事件
-
数据库
为了将 LC 应用程序处理功能提取为其自己的、独立可部署的组件,我们需要支持远程调用接口,而不是我们目前拥有的进程内接口。因此,让我们逐一考察每个远程 API 选项。
前端交互的变更
目前,如这里所示,CommandGateway用于命令和QueryGateway用于查询:

替换这些进程内调用的一个非常简单的方法是引入某种形式的远程过程调用(RPC)。现在我们的应用程序看起来类似于以下这样:

图 10.2 – 向前端引入远程交互
当处理进程内交互时,我们只是在同一进程的范围内调用对象的方法。然而,当我们切换到使用进程外调用时,需要考虑很多因素。如今,在处理远程 API 时,我们有几种流行的选择,包括基于JSON的 Web 服务、GraphQL、gRPC等。虽然可以使用完全定制的格式来促进通信,但领域驱动设计(DDD)倡导者推荐使用开放主机服务模式(ddd-book.karthiks.in/10-distributing-into-multiple-components.html#_open_host_service_ohs),使用我们在第九章中介绍发布的语言,即与外部系统集成。即使使用开放主机服务风格的通信,也有一些考虑因素,其中一些我们在以下小节中讨论。
协议选项
当暴露远程 API 时,我们有几种选择。如今,使用基于 JSON 的 API(通常标记为表示状态转移或REST)似乎相当流行。然而,这并不是我们唯一的选择。在基于资源的方案中,第一步是确定一个资源(名词),然后作为下一步映射与资源相关的交互(动词)。在基于动作的方案中,重点是执行的动作。可以说,REST 采用基于资源的方案,而GraphQL、gRPC、SOAP等似乎基于动作。让我们以一个 API 为例,我们想要启动一个新的 LC 应用程序。在 RESTful 世界中,这可能看起来像这样:

相比之下,使用 GraphQL 实现,这可能看起来像以下这样:

在我们的经验中,使用 REST 设计 API 在尝试映射领域语言时确实会导致一定程度上的稀释——因为首要关注的是资源。纯粹主义者会迅速指出,前面的例子并不符合 REST 原则,因为没有名为start-new的资源,我们应该让 URL 仅包含资源的名称(使用/lc-applications而不是/lc-applications/start-new)。我们的方法是将保持通用语言的准确性置于对技术纯度的教条式遵循之上。
传输格式
在这里,我们有两种广泛的选择:基于文本的(例如,JSON或XML)与二进制(例如,协议缓冲区,developers.google.com/protocol-buffers,或 Avro,avro.apache.org/))。如果满足了非功能性需求(如性能),我们的首选是使用基于文本的协议作为起点。这是因为它提供了灵活性,不需要任何额外的工具来直观地解释数据(当调试时)。
当设计远程 API 时,我们有选择一个强制执行模式(例如,协议缓冲区或 Avro)或更非正式的格式(例如,纯 JSON)的选项。在这种情况下,为了保持通用语言的准确性,可能需要在更正式的设计和代码审查、文档等方面进行额外的治理。
兼容性和版本控制
随着需求的演变,将需要增强接口以反映这些变化。这意味着我们的通用语言也将随着时间的推移而改变,使旧概念变得过时。一般原则是尽可能长时间地保持与消费者的向后兼容性。但这确实意味着必须同时维护旧的和新的概念——导致难以区分哪些是相关的,哪些不是。使用显式的版本控制策略可以在一定程度上帮助管理这种复杂性——新版本可能能够与旧版本断开向后兼容。然而,继续无限期地支持大量不兼容的版本也是不可行的。因此,确保版本控制策略明确地制定弃用和退役协议是很重要的。
REST API
我们认识到在公开基于 Web 的 API 时,有几种选择,而声称使用 REST 方法似乎在当今相当普遍。REST 是由 Roy Fielding 在其博士论文中提出的。关于构成 REST 的想法一直是争论的焦点,并且可以说,即使在今天,它仍然模糊不清。Leonard Richardson 引入了基于 HTTP 的 REST API 成熟度模型的概念,这在一定程度上有助于提供一些清晰性。该模型描述了 REST 的广泛一致性,分为四个级别,每个级别都比前一个级别更成熟:
-
Adhoc:API 设计时没有使用任何可感知的结构。
-
Resources:API 设计围绕一个事物进行,这个事物本身就有意义(通常是一个名词)。在这里,可以使用非常小的动词子集(要么是 GET,要么是 POST)来模拟所有操作。
-
HTTP 动词:API 设计时利用一组标准操作来对资源进行操作(例如,GET 用于读取,POST 用于创建,PUT 用于更新,DELETE 用于删除等)。
-
HATEOAS:API 包括超媒体链接,以帮助客户端以自助方式发现我们的 API。
在我们的经验中,大多数声称是 RESTful 的基于 Web 服务的解决方案似乎只停留在第 2 级。REST 的发明者 Roy Fielding 似乎声称REST API 必须是超文本驱动的(roy.gbiv.com/untangled/2008/rest-apis-must-be-hypertext-driven)。在我们看来,在 API 中使用超文本控制可以使它们变得自我文档化,从而更明确地促进通用语言的运用。更重要的是,它还指出了在资源生命周期中,在特定时刻可以应用哪些操作。例如,让我们看看一个示例响应,其中列出了所有待处理的 LC 申请:

在前面的示例中,列出了两个lc-applications。根据 LC 的当前状态,链接提供了一种适当处理 LC 的手段。除了self链接外,第一个 LC 申请显示了提交链接,表示它可以被提交,而第二个申请显示了批准和拒绝链接,但没有提交链接。这可能是由于它已经被提交。此外,请注意,响应不需要包含状态属性,这样它们就可以使用这个属性来推断在当前时刻与 LC 申请相关的操作(这是一个告诉,不要询问原则的例子,martinfowler.com/bliki/TellDontAsk.html)。虽然这可能是一个细微的差别,但我们认为在 DDD 旅程的背景下指出这一点是有价值的。
因此,我们已经讨论了从进程内 API 迁移到进程外 API 时的一些考虑因素。还有许多其他考虑因素,特别是关于非功能性需求(如性能、弹性、错误处理等)。我们将在第十一章“分解为更细粒度的组件”中更详细地探讨这些内容。
现在我们已经了解了如何与与前端交互的 API 一起工作,让我们看看我们如何远程处理事件发布和消费*。
事件交互的更改
目前,我们的应用程序通过Axon框架提供的进程内总线发布和消费领域事件。
我们在处理命令时发布事件:

- 在成功处理命令时发布事件,并消费事件以公开查询 API:

- 我们使用 Axon 提供的
@EventHandler注解订阅事件。
为了远程处理事件,我们需要引入一个显式的基础设施组件,即事件总线。常见选项包括消息代理,如ActiveMQ和RabbitMQ,或者分布式事件流平台,如Apache Kafka。应用程序组件可以继续像以前一样发布和消费事件——只是现在,它们将使用进程外调用风格。从逻辑上讲,这使我们的应用程序看起来类似于以下内容:

图 10.3 – 引入进程外事件总线
当在单个进程的范围内处理事件时,假设同步处理(在相同线程上事件发布和消费),我们不会遇到当发布者和消费者分布在多个进程之间时才显现的大多数问题。让我们更详细地检查其中的一些。
原子性保证
在以前,当发布者通过发布事件处理命令,并且消费者处理它时,事务处理作为一个单一的原子单元发生,如下所示:

图 10.4 – 单体中的 ACID 事务处理
注意,前面图中所有高亮显示的操作都是作为单个数据库事务的一部分发生的。这使系统能够从端到端保持强一致性。当事件总线分布到其自身进程内工作时,原子性无法得到保证,就像之前那样。前面编号的每个操作都作为一个独立的交易进行。这意味着它们可以独立失败,这可能导致数据不一致。
为了解决这个问题,让我们更详细地查看处理过程的每个步骤,从命令处理开始:

图 10.5 – 命令处理事务语义
让我们考虑这样一种情况:我们成功保存到数据库,但未能发布事件。消费者将不会意识到正在发生的事件,从而导致不一致。另一方面,如果我们发布了事件,但未能将其保存到数据库中,那么命令处理本身就会变得不一致——更不用说查询方现在认为发生了领域事件,而实际上并没有。再次,这导致了不一致。这种双重写入问题在分布式事件驱动应用程序中相当常见。如果命令处理必须以万无一失的方式进行,那么保存到数据库和发布到事件总线必须原子性地发生——这两个操作应该同时成功或失败。以下是我们用来解决这个问题的几个解决方案(按复杂度递增):
-
不采取行动:可以说,这种方法实际上并不是一个解决方案;然而,它可能是唯一的一个占位符,直到有更稳健的解决方案。虽然看到这个选项可能会让人困惑,但我们确实看到过事件驱动系统就是这样实现的。我们将其留在这里作为警告,以便团队意识到潜在的风险。
-
事务同步:在这种方法中,多个资源管理器以这种方式同步,即任何一个系统的失败都会触发其他系统中已提交事务的清理。值得注意的是,这可能不是万无一失的,因为它可能导致级联故障。
信息
Spring 框架通过TransactionSynchronization接口和已弃用的ChainedTransactionManager接口提供了对这种行为风格的支持。请参阅框架文档以获取更多详细信息。不言而喻,在没有仔细考虑业务需求的情况下不应使用此接口。
-
分布式事务:另一种方法是利用分布式事务。分布式事务是在两个或更多资源管理器(通常,这些是数据库)上执行的一系列数据操作,使用诸如两阶段提交(
martinfowler.com/articles/patterns-of-distributed-systems/two-phase-commit.html)等技术。通常,这种功能是通过在底层资源管理器(数据库)上使用悲观锁来实现的,并且在高度并发的环境中可能会带来扩展性的挑战。 -
事务性输出箱:前面提到的方法都不是完全万无一失的,因为在数据库和事件总线之间仍然存在一个机会窗口,它们可能会变得不一致(即使在两阶段提交的情况下也是如此)。绕过这个问题的方法之一是完全消除双重写入问题。
在这个解决方案中,命令处理器在本地事务中将其数据库写入,并将预期的事件写入一个输出队列表。一个单独的轮询组件轮询输出队列表并将写入事件总线。轮询可能计算密集,并可能导致再次出现双重写入问题,因为轮询器必须跟踪最后写入的事件。这可以通过在消费者端使事件处理幂等来避免,这样处理重复事件就不会引起问题,尤其是在极端高并发和大量场景中。另一种缓解此问题的方法是使用变更数据捕获(CDC)工具(如Debezium,debezium.io/)和 Oracle LogMiner (en.wikipedia.org/wiki/OracleLogMiner)。大多数现代数据库都附带了一些工具来简化这一过程,它们可能值得探索。一种实现方式是使用事务性输出队列模式,如下面的图所示:

图 10.6 – 事务性输出队列模式
事务性输出队列模式是处理双重写入问题的稳健方法。然而,它也引入了相当多的操作复杂性。在我们之前的一个实现中,我们利用事务同步来确保我们永远不会错过对数据库的写入。此外,我们还确保事件总线通过计算和存储层的冗余以及最重要的是,通过避免在事件总线上的任何业务逻辑来保持高可用性。
投递保证
之前,由于所有组件都在单个进程中工作,只要进程保持活跃,向消费者投递事件就有保证。即使事件处理在消费者端失败,检测失败也相对简单,因为异常处理相对直接。
此外,回滚也很简单,因为事件的生产和消费是作为单个数据库事务的一部分发生的。随着 LC 处理应用现在成为一个远程组件,事件投递变得更加具有挑战性。当涉及到消息投递语义时,有三个基本类别:
-
最多一次投递:这意味着每条消息可能只投递一次,或者根本不投递。可以说,这种投递方式最容易实现,因为生产者以“发射并遗忘”的方式创建消息。在可以容忍某些消息丢失的环境中,这可能没问题。例如,点击流分析或日志数据可能属于这一类。
-
至少一次投递:这意味着每条消息将被投递多次,且不会丢失任何消息。未投递的消息会重试投递——可能无限次。这种投递方式可能在无法容忍丢失消息的情况下是必要的,但可以容忍处理相同消息多次。例如,分析环境可以容忍重复的消息投递或具有重复检测逻辑以丢弃已处理的消息。
-
恰好一次投递:这意味着每条消息恰好投递一次,既不会丢失也不会重复。这种消息投递方式非常难以实现,许多解决方案可以通过从消费者那里获得一些帮助来实现“恰好一次”语义,即检测并丢弃重复消息,而生产者坚持至少一次投递语义。
对于领域事件处理,当然,大多数团队都会更喜欢具有“恰好一次”处理语义,因为他们不希望丢失任何这些事件。然而,鉴于保证“恰好一次”语义的实际困难,通过让消费者以幂等的方式处理事件或设计事件以使其更容易检测错误,以实现“恰好一次”处理并不罕见。
例如,考虑一个MonetaryAmountWithdrawn事件,它包括accountId和withdrawalAmount。此事件可以携带一个额外的currentBalance属性,以便消费者在处理提款时知道他们是否与生产者不同步。另一种方法是消费者跟踪最后处理的n个事件。在处理事件时,消费者可以检查此事件是否已被处理。如果是,他们可以将其检测为重复并简单地丢弃它。再次强调,所有上述方法都会给整个系统增加一层复杂性。尽管有所有这些安全措施,消费者仍然可能发现自己与记录系统(产生事件的命令端)不同步。如果是这样,作为最后的手段,可能有必要使用部分或全部事件重放(ddd-book.karthiks.in/10-distributing-into-multiple-components.html#_historic_event_replays),这在第七章 实现查询中已有讨论。
排序保证
在我们正在构建的事件驱动系统中,消费者以确定性的顺序接收事件是理想的。不知道顺序或以错误的顺序接收可能会导致结果不准确。让我们考虑一个例子,即LCApplicationAddressChangedEvent在很短的时间内发生两次。如果这些更改以错误的顺序处理,我们可能会显示错误的地址作为它们的当前地址。这并不一定意味着所有用例都需要对事件进行排序。让我们考虑另一个例子,即当不可能提交给定的 LC 应用程序超过一次时,我们错误地多次收到LCApplicationSubmittedEvent。第一次之后的所有此类通知都可以忽略。
作为消费者,了解事件是否有序是很重要的,这样我们才能为乱序事件做出设计考虑。一个默认的做法可能是将乱序事件作为默认选项。根据我们的经验,这往往会使最终的设计更加复杂,尤其是在顺序很重要的情况下。在这里,我们将讨论三种事件排序策略及其对生产者和消费者的含义:

在大多数应用程序中,按聚合排序可能是一个好的起点,并满足大多数业务场景。
持久性和持久性保证
当事件发布到事件总线时,理想的情况是目标消费者可以成功处理它。然而,有一些场景可能会对消息处理产生不利影响。让我们检查这些场景中的每一个:
-
慢速消费者:消费者无法像生产者发布它们那样快速处理事件。
-
离线消费者:在事件发布时,消费者不可用(已关闭)。
-
失败的消费者:当消费者尝试处理事件时,会经历错误。
在这些情况中,我们可能会积累未处理的事件积压。因为这些是领域事件,我们需要防止在消费者成功处理之前丢失这些事件。为了成功工作,需要满足以下两个通信特性:


图 10.7 – 持久性与持久性
-
持久性:这是生产者实例和事件总线实例之间的通信风格。
-
持久性:这是事件总线实例和消费者实例之间的通信风格。
首先,消息需要是持久的(即存储在磁盘上),其次,消息订阅(消费者与事件总线之间的关系)需要是持久的(在事件总线重启时保持持久)。需要注意的是,事件必须由生产者持久化,以便消费者能够持久地消费它们。
处理保证
当查询端组件处理事件时,如上图所示,以下步骤会发生:

图 10.8 – 事件处理失败场景
-
事件从事件总线实例中被消费(无论是通过推送还是拉取)。
-
变换逻辑应用于事件的负载。
-
转换后的负载保存在查询端存储中。
每个这些步骤都可能遇到失败。无论失败的原因是什么,事件应该是持久的(如前所述),以便在问题解决后可以稍后处理。这些错误可以大致分为四个类别:

现在我们已经看到了由于引入进程外事件总线而需要做出的变更。完成这些变更后,我们可以实际上将LC 应用处理组件提取成其自己的独立部署单元,这将在以下图中类似:

图 10.9 – 独立部署的 LC 应用处理
然而,我们仍在使用公共数据存储来为LC 应用处理组件服务。让我们看看将此隔离到其自己的存储中涉及的内容。
数据库交互的变更
虽然我们已经将我们的应用程序组件提取到其自己的单元中,但我们仍然在数据库层耦合。如果我们想要真正从单体中实现独立,我们需要打破这种数据库依赖。让我们看看实现这一目标所需的变化。
数据迁移
作为开始使用我们自己的数据库的第一步,我们需要开始从命令端事件存储和查询存储(如上图所示)迁移数据:

图 10.10 – 数据迁移
在我们的案例中,我们有一个需要迁移出去的命令端事件存储和查询存储(s)。为了从一开始就最小化工作量,可能明智的做法是进行简单的同构迁移,保持源和目标数据库技术相同。在切换之前,其他事项中,以下事项将是至关重要的:
-
配置以确保延迟数字在可容忍的范围内
-
测试以确保数据已正确迁移
-
通过理解和同意服务等级协议(SLA),如恢复时间目标(RTO)和恢复点目标(RPO)来最小化停机时间
切换
如果我们已经走到这一步,我们就准备好完成 LC 应用处理从单体剩余部分的迁移。我们解决方案的逻辑架构现在看起来类似于以下图:

图 10.11 – 独立数据持久化
通过这一步,我们已经成功完成了第一个组件的迁移。还有很多工作要做。可以说,我们的组件已经结构良好,并且与应用程序的其他部分松散耦合。尽管如此,从进程内模型到有界上下文之间的进程外模型转换是一个相当复杂的过程——这一点应该从我们本章所做的工作中显而易见。
摘要
在本章中,我们学习了如何从一个现有的单一应用程序中提取一个有界上下文,尽管你可以争论这来自于一个结构相当合理的单一应用程序。我们探讨了从前端、事件交换和数据库等不同交互点分解单一应用程序所涉及到的挑战。你应该了解从进程内事件驱动应用程序到进程外应用程序需要具备的条件。
在下一章中,我们将探讨如何从可能结构不佳的单一应用程序中提取部分内容,可能非常接近令人讨厌的大泥球。
参考文献
如需更多信息,请参阅以下资源:
-
roy.gbiv.com/untangled/2008/rest-apis-must-be-hypertext-driven -
martinfowler.com/articles/patterns-of-distributed-systems/two-phase-commit.html
第十一章:分解为更细粒度的组件
在上一章中,我们将 LC 应用处理 功能从单体应用中分解出来。在本章中,我们将进一步将这些组件分解为更细粒度的组件。此外,我们还将探讨何时以及是否应该进行这种分解。
本章将涵盖以下主题:
-
继续我们的设计之旅
-
更细粒度的分解
-
前端的分解
-
哪里应该划线
在本章结束时,你将能够欣赏到在决定如何分解这些组件时,技术因素和非技术因素所起的作用。
继续我们的设计之旅
目前,我们的应用类似于这里展示的图示:
![图 11.1 – 独立数据持久化]

图 11.1 – 独立数据持久化
LC 应用处理 功能作为一个独立的组件存在于整个应用之外。它通过事件总线交换领域事件与单体应用进行通信。它使用自己的持久化存储,并暴露了前端消费的基于 HTTP 的 API。让我们来探讨是否可以将应用进一步分解为更细粒度的组件。当前的 AutoApprovalSaga 组件生活在单体应用的范围内,但这主要是我们之前设计的一个副产品,而不是一个有意的设计选择。接下来,让我们看看如何将其提取为它自己的组件。
Saga 作为独立组件
目前,AutoApprovalSaga 组件(在第 第八章 “实现长期运行的工作流程”中详细讨论)通过监听领域事件来工作,如图所示:
![图 11.2 – AutoApprovalSaga 功能分解]

图 11.2 – AutoApprovalSaga 功能分解
由于这些事件是由不同的有界上下文发布到事件总线上的,因此不需要 AutoApprovalSaga 嵌入到单体应用中。这意味着它可以安全地与其私有数据存储一起提取为它自己的可部署单元。这意味着我们的系统现在看起来像这里展示的图示:
![图 11.3 – AutoApprovalSaga 提取为独立组件]

图 11.3 – 将 AutoApprovalSaga 提取为独立组件
Saga 组件可以被描述为一系列有状态的监听器,它们监听来自多个聚合体的事件,并且可以向多个聚合体发出命令。我们之前看到,我们沿着聚合体的边界形成了边界上下文。鉴于 sagas 往往需要与多个聚合体交互,它们可能不会局限于这些边界上下文之内。在许多方面,sagas 可以被视为它们自己的边界上下文。这使得 sagas 作为独立组件工作变得自然而合理,这些组件在逻辑和物理层面上都与其他解决方案的部分明显区分开来。
如您所见,在LC 应用处理组件中,命令和查询继续使用一个共同的数据存储。让我们看看将它们隔离到自己的数据存储中涉及的内容。
作为独立组件的命令和查询
正如我们在第二章“哪里和如何适用 DDD?”中“CQRS 模式”部分所看到的,我们从中获得的主要好处是能够独立于彼此地演进和扩展这些组件。这一点很重要,因为命令和查询具有完全不同的使用模式,因此需要使用不同的领域模型。这使得我们沿着这些边界进一步分割边界上下文变得相当自然。到目前为止,这种隔离是逻辑上的。物理分离将使我们能够真正独立地扩展这些组件,如下所示:

图 11.4 – 命令和查询作为独立组件
有必要指出,现在命令处理组件现在被显示为可以访问两个不同的数据存储:
-
聚合存储,它存储聚合状态的基于事件源或状态存储的表示。
-
查找存储,在处理命令时执行业务验证时可以用来存储查找数据。这适用于我们需要访问作为聚合状态的一部分存储的数据/不能存储的数据。
我们提出这一点的原因是,我们可能需要继续对仍然存在于单体中的数据进行查找。为了实现完全的独立性,这些查找数据也必须使用诸如历史事件回放(如在第七章“实现查询”中讨论)或其他传统数据迁移技术(如在第十章“开始分解之旅”中讨论)进行迁移。
分布独立的查询组件
到目前为止,我们已经实现了命令和查询边界的隔离。但我们不需要就此停止。我们服务的每个查询不一定必须保持为单个组件。让我们考虑一个例子,其中我们需要为 UI 实现模糊 LC 搜索功能,并为分析用例提供 LC 事实视图。可以想象,这些需求可能由不同的团队实现,从而需要不同的组件。即使这些不是不同的团队,使用模式的差异也可能需要使用不同的持久化存储和 API,这又要求我们至少将这些中的某些作为独立的组件来实现,如图所示:
![图 11.5 – 查询分解为单个组件
![图片/B16716_Figure_11.5.jpg]
图 11.5 – 查询分解为单个组件
拥有域的团队应努力创建表现出良好域数据产品特征的查询 API。这些特征包括可发现性、可信度、自身价值以及自我描述。更多详细信息,请参阅关于从单体数据湖迁移到分布式数据网格的文章:martinfowler.com/articles/data-monolith-to-mesh.html#DomainDataAsAProduct。
更加细粒度的分解
在这个阶段,是否还需要进一步分解且可行?如今,无论是正确还是错误,无服务器架构(特别是函数即服务)可能正变得非常流行。正如我们在第二章中指出的,“DDD 如何适应?”,这意味着我们可能能够以这种方式分解我们的命令端,即每个命令都成为其独立可部署的单位(因此是边界上下文)。换句话说,LCApplicationSubmitCommand和LCApplicationCancelCommand可以独立部署。
但仅仅因为技术上可行,我们就应该这样做吗?虽然很容易将其视为一时的风尚,但沿着命令边界拆分应用程序可能有很好的理由:
-
风险概况:当进行更改时,某些功能组件的风险更高。例如,提交 LC 申请可能被认为比取消申请的能力更为关键。然而,这并不意味着取消不重要。与提交解耦使得取消更改可以以较少的审查进行。这可能会使快速创新并带有更多实验性功能变得更容易,同时最小化造成大规模中断的恐惧。
-
可扩展性需求:系统中的各种命令的可扩展性需求可能会有很大差异。例如,提交 可能需要比 取消 扩展得更多。然而,耦合将迫使我们将它们视为同等,这可能是低效的。
-
成本归属:拥有细粒度的组件使我们能够更准确地衡量每个单独命令所投入的努力和产生的回报率。这可以使我们更容易将精力集中在最关键的功能(“核心”的核心)上,并最大限度地减少浪费。
对领域模型的影响
这些更细粒度的组件正引导我们走向一个点,似乎部署模型开始对设计产生重大影响。现在能够独立部署单个“任务”的事实要求我们重新审视我们如何到达边界上下文。例如,我们最初从工作在 LC 应用处理 边界上下文开始,我们的总体设计基于所有包含在应用处理范围内的功能。现在,我们的总体设计可以更加细粒度。这意味着我们可以有一个专门针对 启动 功能的总体设计,另一个针对 取消,如图所示:
![Figure 11.6 – 细粒度边界上下文示例
![img/B16716_Figure_11.6.jpg]
图 11.6 – 细粒度边界上下文示例
最细粒度的分解可能会引导我们为每个命令创建一个边界上下文,但这并不一定意味着我们必须以这种方式分解系统。在先前的例子中,我们选择为 提交 和 批准 命令创建一个单独的边界上下文。然而,启动 和 取消 有它们自己的边界上下文。你自己在生态系统中所做的实际决策将取决于在重用、耦合、事务一致性以及其他我们之前讨论过的考虑因素之间保持平衡。重要的是要注意,标记为 LCApplication 的聚合,尽管名称相同,但在各自的边界上下文中从领域模型的角度来看是不同的。它们唯一需要共享的属性是 共同标识符。如果我们选择将系统分解为每个命令一个边界上下文,我们的整体解决方案将类似于以下所示图:
![Figure 11.7 – 按命令分解
![img/B16716_Figure_11.7.jpg]
图 11.7 – 按命令分解
有必要指出,命令功能仍然共享一个单独的事件存储,尽管它们可能使用它们自己的单独查找存储。我们理解这种分解可能感觉是不必要的和强制的。然而,这确实允许我们集中精力在核心的核心上。例如,LC 应用程序处理可能是我们的业务差异化。然而,更仔细的检查可能会揭示,我们真正区别于业务的能力是我们在接近实时的情况下对 LC 的决策能力。这意味着可能明智地将该功能从系统其他部分隔离出来。实际上,这样做可能使我们能够优化我们的业务流程,而不会增加整体解决方案的风险。虽然以这种方式分解系统并非严格必要以获得这些见解,但细粒度的分解可能使我们能够细化对我们业务最重要的概念。需要共享持久存储可能会成为实现完全独立的一个难题。因此,最终的分解可能看起来像以下这样:
![Figure 11.8 – 具有个体事件存储的命令组件
![img/B16716_Figure_11.8.jpg]
图 11.8 – 具有个体事件存储的命令组件
显然,没有免费的午餐!这种细粒度的分解可能需要在这些组件之间进行额外的协调和数据复制 – 到一个可能不再具有吸引力的程度。然而,我们认为重要的是要展示可能的技艺。
分解前端
到目前为止,我们一直专注于分解和分配后端组件,同时保持前端作为现有单体系统的一部分保持不变。值得考虑将前端分解,使其更紧密地与功能边界对齐。例如,微前端(micro-frontends.org/,martinfowler.com/articles/micro-frontends.html)将微服务概念扩展到前端。微前端促进团队结构以支持一组功能的端到端所有权。可以想象,一个跨职能、多语言团队既拥有体验(前端)又拥有业务逻辑(后端)功能,从而大大减少沟通成本(类似于垂直切片架构的讨论,如在第2 章中所述,“DDD 如何适应?”)。即使在前端和后端由一个团队组成的团队组织在你的当前生态系统中不可行,这种方法仍然具有许多优点,例如以下内容:
-
增强端到端协作:创建端到端工作的解决方案最终提供了价值。将后端服务与其各自客户体验隔离开来只会导致我们积累未使用的库存。为了减少失败的可能性,后端能力与前端体验团队之间的协作越紧密,我们减少由于需求不匹配造成的浪费的机会就越大。将客户体验作为垂直切片的一部分,使我们能够在整个堆栈中应用通用语言。
-
统一的跨渠道体验:如今,在多个体验渠道中展示相同的功能非常普遍。跨渠道体验不一致可能会导致客户不满和/或不良的商业后果。沿着功能边界(在同一个 泳道 内)紧密对齐团队可以促进在展示业务功能时的高协作和一致性。考虑这里显示的例子。在一个垂直切片中,忠诚度是对正在开发的功能,尽管可能需要使用不同的技术来构建每个渠道(iOS、Android、Web 等)。在一个垂直切片中,图中显示的每个框都可以作为一个团队独立运行,同时与同一泳道内的功能团队保持强烈的凝聚力,如这里所示:

图 11.9 – 沿着功能边界对齐的团队
虽然采用这种方法有很多优点,但与其他任何事情一样,它也带来了一些需要注意的问题:
-
端到端测试复杂性:虽然这在很多分布式架构中都是真的,但由于用户体验是一个视觉媒介,这个问题在用户体验的情况下更为严重。特别是如果真实组件在周期接近结束时才合并,那么在所有视觉元素都就位之前,可能很难可视化端到端的流程。这也可能与最终用户如何整体交互系统相冲突。这可能会使端到端测试变得复杂,因为它需要来自多个团队在周期接近结束时共同参与。
-
部署复杂性:在前面的例子中,我们已经根据功能边界拆分了应用程序。然而,在部署时,它们必须作为一个单一的项目合并在一起(这在移动应用程序的情况下尤其如此)。当完整的应用程序组装时,这可能会增加相当多的部署复杂性。了解团队之间的关系模式(如第 第九章,与外部系统集成)对于解决难题非常重要。
-
依赖管理:鉴于团队可能最终需要将应用程序作为一个单元部署,管理各个模块之间的依赖可能变得繁琐。这可能表现为依赖版本冲突,导致不可预测和低效的运行时行为和性能。例如,两个团队可能使用同一前端库的不同版本,这可能会增加下载到浏览器的总体负载。除了浪费之外,这也可能导致不可预测、难以诊断的错误,并最终导致糟糕的客户体验。
-
不一致的用户体验:尽管我们可能以看似合理的方式拆分了应用程序,但如果我们没有以对最终用户透明的方式去做,这可能会导致令人困惑且可能令人沮丧的体验。为了减轻这种情况,可能需要构建共同的资产、小部件等,这可能会进一步增加交付最终产品时的整体复杂性和协调需求。
如果我们继续按照之前建议的方式继续分解我们的应用程序,我们的应用程序最终会看起来像这里所示的图表:

图 11.10 – 命令和查询前端分解为单个函数
正如我们所见,有多种方法可以将应用程序分解成更细粒度的组件。尽管这样做是可能的,但这并不意味着我们应该这样做。让我们看看何时分解开始变得过于昂贵而无法维持生产力。
何时划线
通常情况下,我们的边界上下文越小,管理复杂性的难度就越低。这意味着我们应该尽可能地将系统分解成更细粒度的组件吗?另一方面,拥有极其细粒度的组件可能会增加它们之间的耦合度,以至于管理操作复杂性变得非常困难。因此,将系统分解成结构良好、协作的组件可能有点棘手,看起来更像是一门艺术而不是一门精确的科学。这里没有对或错的答案。一般来说,如果事情感觉和变得痛苦,你很可能做错了而不是做对了。以下是一些可能有助于指导这一过程的非技术启发式方法:
-
现有的组织边界:寻求与当前的组织结构保持一致。确定你的业务单元/部门/团队已经拥有的哪些应用程序,并按最小化干扰的方式分配责任。
-
最终用户角色和责任:你的最终用户执行哪些工作?什么使他们能够以尽可能少的摩擦完成工作?如果太多人需要参与才能完成一项工作,这可能表明当前的分解可能不是最优的。另一方面,如果很难将一项任务分配给特定的用户,这也可能表明分解不正确。
-
术语变化:寻找对常用术语(通用语言)使用的微妙变化。是否有人用不同的名字称呼在物理世界中相同或感觉相同的事物?例如,信用卡可以被不同的人或同一个人在不同情境下称为“塑料”、“支付工具”和“账户”。术语发生变化的时候,可能是分割功能的时候了。
-
现有(模块化/单体/分布式)应用:你的当前应用是如何在逻辑上隔离的?它们是如何在物理上隔离的?这可能会提供一些灵感。
团队组织
所有的上述技术都从现有结构中汲取灵感。然而,如果上述一个或多个是错误的/繁琐的/次优的,我们的作为开发者/架构师的工作就会更加复杂。
也有必要指出,出错确定领域边界并不罕见。提出一个看似更有道理的初始分解,并应用一系列的“如果...怎么办”问题来评估适用性,可能会有所帮助。如果推理能够经得起领域专家、架构师和其他利益相关者的审查,那么你可能已经处于一个不错的位置。如果你确实选择走这条路,调整现有的组织结构以匹配你提出的架构可能是一个明智的选择。这将有助于减少摩擦(换句话说,应用所谓的康威逆操作(www.thoughtworks.com/en-us/radar/techniques/inverse-conway-maneuver))。
这种团队组织方式可能相当复杂。Spotify 的人普及了多学科、主要自主的团队结构,这种结构紧密地沿着功能边界(称为squad)对齐,如下所示:

图 11.11 – Spotify 团队组织模型
团队结构还包括其他组成部分,如章节、部落和行会,这些部分有助于更好地流动变化、明确团队责任、促进团队内和团队间的更好协作等。您可以在以下帖子中了解更多信息:blog.crisp.se/wp-content/uploads/2012/11/SpotifyScaling.pdf。然而,没有一种适合所有情况的解决方案,在考虑采用这种风格之前,您需要考虑自己的组织结构和现实情况。要了解更多关于Spotify 模型的局限性(www.youtube.com/watch?v=4GK1NDTWbkY)以及如何找到一个更适合您自己需求的团队组织,您可能想看看 Matthew Skelton 和 Manuel Pais 在他们流行的书籍 Team Topologies (teamtopologies.com/book) 中所做的工作。在相关方面,查看 Sriram Narayan 在其书籍 Agile IT Organization Design (www.amazon.com/Agile-Organization-Design-Transformation-Continuous/dp/0133903354) 中的团队设计章节也可能有所帮助,他在书中讨论了以结果为导向的团队与以活动为导向的团队。
尽管我们尽职尽责和有崇高的意图,但仍然有可能出错这些边界,或者业务优先级或竞争对手的提供可能使当时看似完全有效的决策变得不正确。与其寻求达到完美的分解,不如接受变化,投资于构建灵活的设计,同时准备迭代地演进和重构架构。这本书关于构建演进式架构提供了一些很好的建议,如何做到这一点:evolutionaryarchitecture.com/。
为了达到合理的成功水平,需要在如何对领域进行建模、团队组织是什么以及如何架构应用程序之间保持微妙的平衡。当所有这些都达成一致时,您很可能接近实现高水平的成功,如下面的图表所示:

图 11.12 – 影响组件分解的力
作为一般性指南,当需求和/或我们的理解可能仍然不清楚时,从粗粒度分解开始是有帮助的,将更细粒度的分解留到我们的理解提高的时候。
摘要
在本章中,我们学习了如何将已经细粒度的应用程序进一步分解到个体功能层面,每个功能都可以作为一个独立的单元部署。我们探讨了保持端到端功能(一个薄垂直切片)作为一个统一单元的好处,这包括从前端体验到后端的所有组件。
此外,我们探讨了康威定律如何在我们的架构演变中发挥重要作用。我们还探讨了如何通过应用逆康威机动来纠正笨拙的组织结构。最后,我们简要介绍了在设计自己的组织结构时可以从中获得灵感的流行团队组织方法。
在下一章中,我们将探讨各种非功能性特征,这些特征在我们如何分解和分配应用程序方面发挥着重要作用。
进一步阅读

第十二章:超越功能需求
有时候我感觉自己被遗忘了。
—— 匿名
虽然系统核心的功能需求可能得到充分满足,但同样重要的是关注系统的操作特性。在本章中,我们将探讨常见的陷阱以及如何克服它们。
在本章中,我们将涵盖以下主题:
-
可观察性
-
一致性
-
性能和规模
-
基于主干线的开发
-
持续测试
-
部署自动化
-
重构
-
调用风格
-
日志记录
-
版本控制
到本章结束时,我们将了解软件生命周期各个方面的各种方面,从跨职能的角度创建一个健壮的解决方案。我们还将讨论需要添加的额外功能,以使我们的解决方案具有性能、可扩展性、容错性,并能够可靠、重复和快速地进行更改。此外,我们还将检查这些更改的影响,以及这些更改可能对我们边界上下文及其边界产生的潜在影响。
让我们开始吧!
可观察性
在前面的章节中,我们看到了如何根据边界上下文来分解现有应用程序,以及如何将边界上下文分割成非常细粒度的部分,通常作为物理上不同的组件。任何这些组件的故障都可能导致依赖它们的其他组件出现中断。显然,通过主动和被动监控的组合,及早检测并归因于特定组件,可以理想地防止或至少最小化业务中断。
当谈到监控时,大多数团队似乎会想到与组件相关的技术运行时指标(如 CPU 利用率、内存消耗、队列深度、异常计数等)。
为指标提供客观性
为了使其更加正式,我们使用服务级别目标(SLOs)、服务级别指标(SLIs)和服务级别协议(SLA)中的术语来表示以下内容:
-
SLO:供应商和客户之间关于特定可衡量指标的协议。例如,99.99%的可用性,对于 99 百分位数的请求,1000 个并发用户的响应时间为 100 毫秒等。
-
SLA:一组 SLOs(服务级别目标)。
-
SLI:实际数字与 SLO 的对比。例如,您的系统可能有一个 99.95%的可用性 SLI。
技术指标
当谈到监控时,大多数团队似乎会想到与组件(如 CPU 利用率、内存消耗、队列深度、异常计数等)相关的技术运行时指标。
然而,能够将一组与业务相关的指标(如过去一小时提交的 LC 申请数量、被拒绝的 LC 申请数量等)和 DevOps 指标(如领先时间、平均恢复时间等)关联起来,同样重要,甚至可能更重要。
业务指标
无法将业务服务级别指标(SLIs)与组件关联和监控可能表明该组件过于细粒度。另一方面,如果与单个组件关联的业务 SLIs 太多,而这个组件对众多业务利益相关者群体都感兴趣,那么这可能表明进行更细粒度的分解是合理的。最终,我们设置的监控设备应该能够告诉我们是否违反/满足/超出服务级别目标(SLOs)。
DevOps 指标
DevOps 研究和评估(DORA)研究基金会发布了一个在线快速检查工具(www.devops-research.com/quickcheck.html)和报告(www.devops-research.com/research.html),以快速提供有关组织与行业同行相比的情况以及如何向精英地位迈进的信息。虽然讨论建立长期持续改进文化的全部细微差别超出了本书的范围,但我们引用了研究论文中突出的四个关键指标,作为软件交付性能的指标:
-
领先时间:从代码提交到代码成功运行在生产环境中的时间有多长?
-
部署频率:您的组织多久将代码部署到生产环境或发布给最终用户?
-
恢复时间:当发生影响用户的服务事件或缺陷时,通常需要多长时间来恢复服务?
-
变更失败率:有多少比例的生产或用户发布变更导致服务降级?
在可观察性方面,存在专注于特定指标而忽略整体森林的风险。为了避免指标被误用,更重要的是,避免得出错误结论的风险,我们建议以下做法:
-
采取全面视角:相对于只关注特定区域,在交付生命周期的各个方面投入更多或更少的关注可以大有裨益。如果您能够包括规划、需求收集、开发、构建、测试、部署以及运行生产系统的反馈信息,那么您可能可以合理地得出结论,您有一个表现优异的团队。
-
采用阶梯式方法:在识别出改进区域后,你如何为自己设定改进的基础?设定明确、客观、可衡量且可追踪的改进目标对于随后实现它们至关重要(无意中用了双关语)。为了确保持续进行增量改进,阶梯式方法是一种可以采用的技术。阶梯是一种类似于扳手的装置,但它的独特之处在于它只能朝一个方向转动。在此背景下,阶梯式方法包括以下步骤:
-
将当前级别设置为最小起始点。
-
在相对较短的时间内进行小的增量改进。
-
将基线调整到步骤 2 中达到的新水平。
-
如果水平下降到基线以下,则采取停止生产线措施,直到基线恢复。
-
从步骤 1 重新开始。
-
逐步改进允许团队在接近一个更好的地方的同时,设定增量里程碑作为中间目标。
采用持续学习和通过逐步改进来代替试图监督和惩罚的态度,可以大大有助于建立一个有效的系统。
一致性
在前面的章节中,我们投入了大量精力将我们的系统分解成多个细粒度的独立组件。例如,LC 应用程序是针对命令端组件提交的,而 LC 应用程序的状态则由查询端提供服务。因为这些是不同的组件,所以在两个系统不一致的这段时间内会有时间延迟。因此,在提交 LC 应用程序后立即查询其状态可能会产生过时的响应,直到查询端处理提交事件并更新其内部状态。换句话说,命令端和查询端被认为是最终一致的。这是我们与分布式系统合作时需要接受的权衡之一。
埃里克·布赖尔(加州大学伯克利分校计算机科学名誉教授)在所谓的CAP 定理中正式化了构建分布式系统所涉及的权衡。该定理假设在发生网络分区的情况下,分布式系统要么高度可用,要么一致,但不能同时两者都是。考虑到三个特性,一致性、可用性和分区容错性,该定理假设在发生网络分区的情况下,分布式系统要么高度可用,要么一致,但不能同时两者都是。这意味着预期高度可用的分布式应用程序将不得不放弃强一致性。
这可能让人看起来这是一个决定性的因素,但事实上,大多数现实世界的业务问题最终都是可以容忍不一致性的。例如,可能存在一个要求订单一旦发货就不能取消的要求。在一个最终一致性的系统中,可能存在一个时间窗口(尽管很小),我们可以允许已发货的订单被取消。为了处理这种场景,我们可能需要增强业务流程来考虑这些不一致性。例如,在发出取消订单的退款之前,我们可能需要验证订单尚未实际发货或已退货。即使在极端情况下,我们可能错误地发放了已发货订单的退款,我们也可以要求客户在到期前退货以避免被收费。如果客户未能退货,我们可能向客户收费或将其作为损失的业务冲销。显然,所有这些都增加了解决方案的复杂性,因为我们可能需要通过一系列补偿行动来考虑边缘情况。如果所有这些复杂性都不被接受,并且强一致性是不可或缺的,那么发货和订单取消功能将必须属于同一个边界上下文。
性能和规模
在前面的章节中,我们看到了将功能分解成彼此物理上分离的细粒度组件是如何成为可能,有时甚至是必要的——这需要网络进行协作。让我们假设这种协作是以松散耦合的方式实现的——从逻辑角度来看,这为不同边界上下文的存在提供了合理性。
性能是一个非常重要的 SLO,通常与大多数应用程序相关联。当谈到性能时,理解基本术语至关重要。这最好通过以下示例来说明:
![Figure 12.1 – The elements of network performance]
![img/B16716_Figure_12.1.jpg]
图 12.1 – 网络性能的要素
如此所示,以下术语在性能的背景下是相关的:
-
延迟:网络引入的延迟(A + B)
-
响应时间:系统响应用户所需的总时间(A + B + C)
-
带宽:网络的最高容量(D)
-
吞吐量:在给定时间内处理的数据量
在两个组件之间引入网络会在网络延迟和带宽方面引入约束。即使服务器上的处理时间理论上减少到零,延迟和带宽约束也无法避免。随着网络跳数的增加,这个问题只会变得更糟。这意味着网络应用程序无法提供与未联网的同类应用程序相同的性能水平。
需要扩展以支持更多的请求可能会使问题更加复杂。鉴于过去十年左右摩尔定律的显著放缓,继续通过使用越来越强大的机器来扩展规模变得越来越不可行。这意味着超过某个点,通过使用多个实例进行扩展,从而(重新)引入对网络的依赖,是不可避免的。
这使得很明显,性能和扩展要求可以对我们选择如何分配我们的组件产生重大影响。在尝试分配不同的组件之前,对性能和扩展 SLO 有清晰的理解是一个必要的先决条件。另一方面,如果你已经处于这样一种情况,即你已经有分布式组件,但它们没有达到性能和扩展 SLO,一个选择是将它们重新聚合在一起。如果这不可行,那么值得接受替代的客户体验,以及非阻塞的事件驱动架构风格,以创造更好的性能感知。
基于主干线的开发
DDD 的发明者埃里克·埃文斯(Eric Evans)讨论了如何持续集成(CI)帮助在有限范围内保持领域模型的神圣性。当多个人在同一个有限范围内工作时,它往往会变得碎片化。显然,团队越大,这种问题发生的可能性就越高。即使是三四个人的小团队也可能遇到严重的问题。我们也看到,超过某个点,如果我们试图将系统分解成极其细粒度的有限范围,可能会出现收益递减的情况。
这使得定期合并/集成所有代码和其他实现工件的过程变得非常重要,借助自动化测试来标记这种碎片化。此外,这还允许团队不懈地应用通用语言,每次都进一步细化领域模型以更准确地表示问题。换句话说,实践持续集成是至关重要的。许多团队使用 CI 服务器来运行测试,但往往推迟集成,直到非常晚才使用过多的长期分支(Gitflow 推广;www.atlassian.com/git/tutorials/comparing-workflows/gitflow-workflow和合并请求——实践一种被称为 CI 剧院的反模式www.gocd.org/2017/05/16/its-not-CI-its-CI-theatre.html)。
基于分支的开发的一个替代方案是基于主干分支的开发,其中每个开发者以增量批次工作,并将这些工作至少每天(有时是几次)合并到主分支(也称为主干分支)。DORA 团队发布了一项研究(services.google.com/fh/files/misc/state-of-devops-2021.pdf#page=27),该研究显示,精英团队通过实践基于主干分支的开发来最大化他们 CI 实践的有效性,进而扩展到他们持续增强领域模型和满足不断变化业务需求的能力。
在一个理想的世界里,对主干分支的每一次提交都应构成已完成、可用于生产的作品。但某些工作需要更长的时间来完成也是相当正常的。这可能会让人觉得有必要放弃基于主干分支的开发,转而采用基于分支的开发。然而,没有必要为了应对这种可能性而妥协持续集成的流程。保罗·汉曼特(paulhammant.com/)讨论了这种称为抽象分支的技术,其中未完成的工作的影响被隐藏在一个抽象层之后。这个抽象层通常是通过使新的功能对最终用户不可见来实现的,或者在更复杂的情况下,使用功能标志(martinfowler.com/articles/feature-toggles.html)。
持续测试
在一个理想的世界里,持续集成将使我们能够采用持续测试,这为我们提供了持续和早期的反馈。这是至关重要的,因为我们的边界上下文和由此产生的领域模型处于不断演化的状态。如果没有稳定测试套件的基础,维持可靠的过程会变得非常困难。测试金字塔、测试奖杯、蜂巢等方法被认为是实施合理的持续测试策略的合理方式。所有这些方法都是基于这样一个前提:大量的低成本(计算和认知)单元测试构成了策略的基础,随着我们沿着链条前进,其他类别(服务、UI、手动等)的测试数量逐渐减少。
然而,我们处在一个由相互通信的细粒度组件组成的新世界中。因此,有必要以稳健的方式验证外围的交互。仅仅依赖于模拟和存根的单元测试可能不足以满足需求,因为合作者的行为可能会意外地改变。这可能导致单元测试运行成功,但整体功能可能已损坏。这可能导致团队对单元测试的整体实践失去信心,并转而使用更多端到端的功能测试。然而,这些测试风格可能极其昂贵(www.youtube.com/watch?v=VDfX44fZoMc),尤其是在我们试图自动化它们的时候。因此,大多数团队忽略了大多数自动化测试方法的结果,几乎完全依赖于手动测试来验证除最简单功能之外的所有内容。
任何手动测试都需要在有意义测试开始之前,大多数情况下是所有功能都准备好。此外,它耗时、易出错,通常不可重复。因此,几乎所有测试只能在非常接近结束时进行,使得持续测试的想法成为空想。尽管存在所有这些局限性,团队仍然依赖于手动测试,因为它似乎比其自动化的对应物提供了更多的心理安全感。
在一个理想的世界里,我们需要的是单元测试的速度和手动测试提供的信心。我们将探讨几种具体的测试形式,这些形式可以帮助恢复平衡。
合同测试
单元测试的局限性在于,模拟/存根中做出的假设可能无效或随着生产者对合同的更改而变得过时。另一方面,手动测试由于速度慢和浪费而受到困扰。合同测试可以通过提供一个双方都可以依赖的、可执行的合同来提供一种快乐的中庸之道,从而弥合这一差距,该合同在功能变化/发展时双方都可以依赖。从高层次来看,这就像下面描述的那样工作:

图 12.2 – 合同测试:高级流程
这使得消费者和生产者能够协作工作,并在周期早期就获得反馈。对于消费者来说,他们有机会参与与生产者分享他们的期望,并使用经过版本控制的生产者批准的存根进行自己的测试,而无需依赖于生产者的真实系统。同样,生产者也能更深入地了解他们的服务是如何被消费的,这使他们能够自由地做出更大胆的更改,只要它们保持兼容。
测试驱动设计
领域驱动设计的本质在于尽可能彻底地理解问题,以便正确地解决问题。测试优先设计通过减少我们构建的解决方案导致的偏差风险,使得更好地理解问题成为可能。此外,它还促进了这些要求的自动化验证,这使得它们可以作为回归测试的有效辅助工具。正因为如此,我们强烈支持这一实践,并鼓励您考虑采用 TDD 作为核心实践,以增强您在 DDD 中的有效性。
突变测试
许多团队编写各种测试以确保他们构建的是高质量的解决方案。测试覆盖率通常用作评估测试质量的定量指标。然而,测试覆盖率是建立测试质量的一个必要但不充分的条件。低测试覆盖率几乎肯定意味着存在测试质量问题,而高覆盖率并不一定意味着更好的测试。在一个理想的世界里,即使是在生产代码中(由于业务需求的变化)的单行更改,如果没有更改测试代码,也会导致测试失败。如果可以保证代码库中的每个更改都能保证这一点,那么可能可以安全地依赖这样的测试套件。
突变测试是一种实践,它会自动在生产代码中插入小的错误(称为突变),然后重新运行现有的测试套件以确定测试的质量。如果你的测试失败了,突变就会被杀死。而如果你的测试通过了,突变就会存活。被杀死的突变越多,你的测试就越有效。
例如,它可能应用诸如反转条件、替换关系运算符、从方法返回 null 等突变,然后你可以检查这些突变对现有测试的影响。如果尽管这些突变没有测试失败,这些测试可能没有你希望的那样有帮助。这使我们能够更客观地得出关于测试质量的结论。鉴于它是通过突变代码来工作的,因此计算密集,可能需要很长时间才能运行。如果你采用测试优先设计并且有一套快速的单元测试,突变测试可以是一个很好的补充,有助于在开发周期早期发现遗漏的需求和/或测试用例。从这个角度来看,我们认为它是增强团队中 DDD 采用的一个无价工具。例如,PITest (pitest.org/) 是在 Java 应用程序中执行突变测试的一个很好的工具。
混沌测试
如我们之前所见,突变测试可以帮助指出应用程序功能方面的漏洞。混沌测试在帮助识别由对网络和基础设施的依赖引起的非功能性要求不足方面发挥着类似的作用。它通过使用由亚马逊、Netflix 等公司开创的大规模分布式、基于云的架构而开始流行。Netflix 最初发布了一个名为 Chaos Monkey 的工具(netflix.github.io/chaosmonkey/),该工具在生产环境中随机终止实例(!)以确保工程师实现能够抵御失败的服务。随后,他们发布了一系列相关的工具,统称为 Simian Army(现已停用),以测试各种非功能性方面,如延迟、安全合规性、未使用资源等。
当 Netflix 在生产环境中执行这种测试风格时,如果我们一开始就在较低的环境中采用这些实践,我们所有人都能从中受益匪浅。从战略角度来看,混沌测试可以提供关于组件之间耦合程度的反馈,以及这些组件的边界是否适当。例如,如果你依赖的组件出现故障或遇到问题,这也会让你受到影响吗?如果是这样,有没有缓解这种影响的方法?它还可以提供关于你的监控和警报系统的反馈。从战术角度来看,它可以提供关于正在使用的组件间通信调用风格的不足之处。
在本节中,我们选择突出显示契约测试、突变测试和混沌测试,因为我们认为它们是领域驱动设计应用中的颠覆性变革。当团队在制定全面的测试策略时,将这些方法视为其他测试方法的补充将受益匪浅。
部署自动化
应用领域驱动设计的目的是创建一个松散耦合的组件生态系统——这样每个组件都可以独立于彼此进化。这包括这些组件如何部署到生产环境中。在较高层次上,我们至少有三种部署风格:
-
单进程单体:应用的大部分内容作为一个单一单元部署,其中包含在部署中包含的所有组件都在单个进程中运行
-
分布式单体:应用被分割成多个组件,每个组件在自己的进程和/或主机上运行,但作为一个单一单元部署,或需要组件及其所有者之间的大量协调和紧密耦合
-
独立组件:应用被分割成多个组件,每个组件在自己的进程和/或主机上运行,独立于彼此部署,并且组件所有者之间需要最小化到没有协调
我们还有许多可以采用的部署策略。我们按复杂性和丰富程度递增的顺序列出了一些更受欢迎的:
-
基本: 这可能是最古老的部署方式,其中新版本的应用程序替换旧版本,通常伴随着一定程度的停机时间。回滚通常意味着重新部署之前运行版本,这同样会带来一定程度的停机时间。这种部署策略对于可以接受一定停机时间的应用程序来说相当常见。这可能包括非业务关键应用程序和/或第三方包,在这些情况下,我们没有发言权来决定这些应用程序如何管理它们的部署。在某些单体架构的情况下,这可能是唯一可行的选项,因为整个系统的复杂性很高。这种部署方式通常开始时相当简单且易于理解,可能适用于非关键应用程序。另一方面,它要求部署和发布在一个紧密耦合的步骤中完成,可能涉及一定程度的停机时间。
-
蓝绿部署: 一种利用两个相同环境(“蓝”环境和“绿”环境)的部署策略,一个代表当前生产环境,另一个代表新版本。当前版本继续服务流量,而测试和验收在新版本上进行,不会向最终用户暴露。一旦测试活动被认为成功完成,用户流量就会切换到新版本。值得注意的是,在任何给定时间,实时用户流量只会被导向一个环境。这种部署方式可以实现(几乎)零停机时间的部署,并允许部署和发布过程解耦。回滚更容易,因为它仅仅意味着将流量重定向到旧版本。另一方面,它至少在部署期间需要双倍容量。这可能会使单体应用程序的成本变得过高。
-
滚动部署: 一种策略,其中当前版本实例的小部分逐渐被新版本实例替换。旧版本和新版本软件将继续并行运行,直到所有旧实例都被新实例替换。在简单情况下,回滚通常意味着用旧版本实例替换新版本实例。这种部署方式也实现了零停机时间的部署,同时允许与真实用户并行测试新旧版本。滚动部署可以通过中止新版本实例的引入并重新引入旧版本来使回滚相对容易,从而可以减少不良发布的“影响范围”。与蓝绿部署不同,这里的部署和发布不能解耦。部署意味着系统被发布(至少对于用户子集)。
-
金丝雀部署:这是一种滚动部署的变体,流量以受控和分阶段的方式路由到新实例,通常是一个递增的比例的请求量(例如,2% → 25% → 75% → 100%的用户)。这种部署风格与滚动部署相比,能够更精细地控制发布范围。
-
A/B 部署:这是一种金丝雀部署的变体,其中多个版本(带有一个或多个变体)的新功能可以与当前版本同时作为“实验”运行。此外,这些变体可以针对特定的用户组。这允许同时测试超过两个组合,并使用真实用户进行测试。
当与单体应用一起工作时,团队通常被迫限制自己仅使用基本部署或最多蓝绿部署,因为采用更复杂的部署策略的成本和复杂性要高得多。另一方面,分布式单体使这个问题变得更加复杂,因为它现在需要协调物理上分散的组件和团队。只要我们能够在组件粒度和耦合之间保持平衡,我们就应该能够支持各种高级部署策略。
在今天这个现代生态系统中,为了在竞争中提供新功能和更快地创新,我们需要以最小的风险和业务中断来支持更复杂的部署形式。如果支持灵活的部署策略证明过于困难,那么很可能需要重新审视你的上下文边界。
重构
在一段时间内,将需要重新调整上下文边界、领域事件、API 等等。通常,与第一次不完美工作相关的事情和需要在组件间规模上进行重构的需要会带来一种耻辱感。然而,这可能是由于我们无法控制的多重原因所必需的,包括竞争对手生态系统变化、不断演变/误解的需求、无法满足非功能性要求、组织和个人责任的变化等等。因此,重构是软件团队需要接受的核心学科,作为一项一级实践。
注意
我们在本章中仅涵盖重构的战略(组件间)方面。关于重构的战术(组件内)方面有许多优秀的作品,例如马丁·福勒的《重构》(refactoring.com/)一书和迈克尔·费瑟斯的《与遗留代码高效工作》,以及其他作品。
从战略角度来看,这可能意味着必须将现有的单体分解成更细粒度的边界上下文,或者将细粒度的边界上下文合并成更粗粒度的上下文。让我们逐一探讨这些内容。
打破现有的单体
在前面的章节(第十章和第十一章)中,我们探讨了如何将现有的单体拆分成更细粒度的组件。然而,可以说单体一开始的结构相对较好。许多团队可能没有这么幸运。在这种情况下,以下是一些可能需要满足的先决条件:
- 执行战术重构:这将帮助你更好地理解现有系统。为此,从一个健身函数集(
en.wikipedia.org/wiki/Fitness_function)和一个黑盒功能测试集开始,执行重构,然后用运行速度更快的单元测试替换功能测试。最后,使用健身函数来评估这项工作的成功程度。重复此过程,直到达到尝试更复杂重构的舒适度。


图 12.3 – 持续改进循环
-
引入领域事件:识别软件缝隙(
wiki.c2.com/?SoftwareSeam)并沿着这些缝隙发布领域事件。使用领域事件来开始解耦生产者和消费者。 -
选择低垂的组件:如果可能的话,一开始选择具有低输入耦合和低到中等复杂性的区域。这将使你在尝试更复杂的组件之前,对这些技术的应用有更牢固的把握。请参阅第十章《开始分解之旅》和第十一章《分解为更细粒度的组件》以了解如何进行下一步。
合并到粗粒度边界上下文
将两个不同的边界上下文合并可能比拆解现有的一个要简单一些。然而,有几个细微之处值得注意,顺序如下:
-
通用语言的统一:在第九章《与外部系统集成》中,我们探讨了各种边界上下文之间相互集成的多种方式。如果这些边界上下文之间的关系是对称的,那么可能需要做的工作会少一些。这是因为,在对称关系中,一开始很可能存在很多协同效应。然而,如果关系是不对称的,例如,通过生产侧的开放主机服务和消费侧的反腐败层,这意味着可能存在两种不同的通用语言和可能不同的领域模型。为了达到适用于新合并的边界上下文的通用语言,需要仔细思考。
-
调整内部领域模型:采用通用通用语言的主要意义在于,在刚刚合并的边界上下文中使用一个通用的领域模型。这意味着聚合、实体和值对象需要统一,这可能会要求在持久化层进行更改。如果存在仅在组件之间发布和消费的领域事件,那么这些领域事件可能是退休的候选者。在这个阶段,对任何公共接口进行更改可能并不明智——特别是那些使用开放主机服务(例如,公共 HTTP API 和其他领域事件)公开的接口。
-
调整公共 API 设计:作为最后一步,重构冗余和/或低效的公共接口,以完成练习并获得预期的收益。
有必要指出,没有一套坚实的工程实践作为坚实基础,这种持续改进的风格可能极具挑战性,特别是我们在本节中讨论的测试和部署自动化实践。
调用风格
当将运行在不同进程中的两个边界上下文集成时,有两种方式来完成交互:同步和异步。
同步调用
客户端会阻塞,直到服务器提供响应。实现可以选择等待一定时间,以便在超时之前完成被调用的操作。这种交互的一个例子是,像这样对一个启动新 LC 应用的 HTTP 调用进行阻塞:


图 12.4 – 同步调用
当调用成功返回时,客户端可以确信他们创建新 LC 应用的请求已经成功。如果服务器响应缓慢,可能会导致性能瓶颈,尤其是在高规模场景中。为了应对这种情况,客户端和服务器可以就那个交互的响应时间 SLO 达成一致。客户端可以选择等待服务器响应,等待约定的时间后,客户端超时请求并认为它失败。鉴于客户端在服务器响应上阻塞,它在等待期间无法做其他任何事情,尽管它可能拥有做其他事情的资源。为了处理这种情况,客户端可以采用异步调用。
异步调用
在异步调用风格中,客户端以使其能够执行其他活动的方式与服务器交互。有几种方法可以做到这一点:
- 触发并忘记:客户端向服务器发起请求,但不等待服务器的响应,也不关心结果。这种交互风格可能适用于低优先级活动,如向远程服务器记录日志、推送通知等。


图 12.5 – 火速遗忘
- 延迟响应:在某些(很多?)情况下,客户端可能需要知道他们之前发出的请求的结果。如果服务器支持,客户端可以提交一个请求,只需等待确认请求已被接收,并附带要跟踪的资源标识符,然后轮询服务器以跟踪其原始请求的状态,如下所示:

图 12.6 – 使用轮询的延迟响应
- 带有回调的请求:当客户端轮询响应时,服务器可能还没有完成对原始请求的处理。这意味着客户端可能需要多次轮询服务器才能了解请求的状态,这可能是浪费的。一种替代方案是,当服务器完成处理时,通过调用客户端在发出请求时提供的回调函数,将响应推回客户端。

图 12.7 – 使用回调的延迟响应
由于这些交互发生在可能不可靠的网络中,客户端和服务器需要采用各种技术来实现某种程度的可靠性。例如,客户端可能需要实现超时、重试、补偿事务、客户端负载均衡等功能的支持。同样,服务器可能需要通过使用速率限制器、断路器、防护舱、回退、健康端点等技术来保护自己免受错误客户端的影响。
注意
对这里提到的具体技术进行详细阐述超出了本书的范围。像《Release It》和《Mastering Non-Functional Requirements》这样的书籍对这些模式进行了更深入的探讨。
在很多情况下,通常需要结合使用前面提到的几种技术来提供一种弹性解决方案。正如我们在日志记录部分讨论的那样,将这些关注点与核心业务逻辑混合可能会模糊问题的原始意图。为了避免这种情况,建议将这些模式应用于核心业务逻辑之外。也可能明智地考虑使用 Resilience4j (resilience4j.readme.io/) 或 Sentinel (github.com/alibaba/Sentinel) 等库。
日志记录
应用日志记录是诊断运行代码问题时最基本的支持之一。在许多代码库中,日志记录往往是在遇到问题后才被考虑的,开发者只在遇到问题时才添加日志语句。这导致日志语句几乎随机地散布在整个代码库中。以下是一个简单的示例,展示了命令处理器中的代码,用于记录其执行时间等:

毫无疑问,这段日志代码在调试问题时非常有价值。然而,当我们查看前面的代码时,日志代码似乎占据了整个方法,掩盖了领域逻辑。这可能会感觉无害,但当这种做法在多个地方进行时,它可能会变得相当重复、繁琐且容易出错——影响可读性。实际上,我们见过看似无害的日志语句引入了性能问题(例如,在具有昂贵参数评估的循环中)或甚至错误(例如,在尝试评估参数时可怕的NullPointerException)。在我们看来,将日志视为一等公民,并给予它与核心领域逻辑相同的严谨性非常重要。这意味着它需要遵守我们与良好设计的生产代码相关联的所有良好实践。
分离日志代码
理想情况下,我们将能够在可读性和可调试性之间保持平衡。如果我们能够分离这两个关注点,就可以实现这一点。分离这种横切逻辑的一种方法是通过使用面向切面编程(了解更多关于 AOP 的信息,请参阅www.eclipse.org/aspectj/和docs.spring.io/spring-framework/docs/current/reference/html/core.html#aop)如下所示:

注意
一个切入点定义了所有使用@CommandHandler注解的方法的around方面。在这个例子中,我们使用编译时织入,而不是通过 Spring 框架提供的运行时织入,来使用 AspectJ 注入执行时间逻辑。你可以在本文中找到更多关于使用特定织入技术的优缺点的详细信息(www.baeldung.com/spring-aop-vs-aspectj)。
在这里所示的风格中,我们通过使用面向切面编程将日志代码与应用程序代码分离。在示例中,日志代码适用于所有使用@CommandHandler注解的方法。这有一个优点,即所有这样的方法现在都将产生一致的进入/退出日志语句。另一方面,如果需要对特定命令处理器进行额外的日志记录,仍然需要在那个方法的主体内完成。如果你发现自己需要大量临时的日志语句,除了简单的进入/退出日志之外,这可能是一个信号,表明你的方法可能需要重构。
处理敏感数据
通常情况下,在添加日志代码时,包括尽可能多的上下文信息是有帮助的。在某些领域,如医疗保健或金融,可能存在法律/监管要求限制对敏感信息的访问,这可能会带来挑战。例如,在 LC 申请过程中,我们可能需要使用申请人的政府颁发的标识符(例如toString方法)对申请人的信用进行检查,以确保在有限上下文中满足业务需求的完整性。
虽然掩码可能在大多数用例中足够使用,但它存在一个限制,即即使是有权限的用户也无法访问原始信息。如果这是一个要求,可能需要利用令牌化(用一个称为令牌的非敏感占位符值替换敏感信息的过程)解决方案。这可以在有限上下文中不受限制地记录令牌化值,并且通常可以提供更高的安全性。但这可能意味着必须处理另一个有限上下文的额外复杂性,以提供令牌化值和授权控制,当需要访问真实值时。
日志格式
到目前为止,我们只关注了日志消息。然而,日志不仅仅是这些。通常还会包括其他信息,如发生时间、日志级别等,以帮助快速故障排除。例如,Spring Boot 默认使用以下日志格式:
2022-06-05 10:57:51.253 INFO 45469 --- [ost-startStop-1] c.p.lc.app.domain.LCApplication : Root WebApplicationContext: Ending StartNewLCApplication in 1200495 ns.
虽然这是一个很好的默认设置,但它仍然主要是非结构化文本,为了提高可读性而丢失了一些信息(例如,记录器名称被缩写)。虽然日志主要是供人类消费的,但大量日志可能会妨碍找到相关的日志。因此,生成既适合机器又适合人类阅读的日志非常重要,这样它们就可以轻松地进行索引、搜索、过滤等操作。换句话说,使用如以下所示的结构化日志格式可以大大有助于满足机器和人类可读性的目标:

利用结构化日志格式可以将日志的使用从仅仅是一个调试工具提升为另一个丰富且廉价的业务洞察来源。
注意
虽然选择自定义日志格式可能很有吸引力,但我们强烈建议选择与流行的格式兼容的格式,例如 Apache 的通用日志格式(CLF)(httpd.apache.org/docs/current/logs.html#common)或 Logstash 的默认格式(github.com/logfellow/logstash-logback-encoder#standard-fields)。
日志聚合
事实是我们的应用程序被分解成多个组件,每个组件通常运行多个实例,这意味着这可能会产生大量的日志,这些日志彼此之间是断开的。为了能够有意义地处理这些日志,我们需要按时间顺序聚合和排序它们。考虑使用正式的日志聚合解决方案可能是值得的。正如之前讨论的那样,使用结构化日志解决方案在处理来自多个系统的日志时可以大有裨益。
注意
关于日志最佳实践的更多信息,请参阅 OWASP 的这个日志备忘单(github.com/OWASP/CheatSheetSeries/blob/master/cheatsheets/Logging_Cheat_Sheet.md)以及关于日志艺术的这篇文章(www.codeproject.com/Articles/42354/The-Art-of-Logging)。
在一个地方聚合日志允许我们查看来自多个应用程序的诊断信息。然而,在流程进行中,我们仍然需要关联这些信息。分布式追踪解决方案可以帮助我们做到这一点。让我们看看下一个例子。
追踪
想象一个场景,申请者通过用户界面提交了 LC 申请。如果一切顺利,在几毫秒内,申请者应该会收到成功提交的通知,如下所示:

图 12.8 – 提交 LC 应用流程
即使在这个简单的例子中,也有几个组件参与,每个组件都会产生自己的日志。当工程师试图诊断问题时,需要关联来自多个组件的日志条目。为了实现这一点,需要在交互开始时尽可能早地引入一个关联标识符,并将其传播到组件边界之外。此外,每个组件中的日志条目在产生日志时需要携带这个关联标识符。这样做将允许我们通过关联标识符作为统一线索来查看跨越进程边界的日志条目。从技术角度来说,整个流程被称为追踪,流程中的每个部分被称为跨度。在日志条目上添加此类信息的这个过程被称为分布式追踪。
如此明显,用户流程可能——并且通常确实——跨越多个边界上下文。为了有效地工作,边界上下文需要就统一传播跟踪和跨度标识符达成一致。例如,Spring Cloud Sleuth 和 OpenTracing 等工具可以帮助简化使用不同技术栈的团队的实施。
基本上,分布式跟踪可视化可以帮助诊断性能瓶颈和组件之间的嘈杂度。但可能不那么明显的是,它可以在获取对组件在端到端用户旅程中如何交互的更深入理解方面提供洞察。在许多方面,这可以被视为您系统的一个近实时上下文映射可视化,以及组件之间是如何相互耦合的。从领域驱动设计(DDD)的角度来看,这可以在必要时提供对重新评估边界上下文边界的更深入见解。因此,我们强烈建议从一开始就轻松设置和配置分布式跟踪设备,以便无痛苦地完成。
版本化
当我们与单体应用程序一起工作时,大部分内容都捆绑成一个单一的统一单元。这意味着除了第三方依赖项之外,我们不必担心显式版本化我们的组件。然而,当我们开始将组件分解成它们各自的部署单元时,我们需要仔细注意我们的解决方案的组件、API 和数据元素是如何进行版本化的。让我们逐一来看。
组件
当我们创建组件时,有两个广泛的类别——那些独立部署的组件和那些嵌入在其他组件中的组件。对于可部署组件,需要使用显式版本来标识组件的具体实例,即使只是为了部署目的。对于嵌入式组件,同样需要使用显式版本,因为其他组件需要理解它们依赖于哪个实例。换句话说,所有组件都需要有一个版本来唯一标识它们。
因此,我们需要为我们的组件选择一个合理的版本化策略。我们推荐使用语义版本化(semver.org/),它使用一个版本标识符,该标识符采用三个数字组件,符合主版本.次版本.修补版本的方案:
-
主版本(MAJOR):当你进行向后不兼容的更改时进行增量。
-
次版本(MINOR):以向后兼容的方式添加功能时进行增量。
-
修补(PATCH):当你进行向后兼容的错误修复时进行增量。
此外,我们可以利用可选扩展来指示预发布和构建元数据。例如,我们组件的版本标识符可能为 3.4.1-RC1,以反映这是我们组件 3.4.1 版本的发布候选。使用标准的版本控制方案可以启用构建工具(如 Maven 和 Gradle)来声明对直接和间接依赖的细粒度升级规则和约束。这里的良好实践是声明不带版本的依赖项,并使用依赖管理(maven.apache.org/guides/introduction/introduction-to-dependency-mechanism.html#Dependency_Management)或依赖约束(https://docs.gradle.org/current/userguide/dependency_constraints.html#sec:adding-constraints-transitive-deps)来集中管理依赖组件的版本。
API
作为生产者,我们以多种方式公开 API。在这种情况下,我们特别指的是通过远程接口(如 HTTP、事件等)提供的 API。当涉及到 API 时,最重要的是保持消费应用的功能性。实现这一点的有效方法是从消费者的角度思考,并采用消费者驱动的契约(martinfowler.com/articles/consumerDrivenContracts.html)。
从消费者的角度来看,稳健性原则(Postel 定律)适用:发送时要保守,接受时要宽容。换句话说,当向提供者发送请求时,严格遵循生产者设定的约束。例如,不要在请求中发送意外数据。而当我们接收响应时,对来自生产者的内容要宽容。例如,只要所有期望的属性都存在,就可以忽略响应中的未知属性。这将允许生产者在不破坏现有消费者的情况下进行演变。
我们的建议是在尽可能长的时间内保持 API 无版本,通过继续维护向后兼容性。尽管我们付出了所有努力,但仍可能需要对我们 API 进行破坏性变更。破坏性变更包括以下内容:
-
删除/重命名一个或多个属性
-
更改一个或多个现有属性的类型
-
更改请求/响应的格式
在这种情况下,使用版本标识符来指示主要版本变更(例如,从 v2 到 v3)。常见的选项包括在 URI、标题或有效载荷中指定版本。但正如我们之前提到的,API 版本化需要谨慎使用。如果你发现自己经常需要引入不向后兼容的变更,这可能表明需求被误解,以及 DDD 原则是否真正得到应用。
数据
在定义明确的边界上下文中,我们不应再处于需要直接向消费者暴露数据的情况。然而,可能存在需要通过直接向消费者暴露数据来集成的情况。例如,我们可能需要为了分析目的而暴露一个报告数据库。我们为 API 概述的所有良好实践也适用于数据。
此外,从生产者的角度来看,将需要演进数据模式以适应不断变化的企业需求。当与关系型数据库一起工作时,使用如 Liquibase (liquibase.org/) 或 Flyway (flywaydb.org/) 这样的良好模式迁移工具可以大有裨益。NoSQL 数据库也有类似的工具,如 MongoBee (github.com/mongobee/mongobee) 和 Cassandra-Migration (cassandra.tools/cassandra-migration)。
在这个背景下,将数据视为产品并将产品思维应用于领域对齐的数据是相关的。有关如何从单体数据湖迁移到分布式数据网格的更多信息,请参阅这篇文章(martinfowler.com/articles/data-monolith-to-mesh.html#DomainDataAsAProduct)。
在可能需要支持给定组件、API 或数据的一个以上活动版本的情况下,这种情况并不少见。这可能会给解决方案增加显著程度的复杂性。为了控制复杂性,重要的是要为废弃和最终停止对旧版本的支持做出规定。
摘要
在本章中,我们探讨了纯粹超出功能需求方面的内容——每一方面都可能对我们有效应用领域驱动设计的能力产生深远影响。具体来说,我们探讨了这些方面的相互关系,以及为了实现和维持高水平成功,我们必须从整体上看待它们。
收尾思考
尽管领域驱动设计是在 2000 年代初构思的,但它远远领先于时代。我们现在处于解决最复杂问题的时代。鉴于技术的进步,人们期望以更快的速度构建这些解决方案。虽然解决方案的整体认知复杂性直接与问题的复杂性成正比,但我们需要有效地管理这种复杂性。DDD 及其原则通过将复杂问题分解为更小、更易于管理的部分,使我们能够实现这一点。在这本书中,我们试图提炼我们的经验,并提供一套具体的技术,以在您各自的上下文中应用 DDD。

订阅我们的在线数字图书馆,全面访问超过 7,000 本书和视频,以及领先的行业工具,帮助你规划个人发展并推进职业生涯。如需更多信息,请访问我们的网站。
第十三章:为什么订阅?
-
使用来自 4,000 多位行业专业人士的实用电子书和视频,节省学习时间,多花时间编码
-
通过为你量身定制的技能计划提高你的学习效果
-
每月免费获得一本电子书或视频
-
完全可搜索,便于轻松访问关键信息
-
复制粘贴、打印和收藏内容
你知道 Packt 为每本书都提供电子书版本,包括 PDF 和 ePub 文件吗?你可以在 packt.com 升级到电子书版本,并且作为印刷书客户,你有权获得电子书副本的折扣。如需了解更多详情,请联系我们 customercare@packtpub.com。
在 www.packt.com,你还可以阅读一系列免费的技术文章,订阅各种免费通讯,并享受 Packt 书籍和电子书的独家折扣和优惠。
你可能还会喜欢的其他书籍
如果你喜欢这本书,你可能还会对 Packt 的其他书籍感兴趣:

使用 Java 进行实战软件架构
朱塞佩·博诺科雷
ISBN: 978-1-80020-730-1
-
理解需求工程的重要性,包括功能需求与非功能需求
-
探索设计技术,如领域驱动设计、测试驱动开发(TDD)和行为驱动开发
-
发现选择现代应用程序正确架构模式的心法
-
探索不同的集成模式
-
使用必要的云原生模式和推荐的最佳实践增强现有应用程序
-
在不考虑架构选择和应用类型的情况下,解决企业应用程序中的跨领域问题
Java 完整编码面试指南
安格尔·列昂纳德
ISBN: 978-1-83921-206-2
-
高效解决最流行的 Java 编码问题
-
解决有助于你开发稳健且快速逻辑的挑战性算法
-
练习回答常见的非技术面试问题,这些问题可能会在通过和失败之间产生差异
-
从 Java 开发者的角度了解潜在雇主的期望
-
解决各种并发编程、函数式编程和单元测试问题
Packt 正在寻找像你这样的作者
如果您有兴趣成为 Packt 的作者,请访问authors.packtpub.com并今天申请。我们已与成千上万的开发者和技术专业人士合作,就像您一样,帮助他们将见解分享给全球科技社区。您可以提交一般申请,申请我们正在招募作者的特定热门话题,或者提交您自己的想法。
分享您的想法
现在您已经完成了《使用 Java 进行领域驱动设计 - 实践指南》,我们非常想听听您的想法!如果您在亚马逊购买了这本书,请点击此处直接跳转到该书的亚马逊评论页面,并分享您的反馈或在该购买网站上留下评论。
您的评论对我们和科技社区都非常重要,它将帮助我们确保我们提供的是高质量的内容。


浙公网安备 33010602011771号