原文:
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。"
代码块设置如下:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/preface_image.jpg
小贴士或重要提示
看起来像这样。
联系我们
我们始终欢迎读者的反馈。
一般反馈: 如果你对此书的任何方面有疑问,请通过 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 的《项目管理杂志》发布的项目成功报告,以下六个因素必须真实存在,一个项目才能被认为是成功的:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_01_Table_01.jpg
表 1.1 -- 项目成功因素
在应用所有这些标准来评估项目成功的情况下,大量项目由于各种原因而失败。让我们更详细地考察一些主要原因。
不准确的需求
PMI 的《职业脉搏》报告(2017 年)强调了一个非常明显的事实------绝大多数项目失败是由于不准确或误解的需求。因此,如果建成了错误的东西,那么就不可能建成客户可以使用、满意并且使他们工作更有效率的东西------更不用说项目能否按时并在预算内完成。
IT 团队,尤其是在大型组织中,由单一技能角色组成,如 UX 设计师、开发者、测试员、架构师、业务分析师、项目经理、产品所有者和业务赞助人。在许多情况下,这些人属于不同的组织单位/部门------每个单位/部门都有自己的优先级和动机。更糟糕的是,这些人之间的地理分隔还在不断加大。为了降低成本和最近的 COVID-19 生态系统也不利于解决这个问题。
![Figure 1.1 -- 隔离思维和信息保真度损失]
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_Figure_1.1.jpg
图 1.1 -- 隔离思维和信息保真度损失
所有这些都导致在装配线每个阶段的信息保真度下降,进而导致误解、不准确、延误,最终失败!
过多的架构
编写复杂的软件是一项相当艰巨的任务。你不能只是坐下来开始敲代码------尽管这种方法在某些简单情况下可能有效。在将业务理念转化为可工作的软件之前,对当前问题的彻底理解是必要的。例如,如果不了解信用卡是如何工作的,就不可能(或者至少非常困难)构建信用卡软件。为了传达你对问题的理解,在编写代码之前创建软件模型来表示问题及其解决方案的架构是很常见的。
努力创建一个完美的问题模型------在非常广泛的背景下都是准确的------并不亚于传说中的圣杯之旅。负责产生架构的人可能会陷入分析瘫痪和/或前期大设计,产生出过于高级、充满幻想、镀金、口号驱动或脱离现实世界的工件------而未能解决任何真正的业务问题。这种锁定在项目早期阶段,当团队成员的知识水平还在上升时,可能会特别有害。不用说,采用这种方法的项目的成功往往难以持续。
小贴士
要获取建模反模式的更全面列表,请参考 Scott W. Ambler 的网站(agilemodeling.com/essays/enterpriseModelingAntiPatterns.htm)和书籍,《敏捷建模:极限编程和统一过程的实用方法》,该书专门论述了这一主题。
架构过少
敏捷软件开发方法在 20 世纪 90 年代末和 21 世纪初显现出来,是对被称为"瀑布"的重量级流程的反应。这些流程似乎更倾向于前期的大规模设计和基于愿望、理想世界场景的抽象象牙塔思维。这是基于这样一个前提:提前深思熟虑可以避免在项目进展过程中出现严重的开发问题。
相比之下,敏捷方法似乎更倾向于一种更加灵活和迭代的软件开发方法,高度关注可工作的软件而不是其他工件,如文档。如今,大多数团队都声称在实践某种形式的迭代软件开发。然而,这种对声称符合特定敏捷方法论家族而不是基本原理的执着,导致许多团队误解了"足够的架构"与"没有明显架构"之间的区别。这导致了一种情况,即添加新功能或增强现有功能所需的时间比以前更长------这进而加速了解决方案的退化,变成了令人恐惧的"大泥球"(www.laputan.org/mud/mud.html#BigBallOfMud)。
过度的偶然复杂性
迈克·科恩(Mike Cohn)普及了测试金字塔的概念,他在其中谈到,大量的单元测试应该构成一个健全测试策略的基础------随着你向上移动金字塔,数字会显著减少。这里的逻辑是,随着你向上移动金字塔,维护成本急剧上升,而执行速度却大幅下降。然而,在现实中,许多团队似乎采用了与此完全相反的策略------被称为测试冰淇淋锥形,如图所示:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_Figure_1.2.jpg
图 1.2 -- 测试策略:期望与现实
测试冰淇淋锥形是弗雷德·布鲁克斯在其经典论文《没有银弹------软件工程中的本质与偶然》中提到的偶然复杂性的一个典型案例(worrydream.com/refs/Brooks-NoSilverBullet.pdf)。所有软件都有一定程度的本质复杂性,这是解决问题的固有属性。这在为非平凡问题创建解决方案时尤其如此。然而,偶然或意外的复杂性并不是直接归因于问题本身------而是由涉及人员的局限性、他们的技能水平、工具和/或使用的抽象造成的。不关注偶然复杂性会导致团队偏离关注真正的问题,解决这些问题可以提供最大的价值。因此,这样的团队成功的机会显著降低。
无法控制的债务
财务债务是指从外部借款以快速资助企业的运营------承诺及时偿还本金加上约定的利率。在适当的条件下,这可以显著加速企业的增长,同时允许所有者保留所有权、降低税收和较低的利率。另一方面,如果不能按时偿还这笔债务,可能会对信用评级产生不利影响,导致利率上升、现金流困难和其他限制。
技术债务是当开发团队采取可能不是最佳的行动来加速一组功能或项目的交付时产生的。在一段时间内,就像借款允许你比其他方式更快地做事一样,技术债务可以带来短期速度。然而,从长远来看,软件团队将不得不投入更多的时间和精力来简单地管理复杂性,而不是思考产生架构上合理的解决方案。这可能导致以下图中所示的恶性负面循环:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_Figure_1.3.jpg
图 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 的部分列表:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_Figure_1.4.jpg
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_Figure_1.4.jpg
图 1.4 -- 非功能性需求(NFRs)
用户很少会明确要求非功能性需求(NFRs),但他们几乎总是期望这些功能成为他们使用的任何系统的一部分。很多时候,系统可能在没有满足非功能性需求的情况下继续运行,但这会对用户体验的"质量"产生不利影响。例如,在低负载下加载时间不到 1 秒,而在高负载下加载时间超过 30 秒的网站主页,在压力时期可能无法使用。不用说,如果不以与显式、增值的功能特性相同的标准来对待非功能性需求,可能会导致无法使用的系统------进而导致失败。
在本节中,我们探讨了导致软件项目失败的一些常见原因。我们能否提高我们的胜算?在我们这样做之前,让我们看看现代软件系统的本质以及我们如何应对随之而来的复杂性。
现代系统和处理复杂性
我们不能用创造问题的同一层次的思维来解决我们的问题。
------ 阿尔伯特·爱因斯坦
正如我们在上一节中看到的,软件项目失败有几个原因。在本节中,我们将尝试理解软件是如何被构建的,目前存在的现实情况是什么,以及我们需要做出哪些调整来应对。
软件是如何被构建的
构建成功的软件是一个不断精炼知识和以模型形式表达的过程。我们试图在这里从高层次上捕捉这一过程的精髓:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_Figure_1.5.jpg
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_Figure_1.5.jpg
图 1.5 -- 开发软件是一个持续的知识和模型精炼过程
在我们将解决方案以工作代码的形式表达出来之前,有必要理解问题所包含的"什么",为什么这个问题需要解决,以及最后"如何"解决它。无论使用的方法论(瀑布、敏捷,以及两者之间的任何方法),构建软件的过程都是一个需要我们不断运用知识来精炼心理/概念模型,以便能够创造有价值的解决方案的过程。
复杂性是不可避免的
我们发现自己正处于第四次工业革命之中,世界正变得越来越数字化------技术成为企业价值的重要驱动力。正如摩尔定律所展示的,计算技术已经取得了指数级的进步:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_Figure_1.6.jpg
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_Figure_1.6.jpg
图 1.6 -- 摩尔定律
这也与互联网的兴起相吻合。
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_Figure_1.7.jpg
图 1.7 -- 全球互联网流量
这意味着公司需要比以往任何时候都要更快地现代化他们的软件系统。伴随着所有这些,商品计算服务的出现,如公共云,导致了从昂贵的集中式计算系统转向更分散的计算生态系统。当我们试图构建最复杂的解决方案时,单体正在被分布式、协作的微服务环境所取代。现代哲学和实践,如自动化测试、架构适应性函数、持续集成、持续交付、DevOps、安全自动化和基础设施即代码等,正在颠覆我们交付软件解决方案的方式。
所有这些进步都引入了自己的复杂性。而不是试图控制复杂性的数量,我们需要接受并应对它。
优化反馈循环
随着我们进入遇到最复杂商业问题的时代,我们需要拥抱新的思维方式、发展哲学和一系列技术,以迭代地进化成熟的软件解决方案,这些解决方案将经受住时间的考验。我们需要更好的沟通方式、分析问题、达成共识、创建和建模抽象,然后实施和增强解决方案。
明白地说------我们都在用看似绝妙的商业理念一边构建软件,另一边则是我们不断要求苛刻的客户,正如这里所示:
![图 1.8 -- 软件交付连续体
![图片/B16716_Figure_1.8.jpg]
图 1.8 -- 软件交付连续体
在此过程中,我们需要跨越两个鸿沟------交付管道 和反馈管道。交付管道使我们能够将软件交到客户手中,而反馈管道则允许我们调整和适应。正如我们所见,这是一个连续体。如果我们想要构建更好、更有价值的软件,这个连续体,这个可能无限循环的过程,必须得到优化!
为了优化这个循环,我们需要三个特征同时存在:我们需要快速、我们需要可靠,并且我们需要不断地重复这样做。换句话说,我们需要快速、可靠和可重复------同时!去掉任何一个,它都无法持续。
领域驱动设计(DDD)承诺以系统化的方式提供答案。在接下来的章节中,以及本书的其余部分,我们将探讨领域驱动设计(DDD)是什么,以及为什么在为当今大规模分布式团队和应用程序中的非平凡问题提供解决方案时,它是不可或缺的。
什么是领域驱动设计(DDD)?
生活其实很简单,但我们却坚持让它变得复杂。
------ 孔子
在上一节中,我们看到了众多原因以及系统复杂性是如何阻碍软件项目成功的。DDD(领域驱动设计)的概念,最初由埃里克·埃文斯在其 2003 年的著作中提出,是一种专注于以模型的形式表达软件解决方案的软件开发方法,该模型紧密体现了所解决问题的核心。它提供了一套原则和系统化的技术,以分析、设计和实现软件解决方案,从而提高成功的可能性。
虽然埃文斯的工作确实是开创性的、突破性的,并且远远领先于其时代,但它并不具有规范性。这实际上是一种优势,因为它使得 DDD 的演变超越了埃文斯当时所构想的。另一方面,这也使得定义 DDD 实际上包含的内容变得极其困难,使得实际应用成为一个挑战。在本节中,我们将探讨 DDD 背后的某些基础术语和概念。这些概念的详细阐述和实际应用将在本书的后续章节中展开。
当遇到复杂的企业问题时,DDD 建议采取以下措施:
-
理解问题 :为了对问题有一个深入、共享的理解,商业和技术专家需要紧密合作。在这里,我们共同理解问题的本质以及为什么解决问题是有价值的。这被称为问题的领域。
-
将问题分解为更易管理的部分 :为了保持复杂性在可管理的水平,将复杂问题分解为更小、可独立解决的组成部分。这些部分被称为子领域 。如果子领域仍然过于复杂,可能还需要进一步分解子领域。为每个子领域分配明确的边界以限制其功能。这个边界被称为该子领域的边界上下文。也可能方便地将子领域视为对领域专家(在问题空间中)更有意义的概念,而边界上下文是对技术专家(在解决方案空间中)更有意义的概念。
-
对于这些边界上下文中的每一个,都要做以下事情:
-
达成共识的共享语言:通过建立一个适用于子领域范围内的明确共享语言来正式化理解。这种共享语言被称为领域的通用语言。
-
在共享模型中表达理解 :为了生成可工作的软件,将通用语言以共享模型的形式表达出来。这个模型被称为领域模型。可能存在多个这种模型的变体,每个变体旨在阐明解决方案的特定方面,例如,流程模型、序列图、工作代码和部署拓扑。
-
拥抱问题的偶然复杂性:需要注意的是,无法回避给定问题的本质复杂性。通过将问题分解为子域和边界上下文,我们试图将其(或多或少)均匀地分布在更易于管理的部分。
-
持续进化以获得更深入的洞察:重要的是要理解,之前的步骤并不是一次性的活动。企业、技术、流程以及我们对这些的理解都在不断进化,因此,我们的共同理解需要通过持续的重构与这些模型保持同步。
-
这里展示了 DDD 本质的图示表示:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_Figure_1.9.jpg
图 1.9 -- DDD 的本质
我们认识到,这只是一个关于领域驱动设计(DDD)主题的快速介绍。
使用战略设计理解问题
在本节中,让我们揭开在使用 DDD 时一些常用概念和术语的神秘面纱。首先,我们需要理解我们所说的第一个 D ------ 领域。
什么是领域?
在使用领域驱动设计(DDD)时,基础概念是领域这一概念。但领域究竟是什么?这个词"领域"起源于 17 世纪,源自古老的法语单词 domaine (权力)和拉丁语单词 dominium(财产、所有权),是一个相当令人困惑的词。根据谁、何时、何地以及如何使用,它可以有不同的含义。
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_Figure_1.10.jpg
图 1.10 -- 领域的含义随上下文而变化
然而,在商业的背景下,这个词"领域"涵盖了其主要活动的整体范围------它向客户提供的服务。这也被称为问题域。例如,特斯拉在电动汽车领域运营,Netflix 提供在线电影和电视节目,麦当劳提供快餐。一些公司,如亚马逊,在多个领域提供服务------在线零售和云计算等。一个企业的领域(至少是成功的那些)几乎总是包含相当复杂和抽象的概念。为了应对这种复杂性,通常将这些领域分解成更易于管理的部分,称为子域。接下来,让我们更详细地了解子域。
什么是子域?
在本质上,DDD 提供了解决复杂性的方法。工程师通过将复杂问题分解为更易于管理的子域来实现这一点。这有助于更好地理解,并使找到解决方案变得更加容易。例如,在线零售领域可以划分为如产品、库存、奖励、购物车、订单管理、支付和运输等子域,如下面的图所示:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_Figure_1.11.jpg
图 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 可能选择在特定子领域专业化,而其他人可能对整个业务如何运作有更广泛的理解。
-
技术专家:另一方面,喜欢解决具体、可量化的计算机科学问题。通常,技术专家并不觉得了解他们所在业务的环境值得他们投入精力。相反,他们似乎过于渴望只提升他们在学术界学习延续的技术技能。
当领域专家指定"为什么"和"是什么"时,技术专家(软件工程师)主要帮助实现"如何"。两组之间的强大合作和协同作用对于确保持续的高性能和成功至关重要。
来自语言的分歧
虽然这些团队之间的紧密合作是必要的,但重要的是要认识到,这些人似乎有截然不同的动机和思维方式。表面上,这似乎仅限于他们日常语言中的差异。然而,更深入的分析通常揭示出在目标、动机等方面存在更大的分歧。这一点在本图中有体现:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_Figure_1.15.jpg
图 1.15 -- 语言起源的划分
但这本书主要关注技术专家。我们的观点是,仅仅通过解决技术难题而不对基础商业环境有深入理解,是不可能取得成功的。
我们对组织的每一个决策,无论是需求、架构还是代码,都会产生商业和用户后果。为了有效地构思、设计、构建和演进软件,我们的决策需要帮助创造最佳的商业影响。正如之前提到的,这只能在我们对要解决的问题有清晰理解的情况下实现。这使我们认识到,在解决问题的解决方案中存在两个截然不同的域。
注意
在这个上下文中,使用"域"一词是抽象意义上的,不要与之前介绍的商业域概念混淆。
问题域
这是一个术语,用于捕捉仅定义问题而有意避免任何解决方案细节的信息。它包括诸如为什么 我们试图解决问题、我们试图实现什么 以及如何解决 等细节。重要的是要注意,为什么 、是什么 和如何是从客户/利益相关者的角度出发,而不是从提供软件解决方案的工程师的角度出发。
考虑一个零售银行的例子,该银行已经为其客户提供支票账户功能。他们希望获得更多流动资金。他们需要鼓励客户保持更高的账户余额来实现这一点。他们正在寻求推出一个名为高级支票账户 的新产品,该产品具有更高的利率、透支保护和免费 ATM 访问等附加功能。以下是以为什么 、是什么 和如何的形式表达的问题域:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_01_Table_02.jpg
表 1.2 -- 问题域:为什么、是什么以及如何
现在我们已经定义了问题和与之相关的动机,让我们来看看它如何能指导解决方案。
解决方案域
术语,用于描述解决方案开发的环境。换句话说,将需求转化为工作软件的过程(这包括设计、开发、测试和部署)。在这里,重点是如何从软件实现的角度解决被解决的问题。然而,如果没有对"为什么"和"是什么"的理解,很难找到解决方案。
建立在之前的优质支票账户示例之上,这个问题的代码级解决方案可能看起来像这样:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/ch1-1.jpg
这可能看起来是从问题域描述到问题的一个重大飞跃,确实如此。在达到这样的解决方案之前,可能需要存在多个级别的问题细化。这个过程通常是混乱的,可能会导致对问题的理解不准确,从而导致一个可能很好(例如,从工程、软件架构的角度来看是合理的)但不是解决当前问题的解决方案。让我们看看我们如何通过缩小问题和解决方案域之间的差距来不断细化我们的理解。
使用通用语言促进共同理解
以前,我们看到了组织壁垒如何导致有价值的信息被稀释。在我曾经工作的一家信用卡公司,塑料、支付工具、账户、PAN (主要账户号码 )、BIN (银行识别号码 )和卡片这些词都被不同的团队成员用来指代同一个东西------信用卡 ------当他们在应用的同区域内工作时。另一方面,像用户 这样的术语有时会被用来指代客户、关系经理或技术客户支持员工。更糟糕的是,很多这些混乱的术语也被应用到代码中。虽然这可能看起来是一件微不足道的事情,但它有着深远的影响。产品专家、架构师和开发者来了又去,每个人都逐渐增加了更多的混乱、混乱的设计、实施和技术债务,加速了走向令人恐惧的、难以维护的大泥球(www.laputan.org/mud/)的过程。
DDD 倡导打破这些人为的障碍,通过共同努力创建 DDD 所说的通用语言 ------一个共享的术语、单词和短语词汇,以不断增进整个团队的理解。这种说法随后被积极用于解决方案的各个方面:日常词汇、设计、代码------简而言之,由每个人 和每个地方使用。一致地使用这种常见的通用语言有助于加强共同的理解,并产生更好地反映领域专家心智模型的解决方案。
领域模型和解决方案的演变
通用语言有助于在团队成员之间建立一致、尽管是非正式的术语。为了提高理解,这可以进一步细化为一套正式的抽象------一个领域模型,用于在软件中表示解决方案。当我们面临问题时,我们无意识地试图形成潜在解决方案的心理表征。此外,这些表征(模型)的类型和性质可能因我们的问题理解、背景和经验等因素而大相径庭。这意味着这些模型的不同是自然的。例如,同一问题可能由不同的团队成员以不同的方式思考,如下所示:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_Figure_1.16.jpg
图 1.16 -- 多个模型表示解决方案
如此所示,业务专家可能会考虑流程模型,而测试工程师可能会考虑异常和边界条件,以制定测试策略等。
备注
图 1.16 描述了多个模型的存在。可能还有其他视角,例如客户体验模型和信息安全模型,这些并未在图中展示。
应当始终注意专注于解决当前的业务问题。如果团队在建模业务逻辑和技术解决方案方面投入相同数量的努力,那么他们将得到更好的服务。为了控制偶然的复杂性,最好将解决方案的基础设施方面与该模型隔离开来。这些模型可以采取多种形式,包括对话、白板会议、文档、图表、测试以及其他形式的架构健康函数。还应注意,这不是一次性的活动。随着业务的演变,领域模型和解决方案需要保持同步。这只能通过领域专家和开发者之间的紧密合作来实现。
领域模型和边界上下文的范围
在创建领域模型时,一个常见的困境在于决定如何限制这些模型的范围。你可以尝试创建一个单一的领域模型,作为整个问题的解决方案。另一方面,我们可能选择创建极其细粒度的模型,这些模型在没有对其他模型有强烈依赖的情况下无法有意义地存在。每种方法都有其优缺点。无论情况如何,每个解决方案都有一个范围------它被限制在一定的边界内。这个边界被称为边界上下文。
在子域和有界上下文这两个术语之间似乎存在很多混淆。它们之间的区别是什么?结果是,子域是问题空间的概念,而有界上下文是解决方案空间的概念。这最好通过一个例子来解释。让我们考虑一个虚构的 Acme 银行,它提供两种产品:信用卡和零售银行。这可能分解为以下子域,如图所示:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_Figure_1.17.jpg
](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
](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
](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聚合的例子:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_Figure_1.23.jpg
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_Figure_1.23.jpg
图 1.23 -- CheckingAccount 聚合的简单表示
注意CheckingAccount是如何由AccountHolder和Transaction实体以及其他事物组成的。在这个例子中,让我们假设透支功能(保持负账户余额的能力)仅适用于currentBalance需要以唯一的Transaction形式出现------无论其结果如何。因此,CheckingAccount聚合使用Transaction实体。尽管Transaction具有作为其接口一部分的approve和reject方法,但只有聚合可以访问这些方法。通过这种方式,聚合强制执行业务不变性,同时保持高度的封装。tryWithdraw方法的一个潜在实现如下所示:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/ch1-2.jpg
-
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)
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 的能力的相对放缓相吻合,这意味着转向更多去中心化的解决问题方法。
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_Figure_1.25.jpg
图 1.25 -- 全球信息存储容量
DDD(领域驱动设计),通过将难以驾驭的单一实体分解为更易于管理的子域和边界上下文的形式来处理复杂性,自然地融入了这种编程风格。因此,当我们在构建现代解决方案时,对采用 DDD 原则和技术的兴趣重新焕发并不令人惊讶。正如埃里克·埃文斯所说,DDD 现在比最初构想时更加相关!
摘要
在本章中,我们探讨了软件项目失败的一些常见原因。我们看到了不准确或误解的需求、架构(或缺乏架构)以及过度的技术债务是如何阻碍实现商业目标和成功的。
我们探讨了 DDD 的基本构建块,如领域、子域、通用语言、领域模型、边界上下文和上下文图。我们还考察了为什么 DDD 的原则和技术在现代微服务和无服务器时代仍然非常相关。你现在应该能够欣赏 DDD 的基本术语,并理解为什么它在当今的背景下很重要。
在下一章中,我们将更深入地探讨 DDD 的实际运作机制。我们将深入研究 DDD 的战略和战术设计元素,并探讨如何使用这些元素来帮助形成更好的沟通和更稳健的设计基础。
进一步阅读
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_01_Table_03a.jpghttps://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_01_Table_03b.jpg
第二章:DDD 在哪里和如何适用?
"如果我们被目的所吸引,我们就不会被比较所分散。"
------鲍勃·戈夫
软件架构指的是软件系统的基本结构以及创建这些结构和系统的学科。多年来,我们积累了一系列架构风格和编程范式,以帮助我们处理系统复杂性。
在本章中,我们将探讨如何将领域驱动设计(DDD)应用于与这些架构风格和编程范式相辅相成的模式。我们还将探讨在构建软件解决方案时,它如何/在哪里融入整体方案。
在本章中,我们将涵盖以下主题:
-
架构风格
-
编程范式
-
应该选择哪种范式?
到本章结束时,你将能够欣赏到各种架构风格和编程范式的优点,以及在使用它们时需要注意的一些陷阱。你还将了解领域驱动设计(DDD)在增强这些架构中的作用。
架构风格
领域驱动设计以战略和战术设计元素的形式提供了一套架构原则。这使得你能够将大型、可能难以管理的业务子域分解成良好设计的、独立的边界上下文。
DDD 的一个巨大优势是它不要求使用任何特定的架构。然而,在过去几年中,软件行业已经使用了大量的架构风格。让我们看看 DDD 如何与一系列流行的架构风格结合使用,以得出更好的解决方案。
分层架构
分层架构 是最常见的架构风格之一,解决方案通常组织为四个广泛的类别:表示层 、应用层 、领域层 和持久层。每一层都为其代表的特定关注点提供了解决方案,如图所示:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_Figure_2.1.jpg
图 2.1 -- 分层架构的本质
分层架构背后的主要思想是关注点的分离------层与层之间的依赖关系是单向的(从上到下)。例如,领域层可以依赖于持久层,但不能反过来。此外,任何给定的层通常只访问其下方的层,而不会绕过中间的层。例如,表示层可能只能通过应用层访问领域层。
这种结构使得层与层之间的耦合更加松散,并允许它们相互独立地发展。分层架构的理念与 DDD 的战略和战术设计元素非常契合,如图所示:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_Figure_2.2.jpg
图 2.2 -- 分层架构映射到 DDD 的战术设计元素
DDD 积极推广使用分层架构,主要是因为它使得可以独立于其他关注点(如信息如何显示、端到端流程如何管理以及数据如何存储和检索)专注于领域层。从这个角度来看,自然应用 DDD 的解决方案往往也是分层的。
显著的变体
分层架构的一种变体是由 Alistair Cockburn 发明的,他最初称之为六边形架构(alistair.cockburn.us/hexagonal-architecture/),也称为端口和适配器架构。这种风格背后的想法是避免层与层之间(在分层架构中可能会发生)的不自觉依赖,特别是系统核心与外围层之间的依赖。
这里的主要思想是在核心中仅使用接口(端口)来启用现代驱动程序,例如测试和松散耦合。这使得核心可以独立于非核心部分和外部依赖进行开发和演进。通过端口的具体实现(适配器)与数据库、文件系统、网络服务等现实世界组件的集成得以实现。在核心中使用接口使得在隔离系统其他部分的情况下对核心进行测试变得容易得多,可以使用模拟和存根。在端到端环境中与真实系统一起工作时,也常见使用依赖注入框架动态替换这些接口的实现。这里展示了六边形架构的视觉表示:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_Figure_2.3.jpg
图 2.3 -- 六边形架构
结果表明,在这个上下文中使用"六边形"一词纯粹是为了视觉目的------并不是要将系统限制为恰好六种类型的端口。
与六边形架构类似,洋葱架构(jeffreypalermo.com/2008/07/the-onion-architecture-part-1/),由 Jeffrey Palermo 构想,其基础是在核心中创建一个基于独立对象模型的应用程序,它可以独立于外部层进行编译和运行。这是通过在核心中定义接口(六边形架构中的端口)并在外部层实现它们(六边形架构中的适配器)来实现的。从我们的角度来看,六边形和洋葱架构风格没有我们能够识别的明显差异。
这里展示了洋葱架构的视觉表示:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_Figure_2.4.jpg
图 2.4 -- 洋葱架构
另一种流行的分层架构变体,由罗伯特·C·马丁(亲切地称为 Uncle Bob)推广,是清洁架构。这是基于遵循 SOLID 原则(blog.cleancoder.com/uncle-bob/2020/10/18/Solid-Relevance.html),这也是他提出的。这里的基本信息(就像六边形和洋葱架构的情况一样)是避免核心(即包含业务逻辑的核心)与其他易变层(如框架、第三方库、UI 和数据库)之间的依赖关系。
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_Figure_2.5.jpg
图 2.5 -- 清洁架构
所有这些架构风格都与 DDD(领域驱动设计)为核心子域(以及由此扩展的边界上下文)独立于系统其余部分开发领域模型的想法相辅相成。
虽然每种架构风格都在如何构建分层架构方面提供了额外的指导,但我们选择的任何架构方法都伴随着其自身的权衡和限制,您需要对此有所认识。我们将在下一小节中讨论一些这些考虑因素。
层蛋糕反模式
坚持一组固定的层提供了一定程度的隔离,但在更简单的情况下,它可能证明是过度杀鸡用牛刀,除了遵守约定的架构指南外,没有增加任何可感知的好处。在层蛋糕反模式中,每个层只是代理对下层层的调用,而没有增加任何价值。以下示例说明了这种相当常见的场景:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_Figure_2.6.jpg
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_Figure_2.6.jpg
图 2.6 -- 通过 ID 查找实体表示的层蛋糕反模式示例
在这里,findById方法在每个层都被复制,并简单地调用下一层中相同名称的方法,没有任何额外的逻辑。这给解决方案引入了意外复杂性。为了标准化目的,层中的某些冗余可能是不可避免的。如果代码库中"层蛋糕"出现明显,最好重新审视分层指南。
贫弱转换
我们常见的一种层蛋糕变体是,层拒绝在更高隔离和更松耦合的名义下共享输入和输出类型。这使得在每个层的边界执行转换成为必要。如果被转换的对象在结构上大致相同,我们就有了一个之前讨论过的findById示例。
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_Figure_2.7.jpg
图 2.7 -- 贫弱转换反模式示例
在这种情况下,每一层定义了自己的实体类型,需要在每一层之间进行类型转换。更糟糕的是,实体类型的结构可能会有看似微小的变化(例如,lastName被称为surname)。虽然这种转换在有限范围内可能是必要的,但团队应努力避免在单个有限范围内同一概念名称和结构的变化。有意使用通用语言有助于避免此类情况。
层绕过
当与分层架构一起工作时,从严格限制层只与直接下方的层交互开始是合理的。正如我们之前看到的,这种严格的执行可能导致无法容忍的意外复杂性,尤其是在将它们普遍应用于大量用例时。在这种情况下,有意识地允许一个或多个层被绕过可能是有价值的。
例如,控制器层可能被允许直接与仓储层工作而不使用服务层。在许多情况下,我们发现使用一套独立的规则来区分命令 和查询作为起点是有用的。
这可能是一个滑稽的斜坡。为了继续维持一定的理智,团队应考虑使用轻量级的架构治理工具,如ArchUnit (www.archunit.org/),以使协议明确并提供快速反馈。这里展示了如何使用 ArchUnit 实现此目的的简单示例:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/ch2-1.jpg
仓储层可以被服务和控制器层访问 -- 有效地允许控制器绕过使用服务层。
垂直切片架构
分层架构及其变体为如何构建复杂应用程序提供了合理的指导。由 Jimmy Boggard 倡导的垂直切片架构认识到,在整个应用程序的所有用例中采用标准的分层策略可能过于僵化。
此外,值得注意的是,不能通过单独实现这些水平层来获得业务价值。这样做只会导致无法使用的库存和大量的不必要的上下文切换,直到所有这些层都连接起来。因此,垂直切片架构建议最小化切片之间的耦合,并最大化切片内的耦合 (jimmybogard.com/vertical-slice-architecture/),如图所示:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_Figure_2.8.jpg
图 2.8 -- 垂直切片架构
在此示例中,下单 可能需要我们通过应用层与其他组件进行协调,并在 ACID 事务的范围内应用复杂业务不变性。同样,取消订单 可能需要在 ACID 事务内应用业务不变性,而不需要任何额外的协调------在这种情况下,无需应用层。然而,搜索订单可能只需要我们从查询优化的视图中检索现有数据。这种风格利用了"按需分配"的方法来分层,这可能在实现纯分层架构时帮助缓解之前列出的某些反模式。
考虑事项
垂直切片架构在实现解决方案时提供了很大的灵活性------考虑到正在实施的使用案例的具体需求。然而,如果没有一定程度的治理,这可能会迅速演变成一团糟,分层决策似乎是基于个人偏好和经验(或缺乏经验)任意做出的。作为一个合理的默认选项,您可能希望考虑为命令和查询使用不同的分层策略。除此之外,非功能性需求可能规定了您可能需要如何偏离这里。例如,您可能需要绕过某些层以满足某些用例的性能服务级别协议(SLA)。
当实用地使用垂直切片架构时,它确实使您能够在每个或一组相关的垂直切片中非常有效地应用领域驱动设计(DDD)------允许它们被视为边界上下文。以下展示了使用下单和取消订单示例的两个可能性:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_Figure_2.9.jpg
图 2.9 -- 用于演进边界上下文的垂直切片
在前面图表中的 (i) 示例中,下单和取消订单各自使用不同的领域模型,而在 (ii) 示例中,这两个用例共享一个共同的领域模型,并且由此扩展,成为同一个边界上下文的一部分。这确实为在用例边界采用无服务器架构时切片功能铺平了道路。
面向服务架构(SOA)
面向服务架构 (SOA)是一种架构风格,其中软件组件通过标准化的接口(例如 SOAP、REST 和 gRPC 等)暴露(可能)可重用的功能。使用标准化接口(如 SOAP、REST 和 gRPC 等)可以在集成异构解决方案时实现更简单的互操作性,如下所示:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_Figure_2.10.jpg
图 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 的扩展 -- 其中重点在于创建专注于执行有限数量事情并正确执行它们的组件。《构建微服务》一书的作者山姆·纽曼将微服务定义为小型化 、独立部署的组件,它们维护自己的状态,并且围绕业务领域建模。这提供了采用针对特定解决方案的"按需定制"方法、限制爆炸半径、提高生产力和速度、自主跨职能团队等好处。
微服务通常作为一个整体存在,共同协作以实现预期的业务成果,如图所示:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_Figure_2.11.jpg
图 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 中,我们创建不同的模型来处理更新(命令)和查询。这将在以下图中展示:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_Figure_2.12.jpg
图 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 呢?让我们看一个简单的例子:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/ch2-2.jpg
面向对象纯粹主义者会迅速指出PasswordService是过程式的,可能需要一个Password类来封装相关行为。同样,DDD 爱好者可能会指出这是一个贫血的领域模型实现。一个可能更好的面向对象版本可能看起来像以下这样:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/ch2-3.jpg
在这种情况下,Password 类停止暴露其内部结构,并以行为(isStrong 和 isWeak 方法)的形式暴露了强密码或弱密码的概念。从面向对象的角度来看,第二种实现可能更优越。如果是这样,我们是否应该始终使用面向对象的版本?实际上,答案是有细微差别的,这取决于消费者在特定情境下的需求以及普遍使用的语言。如果 Password 的概念在领域内广泛使用,那么在实现中引入这样的概念可能是合理的。如果不是,第一种解决方案可能就足够了,即使它似乎违反了面向对象的封装原则。
我们默认的立场是将良好的面向对象实践作为起点。然而,更重要的是要反映领域语言,而不是教条地应用面向对象。因此,如果在这种情况下这样做显得不自然,我们愿意在面向对象的纯粹性上做出妥协。如前所述,清楚地传达做出此类决策的理由可以走很长的路。
函数式编程
函数是代码组织的基本构建块,存在于所有高级编程语言中。函数式编程是一种编程范式,其中程序通过应用和组合函数来构建。这与使用语句来改变程序状态的命令式编程形成对比。最显著的区别源于函数式编程避免了命令式编程中常见的副作用。纯函数式编程完全防止副作用并强制不可变性。在设计领域模型时采用函数式风格,使其更具声明性,可以更清晰地表达意图,同时保持简洁。它还使我们能够通过使用更简单的概念来组合更复杂的概念,从而控制复杂性。函数式实现使我们能够使用更接近问题域的语言,同时也有简洁的附加好处。考虑一个简单的例子,我们需要使用函数式风格在所有仓库中找到库存最少的物品,如下所示:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/ch2-4.jpg
这里展示的命令式风格确实完成了工作,但可能更加冗长且难以理解,有时甚至对技术团队成员来说也是如此!
这里有一个命令式的例子:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/ch2-5.jpg
从领域驱动设计(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/)。就个人而言,我们喜欢先详细阐述问题,然后再转向画布的其他方面。商业模式画布和精益画布都提供了对商业模式、高优先级问题和潜在解决方案的高级视图。接下来,让我们看看影响图,这是一种基于思维导图的轻量级规划技术,旨在制定以结果为导向的计划。
影响图
影响图是一种可视化和战略规划工具,它使您能够理解范围和潜在假设。它是由高级技术和业务人员通过考虑以下四个方面共同创建的,以思维导图的形式:
-
目标 :为什么我们要做这件事?
-
参与者 :我们的产品消费者或用户是谁?换句话说,谁会受到它的影响?
-
影响 :消费者行为的变化如何帮助我们实现目标?换句话说,就是我们试图创造的影响?
-
交付成果:作为组织或交付团队,我们能做什么来支持所需的影响?换句话说,作为解决方案的一部分需要实现哪些软件功能或流程变更?
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_Figure_3.3.jpg
图 3.3 -- 一个简单的影响图
影响图提供了一种易于理解的视觉表示,展示了目标、用户和交付成果之间的影响关系。接下来,让我们来探讨沃德利图,它使我们能够更深入地了解我们的目的,并确定哪些业务部分提供了最大的价值。
沃德利图
商业模式画布和精益画布可以帮助在较高层次上建立目的的清晰性。沃德利图是另一个帮助构建商业策略和建立目的的工具。它提供了一个系统为谁而建的草图,然后是系统为他们提供的利益,以及提供这些利益所需的需求链(称为价值链)。接下来,价值链沿着一个进化轴进行绘制,该轴的范围从未探索和不确定到高度标准化。构建沃德利图可以通过六个步骤完成:
-
目的:你的目的是什么?为什么组织或项目存在?
-
范围:地图(范围)包括什么(以及不包括什么)?
-
用户:谁使用或与你所绘制的物品互动?
-
用户需求:你的用户需要从你所绘制的物品中获得什么?
-
价值链:为了满足之前捕获的需求,我们需要做什么?这些需求根据其依赖关系排列,从而创建了一个价值链,将用户需求映射到一系列按用户可见性顺序排列的活动(从最明显到最不明显)。
-
绘制地图:最后,使用进化特征绘制地图,以决定将每个组件放置在水平轴的哪个位置。
我们在 KP 银行进行了沃德利图绘制练习,以展示他们的国际贸易业务,如下所示:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_Figure_3.4.jpg
图 3.4 -- KP 银行国际贸易业务的沃德利图
注意
在这个画布上,我们为了简洁起见,只详细阐述了某一类用户(进口商和出口商)的需求。在现实世界的场景中,我们不得不为所有类型的用户重复步骤 4、5 和 6。
沃德利图使得理解我们解决方案提供的功能、它们的依赖关系以及价值是如何产生的变得容易。它还帮助描绘了这些功能与竞争对手提供的功能相比的表现,使你能够适当地优先考虑关注点并做出构建或购买的决定。
我们已经检查了多种轻量级和协作技术,以快速了解问题空间以及我们对我们用户和业务可能产生的影响。这些技术中的每一种都相当轻量,可以在几个小时内完成。每一种都能让我们专注于最有影响力的业务领域,并最大化投资回报率。根据我们的经验,尝试这些练习中的多个(甚至所有)都是值得的,因为每个练习都可以突出业务/用户需求的不同方面。
国际贸易产品和服务的
国际贸易充满风险,这导致卖方(出口商)和买方(进口商)之间支付时间的确定性程度较高,尤其是在涉及各方之间缺乏信任的情况下。对于出口商来说,在收到付款之前,所有销售都是礼物。因此,出口商更喜欢在订单下单后或至少在货物发货前收到付款。对于进口商来说,在收到货物之前,所有为购买支付的款项都是捐赠。因此,进口商更喜欢尽快收到货物,并推迟付款,直到货物转售以筹集足够的资金来支付卖方。
这种情况为可信赖的中间机构(如 KP 银行)提供了一个机会,在安全的方式下在国际贸易交易中发挥重要作用。KP 银行提供了一系列产品来促进国际贸易支付,如下所示:
-
信用证(LC)
-
跟单托收(DC)
-
赊账
-
预付款
-
寄售
下面的图表显示了从出口商和进口商的角度来看,这些支付方式各自的风险特征:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_Figure_3.5.jpg
图 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 工作坊来捕捉当前的业务流程。以下是一个这样的对话摘录,对话双方是凯蒂 ,领域专家 ,和帕特里克 ,软件开发者:
帕特里克 :你能给我一个典型的信用证流程的概述吗?
凯蒂 :当然,一切始于进口商和出口商签订购买商品或服务的合同。
帕特里克 :这份合同的形式是什么?是正式文件条款吗?还是这只是个对话?
凯蒂 :这只是一个对话。
帕特里克 :哦,明白了。对话涵盖了什么内容?
凯蒂 :有几个方面------商品的性质和数量、定价细节、付款条款、运输成本和时间表、保险和保修等。这些细节可以包含在采购订单中------这是一个简单的文件条款,详细说明了上述内容。
此时,帕特里克绘制了进口商和出口商之间的互动部分。这个图形在以下图表中展示:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_04_02.jpg
图 4.2 -- 进口商和出口商之间的互动
帕特里克 :这似乎很简单,那么银行在其中的作用是什么?
凯蒂 :这是一项国际贸易,进口商和出口商都需要减轻这种商业交易中涉及到的财务风险。因此,他们需要一家银行作为可信赖的中介。
帕特里克 :这是什么类型的银行?
凯蒂 :通常涉及多个银行。但一切始于一个 开证行 *。
帕特里克 :开证行是什么?
凯蒂 :任何被授权调解国际贸易交易的银行。这必须是进口国的一家银行。
帕特里克 :进口商需要与这家银行有现有关系吗?
凯蒂 :不一定。进口商可能与其他银行有关系------这些银行反过来会代表进口商与开证行联络。但为了简单起见,让我们假设进口商与开证行有现有关系------在这种情况下,就是我们的银行。
帕特里克 :进口商需要向开证行提供采购订单的详情以开始吗?
凯蒂 :是的。进口商通过提交 信用证申请 来提供交易详情。
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_04_03.jpg
图 4.3 -- 介绍信用证和开证行
帕特里克 :开证行在收到这份信用证申请时会做什么?
凯蒂 :主要有两点------核实进口商的财务状况和进口货物的合法性。
帕特里克 :好的。如果一切都检查无误,会发生什么?
凯蒂 :开证行批准信用证并通知进口商。
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_04_04.jpg
图 4.4 -- 通知进口商信用证批准
帕特里克 :接下来会发生什么?开证行现在会联系出口商吗?
凯蒂 :还没有。这并不简单。开证行只能与出口国的一家对应银行交易。这家银行被称为 通知行 。
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_04_05.jpg
图 4.5 -- 介绍通知行
帕特里克 :通知行做了什么?
凯蒂 :通知行通知出口商关于信用证的信息。
帕特里克 :进口商不需要知道信用证已通知吗?
凯蒂 :是的。开证行通知进口商,信用证已通知出口商。
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_04_06.jpg
图 4.6 -- 建议通知给进口商
帕特里克 :出口商如何知道如何进行操作?
凯蒂 :通过通知行------他们通知出口商信用证已发行。
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_04_07.jpg
图 4.7 -- 将建议发送给出口商
帕特里克 :出口商现在就开始发货,他们如何收款?
凯蒂 :通过通知行------他们通知出口商信用证已发行,这触发了流程中的下一步------这个过程称为结算*。但让我们现在专注于发行。我们将在稍后讨论结算。*
我们现在已经查看了一个典型的 DST 工作坊的摘录。它提供了一个相当好的对高级业务流程的理解。请注意,在过程中我们没有引用任何技术工件。
为了细化这个流程并将其转换为可用于设计软件解决方案的形式,我们需要进一步改进这个视图。在下一节中,我们将使用EventStorming作为一种结构化的方法来实现这一点。
EventStorming
"驳斥胡说八道的能量比产生它的能量大一个数量级。"
-- 阿尔贝托·布兰多利尼
介绍 EventStorming
在上一节中,我们获得了对信用证发行流程的高级理解。为了能够构建一个现实世界的应用,使用一种深入到下一级细节的方法是有帮助的。EventStorming,最初由阿尔贝托·布兰多利尼构想,就是这样一种用于协作探索复杂领域的方法。
在这种方法中,你只需从墙上或白板上按大致时间顺序列出对业务领域有重要意义的所有事件,使用一堆彩色便利贴。每种便签类型(用不同颜色表示)都有特定的用途,如下所述:
-
领域事件:对业务流程有重要意义的事件------用过去时态表达。
-
命令:可能引起一个或多个领域事件发生的动作或活动。这是由用户发起或系统发起的,作为对领域事件的响应。
-
用户:执行业务动作/活动的人。
-
策略:一组需要遵守的业务不变量(规则),以便成功执行动作/活动。
-
查询/读取模型:执行动作/活动所需的信息。
-
外部系统:对业务流程有重要意义但在当前上下文中不在范围内的系统。
-
热点:系统内部的一个争议点,可能对团队的小部分人来说既令人困惑又令人费解。
-
聚合 :一个状态变化一致且原子化的对象图。这与我们在第二章 中看到的聚合 定义一致,DDD 如何适应?。
这里展示了我们的事件风暴研讨会中贴纸的描绘:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_04_08.jpg
图 4.8 -- 事件风暴图例
为什么是领域事件?
当试图理解一个业务流程时,解释该背景下的重要事实或事物是很方便的。这种做法也可以是非正式的,并且对未经介绍的人群来说很容易理解。这提供了一个易于消化的领域复杂性的视觉表示。
使用事件风暴法处理 LC 发行申请
现在我们已经通过领域讲故事研讨会对当前业务流程有了高级理解,让我们看看我们如何使用事件风暴法深入挖掘。以下是从同一应用程序的事件风暴研讨会阶段摘录的内容:
- 概述事件时间线 :在这个练习中,我们回忆系统中的重要领域事件(使用橙色贴纸),并将它们贴在白板上,如图所示。我们确保事件贴纸按发生的大致时间顺序粘贴。由于时间线的实施,业务流程将开始显现:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_04_10.jpg
图 4.10 -- 处理被拒绝申请的新事件
- 识别触发活动和外部系统 :在达到对事件时间线的高级理解之后,下一步是使用蓝色贴纸添加导致这些事件发生的活动/动作,以及与外部系统的交互(使用粉色贴纸):
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_04_11.jpg
图 4.11 -- 活动和外部系统
- 捕获用户、上下文和政策 :下一步是捕获执行这些活动的用户 以及他们的功能上下文 (使用黄色贴纸)和政策(使用紫色贴纸)。
为了解决这个问题,我们添加了一个新的领域事件,明确表示申请已被拒绝,如图所示:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_04_12.jpg
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_04_09.jpg
- 概述查询模型 :每个活动都需要一组数据。用户需要查看他们需要采取行动的带外数据,并看到他们行动的结果。这些数据集以 查询模型(使用绿色便签)表示:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_04_13.jpg
图 4.13 -- 一个大型的 EventStorming 实作工作板
重要提示
对于领域故事讲述和 EventStorming 实作,当有大约六到八人参与,并且有合适的领域和技术专家混合时,效果最佳。
这标志着 EventStorming 实作结束,以获得对 LC 应用和发行流程的合理详细理解。这意味着我们已经结束了领域需求收集过程吗?绝非如此------虽然我们在理解领域方面取得了重大进展,但仍有一段很长的路要走。阐述领域需求的过程是持续的。我们在这一连续体中处于什么位置?以下图表试图阐明:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_04_14.jpg
图 4.14 -- 阐述领域需求连续体
在随后的章节中,我们将更详细地探讨其他技术。
摘要
在本章中,我们探讨了两种使用轻量级建模技术------领域故事讲述和 EventStorming------来增强我们对问题领域集体理解的方法。
领域故事讲述使用简单的图形符号在领域专家和技术团队成员之间共享业务知识。另一方面,EventStorming 使用业务流程中发生的领域事件的时序顺序来获得相同的共享理解。
领域故事讲述可以用作一种入门技术,以建立对问题空间的高级理解,而 EventStorming 可以用来指导解决方案空间详细设计决策。
带着这些知识,我们应该能够深入到解决方案实施的技术细节。在下一章中,我们将开始实施业务逻辑,并建模我们的聚合,包括命令和领域事件。
进一步阅读
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_04_Table_01.jpg
第五章:实现领域逻辑
为了有效地沟通,代码必须基于编写需求所使用的相同语言------开发人员彼此之间以及与领域专家交流的语言。
-- 埃里克·埃文斯
在本书的*命令查询责任分离(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)
继续我们的设计之旅
在上一章中,我们讨论了事件风暴作为一种轻量级的方法来阐明业务流程。作为提醒,这是我们的事件风暴会议产生的输出:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_05_02.jpg
图 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中的详细说明。
要启动应用程序,请使用以下命令:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/ch5-1.jpg
这应该在当前目录中创建一个名为lc-issuance-api.zip的文件。将此文件解压缩到您选择的位置,并在pom.xml文件的dependencies部分添加 Axon 框架的依赖项:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/ch5-2.jpg
- 您可能需要更改版本。本书编写时,我们处于 4.5.3 版本。
此外,添加以下对axon-test库的依赖项以启用聚合的单元测试:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/ch5-3.jpg
使用前面的设置,您应该能够运行应用程序并开始实现 LC 发行功能。
让我们看看如何使用 Axon 框架实现这些命令。
识别命令
从上一章的事件风暴会话中,我们有以下命令作为起点:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_05_03.jpg
图 5.3 -- 识别出的命令
命令总是针对聚合(根实体)进行处理(处理)。这意味着我们需要解决这些命令,以便由聚合处理。虽然命令的发送者不关心系统中的哪个组件处理它,但我们需要决定哪个聚合将处理每个命令。还应注意,任何给定的命令只能由系统中的单个聚合处理。让我们看看如何对这些命令进行分组并将它们分配给聚合。为了能够做到这一点,我们首先需要识别系统中的聚合。
识别聚合
查看我们 LC 应用的 eventstorming 会话的输出,一个潜在的分组可以如下所示:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_05_04.jpg
图 5.4 -- 对聚合设计的第一次尝试
这些实体中的某些或所有可能是聚合(有关聚合和实体的区别的更详细解释,请参阅第一章 ,领域驱动设计的原理)。乍一看,我们似乎有四个潜在的实体来处理这些命令:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_05_05.jpg
图 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。阅读这本书以深入了解这一实践是值得的。
因此,让我们从第一个测试开始。对于外部世界来说,一个事件驱动系统通常以以下图示的方式进行工作:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_05_07.jpg
图 5.7 -- 事件驱动系统
这个图示可以这样解释:
-
可能已经发生了一组可选的领域事件。
-
系统接收到一个命令(由用户手动发起或由系统的一部分自动发起),它充当刺激。
-
命令由一个聚合体处理,然后继续验证接收到的命令以强制执行不变性(结构性和领域验证)。
-
系统随后以两种方式之一做出反应:
-
发射一个或多个事件。
-
抛出异常。
-
重要提示
本章中展示的代码片段是为了突出显著的概念和技术。对于完整的示例,请参阅本章附带源代码(包含在 ch05 目录中)。
Axon 框架允许我们以下形式表达测试:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/ch5-4.jpg
-
FixtureConfiguration是 Axon 框架的一个实用工具,用于帮助使用 BDD 风格的给定-当-然后语法测试聚合行为。 -
AggregateTestFixture是FixtureConfiguration的具体实现,其中您需要注册您的聚合类 -- 在我们的情况下,LCApplication是处理指向我们解决方案的命令的候选者。 -
由于这是业务流程的开始,到目前为止还没有发生任何事件。这一点通过我们没有向给定方法传递任何参数来表示。在稍后讨论的示例中,可能会在接收到此命令之前已经发生了一些事件。
-
这是我们实例化命令对象的新实例的地方。命令对象通常类似于数据传输对象,携带一组信息。这个命令将被路由到我们的聚合体进行处理。我们将在稍后详细查看这是如何工作的。
-
在这里,我们声明我们期望匹配确切序列的事件。
-
在这里,我们期望在成功处理命令后发射一个
LCApplicationCreated类型的事件。 -
我们最终表示我们不期望有更多的事件,这意味着我们期望恰好发射一个事件。
实现命令
在前面的简单示例中,CreateLCApplicationCommand 不携带任何状态。现实中,命令可能看起来像以下所示:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/ch5-5.jpg
-
这是命令类。在命名命令时,我们通常使用祈使句风格;也就是说,它们通常以一个表示所需动作的动词开头。请注意,这是一个数据传输对象。换句话说,它只是一个数据属性袋。同时请注意,它没有任何逻辑(至少目前是这样)。
-
这是 LC 应用程序的标识符。在这种情况下,我们假设使用客户端生成的标识符。关于使用服务器生成标识符与客户端生成标识符的主题超出了本书的范围。你可以根据你的上下文优势选择使用其中之一。此外,请注意,我们使用强类型
LCApplicationId标识符,而不是原始类型,如数值或字符串值。在某些情况下,使用 UUID 作为标识符也很常见。然而,我们更喜欢使用强类型来区分标识符类型。注意我们是如何使用ClientId类型来表示应用程序的创建者的。 -
Party和AdvisingBank类型是我们解决方案中代表这些概念的复杂类型。应小心使用与问题(业务)领域相关的名称,而不是使用仅在解决方案(技术)领域有意义的名称。注意在两种情况下都尝试使用领域专家的通用语言。这是我们命名系统中的事物时应该始终意识到的实践。
值得注意的是,merchandiseDescription被保留为原始的String类型。这可能与之前我们提出的评论相矛盾。我们将在接下来的结构验证部分解决这个问题。
现在,让我们看看成功处理命令后我们将发出的事件将是什么样子。
实现事件
在事件驱动系统中,通过成功处理命令来改变系统状态通常会导致域事件被发出,以向系统的其余部分信号状态的变化。这里展示了现实世界中的LCApplicationCreatedEvent事件的简化表示:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/ch5-6.jpg
- 当命名事件时,我们通常使用过去时态的名称来表示已经发生并且无条件接受为无法改变的经验事实。
你可能会注意到,事件的结构目前与命令的结构完全相同。虽然在这个案例中这是正确的,但并不总是这样。我们在事件中选择的披露信息量是上下文相关的。在将信息作为事件的一部分发布时,与领域专家进行咨询非常重要。你可能会选择在事件有效负载中保留某些信息。例如,考虑ChangePasswordCommand,它包含新更改的密码。可能明智的做法是不在结果PasswordChangedEvent中包含更改后的密码。
在之前的测试中,我们已经看到了命令和结果事件。让我们通过查看聚合实现来了解这是如何实现的。
设计聚合
聚合是处理命令和发出事件的场所。我们编写的测试的好处是它以一种隐藏实现细节的方式表达。但让我们看看实现,以便能够欣赏我们如何让测试通过并满足业务需求:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/ch5-7.jpg
-
这是
LCApplication聚合体的聚合标识符。对于聚合体,标识符唯一地标识了一个实例与另一个实例的不同。因此,所有聚合体都需要声明一个标识符,并使用框架提供的@AggregateIdentifier注解将其标记为使用。 -
处理命令的方法需要使用
@CommandHandler注解进行标注。在这种情况下,命令处理器恰好是这个类的构造函数,因为这是这个聚合体可以接收的第一个命令。我们将在本章后面看到后续命令由其他方法处理的示例。 -
@CommandHandler注解将一个方法标记为命令处理器。这个方法可以处理的确切命令需要作为参数传递给方法。请注意,对于任何给定的命令,整个系统中只能有一个命令处理器。 -
在这里,我们使用框架提供的
AggregateLifecycle实用工具发出LCApplicationCreatedEvent。在这个非常简单的例子中,我们在收到命令后无条件地发出一个事件。在现实世界的场景中,在决定发出一个或多个事件或用异常失败命令之前,可能需要执行一系列验证。我们将在本章后面查看更实际的示例。 -
在这个阶段,
@EventSourcingHandler的需求及其作用可能非常不清楚。我们将在本章的后续部分详细解释这一需求。
这是对一个简单事件驱动系统的快速介绍。我们仍然需要理解@EventSourcingHandler的作用。为了理解这一点,我们需要欣赏聚合持久化的工作方式及其对我们整体设计的影响。
聚合持久化
当与任何具有适度复杂性的系统一起工作时,我们需要确保交互持久化;也就是说,交互需要超越系统重启、崩溃等情况。因此,持久化的需求是既定的。虽然我们应该始终努力将持久化关注点从系统的其余部分抽象出来,但我们的持久化技术选择可能会对我们整体解决方案的架构产生重大影响。在如何选择持久化聚合状态方面,我们有几个选择值得提及:
-
状态存储
-
事件源
让我们在接下来的几节中更详细地检查这些技术。
状态存储聚合
保存实体的当前值到目前为止仍然是持久化状态的最流行方式------得益于关系数据库和 LCApplication 的巨大普及,我们可以设想使用一个结构类似于以下的关系数据库:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_05_08.jpg
图 5.8 -- 典型的实体关系模型
无论我们选择使用关系数据库还是更现代的 NoSQL 存储------例如,文档存储、键值存储、列族存储等等------我们用来持久化信息的风格基本上保持不变,即存储该聚合/实体的属性当前值。当属性值发生变化时,我们只需用新的值覆盖旧值;也就是说,我们存储聚合和实体的当前状态------因此得名 状态存储。这种技术在过去的几年里一直为我们服务得很好,但至少还有另一种机制我们可以用来持久化信息。我们将在下一节更详细地探讨这一点。
事件源聚合
开发者已经非常长时间以来一直在依赖日志来完成各种诊断目的。同样,关系数据库几乎从诞生之初就开始使用提交日志来持久化存储信息。然而,在主流系统中,开发者将日志作为结构化信息的首选持久化解决方案的使用仍然极为罕见。
日志是一个极其简单、只追加且不可变的按时间顺序排列的记录序列。这里的图展示了记录按顺序写入的日志结构。本质上,日志是一个只追加的数据结构,如图所示:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_05_09.jpg
图 5.9 -- 日志数据结构
与比表等更复杂的数据结构相比,写入日志是一个相对简单且快速的操作,并且可以处理极大量的数据,同时提供可预测的性能。确实,现代事件流平台如 Apache Kafka 就利用这种模式来扩展以支持极大量的数据。我们确实觉得这可以应用于在主流系统中处理命令时作为持久化存储,因为这具有超出之前列出的技术优势。考虑以下这里展示的在线订单流程示例:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_05_Table_01.jpg
如您所见,在事件存储中,我们继续对所有用户执行的操作保持全面可见。这使我们能够更全面地推理这些行为。在传统存储中,我们失去了用户将白面包替换为小麦面包的信息。虽然这本身不影响订单,但我们失去了从这一用户行为中获取洞察的机会。我们认识到,这种信息可以通过其他方式使用专门的分析解决方案来捕获;然而,事件日志机制提供了一种无需额外努力的自然方式来完成这项工作。它还充当审计日志,提供迄今为止发生的所有事件的完整历史。这与领域驱动设计的本质非常吻合,我们不断探索减少复杂性的方法。
然而,以简单事件日志的形式持久化数据有一些影响。在处理任何命令之前,我们需要按照发生顺序恢复过去的事件,并重建聚合状态,以便我们能够执行验证。例如,在确认结账时,仅仅有已过期的事件集合是不够的。我们仍然需要在允许下单之前计算出购物车中确切的商品。在处理该命令之前,这种事件回放 以恢复聚合状态(至少是那些需要验证该命令的属性)是必要的。例如,在处理RemoveItemFromCartCommand之前,我们需要知道当前购物车中包含哪些商品。这在下表中得到了说明:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_05_Table_02.jpg
整个场景的对应源代码在以下代码片段中展示:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/ch5-8.jpg
-
在处理任何命令之前,聚合加载过程首先通过调用无参构造函数开始。因此,我们需要无参构造函数是
状态。状态的恢复必须 只发生在触发事件回放的那些方法中。在 Axon 框架的情况下,这相当于带有@EventSourcingHandler注解的方法。 -
重要的是要注意,在之前的代码中,在发出
CartCreatedEvent和ItemAddedEvent的地方发出AddItemCommand是可能的(但不是必需的)。命令处理器不会改变聚合的状态。它们只利用现有的聚合状态来强制执行不变性(验证)并在这些不变性为true时发出事件。 -
加载过程通过调用事件源处理器方法按发生顺序继续进行,这些方法针对该聚合实例。事件源处理器仅需要根据过去的事件来恢复聚合状态。这意味着它们通常不包含任何业务(条件)逻辑。不言而喻,这些方法不会发出任何事件。事件发射仅限于在强制执行不变性成功时在命令处理器中发生。
当与事件源聚合一起工作时,非常重要的一点是要对可以编写的代码类型保持纪律性:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_05_Table_03.jpg
如果有大量历史事件需要恢复状态,聚合加载过程可能会变得耗时------直接与该聚合已过事件的数量成正比。我们可以采用一些技术(如事件快照)来克服这一点。
持久化技术选择
如果你使用状态存储来持久化你的聚合,使用你通常的评估过程来选择你的持久化技术应该足够。然而,如果你正在查看事件源聚合,决策可能会更加微妙。根据我们的经验,即使是简单的关系型数据库也能做到这一点。事实上,我们曾经使用关系型数据库作为具有数十亿事件的超大规模事务性应用的事件存储。这种设置对我们来说效果很好。值得注意的是,我们只使用了事件存储来插入新事件和按顺序加载给定聚合的事件。然而,有许多专门构建来作为事件存储的技术,它们支持其他增值功能,如时间旅行、完整事件回放、事件负载内省等。如果你有这样的需求,考虑其他选项可能值得,例如 NoSQL 数据库(如 MongoDB 这样的文档存储或如 Cassandra 这样的列族存储)或专门构建的商业产品,如 EventStoreDB 和 Axon Server,以评估在你的环境中是否可行。
我们应该选择哪种持久化机制?
现在我们对两种聚合持久化机制(状态存储和事件源)有了相当好的理解,这就引出了我们应该选择哪一种的问题。我们在此列出使用事件源的一些好处:
-
我们可以在高合规性场景中将事件用作自然审计日志。
-
它提供了在细粒度事件数据的基础上执行更深入的分析的能力。
-
当我们与基于不可变事件的系统一起工作时,这可能会产生更灵活的设计,因为持久化模型的复杂性受到限制。此外,我们无需处理复杂的 ORM 阻抗不匹配问题。
-
领域模型与持久化模型之间的耦合更加松散,使其能够主要独立于持久化模型而演进。
-
它使得能够回到过去,以便能够创建临时视图和报告,而无需处理前置复杂性。
反过来,在实施基于事件源解决方案时,你可能需要考虑以下一些挑战:
-
事件源需要范式转变,这意味着开发和业务团队将不得不花费时间和精力去理解它是如何工作的。
-
持久化模型不直接存储状态。这意味着在持久化模型上直接进行临时查询 可能会更加具有挑战性。这可以通过实现新的视图来缓解;然而,这样做会增加复杂性。
-
事件源通常在结合 CQRS 实现时工作得非常好,这可能会给应用程序增加更多的复杂性。它还要求应用程序更加关注强一致性 versus 最终一致性的问题。
我们的经验表明,事件源系统在现代事件驱动系统中带来了许多好处。然而,在做出持久化选择时,你需要意识到在你自己的生态系统中所提出的先前考虑。
强制执行策略
在处理命令时,我们需要强制执行策略或规则。策略分为两大类:
-
结构性规则 -- 那些强制执行已发送命令的语法有效性的规则
-
领域规则 -- 那些强制执行业务规则得到遵守的规则
在系统的不同层执行这些验证也可能是明智的。而且,在系统的多个层中重复执行这些策略强制也是常见的。然而,重要的是要注意,在命令成功处理之前,所有这些策略强制都是统一应用的。让我们在接下来的部分中看看这些示例。
结构性验证
目前,要创建 LC 应用程序,你需要发送CreateLCApplicationCommand。虽然命令规定了结构,但目前没有任何强制执行。让我们纠正这一点。
为了能够声明式地启用验证,我们将使用 JSR-303 Bean 验证库。我们可以通过在pom.xml文件中使用spring-boot-starter-validation依赖轻松地添加它,如图所示:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/ch5-9.jpg
现在,我们可以使用 JSR-303 注解向命令对象添加验证,如图所示:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/ch5-10.jpg
大多数结构性验证可以使用内置的验证注解来完成。也有可能为单个字段创建自定义验证器,或者验证整个对象(例如,验证相互依赖的属性)。有关如何操作的更多详细信息,请参阅beanvalidation.org/2.0/的 Bean 验证规范和hibernate.org/validator/的参考实现。
业务规则强制
结构性验证可以使用命令中已经存在的信息来完成。然而,还有另一类验证需要的信息并不在传入的命令本身中。这类信息可能存在于两个地方之一 -- 在我们正在操作的聚合内,或者不在聚合本身内,但可以在有限范围内提供。
让我们看看一个需要聚合内存在状态验证的验证示例。考虑提交 LC 的例子。当 LC 处于草稿状态时,我们可以对其进行多次编辑,但在提交后就不能再进行任何更改。这意味着我们只能提交一次 LC。通过发出SubmitLCApplicationCommand来实现提交 LC 的行为,如事件风暴会议中的工件所示:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_05_10.jpg
图 5.10 -- 提交 LC 应用程序过程中的验证
让我们从编写一个测试来表示我们的意图开始:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/ch5-11.jpg
-
已知
LCApplicationCreatedEvent已经发生 -- 换句话说,LC 申请已经创建。 -
这是我们尝试通过发出针对同一应用的
SubmitLCApplicationCommand来提交应用程序的时候。 -
我们期望会发出
LCApplicationSubmittedEvent事件。
相应的实现将类似于以下内容:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/ch5-12.jpg
上述实现允许我们无条件地提交 LC 应用程序 -- 超过一次。然而,我们希望限制用户只能提交一次。为了能够做到这一点,我们需要记住 LC 应用程序已经被提交。我们可以在相应事件的@EventSourcingHandler处理程序中做到这一点,如下所示:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/ch5-13.jpg
- 当
LCApplicationSubmittedEvent被重放时,我们将 LC 应用程序的状态设置为SUBMITTED。
虽然我们已经记住应用程序已变为SUBMITTED状态,但我们仍然没有阻止多次提交尝试。我们可以通过编写一个测试来修复这个问题,如下所示:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/ch5-14.jpg
-
LCApplicationCreatedEvent和LCApplicationSubmittedEvent已经发生,这意味着LCApplication已经被提交过一次。 -
我们现在向系统发送另一个
SubmitLCApplicationCommand命令。 -
我们期望会抛出
AlreadySubmittedException异常。 -
我们也期望不会发出任何事件。
使其工作的命令处理程序的实现如下所示:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/ch5-15.jpg
- 注意我们是如何使用
LCApplication聚合的状态属性来执行验证的。如果应用程序不在DRAFT状态,我们将通过AlreadySubmittedException域异常失败。
让我们再看看一个例子,其中执行验证所需的信息既不是命令的一部分,也不是聚合的一部分。让我们考虑这样一个场景,即国家法规禁止与所谓的受制裁 国家进行交易。这个国家列表的变化可能受到外部因素的影响。因此,将这个受制裁国家的列表作为命令有效负载的一部分是没有意义的。同样,将其作为每个单个聚合状态的一部分来维护也没有意义------鉴于它可以改变(尽管非常不频繁)。在这种情况下,我们可能想要考虑使用一个位于聚合类之外的命令处理器。到目前为止,我们只看到了聚合内部@CommandHandler方法的示例。但是,@CommandHandler注解可以出现在任何其他外部于聚合的类上。然而,在这种情况下,我们需要自己加载聚合。Axon 框架提供了一个org.axonframework.modelling.command.Repository接口,允许我们这样做。重要的是要注意,这个仓库与 Spring 数据库中作为其一部分的 Spring 框架接口是不同的。这里展示了如何工作的一个示例:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/ch5-16.jpg
-
我们正在注入 Axon
Repository接口以允许我们加载聚合。之前这并不是必需的,因为@CommandHandler注解直接出现在聚合方法上。 -
我们正在使用
Repository接口来加载聚合并与之交互。Repository接口支持其他方便的方法来处理聚合。请参阅 Axon 框架文档以获取更多使用示例。
回到受制裁国家的例子,让我们看看我们需要如何稍微不同地设置测试:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/ch5-17.jpg
-
我们像往常一样创建一个新的聚合夹具。
-
我们正在使用夹具来获取 Axon
Repository接口的一个实例。 -
我们实例化了一个自定义命令处理器,传递了
Repository实例。同时,请注意我们如何通过简单的依赖注入将受制裁国家的集合注入到处理器中。在现实生活中,这组受制裁国家很可能会从外部配置中获得。 -
我们最终需要将命令处理器注册到夹具中,以便它可以路由命令到这个处理器。
对于这个测试,看起来相当直接:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/ch5-18.jpg
-
为了测试的目的,我们将国家
SOKOVIA标记为受制裁国家。在更现实的场景中,这很可能是来自某种形式的外部配置(例如,查找表或某种形式的外部配置)。然而,这对于我们的单元测试是合适的。 -
然后将这组受制裁国家注入到命令处理器中。
-
当为受制裁国家创建 LC 应用程序时,我们预计不会发出任何事件,并且还会抛出
CannotTradeWithSanctionedCountryException异常。 -
最后,当受益人属于非制裁国家时,我们发出
LCApplicationCreatedEvent事件。
命令处理器的实现如下所示:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/ch5-19.jpg
-
我们将类标记为
@Service以标记它为一个没有封装状态的组件,并在使用基于注解的配置或类路径扫描时启用自动发现。因此,它可以用于执行任何"管道"活动。 -
请注意,受益人所在国家是否被制裁的验证也可以在行 18 进行。有些人可能会认为这是理想的,因为我们如果那样做可以避免调用 Axon
Repository方法的潜在不必要的调用。然而,我们更喜欢尽可能地将业务验证封装在聚合的范围内,这样我们就不会遭受创建贫血领域模型的问题。 -
我们使用聚合存储库作为工厂来创建
LCApplication领域对象的新的实例。
最后,这里展示了聚合实现以及验证:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/ch5-20.jpg
- 验证本身相当直接。当验证失败时,我们抛出
CannotTradeWithSanctionedCountryException异常。
通过这些示例,我们探讨了在聚合边界内实现策略执行的不同方法。
摘要
在本章中,我们使用了事件风暴会议的输出,并将其用作创建我们边界上下文领域模型的主要辅助工具。我们探讨了如何使用 CQRS 架构模式来实现这一点。我们探讨了持久化选项以及使用基于事件的聚合与存储状态的聚合的后果。最后,我们通过一系列代码示例来查看执行业务验证的多种方式。我们通过 Spring Boot 和 Axon 框架的代码示例来查看所有这些。有了这些知识,我们应该能够实现健壮、封装良好、事件驱动的领域模型。
在下一章中,我们将探讨实现这些领域能力的用户界面,并考察一些选项,例如基于 CRUD 的 UI 与基于任务的 UI。
进一步阅读
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_05_Table_04.jpg
第六章:基于任务的用户界面实现
要完成一项艰巨的任务,首先必须使其变得简单。
-- 马蒂·鲁宾
领域驱动设计(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 样式
如果你还记得第五章中的实现领域逻辑,我们创建了以下命令:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_Figure_6.1.jpg
图 6.1 -- 事件风暴会议中的命令
如果你仔细观察,似乎有两个粒度的命令。创建 LC 应用程序 和更新 LC 应用程序是粗粒度的,而其他的则更专注于它们的意图。粗粒度命令的可能分解方式如下所示:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_Figure_6.2.jpg
图 6.2 -- 分解的命令
除了比前一个迭代的命令更细粒度之外,修订后的命令似乎更好地捕捉了用户的意图。这可能感觉像是一个微小的语义变化,但可能会对我们解决方案的最终用户使用方式产生巨大影响。那么问题来了,我们是否应该始终偏好细粒度 API 而不是粗粒度 API。答案可能更加复杂。在设计 API 和体验时,我们看到两种主要风格被采用:
-
基于 CRUD
-
基于任务
让我们更详细地看看这些。
基于 CRUD 的 API
Insert、Select、Update 和 Delete。同样,HTTP 协议有 POST、GET、PUT 和 DELETE 作为动词来表示这些 CRUD 操作。这种方法已经扩展到我们 API 的设计中。这导致了基于 CRUD 的 API 和用户体验的激增。看看来自 第五章 的 CreateLCApplicationCommand,实现领域逻辑:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/ch6-1.jpg
沿着类似的思路,创建相应的 UpdateLCApplicationCommand 并不罕见,如下所示:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/ch6-2.jpg
虽然这非常常见且很容易理解,但并非没有问题。以下是一些采取这种方法会引发的问题:
-
我们是否允许更改
update命令中列出的所有内容? -
假设一切都可以改变,它们是否同时改变?
-
我们如何知道确切发生了什么变化?我们应该进行差异比较吗?
-
如果上述所有属性都没有包含在
update命令中怎么办? -
如果未来需要添加属性怎么办?
-
用户想要完成的事务的业务意图是否被捕捉到了?
在一个简单的系统中,这些问题的答案可能并不那么重要。然而,随着系统复杂性的增加,这种方法是否仍然能够适应变化?我们认为,有必要考虑另一种方法,即基于任务的 API,以便能够回答这些问题。
基于任务的 API
在一个典型的组织中,个人执行与其专业相关的任务。组织越大,专业化的程度越高。根据个人专业来隔离任务的方法是有道理的,因为它减少了相互干扰的可能性,尤其是在完成复杂工作的时候。例如,在 LC 申请流程中,需要确定产品的价值/合法性,同时也要确定申请人的信用度。这些任务通常由不同部门的个人执行是有意义的,这也意味着这些任务可以独立于其他任务执行。
在业务流程方面,如果我们有一个名为 CreateLCApplicationCommand 的命令先于这些操作,那么两个部门的个人首先必须等待整个申请表填写完毕,然后才能开始他们的工作。其次,如果通过单个 UpdateLCApplicationCommand 命令更新任何信息,那么不清楚具体发生了什么变化。这种流程中的不明确性可能导致至少一个部门收到虚假通知。
由于大部分工作都是以特定任务的形式进行的,如果我们的流程和 API 能够反映这些行为,这将对我们有利。
考虑到这一点,让我们重新审视我们修订后的 LC 申请流程 API:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_Figure_6.3.jpg
图 6.3 -- 修订后的命令
虽然之前可能看起来我们只是将粗粒度的 API 转换为更细粒度,但实际上,这更好地代表了用户意图要执行的任务。因此,本质上,基于任务的 API 是以与用户意图更紧密对齐的方式对工作进行分解。使用我们新的 API,产品验证可以在ChangeMerchandise发生时立即开始。此外,用户的行为以及对此行为需要做出何种反应都变得明确无误。这随即引发了一个问题:我们是否应该始终使用基于任务的 API。让我们更详细地看看其影响。
基于任务还是基于 CRUD?
基于 CRUD 的 API 似乎在聚合级别上运行。在我们的例子中,我们有 LC 聚合。在最简单的情况下,这本质上等同于与每个 CRUD 动词对齐的四个操作。然而,正如我们所看到的,即使在我们的简化版本中,LC 正成为一个相当复杂的概念。在 LC 级别上仅需要处理四个操作,从认知上来说是复杂的。随着需求的增加,这种复杂性只会继续增加。例如,考虑一种情况,业务表达了对捕获更多关于"商品"信息的需要,而今天,这仅仅以自由文本的形式被捕获。这里展示了商品信息的更详细版本:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/ch6-3.jpg
在我们当前的设计中,这种变化的含义对提供者和消费者(们)都产生了深远的影响。让我们更详细地看看一些后果:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_06_Table_01.jpg
在我们当前的设计中,这种变化的含义对提供者和消费者(们)都产生了深远的影响。让我们更详细地看看一些后果。
如我们所见,基于 CRUD 和基于任务的接口之间的选择是微妙的。我们并不是建议你应该选择其中之一。你使用哪种风格将取决于你的具体需求和上下文。根据我们的经验,基于任务的接口将用户意图视为一等公民,并以非常优雅的方式延续了 DDD 的通用语言精神。我们更倾向于尽可能地将接口设计为基于任务的,因为它们会产生更直观的界面,更好地表达问题域。
随着系统的演变,它们开始支持更丰富的用户体验和多个渠道,基于 CRUD 的接口似乎需要额外的翻译层来满足用户体验需求。下面的图示描绘了一个支持多用户体验渠道的解决方案的典型分层架构:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_Figure_6.4.jpg
图 6.4 -- 支持多用户体验渠道的分层架构
这种设置通常由以下内容组成:
-
由基于 CRUD 的服务组成的领域层,这些服务简单地映射到数据库实体
-
由跨越多个核心服务的企业能力组成的复合层
-
由特定通道 API 组成的前端后端 (BFF)层
注意,复合层和 BFF 层主要作为将后端能力映射到用户意图的手段。在一个理想的世界里,如果后端 API 紧密反映用户意图,那么翻译的需求应该是最小的(如果有的话)。我们的经验表明,这种设置会导致业务逻辑被推向用户通道,而不是封装在精心设计的业务服务中。此外,这些层会导致同一功能在不同通道上产生不一致的体验,因为现代团队是按照层边界结构化的。
重要提示
我们并不反对使用分层架构。我们认识到分层架构可以带来模块化、关注点分离和其他相关好处。然而,我们反对仅仅为了补偿核心领域 API 设计不当而创建额外的层级。
一个精心设计的 API 层可以对构建出色的用户体验产生深远的影响。然而,这是一章关于实现用户界面的内容。让我们回到为 LC 应用程序创建用户界面的任务。
启动 UI
我们将简单地为我们创建的 LC 应用程序构建 UI,该应用程序在第五章 中实现,实现领域逻辑 。有关详细说明,请参阅启动应用程序 部分。此外,我们还需要将以下依赖项添加到项目根目录下的 Maven pom.xml文件的dependencies部分:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/ch6-4.jpg
要运行 UI 测试,你需要添加以下依赖项:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/ch6-5.jpg
要能够从命令行运行应用程序,你需要在pom.xml文件的plugins部分添加javafx-maven-plugin,如下所示:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/ch6-6.jpg
要从命令行运行应用程序,请使用以下命令:
java
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 兼容,应用程序启动器看起来如下所示:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/ch6-7.jpg
重要提示
我们需要从MvvmfxSpringApplication mvvmFX 框架类扩展。
请参考附带源代码仓库中的ch06目录以获取完整示例。
实现 UI
当与用户界面一起工作时,使用以下这些表示模式是相当常见的:
-
模型-视图-控制器 (MVC)
-
模型-视图-演示者 (MVP)
-
模型-视图-视图模型 (MVVM)
MVC 模式存在时间最长。在协作的模型、视图和控制器对象之间分离关注点的想法是合理的。然而,在实际实现中,这些对象的定义似乎有很大的差异------在许多情况下,控制器变得过于复杂。相比之下,MVP 和 MVVM,虽然都是 MVC 的衍生品,但似乎在协作对象之间带来了更好的关注点分离。特别是当与数据绑定构造结合使用时,MVVM 使得代码更加易于阅读、维护和测试。在这本书中,我们使用 MVVM,因为它使我们能够进行测试驱动开发,这是我们强烈偏好的。让我们快速了解一下 MVVM 入门,如 mvvmFX 框架中实现的那样。
MVVM 入门
现代 UI 框架开始采用声明性风格来表示视图。MVVM 旨在通过使用绑定表达式从视图中移除所有 GUI 代码(代码后置),从而实现风格与编程关注点之间的更清晰分离。这里展示了如何实现此模式的视觉高级概述:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_Figure_6.6.jpg
图 6.5 -- MVVM 设计模式
该模式包含以下组件:
-
模型:负责容纳业务逻辑和管理应用程序的状态。
-
视图:负责向用户展示数据,并通过视图代理通知视图模型关于用户交互。
-
视图代理:负责在用户或视图模型进行更改时保持视图和视图模型同步。它还负责将视图上执行的操作传输到视图模型。
-
视图模型:负责代表视图处理用户交互。视图模型使用观察者模式(通常是一向或双向数据绑定,使其更方便)与视图交互。视图模型与模型交互以进行更新和读取操作。
创建新的 LC
让我们考虑创建新的 LC 的例子。要开始创建新的 LC,我们只需要申请人提供一个友好的客户端引用。这是一个容易记住的文本字符串。这里展示了这个 UI 的简单版本:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_Figure_6.7.jpg
图 6.7 -- 开始 LC 创建屏幕
让我们更详细地检查每个组件的实现和目的。
声明性视图
当使用 JavaFX 时,视图可以使用 FXML 格式的声明性风格进行渲染。以下是从StartLCView.fxml文件中提取的重要片段,用于开始创建新的 LC:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/ch6-8.jpg
-
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视图代理的结构:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/ch6-9.jpg
-
这是
StartLCView.fxml视图的视图代理类。 -
这是视图中的
clientReference文本框的 Java 绑定。成员的名称需要与视图中的fx:id属性值完全匹配。此外,它需要使用@javafx.fxml.FXML注解进行注解。如果视图代理中的成员是公共的并且与视图中的名称匹配,则使用@FXML注解是可选的。 -
同样,
startButton被绑定到视图中的相应按钮小部件。 -
这是当按下
startButton时的动作处理方法。
视图模型
这是StartLCView的StartLCViewModel视图模型类:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/ch6-10.jpg
-
这是
StartLCView的视图模型类。请注意,我们必须实现 mvvmFX 框架提供的de.saxsys.mvvmfx.ViewModel接口。 -
我们正在使用 JavaFX 提供的
SimpleStringProperty初始化clientReference属性。还有其他几个属性类可以定义更复杂的数据类型。请参阅 JavaFX 文档以获取更多详细信息。 -
这是视图模型中
clientReference的值。我们很快将看看如何将其与视图中的clientReference文本框的值关联起来。请注意,我们正在使用 JavaFX 提供的StringProperty,它提供了对客户端引用底层字符串值的访问。 -
除了为底层值的标准获取器和设置器之外,JavaFX beans 还需要创建一个特殊的访问器来处理该属性本身。
将视图绑定到视图模型
接下来,让我们看看如何将视图与视图模型关联起来:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/ch6-11.jpg
-
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 的优势在于,这个逻辑可以很容易地在视图模型的简单单元测试中进行测试。现在让我们看看它是如何工作的:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/ch6-12.jpg
现在,让我们看看视图模型中这个功能的实现:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/ch6-13.jpg
-
我们在视图模型中声明了一个
startDisabled属性来管理何时应禁用启动按钮。 -
有效客户端引用的最小长度被注入到视图模型中。可以想象,这个值可能是作为外部配置的一部分提供的,或者可能来自后端。
-
我们创建了一个绑定表达式来匹配业务要求。
-
我们将视图模型属性绑定到视图代理中启动按钮的禁用属性。
让我们再看看如何编写一个端到端、无头 UI 测试,如下所示:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/ch6-14.jpg
-
我们编写了一个方便的
@UITest扩展,用于结合 Spring 框架和 TestFX 测试。请参阅书中附带的源代码以获取更多详细信息。 -
我们设置了 Spring 上下文,使其作为 mvvmFX 框架及其注入注解(例如,
@InjectViewModel)的依赖注入提供者。 -
我们正在使用 TestFX 框架提供的
@Start注解来启动 UI。 -
TestFX 框架注入了一个 FxRobot UI 辅助实例,我们可以使用它来访问 UI 元素。
-
我们使用 TestFX 框架提供的便利匹配器进行测试断言。
现在,当我们运行应用程序时,我们可以看到在输入有效的客户端引用时启动按钮是启用的:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_Figure_6.8.jpg
图 6.8 -- 使用有效的客户端引用启用启动按钮
现在我们已经正确地启用了启动按钮,让我们实现 LC 本身的实际创建,通过调用后端 API。
集成后端
LC 创建是一个复杂的过程,需要各种物品的信息,正如我们在分解 LC 创建过程时所证明的那样。在本节中,我们将集成 UI 与启动新 LC 创建的命令。这发生在我们按下StartNewLCApplicationCommand时,如下所示:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/ch6-15.jpg
- 要启动一个新的 LC 应用程序,我们需要
applicantId和clientReference。
由于我们使用 MVVM 模式,调用后端服务的代码是视图模型的一部分。让我们驱动测试这个功能:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/ch6-16.jpg
视图模型相应增强以注入BackendService的实例,如下所示:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/ch6-17.jpg
现在,让我们测试以确保只有在输入有效的客户端引用时才调用后端:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/ch6-18.jpg
-
我们设置已登录用户。
-
当客户端引用为空时,不应与后端服务有任何交互。
-
当输入有效的客户端引用值时,后端应该使用输入的值进行调用。
使此测试通过的实现看起来像这样:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/ch6-19.jpg
-
我们在调用后端之前检查启动按钮是否启用。
-
这些是实际的带有适当值的后端调用。
现在,让我们看看如何从视图中集成后端调用。我们在 UI 测试中进行了此测试,如下所示:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/ch6-20.jpg
-
我们注入后端服务的模拟实例。
-
我们模拟后端调用以成功返回。
-
我们输入有效的客户端引用值。
-
我们点击启动按钮。
-
我们验证服务确实使用正确的参数进行了调用。
-
我们验证我们已经移动到 UI 中的下一个屏幕(LC 详细信息屏幕)。
让我们看看当服务调用在另一个测试中失败时会发生什么:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/ch6-21.jpg
-
我们模拟后端服务调用失败并抛出异常。
-
我们验证我们继续停留在
start-lc-screen。
此功能的视图实现如下所示:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/ch6-22.jpg
-
JavaFX,像大多数前端框架一样,是单线程的,并且要求长时间运行的任务不要在 UI 线程上调用。为此,它提供了
javafx.concurrent.Service抽象,以在后台线程中优雅地处理此类交互。 -
通过视图模型实际调用后端发生在这里。
-
我们在这里显示下一个屏幕以输入更多的 LC 详细信息。
最后,服务实现本身如下所示:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/ch6-23.jpg
-
我们注入由 Axon 框架提供的
org.axonframework.commandhandling.gateway.CommandGateway来调用命令。 -
实际上,使用
sendAndWait方法调用后端发生在这里。在这种情况下,我们会阻塞,直到后端调用完成。还有其他不需要这种阻塞的变体。请参阅 Axon 框架文档以获取更多详细信息。
我们现在已经看到了一个完整的示例,展示了如何实现 UI 和调用后端 API。
摘要
在本章中,我们探讨了 API 风格的细微差别,并阐明了设计能够紧密捕捉用户意图的 API 非常重要的原因。我们比较了基于 CRUD 和基于任务的 API 的区别。最后,我们利用 MVVM 设计模式实现了 UI,并展示了它是如何帮助测试驱动前端功能的。
现在我们已经实现了创建新的 LC,为了实现后续的命令,我们需要访问现有的 LC。在下一章中,我们将探讨如何实现查询端以及如何使其与命令端保持同步。
进一步阅读
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_06_Table_02.jpg
第七章:实现查询
最美的风景总是在最艰难的攀登之后。
-- 匿名
在第三章"理解领域"的*命令查询责任分离(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
继续我们的设计之旅
在第四章"领域分析和建模"中,我们讨论了事件风暴作为一种轻量级方法来阐明业务流程。作为提醒,这是我们事件风暴会议的输出:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_Figure_7.1.jpg
图 7.1 -- 事件风暴会议回顾
如前所述,我们正在使用 CQRS 架构模式来创建解决方案。关于为什么这是一个值得采用的方法的详细解释,您可以回顾第三章 中的何时使用 CQRS 部分,理解领域 ,我们已经对此进行了讨论。在前面的图中,绿色的便利贴代表读取/查询模型。当验证一个命令(例如,处理ValidateProduct命令时的有效产品标识符列表)或信息需要简单地展示给用户时(例如,申请人创建的 LC 列表),这些查询模型是必需的。让我们看看在实际应用中如何将 CQRS 应用于查询端。
实现查询端
在第五章 中,实现领域逻辑,我们探讨了在命令成功处理时如何发布事件。现在,让我们看看我们如何通过监听这些领域事件来构建查询模型。从逻辑上讲,这看起来像以下图示:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_Figure_7.2.jpg
图 7.2 -- CQRS 应用 -- 查询端
关于命令端是如何实现的详细解释,请参阅第五章 中的实现领域逻辑部分。
查询端的高级序列在此描述:
-
一个事件监听组件监听在事件总线上发布的这些领域事件。
-
它构建一个专门用于满足特定查询用例的查询模型。
-
此查询模型持久化在针对读取操作优化的数据存储中。
-
然后将此查询模型以 API 的形式公开。
注意,可以存在多个查询端组件来处理相应的场景。
让我们逐一实施这些步骤,看看这对我们的 LC 发行申请是如何工作的。
工具选择
在 CQRS 应用中,命令端和查询端之间存在分离。目前,这种分离在我们的应用中是逻辑上的,因为命令端和查询端都在同一个应用进程中作为组件运行。为了说明这些概念,我们将使用 Axon 框架提供的便利性来实现本章的查询端。在第十章 中,开始分解之旅,我们将探讨是否有必要使用专门的框架(如 Axon)来实现查询端。
当实现查询端时,我们有两个关注点需要解决,如下面的图示所示:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_Figure_7.3.jpg
图 7.3 -- 查询端剖析
这些关注点如下:
-
消费领域事件和持久化一个或多个查询模型
-
将查询模型公开为 API
在我们开始实现这些关注点之前,让我们确定我们需要为我们的 LC 发放应用程序实现的查询。
识别查询
从事件风暴会议中,我们开始有以下查询:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_Figure_7.4.jpg
图 7.4 -- 识别到的查询
在事件风暴会议的输出(如图 7.1 所示)中用绿色标记的查询都需要我们暴露各种状态的 LC 集合。为了表示这一点,我们可以创建一个 LCView 类,这是一个没有任何逻辑的极其简单的对象,如下所示:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/ch7-1.jpg
这些查询模型是实现由业务需求决定的基本功能的绝对必要条件。但是,随着系统需求的发展,我们很可能还需要额外的查询模型。我们将根据需要增强我们的应用程序以支持这些查询。
创建查询模型
如第五章 中所述,实现领域逻辑 ,当启动一个新的 LC 应用程序时,导入器发送 StartNewLCApplicationCommand,这将导致 LCApplicationStartedEvent 被触发,如下所示:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/ch7-2.jpg
让我们编写一个事件处理组件,它将监听此事件并构建查询模型。在处理 Axon 框架时,我们可以通过用 @EventHandler 注解标注事件监听方法来方便地完成这项工作。
要使任何方法成为事件监听器,我们需要用 @EventHandler 注解来标注它:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/ch7-3.jpg
-
要使任何方法成为事件监听器,我们需要用
@EventHandler注解来标注它。 -
处理方法需要指定我们打算监听的事件。事件处理器还支持其他一些参数。请参阅 Axon 框架文档以获取更多信息。
-
我们最终将查询模型保存在合适的查询存储中。在持久化这些数据时,我们应该考虑以优化数据访问的形式存储。换句话说,我们希望在查询这些数据时尽可能减少复杂性和认知负荷。
@EventHandler 注解不应与我们在第五章 中查看的 @EventSourcingHandler 注解混淆,实现领域逻辑 。@EventSourcingHandler 注解用于在命令端加载事件源聚合时重放事件并恢复聚合状态,而 @EventHandler 注解用于在聚合上下文之外监听事件。换句话说,@EventSourcingHandler 注解仅用于聚合内部,而 @EventHandler 注解可以在需要消费领域事件的地方使用。在这种情况下,我们正在使用它来构建查询模型。
查询端持久化选择
以这种方式隔离查询端使我们能够选择最适合在查询端解决问题的持久化技术。例如,如果极端性能和简单的过滤标准很重要,那么选择一个内存存储如 Redis 或 Memcached 可能是明智的。如果需要支持复杂的搜索/分析要求和大数据集,那么我们可能想考虑像 Elasticsearch 这样的东西。或者,我们甚至可以简单地选择坚持使用关系数据库。我们想强调的是,采用 CQRS 提供了一种以前我们没有的灵活性级别。
暴露查询 API
申请者喜欢查看他们创建的 LC,特别是处于草稿状态的 LC。让我们看看我们如何实现此功能。让我们首先定义一个简单的对象来捕获查询标准:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/ch7-4.jpg
让我们使用 Spring 的仓库模式来实现查询,以检索这些标准的结果:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/ch7-5.jpg
-
这是我们将用于查询数据库的动态 Spring 数据查找方法。
-
Axon 框架提供的
@QueryHandler注解将查询请求路由到相应的处理器。 -
最后,我们调用查找方法以返回结果。
在前面的示例中,为了简洁起见,我们在仓库本身中实现了QueryHandler方法。QueryHandler也可以放在其他地方。
为了将此与 UI 连接,我们在BackendService中添加了一个新方法(最初在第六章,*实现用户界面 - 基于任务的实现)中介绍)来调用查询,如下所示:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/ch7-6.jpg
-
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 框架时,这相当简单:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/ch7-7.jpg
可以使用@DisallowReplay(或其对应项@AllowReplay)来明确标记事件处理器在回放期间不可运行。
事件作为 API
在一个事件源系统中,事件被持久化而不是领域状态,事件结构随时间演变是很自然的。考虑一个BeneficiaryInformationChangedEvent的例子,它在一段时间内发生了演变,如下所示:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_Figure_7.5.jpg
图 7.5 -- 事件演变
由于事件存储是不可变的,我们可以设想对于给定的 LC,我们可能有一组或多个这些事件版本。这可能会在我们执行事件回放时带来一系列需要做出的决策:
-
生产者可以简单地提供事件存储中存在的历史事件,并允许消费者处理事件的旧版本。
-
生产者可以在将事件暴露给消费者之前将旧版本的事件升级到最新版本。
-
允许消费者指定他们能够处理的事件的显式版本,并在将其暴露给消费者之前将其升级到该版本。
-
随着演变的进行,将事件存储中的事件迁移到最新版本。考虑到事件存储中事件的不可变性承诺,这可能不可行。
你选择哪种方法完全取决于你的具体环境和生产者/消费者生态系统的成熟度。Axon 框架为它们称为事件上溯的过程提供了规定,允许事件在消费前即时升级。请参阅 Axon 框架文档以获取更多详细信息。
在事件驱动系统中,事件是你的 API。这意味着在做出生命周期管理决策(例如,版本控制、弃用和向后兼容性)时,你需要应用与 API 相同的严谨性。
摘要
在本章中,我们研究了如何实现基于 CQRS 的系统的查询端。我们探讨了如何实时消费领域事件来构建可用来服务查询 API 的物化视图。我们研究了可以用来高效访问底层查询模型的不同查询类型。最后,我们探讨了查询端的持久化选项。
最后,我们探讨了历史事件回放及其如何被用于在事件驱动系统中纠正错误或引入新功能。
本章应使您对如何构建和演进基于 CQRS 的系统查询端以满足不断变化的企业需求,同时保留命令端的所有业务逻辑有一个良好的理解。
在本章中,我们探讨了如何以无状态的方式消费事件(即没有两个事件处理器知道彼此的存在)。在下一章中,我们将继续探讨如何消费事件,但这次是以有状态的方式进行,即通过长时间运行的用户事务(也称为叙事)。
进一步阅读
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/ddd-java/img/B16716_07_Table_01.jpg