整洁架构实践指南-全-

整洁架构实践指南(全)

原文:zh.annas-archive.org/md5/40e2fd564d25ef6a5ccbc1d504238acf

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

如果你拿起这本书,你关心你正在构建的软件架构。你希望你的软件不仅满足客户的明确要求,还要满足可维护性的隐性要求,以及你自己的关于结构和美学的需求。

满足这些要求很难,因为软件项目(或者更一般地说,项目)通常不会按计划进行。经理们在整个项目团队周围设定截止日期 1,外部合作伙伴构建的 API 与承诺的不同,而我们依赖的软件产品并不按预期工作。

1 “截止日期”这个词可能起源于 19 世纪,描述的是围绕监狱或囚犯营地画的一条线。越过这条线的囚犯会被枪毙。下次有人在你周围“设定截止日期”时,想想这个定义……这肯定会打开新的视角。参见www.merriam-webster.com/words-at-play/your-deadline-wont-kill-you

然后是我们的软件架构。一开始它非常美好。一切都很清晰和美丽。然后截止日期迫使我们走捷径。现在,架构中只剩下这些捷径,交付新功能所需的时间越来越长。

我们以捷径驱动的架构使得对必须更改的 API 做出反应变得困难,因为外部合作伙伴出了问题。似乎更容易让我们的项目经理与该合作伙伴战斗,告诉他们交付我们已同意的 API。

现在,我们已经放弃了所有对局势的控制。很可能会发生以下事情之一:

  • 项目经理不足以赢得与外部合作伙伴的战斗

  • 外部合作伙伴在 API 规范中找到一个漏洞,证明他们是正确的

  • 外部合作伙伴需要再过<在此处输入数字>个月来修复 API

所有这些都导致同样的结果——我们必须快速更改代码,因为截止日期即将到来。

我们又添加了一个捷径。

这本书采取的立场不是让外部因素控制我们的软件架构状态,而是我们自己掌握控制权。我们通过创建一个使软件变得“灵活”、“可扩展”和“可适应”的架构来实现这种控制。这样的架构将使我们对外部因素的反应变得容易,并减轻我们背上的压力。

这本书的目标

我写这本书是因为我对领域中心架构风格(如 Robert C. Martin 的《Clean Architecture》和 Alistair Cockburn 的《Hexagonal Architecture》)可用资源的实用性感到失望。

许多书籍和在线资源解释了有价值的概念,但没有说明我们如何实际实现它们。

这可能是因为实现任何架构风格都有不止一种方法。

通过这本书,我试图通过提供关于以六边形架构或“端口和适配器”风格创建 Web 应用程序的动手代码讨论来填补这一空白。为了达到这个目标,本书中讨论的代码示例和概念提供了我对如何实现六边形架构的解释。当然,还有其他解释,我并不声称我的解释是权威的。

然而,我确实希望,你能从这本书中的概念中获得一些灵感,以便你可以创建自己对于六边形/整洁架构的解释。

本书面向对象

本书面向所有层次的软件开发者,他们参与创建 Web 应用程序。作为一名初级开发者,你将学习如何以干净和可维护的方式设计软件组件和应用程序。你还将学习一些何时应用特定技术的论点。然而,你应该参与过构建 Web 应用程序,以便充分利用这本书。如果你是经验丰富的开发者,你将享受将书中的概念与自己的做事方式进行比较,并希望将一些片段融入自己的软件开发风格中。本书中的代码示例使用 Java 和 Kotlin 编写,但所有讨论都同样适用于其他面向对象编程语言。如果你不是 Java 程序员,但能阅读其他语言中的面向对象代码,你没问题。在需要一些 Java 或框架特定信息的地方,我会解释它们。

示例应用

为了在整本书中保持一个反复出现的主题,大多数代码示例展示了用于在线转账的示例 Web 应用程序的代码。我们将称之为“BuckPal”。2

2 BuckPal:快速在线搜索发现,一家名为 PayPal 的公司盗用了我的想法,甚至复制了部分名称。开玩笑地说:尝试找到一个与“PayPal”类似但不属于现有公司名称的名字。这真是太有趣了!

BuckPal 应用程序允许用户注册账户,在账户之间转账,并查看账户上的活动(存款和取款)。

我绝不是金融专家,所以请不要根据法律或功能正确性来判断示例代码。相反,请根据结构和可维护性来判断。

软件工程书籍和在线资源中示例应用的诅咒在于它们过于简单,无法突出我们每天努力解决的现实世界问题。另一方面,示例应用必须足够简单,才能有效地传达讨论的概念。

我希望在我们讨论 BuckPal 应用程序的用例时,在“过于简单”和“过于复杂”之间找到了一个平衡。

示例应用的代码可以在 GitHub 上找到。3

3 BuckPal GitHub 仓库:github.com/thombergs/buckpal

下载彩色图像

我们还提供包含本书中使用的截图和图表的彩色图像的 PDF 文件。请查看本书末尾的注释。4

4 本书使用的彩色图像的 PDF 版本:packt.link/eBKMn

联系我们

如果您对本书有任何意见,我非常乐意听到!请通过电子邮件直接与我联系至 tom@reflectoring.io 或在 Twitter 上通过@TomHombergs联系我。

总体反馈:如果您对本书的任何方面有任何疑问,请通过电子邮件发送至 customercare@packtpub.com,并在邮件主题中提及书名。

勘误:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在本书中发现任何错误,我们将不胜感激,如果您能向我们报告,我们将不胜感激。请访问 www.packtpub.com/support/errata 并填写表格。

盗版:如果您在互联网上以任何形式发现我们作品的非法副本,我们将不胜感激,如果您能提供位置地址或网站名称,我们将不胜感激。请通过 copyright@packt.com 与我们联系,并提供材料的链接。

如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为本书做出贡献,请访问 authors.packtpub.com。

分享您的想法

一旦您阅读了《Get Your Hands Dirty on Clean Architecture–Second Edition》,我们非常乐意听到您的想法!请点击此处直接进入本书的亚马逊评论页面并分享您的反馈。

您的评论对我们和科技社区非常重要,并将帮助我们确保我们提供高质量的内容。

下载本书的免费 PDF 副本

感谢您购买本书!

您喜欢在路上阅读,但无法携带您的印刷书籍到处走?

您的电子书购买是否与您选择的设备不兼容?

不要担心,现在,每购买一本 Packt 书籍,您都可以免费获得该书的 DRM 免费 PDF 版本。

在任何地方、任何设备上阅读。直接从您最喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。

优惠远不止这些,您还可以获得独家折扣、时事通讯和每日收件箱中的精彩免费内容。

按照以下简单步骤获取优惠:

  1. 扫描二维码或访问以下链接

图片

packt.link/free-ebook/9781805128373

  1. 提交您的购买证明

  2. 就这些了!我们将直接将您的免费 PDF 和其他优惠发送到您的电子邮件。

第一章:可维护性

这本书是关于软件架构的。架构的一个定义是系统或过程的结构。在我们的情况下,它是软件系统的结构。

架构是设计这个结构以达到某种目的。我们是有意识地设计我们的软件系统以满足某些要求。软件必须满足的功能性要求是为了为用户创造价值。没有功能性,软件就毫无价值,因为它不会产生价值。

同样,软件还应该满足一些质量要求(也称为非功能性要求),以使其用户、开发人员和利益相关者认为它是高质量的。其中一个质量要求就是可维护性

如果我告诉你,可维护性作为一个质量属性,从某种意义上说,比功能更重要,而且我们应该优先设计软件的可维护性,而不是其他一切,你会说什么?一旦我们确立了可维护性作为一个重要的质量,我们将使用本书的其余部分来探讨我们如何通过应用清洁和六边形架构的概念来提高我们软件的可维护性。

可维护性到底是什么意思?

在你把我当作疯子并开始寻找退书选项之前,让我解释一下我所说的可维护性是什么意思。

可维护性只是构成软件架构的许多潜在质量要求之一。我向 ChatGPT 询问了一份质量要求清单,这是结果:

  • 可扩展性

  • 灵活性

  • 可维护性

  • 安全性

  • 可靠性

  • 模块化

  • 性能

  • 互操作性

  • 可测试性

  • 成本效益

列表还没有结束。1

1 关于软件质量的一些灵感(这是由人类创造的,而不是语言模型),请查看 quality.arc42.org/

作为软件架构师,我们设计我们的软件以满足对软件最重要的质量要求。对于一个高吞吐量交易应用,我们可能会关注可扩展性和可靠性。对于一个处理德国个人可识别信息的应用,我们可能希望关注安全性。

我认为将可维护性与其他质量要求混为一谈是不正确的,因为可维护性是特殊的。如果软件是可维护的,这意味着它很容易改变。如果它容易改变,它就灵活,可能模块化。它也可能具有成本效益,因为容易的改变意味着低成本的改变。如果它是可维护的,我们可能可以将其演变为可扩展的、安全的、可靠的和性能良好的,如果需要的话。我们可以改变软件以与其他系统互操作,因为它容易改变。最后但同样重要的是,可维护性意味着可测试性,因为可维护的软件最有可能是由较小的、更简单的组件设计的,这使得测试变得容易。

你可以在这里看到我所做的工作。我向人工智能请求了一份质量要求列表,并将它们全部与可维护性联系起来。我可能可以用类似合理的论据将更多的质量要求与可维护性联系起来。当然,这有点过于简单化,但核心是真实的:如果软件是可维护的,它就更容易在任何方向上进化,无论是功能性的还是非功能性的。我们都知道,在软件系统的生命周期中,变化是常见的。

可维护性使功能得以实现

现在回到我在这章开头提出的观点:可维护性比功能性更重要。

如果你问一个产品人员一个软件项目中最重要的东西是什么,他们会告诉你,软件为用户提供的价值是最重要的。对用户没有价值的软件意味着用户不会为其付费。而没有付费的用户,我们就无法拥有一个可行的商业模式,这是商业世界中成功的主要衡量标准。

因此,我们的软件需要提供价值。但是,它不应该以牺牲可维护性为代价。2 想想,与那些你必须逐行代码地艰难推进的软件系统相比,向一个易于更改的软件系统添加功能要高效和愉快得多!我非常确信,你曾经参与过那些充斥着冗余和仪式的软件项目,要完成一个你认为只需几个小时就能完成的功能,却需要花费几天或几周的时间。

2 在本书的语境中,我将“可维护性”一词与“代码库的可更改性”同义使用。有关可维护性的定义,请参阅quality.arc42.org/qualities/maintainability(所有这些都与更改软件有关)。

以这种方式,可维护性是功能性的关键支持者。糟糕的可维护性意味着功能的变化随着时间的推移变得越来越昂贵,如图 图 1.1 所示:

图 1.1 – 一个可维护的软件系统比一个不太可维护的软件系统具有更小的生命周期成本

图 1.1 – 一个可维护的软件系统比一个不太可维护的软件系统具有更小的生命周期成本

在一个不太可维护的软件系统中,功能的变化很快就会变得非常昂贵,以至于变化变得痛苦。产品人员会对工程师抱怨变化的成本。工程师会通过说推出新功能始终比提高可维护性具有更高的优先级来为自己辩护。随着变化成本的提高,冲突的可能性也会增加。

可维护性是一种安抚剂。它与变更成本成反比,因此与冲突的可能性成反比。你有没有想过在软件系统中添加可维护性以避免冲突?我认为那本身就是一项很好的投资。

但那些尽管可维护性差却取得成功的庞大软件系统怎么办?确实,有些商业上成功的软件系统几乎不可维护。我工作过的系统中,向表单中添加一个字段的项目需要花费开发者几周的时间,而客户很高兴为我的时间支付溢价。

这些系统通常属于以下一个(或两个)类别之一:

  • 它们处于生命周期的末期,系统变更很少

  • 他们背后有一个财务状况良好的公司,愿意花钱解决问题

即使公司有大量的资金可以花费,公司也会意识到,通过投资可维护性,他们可以减少维护成本。因此,通常,已经有计划进行中,以使软件更具可维护性。

我们应该始终关注我们正在创建的软件的可维护性,以免其退化成令人恐惧的大泥球,但如果我们的软件不属于之前提到的两种类别之一,我们应该更加关注。

这是否意味着我们甚至在开始编程之前就必须花大量时间规划可维护的架构?我们是否必须进行大设计前期(通常被认为与瀑布方法同义)?不,我们不必。但我们确实需要进行一些前期设计(我们应该称之为SDUF?)来将可维护性的种子植入软件中,这样随着时间的推移,可以更容易地将架构演变到所需的状态。

那部分前期设计包括选择一种定义我们正在构建的软件的护栏的架构风格。这本书将帮助你决定纯净——或端口和适配器/六边形——架构是否适合你的环境。

可维护性带来开发者的快乐

作为一名开发者,你更愿意在易于更改的软件上工作,还是更愿意在难以更改的软件上工作?不必回答;这是一个修辞问题。

除了直接影响变更成本之外,可维护性还有另一个好处:它使开发者快乐(或者,根据他们目前正在从事的项目,至少使他们不那么悲伤)。

我想用来描述这种快乐的是开发者快乐。它也被称为开发者体验开发者赋能。无论我们称之为什么,它都意味着我们提供了开发者完成工作所需的环境。

开发者快乐与开发者生产力直接相关。一般来说,如果开发者快乐,他们会做得更好。而且如果他们做得好,他们会更快乐。开发者快乐与开发者生产力之间存在双向相关性:

图 1.2 – 开发者快乐影响开发者生产力,反之亦然

图 1.2 – 开发者快乐影响开发者生产力,反之亦然

这种相关性已经在开发者生产力SPACE 框架中被认可。3 虽然 SPACE 没有提供一个关于如何衡量开发者生产力的简单答案,但它提供了五个类别来衡量此类指标,以便我们可以有意识地选择一组涵盖所有这些类别的指标,以最好地衡量我们公司和项目背景下的开发者生产力。其中这些类别之一(SPACE中的S)是满意度和福祉,我在本章将其翻译为开发者快乐。

3 《开发者生产力空间》 由 Nicole Forsgren 等人著,2021 年 3 月 6 日。“SPACE”代表满意度与福祉、绩效、活动、沟通与协作,以及效率和流程。参见queue.acm.org/detail.cfm?id=3454124

开发者快乐不仅会导致更高的生产力,而且自然会带来更好的留存率。一个享受自己工作的开发者会留在公司。或者更确切地说,一个不喜欢自己工作的开发者更有可能离开去寻找更广阔的天地。

那么,可维护性是如何进入画面的呢?嗯,如果我们的软件系统是可维护的,我们实施更改所需的时间会更少,因此我们更有效率。此外,如果我们的软件系统是可维护的,我们在进行更改时会感到更多的快乐,因为效率更高,我们可以从中获得更多的自豪感。即使我们的软件的可维护性不如我们希望的那样(坦白说,这是一个同义反复),但如果我们有机会随着时间的推移提高可维护性,我们会更加快乐和高效。如果我们快乐,我们更有可能留下来。

以图表形式表达,看起来是这样的:

图 1.3 – 可维护性直接影响开发者快乐和生产力,而开发者快乐影响留存

图 1.3 – 可维护性直接影响开发者快乐和生产力,而开发者快乐影响留存

可维护性支持决策

在构建软件系统时,我们每天都要解决问题。对于大多数我们面临的问题,都有不止一个解决方案。我们必须做出决定,在那些解决方案之间进行选择。

我们是否为构建的新功能复制这段代码?我们是自己创建对象还是使用依赖注入框架?我们是使用重载构造函数来创建这个对象,还是创建一个构建器?

我们中的许多决定甚至都不是有意识地做出的。我们只是应用之前使用过的模式或原则,直觉告诉我们这些在当前情况下会有效,如下所示:

  • 当我们发现代码重复时,我们应用不要重复自己(DRY)原则

  • 我们使用依赖注入来使代码更易于测试

  • 我们引入了一个构建者来简化对象的创建过程

如果我们看看这些以及其他许多众所周知的模式,那么它们的效果是什么?在许多情况下,主要的效果是使代码在未来更容易更改(即,使它更易于维护)。可维护性已经融入了我们每天自动做出的许多决策中!

我们甚至可以在面对需要更多比仅仅应用预定义模式更复杂的决策时利用这一点。每当我们必须在多个选项之间做出决定时,我们可以选择那些在未来使代码更容易更改的选项。4 不再需要在不同的选项之间痛苦挣扎。我们只需选择最能提高可维护性的那个。用图表表达,这很简单:

4 在 2022 年的一次同名演讲中,(实用主义)Dave Thomas 将基于可变性的决策原则称为“一条规则统治一切”。我没有在网上找到这次演讲,但希望他将来会在他的网站上添加。请参阅 pragdave.me/talks-and-interviews.html

图 1.4 – 可维护性影响决策

图 1.4 – 可维护性影响决策

就像大多数原则一样,这当然是一种概括。在特定的情境下,正确的决策可能是不提高甚至降低可维护性的选项。但作为一个默认的规则,选择可维护性是一个简化日常决策的指南。

维护可维护性

好的,我假设你相信我,认为可维护性对开发者的快乐、生产力和决策能力有积极影响。我们如何知道我们对代码库所做的更改增加了(至少没有减少)可维护性?我们如何随着时间的推移管理可维护性?

那个问题的答案是创建和维护一个使创建可维护代码变得容易的架构。一个好的架构使得在代码库中导航变得容易。在一个易于导航的代码库中,修改现有功能或添加新功能变得轻而易举。我们应用程序组件之间的依赖关系清晰且不混乱。总之,好的架构提高了可维护性:

图 1.5 – 软件架构影响可维护性

图 1.5 – 软件架构影响可维护性

通过扩展,一个好的架构可以提升开发者的快乐、开发效率、留存率以及决策能力。我们可以继续探讨更多直接或间接受到软件架构影响的事物。

这种相关性意味着我们应该在如何构建我们的代码结构上投入一些思考。我们如何将代码文件分组为组件?我们如何管理这些组件之间的依赖关系?哪些依赖是必要的,哪些应该被劝阻以保持代码库的灵活性,便于更改?这引出了本书的目的。本书展示了一种构建代码库的方法,使其易于维护。本书中描述的架构风格是实现 Clean/六边形架构的一种方式。然而,这种架构风格并不是解决所有软件构建问题的万能钥匙。正如我们将在第十五章,“选择架构风格”中学习到的,它并不适合所有类型的软件应用。

我鼓励你将本书中学到的知识运用到实践中,尝试不同的想法,修改它们以使其成为你自己的,然后将它们添加到你的工具箱中,以便在特定情境下觉得合适时应用。以下每一章的结尾都有一个名为这如何帮助我构建可维护的软件?的部分。这一部分将总结每一章的主要思想,并希望帮助你做出关于当前或未来软件项目架构的决定。

第二章:层的问题是什么?

很可能你过去已经开发了一个分层(网络)应用。你甚至可能正在你的当前项目中这样做。

在计算机科学课程、教程和最佳实践中,我们已经习惯了层级的思维方式。甚至在书中也有教授。1

1 作为一种模式,层在 Mark Richards 的《软件架构模式》一书中被教授,O'Reilly,2015 年。

图 2.1 – 传统 Web 应用架构由网络层、领域层和持久化层组成

图 2.1 – 传统 Web 应用架构由网络层、领域层和持久化层组成

图 2.1 展示了非常常见的三层架构的高级视图。我们有一个网络层,它接收请求并将它们路由到领域层中的服务。2 该服务执行一些业务逻辑,并从持久化层调用组件以查询或修改数据库中我们的领域实体的当前状态。

2 领域与业务:在这本书中,我使用“领域”和“业务”这两个词同义。领域层或业务层是代码中解决业务问题的位置,与解决技术问题的代码不同,例如在数据库中持久化事物或处理 Web 请求。

你知道吗?层是一种稳固的架构模式!如果我们做得正确,我们就能构建出独立于网络和持久化层的领域逻辑。如果需要的话,我们可以更换网络或持久化技术,而不会影响我们的领域逻辑。我们还可以在不影响现有功能的情况下添加新功能。

好的分层架构使我们保持选择余地,能够快速适应不断变化的需求和外部因素(例如我们的数据库供应商一夜之间将价格翻倍)。好的分层架构是可维护的。

那么,层有什么问题呢?

根据我的经验,分层架构非常容易受到变化的影响,这使得它难以维护。它允许不良依赖性逐渐渗透,使得软件随着时间的推移越来越难以更改。层没有提供足够的护栏来保持架构的轨迹。我们需要过度依赖人类的纪律和勤奋来保持其可维护性。

在接下来的章节中,我会告诉你原因。

它们促进了数据库驱动的设计

根据其定义,传统分层架构的基础是数据库。网络层依赖于领域层,领域层又依赖于持久化层和数据库。一切都是在持久化层之上构建的。这有几个问题。

让我们退一步,思考一下我们在构建几乎任何应用程序时试图实现的目标。我们通常试图创建一个模型,以反映管理业务的规则或“政策”,以便用户更容易与之互动。

我们主要试图模拟行为,而不是状态。是的,状态是任何应用程序的一个重要部分,但行为是改变状态并因此推动业务的东西!

那么,为什么我们要将数据库作为我们架构的基础,而不是领域逻辑?

想想你在任何应用程序中最近实现的使用案例。你是从实现领域逻辑还是持久层开始的?很可能是你考虑了数据库结构会是什么样子,然后才继续在它之上实现领域逻辑。

在传统的分层架构中,这是有意义的,因为我们遵循依赖关系的自然流动。但从业务角度来看,这完全没有意义!我们应该在构建其他任何东西之前先构建领域逻辑!我们想要弄清楚我们是否正确理解了业务规则。只有当我们知道我们正在构建正确的领域逻辑时,我们才应该继续构建围绕它的持久层和 Web 层。

在这种以数据库为中心的架构中,一个推动力是使用对象关系映射ORM)框架。请别误会,我非常喜欢这些框架,并且经常使用它们。但如果我们结合 ORM 框架和分层架构,我们很容易被诱惑将业务规则与持久性方面混合在一起。

图 2.2 – 在领域层中使用数据库实体会导致与持久层的强耦合

图 2.2 – 在领域层中使用数据库实体会导致与持久层的强耦合

通常,我们将在持久层中作为 ORM 管理的实体,如图图 2.2所示。由于一个层可能访问其下方的层,领域层被允许访问这些实体。如果允许它们使用,它们最终会使用它们。

这在领域层和持久层之间创建了一个强耦合。我们的业务服务使用持久性模型作为它们的业务模型,不仅要处理领域逻辑,还要处理 eager(急切)加载与 lazy(延迟)加载、数据库事务、刷新缓存和类似的维护任务。3

3 在他的开创性著作《重构》(Pearson,2018)中,马丁·福勒将这种症状称为“发散性变化”:为了实现一个功能而不得不更改看似无关的代码部分。这是一个应该触发重构的代码异味。

持久性代码几乎与领域代码融合在一起,因此很难在不影响另一个的情况下对其进行更改。这与灵活性和保持选项开放的目标相反,而我们的架构应该追求这样的目标。

它们容易走捷径

在传统的分层架构中,唯一的全局规则是从某个层开始,我们只能访问同一层或其下层的组件。可能还有开发团队达成一致的其他规则,其中一些甚至可能由工具强制执行,但分层架构风格本身并不强加这些规则给我们。

因此,如果我们需要访问我们之上层的某个组件,我们只需将组件向下推一层,我们就被允许访问它。问题解决。做一次可能没问题。但做一次就打开了做第二次的门。如果其他人被允许这么做,那么我也被允许,对吧?

我并不是说作为开发者,我们会轻易地采取这样的捷径。但如果有一个做某事的选择,有人会这么做,尤其是在面临即将到来的截止日期的情况下。而且如果某件事已经做过,有人再次做它的可能性会大大增加。这是一种称为破窗理论的心理效应 – 更多内容请见第十一章有意识地采取 捷径

图 2.3 – 由于任何层都可以访问持久层中的所有内容,随着时间的推移,它往往会变得臃肿

图 2.3 – 由于任何层都可以访问持久层中的所有内容,随着时间的推移,它往往会变得臃肿

在多年的软件开发和维护过程中,持久层可能会变得像图 2**.3所示的那样。

随着我们通过层将组件向下推,持久层(或者更通用地,最底层的层)会变得臃肿。这种组件的完美候选者是辅助或实用组件,因为它们似乎不属于任何特定的层。

因此,如果我们想为我们的架构禁用快捷模式,层不是最佳选择,至少在没有强制执行某些额外的架构规则的情况下不是。而当我提到强制执行时,我并不是指高级开发者进行代码审查,而是当规则被破坏时自动强制执行的规则,导致构建失败。

它们变得难以测试

在分层架构中,一个常见的演变是跳过层。我们直接从网络层访问持久层,因为我们只操作一个实体的单个字段,为此,我们不需要麻烦领域层,对吧?

图 2.4 – 跳过领域层往往会将领域逻辑分散到代码库中

图 2.4 – 跳过领域层往往会将领域逻辑分散到代码库中

图 2**.4展示了我们如何跳过领域层,直接从网络层访问持久层。

再次强调,前几次可能感觉没问题,但如果经常发生(一旦有人迈出了第一步,就会这样),它有两个缺点。

首先,我们在网络层实现领域逻辑,即使它只是操作单个字段。如果用例在未来扩展,我们很可能会在网络层添加更多领域逻辑,混合责任并使关键领域逻辑散布在所有层中。

其次,在我们的网络层单元测试中,我们不仅要管理对领域层的依赖,还要管理对持久化层的依赖。如果我们使用模拟进行测试,这意味着我们必须为这两个层创建模拟。这增加了测试的复杂性。而复杂的测试设置是走向完全没有测试的第一步,因为我们没有时间进行它们。随着时间的推移,网络组件可能会积累大量对不同的持久化组件的依赖,从而增加了测试的复杂性。在某个时候,我们理解依赖和为它们创建模拟所需的时间可能比实际编写测试代码的时间还要多。

它们隐藏了用例

作为开发者,我们喜欢创建实现新用例的新代码。但通常我们花费在修改现有代码上的时间比创建新代码的时间要多得多。这不仅适用于那些令人讨厌的遗留项目,在这些项目中我们正在处理几十年的代码库,也适用于在初始用例实现之后的热门新绿色项目。

由于我们经常需要寻找合适的地点来添加或更改功能,我们的架构应该帮助我们快速导航代码库。分层架构在这方面表现如何?

如前所述,在分层架构中,领域逻辑很容易散布在各个层中。如果我们跳过“简单”用例的领域逻辑,它可能存在于网络层。如果我们把某个组件推到持久化层,以便从领域层和持久化层都可以访问它,它可能存在于持久化层。这已经使得找到添加新功能正确位置变得困难。

但还有更多。分层架构并没有对领域服务的“宽度”强加规则。随着时间的推移,这往往会导致非常广泛的服务,服务于多个用例(见图2**.5)。

图 2.5 – “广泛”的服务使得在代码库中找到特定用例变得困难

图 2.5 – “广泛”的服务使得在代码库中找到特定用例变得困难

广泛的服务对持久化层有众多依赖,而网络层的许多组件都依赖于它。这不仅使得服务难以测试,也使得我们难以找到我们想要工作的用例所负责的代码。

如果我们拥有高度专业化的、狭窄的领域服务,每个服务都服务于单个用例,那会容易多少?我们不必在UserService中搜索用户注册用例,我们只需打开RegisterUserService并开始修改。

它们使得并行工作变得困难

管理层通常期望我们在某个日期完成他们资助的软件开发。实际上,他们甚至期望我们在一定的预算内完成,但在这里我们不要使事情复杂化。

除了我作为软件工程师的职业生涯中从未见过“完成”的软件之外,到某个日期“完成”通常意味着多个人必须并行工作。

你可能知道“《神话般的月份》”中的这个著名结论,即使你没有读过这本书:向一个落后的软件项目增加人力会使它更晚。4

44 《神话般的月份:软件工程论文集》由 Frederick P. Brooks, Jr.著,Addison-Wesley,1995 年。

在某种程度上,这也适用于尚未落后的软件项目。你不能期望一个由 50 名开发者组成的大型团队比一个 10 人的小团队快 5 倍。如果他们正在开发一个非常大的应用程序,可以分成子团队并分别处理软件的不同部分,这可能行得通,但在大多数情况下,他们可能会互相干扰。

但在健康规模上,我们当然可以期待随着项目人员的增加而加快速度。管理层有权利期望我们这样做。

为了满足这一期望,我们的架构必须支持并行工作。这并不容易。而且分层架构在这里实际上并没有太大帮助。

想象一下,我们正在向我们的应用程序添加一个新的用例。我们有三名开发者可用。一个可以添加到网络层所需的功能,一个到领域层,第三个到持久化层,对吧?

嗯,在分层架构中,通常不是这样工作的。由于一切都是在持久化层之上构建的,因此持久化层必须首先开发。然后是领域层,最后是网络层。所以一次只能有一个开发者工作在功能上!

“啊,但开发者可以先定义接口,”你说,“然后每个开发者都可以针对这些接口工作,而无需等待实际实现。”

当然,这是可能的,但前提是我们没有像之前讨论的那样混合领域和持久化逻辑,这阻止了我们分别处理每个方面。

如果我们的代码库中服务众多,甚至可能难以并行处理不同的功能。在处理不同的用例时,会导致同一服务并行编辑,这会导致合并冲突和潜在的回归。

这如何帮助我构建可维护的软件?

如果你以前构建过分层架构,你可能能够与本章中讨论的一些问题产生共鸣,甚至可能添加一些更多的问题。

如果做得正确,并且对它施加一些额外的规则,分层架构可以非常易于维护,并且可以使更改或添加到代码库变得轻而易举。

然而,讨论表明分层架构允许许多事情出错。如果没有良好的自律,它随着时间的推移容易退化,变得难以维护。而且,每当团队成员进出团队,或者经理为开发团队设定新的截止日期时,我们的自律通常都会受到打击。

将分层架构的陷阱牢记于心,将有助于我们下次在争论反对走捷径,而支持构建更易于维护的解决方案时——无论是分层架构还是其他架构风格。

第三章:依赖反转

在上一章讨论分层架构之后,你期待这一章讨论一种替代方法是很自然的。我们将从讨论SOLID1 原则中的两个开始,然后应用它们来创建一个整洁六边形架构,以解决分层架构的问题。

1 SOLID 代表单一责任原则、开闭原则、里氏替换原则、接口隔离原则和依赖反转原则。你可以在 Robert C. Martin 的《整洁架构》或维基百科上了解更多关于这些原则的信息:en.wikipedia.org/wiki/SOLID

单一责任原则

软件开发中的每个人可能都知道单一责任原则SRP)或者至少认为自己知道。这个原则的常见解释是这样的:

组件应该只做一件事,并且做好这件事

这是一条很好的建议,但并非 SRP 的实际意图。

只做一件事实际上是“单一责任”最明显的解释,因此 SRP 经常被这样解释。我们只需观察 SRP 的名称具有误导性。

这是 SRP 的实际定义:

组件应该只有一个改变的理由

正如我们所见,“责任”实际上应该翻译为“改变的理由”,而不是“只做一件事”。也许我们应该将 SRP 重命名为“单一改变理由原则”。

如果一个组件只有一个改变的理由,它最终可能只做一件事,但更重要的是,它只有一个改变的理由。

这对我们架构意味着什么?

如果一个组件只有一个改变的理由,那么在改变软件的其他任何原因时,我们不必担心这个组件,因为我们知道它仍然会按预期工作。

可惜,改变的理由很容易通过组件的依赖关系传播到其他组件(参见图 3.1)。

图 3.1 – 组件的每个依赖项都是改变此组件的可能原因,即使它只是一个传递依赖(虚线箭头)

图 3.1 – 组件的每个依赖项都是改变此组件的可能原因,即使它只是一个传递依赖(虚线箭头)

在前面的图中,组件A依赖于许多其他组件(无论是直接还是间接),而组件E则没有任何依赖。

改变组件E的唯一理由是当E的功能必须因为某些新需求而改变。然而,组件A可能需要改变,因为其他组件的任何改变都会影响它,因为它依赖于它们。

许多代码库随着时间的推移变得越来越难以更改——因此成本也更高——这是因为违反了单一职责原则。随着时间的推移,组件会积累越来越多的变化理由。一旦收集了许多变化理由,更改一个组件可能会引起另一个组件失败。

一个关于副作用的故事

我曾经参与过一个项目,我的团队继承了另一个软件公司开发的十年老代码库。客户决定更换开发团队以降低持续维护成本并提高新功能的开发速度。因此,我们获得了这个合同。

如预期的那样,理解代码的实际功能并不容易,我们在代码库的一个区域所做的更改往往会在其他区域产生副作用。但我们通过彻底测试、添加自动化测试和大量重构来应对。

在成功维护和扩展代码库一段时间后,客户提出了一个新功能的需求。他们希望我们以对软件用户非常不便的方式来实现这个功能。因此,我提出了一个更用户友好的方案,由于它需要的整体改动更少,所以实现起来甚至更加经济。然而,它需要在某个非常核心的组件上进行一个小改动。

客户拒绝了我们的建议,并要求采用更不便且昂贵的解决方案。当我询问原因时,他们表示担心副作用,因为之前开发团队对那个组件所做的更改过去总是导致其他地方出现问题。

很遗憾,这是一个例子,说明了你如何使客户为了修改糟糕的架构软件而额外付费。幸运的是,大多数客户不会参与这个游戏,所以让我们尝试构建良好的架构软件。

依赖倒置原则

在我们的分层架构中,跨层依赖始终指向下一层。当我们从高层次应用单一职责原则时,我们会注意到上层比下层有更多的变化理由。

因此,由于领域层对持久层有依赖,持久层中的每个更改都可能需要在领域层进行更改。但领域代码是我们应用程序中最重要的代码!我们不希望当持久层代码发生变化时不得不更改它!

那么,我们如何摆脱这种依赖呢?

依赖倒置原则DIP)提供了答案。

与 SRP 不同,DIP 意味着其名称所暗示的含义:

我们可以在我们的代码库中(反转)任何依赖的方向**2

2 实际上,我们只能在控制依赖两端代码的情况下反转依赖。如果我们依赖于第三方库,我们就不能反转它,因为我们不控制那个库的代码。

这是如何工作的?让我们尝试反转域代码和持久化代码之间的依赖关系,使持久化代码依赖于域代码,从而减少需要更改域代码的原因。

我们从一个类似于第二章《层有什么问题?》图 2.2的结构开始。我们有一个在域层中的服务,它使用持久化层中的实体和存储库。

首先,我们希望将实体提升到域层,因为它们代表我们的域对象,我们的域代码基本上是围绕改变这些实体的状态来进行的。

但现在,由于持久化层中的存储库依赖于现在位于域层的实体,我们在这两个层之间有一个循环依赖。这就是我们应用 DIP 的地方。我们在域层为存储库创建一个接口,并让持久化层中的实际存储库实现它。结果是类似于图 3.2中的那样。

图 3.2 – 通过在域层引入接口,我们可以反转依赖,使持久化层依赖于域层

图 3.2 – 通过在域层引入接口,我们可以反转依赖,使持久化层依赖于域层

通过这个技巧,我们使我们的域逻辑摆脱了对持久化代码的压迫性依赖。这是我们在接下来的章节中将要讨论的两个架构风格的核心特性。

清洁架构

罗伯特·C·马丁在他的同名书中提出了“清洁架构”这个术语。3 在他看来,清洁架构中的业务规则是设计可测试的,并且独立于框架、数据库、UI 技术以及其他外部应用程序或接口。

3 《清洁架构》,罗伯特·C·马丁著,普伦蒂斯·霍尔出版社,2017 年,第二十二章

这意味着域代码不能有任何面向外部的依赖。相反,借助 DIP(依赖倒置原则),所有依赖都指向域代码。

图 3.3展示了这样一个架构在抽象层面可能的样子。

图 3.3 – 在清洁架构中,所有依赖都指向域逻辑(来源:罗伯特·C·马丁的《清洁架构》)

图 3.3 – 在清洁架构中,所有依赖都指向域逻辑(来源:罗伯特·C·马丁的《清洁架构》)

这个架构中的层以同心圆的形式相互包裹。这种架构中的主要规则是“依赖规则”,它规定这些层之间的所有依赖都必须指向内部。

架构的核心包含领域实体,这些实体由周围的使用案例访问。使用案例就是我们之前所说的服务,但它们更细粒度,具有单一责任(即单一改变的理由),从而避免了我们之前讨论的宽泛的服务问题。

围绕这个核心,我们可以找到支持业务规则的所有其他应用程序组件。这种支持可能意味着提供持久性或提供用户界面,例如。此外,外部层可能为任何第三方组件提供适配器。

由于领域代码对使用的持久性或 UI 框架一无所知,它不能包含任何特定于这些框架的代码,并将专注于业务规则。我们拥有我们所能希望的所有自由来建模领域代码。例如,我们可以以最纯粹的形式应用领域驱动设计DDD)。不必考虑持久性或 UI 特定的问题使得这变得容易得多。

如我们所预期,整洁架构是有代价的。由于领域层完全与外部层(如持久性和 UI 层)解耦,我们必须在每个层中维护应用程序实体的模型。

假设,例如,我们在持久层使用一个对象关系映射ORM)框架。ORM 框架通常期望特定的实体类,这些类包含描述数据库结构和对象字段到数据库列映射的元数据。由于领域层不知道持久层,我们不能在领域层使用相同的实体类,而必须在两个层中都创建它们。这意味着持久层需要将领域实体映射到其自身的表示。在领域层和其他外部层之间也适用类似的映射。

但这是好事!这种解耦正是我们想要实现的,以使领域代码摆脱框架特定的问题。例如,Java 持久性 API(Java 世界中的标准对象关系 API)要求 ORM 管理的实体具有不带参数的默认构造函数,而我们可能不想在我们的领域模型中避免它。在第九章中,我们将讨论不同的映射策略,包括一种无映射策略,它只是接受领域和持久层之间的耦合。

由于罗伯特·C·马丁的《整洁架构》有些抽象,让我们深入一个层次,看看六边形架构,它给整洁架构原则提供了更具体的形状。

六边形架构

六边形架构这个术语来自阿利斯泰尔·科克本,已经存在一段时间了。4 它应用了罗伯特·C·马丁后来在整洁架构中以更一般术语描述的相同原则。

4 “六角架构”这个术语的主要来源似乎是一篇关于 Alistair Cockburn 网站的文章,网址为alistair.cockburn.us/hexagonal-architecture/

图 3.4 – 六角架构也被称为“端口和适配器”架构,因为应用程序核心为每个适配器提供特定的端口以进行交互

图 3.4 – 六角架构也被称为“端口和适配器”架构,因为应用程序核心为每个适配器提供特定的端口以进行交互

图 3.4展示了六角架构可能的样子。应用程序核心以六角形表示,这为这种架构风格命名。然而,六角形的形状没有意义,所以我们也可以画一个八边形并称之为“八角架构”。根据传说,六角形只是用来代替常见的矩形,以表明一个应用程序可以通过多于四条边连接到其他系统或适配器。

在六角形内部,我们找到我们的领域实体以及与这些实体一起工作的用例。请注意,六角形没有外部依赖,因此马丁的清洁架构中的依赖规则是正确的。相反,所有依赖都指向中心。

在六角形外部,我们找到各种适配器,它们与应用程序进行交互。可能有一个与网络浏览器交互的网络适配器,一些与外部系统交互的适配器,以及一个与数据库交互以实现持久性的适配器。

左侧的适配器是驱动我们应用程序的适配器(因为它们调用我们的应用程序核心),而右侧的适配器是由我们的应用程序驱动的(因为它们被应用程序核心调用)。

为了允许应用程序核心和适配器之间的通信,应用程序核心提供了特定的端口。对于驱动适配器,这样的端口可能是一个由核心中的某个用例类实现并由适配器调用的接口。对于被驱动的适配器,它可能是一个由适配器实现并由核心调用的接口。我们甚至可能有多个适配器实现相同的端口:一个用于与真实的外部系统通信,另一个用于与用于测试的模拟进行通信,例如。

为了清楚地指出六角架构的一个核心属性,应用程序核心(六角形)定义并拥有对外部接口的所有权端口)。然后适配器与这个接口一起工作。这是在架构级别应用依赖倒置原则。

由于其核心概念,这种架构风格也被称为端口和****适配器架构。

就像清洁架构一样,我们可以将这种六边形架构组织成层。最外层由适配器组成,它们在应用和其他系统之间进行转换。

接下来,我们可以将端口和用例实现结合起来形成应用层,因为它们定义了我们的应用接口。最内层包含实现业务规则的领域实体。

业务逻辑在用例类和实体中实现。用例类是狭窄的领域服务,仅实现单个用例。当然,我们可以选择将多个用例组合到一个更广泛的领域服务中,但理想情况下,我们只在用例经常一起使用时这样做,以提高可维护性。

可能的话,我们还想引入应用服务的概念。应用服务是一种协调对用例(领域服务)调用的服务,如图图 3.5所示。

图 3.5 – 使用应用和领域服务的 DDD 概念的六边形架构

图 3.5 – 使用应用和领域服务的 DDD 概念的六边形架构

在这里,应用服务在输入和输出端口以及领域服务之间进行转换,保护领域服务免受外部世界的干扰,并可能协调领域服务之间的交互。领域服务框与图 3.4中的用例框同义;我们只是现在使用了从领域驱动设计(DDD)借用的术语。

正如这次讨论所暗示的,我们可以在六边形内部自由地设计我们的应用代码。我们可以选择简单或复杂,以匹配我们应用的复杂性和规模。我们将在第十三章“管理多个有界上下文”中了解更多关于在六边形内管理代码的知识。

在下一章中,我们将讨论一种在代码中组织这种架构的方法。

这如何帮助我构建可维护的软件?

称其为“清洁架构”、“六边形架构”或“端口和适配器架构”,通过反转我们的依赖关系,使得领域代码对外部没有依赖,我们可以将领域逻辑从所有那些与持久化和 UI 特定的问题解耦,并减少在整个代码库中需要更改的原因。更少的更改原因导致更好的可维护性。

领域代码可以自由地建模以最佳地适应业务问题,而持久化和 UI 代码可以自由地建模以最佳地适应持久化和 UI 问题。

在本书的剩余部分,我们将应用六边形架构风格来构建一个 Web 应用。我们将从创建应用包结构开始,并讨论依赖注入的作用。

第四章:代码组织

只需通过查看代码就能识别架构,这不是很好吗?

在本章中,我们将探讨不同的代码组织方法,并介绍一种直接反映六边形架构的表达式包结构。

在绿色软件项目中,我们首先试图做对的事情是包结构。我们设置了一个看起来很不错的结构,打算在整个项目中使用。然后,在项目进行中,事情变得繁忙,我们意识到在许多地方,包结构只是无序代码混乱的一个漂亮门面。一个包中的类导入其他包中的类,而这些类本不应该被导入。

我们将讨论 BuckPal 示例应用程序的结构化选项,该应用程序在前言中介绍。更具体地说,我们将查看发送金钱用例,它允许用户将资金从他们的账户转移到另一个账户。

分层组织

我们组织代码的第一个方法是按层。我们可能会这样组织代码:

对于我们的每一层——webdomainpersistence——我们都有一个专门的包。如第二章中讨论的,层有什么问题?,简单的层可能不是我们代码的最佳结构,有多个原因,因此我们已应用了domain包。我们通过在domain包中引入AccountRepository接口并在persistence包中实现它来做到这一点。

我们可以找到至少三个原因说明这种包结构不是最优的,然而:

  • 首先,我们在应用的功能切片或特性之间没有包边界。如果我们添加一个用于管理用户的特性,我们将在web包中添加一个UserController;在domain包中添加UserServiceUserRepositoryUser;在persistence包中添加UserRepositoryImpl。如果没有进一步的结构,这可能会迅速变成一个类混乱,导致应用中看似无关的特性之间出现不希望出现的副作用。

  • 第二,我们看不到我们的应用提供了哪些用例。你能说出AccountServiceAccountController类实现了哪些用例吗?如果我们正在寻找某个特性,我们必须猜测哪个服务实现了它,然后在该服务中搜索负责的方法。

  • 最后,我们在包结构中看不到我们的目标架构。我们可以猜测我们已经遵循了六边形架构风格,然后浏览webpersistence包中的类来找到网络和持久性适配器。但我们无法一眼看出哪个功能是由网络适配器调用的,哪个功能是由持久性适配器提供给领域层的。输入和输出端口隐藏在代码中。

让我们尝试解决“按层组织”方法的一些问题。

按功能组织

下一个方法是将我们的代码按功能组织:

代码-4.2

从本质上讲,我们将所有与账户相关的代码都放入了高级包account中。我们还移除了层包。

每个新的特性组都会在account旁边获得一个新的高级包,我们可以通过使用包私有可见性来强制特性之间的包边界,对于不应从外部访问的类。

包边界,结合包私有可见性,使我们能够避免特性之间的不必要依赖。

我们还将AccountService重命名为SendMoneyService以缩小其责任范围(我们实际上也可以在按层划分包的方法中这样做)。现在我们可以看到,只需看类名,代码就实现了发送货币用例。罗伯特·马丁将使应用程序的功能在代码中可见称为“呼啸架构”,因为它在我们面前大声喊出其意图。1

1 呼啸架构:《清晰架构》,罗伯特·C·马丁,普雷蒂斯·霍尔,2017 年,第二十一章

然而,按特性划分包的方法使得我们的架构在代码中的表现比按层划分包的方法更加不明显。我们没有包名来识别我们的适配器,而且我们仍然看不到输入和输出端口。更重要的是,尽管我们已经反转了领域代码和持久化代码之间的依赖关系,使得SendMoneyService只知道AccountRepository接口而不是其实例,但我们不能使用包私有可见性来保护领域代码免受对持久化代码的意外依赖。

那么,我们如何使我们的目标架构一目了然?如果我们可以像图 3**.4那样指向架构图中的一个框,并立即知道代码的哪个部分负责该框,那就太好了。

让我们再迈出一步,创建一个足够表达以支持这种架构的包结构。

一个具有建筑表达性的包结构

在六角架构中,我们有实体、用例、输入输出****端口,以及输入和输出(或“驱动”和“被驱动”)适配器作为我们的主要架构元素。让我们将这些元素放入一个能够表达这种架构的包结构中:

代码-4.3

我们可以直接将架构的每个元素映射到其中一个包。在最高级别,我们有adapterapplication包。

adapter包包含调用应用程序输入端口的输入适配器和为应用程序输出端口提供实现的输出适配器。在我们的案例中,我们正在构建一个简单的 Web 应用程序,使用webpersistence适配器,每个适配器都有自己的子包。

将适配器的代码移动到它们自己的包中有一个好处,那就是我们可以非常容易地用另一个实现替换一个适配器,如果需要的话。想象一下,我们开始实现一个针对简单键值数据库的持久性适配器,因为我们认为我们知道了所需的访问模式,但那些模式已经改变了,现在我们最好使用一个 SQL 数据库。我们只需在新的适配器包中实现所有相关的输出端口,然后删除旧包。

application 包包含“六边形”,即我们的应用程序代码。这段代码由我们的领域模型组成,该模型位于 domain 包中,以及端点接口,它们位于 port 包中。

为什么端口在 application 包内而不是旁边?端口是我们将 port 包应用于 application 包的方式,这表明应用程序拥有端口。

domain 包包含我们的领域实体和领域服务,这些服务实现了输入端口并在领域实体之间进行协调。

最后,有一个 common 包,其中包含一些在代码库的其余部分共享的代码。

哇,这么多听起来很技术性的包。这不令人困惑吗?

想象一下,我们在办公室墙上挂着一个关于我们的六边形架构的高级视图,我们正在和一个同事讨论修改客户端以使用我们正在消费的第三方 API。在讨论这个话题时,我们可以指向海报上的相应输出适配器,以便更好地理解彼此。然后,当我们交谈完毕后,我们坐在 IDE 前,可以立即开始修改客户端,因为我们讨论过的 API 客户端代码可以在 adapter/out/<适配器名称> 包中找到。这比令人困惑要更有帮助,不是吗?

这种包结构是对抗所谓的架构/代码差距模型/代码差距的有力元素。2 这些术语描述了这样一个事实:在大多数软件开发项目中,架构只是一个抽象概念,不能直接映射到代码。随着时间的推移,如果包结构(以及其他因素)不能反映架构,代码通常会越来越偏离目标架构。

2 模型/代码差距:乔治·费尔班克斯、马歇尔和布赖恩德,《Just Enough Architecture》,2010 年,第 167 页。

此外,这种表达式的包结构促进了关于架构的积极思考。我们必须积极决定将我们的代码放入哪个包。但是,包的数量那么多,是不是意味着为了允许跨包访问,所有内容都必须是公开的?

至少对于适配器包来说,这一点并不成立。它们包含的所有类都可以是包私有的,因为它们除了通过端口接口(这些接口位于application包内)之外,不会被外部世界调用。因此,没有从应用层到适配器类的意外依赖。

然而,在application包内部,确实有一些类必须是公共的。端口必须是公共的,因为它们必须按设计对适配器可访问。域模型必须是公共的,以便对服务以及可能对适配器可访问。服务不需要是公共的,因为它们可以隐藏在传入端口接口后面。

因此,是的,这样一个细粒度的包结构要求我们使一些类成为公共的,而在较粗粒度的包结构中它们可能是包私有的。我们将在第十二章中探讨如何捕捉对这些公共类的不希望访问,强制架构边界

你可能会注意到这个包结构只包含一个域,即处理账户交易的域。然而,许多应用程序将包含来自多个域的代码。

正如我们将在第十三章中学习的,管理多个边界上下文,六边形架构并没有真正告诉我们如何管理多个域。当然,我们可以将每个域的代码放入domain包下的自己的子包中,并通过这种方式将域分开。但是,如果你正在考虑按域分别分离端口和适配器,那么请小心,因为这很快就会变成一个映射噩梦。更多关于这一点的内容请参考第十三章

就像每个结构一样,在软件项目的整个生命周期中维护这个包结构需要纪律。此外,也可能会出现包结构根本不适合的情况,我们别无选择,只能扩大架构/代码差距,创建一个不反映架构的包。

没有完美。但通过一个有表达力的包结构,我们至少可以减少代码和架构之间的差距。

依赖注入的作用

之前描述的包结构在很大程度上有助于实现干净的架构,但这样一个架构的基本要求是应用层不依赖于传入和传出的适配器,正如我们在第三章中学习的,反转依赖

对于传入适配器,例如我们的 Web 适配器,这很容易,因为控制流的方向与适配器和域代码之间的依赖关系相同。适配器只需在应用层调用服务。为了清楚地展示我们应用的入口点,我们希望将实际服务隐藏在端口接口后面。

对于外出的适配器,例如我们的持久化适配器,我们必须利用依赖倒置原则来逆转对控制流方向的依赖。

我们已经看到了它是如何工作的。我们在应用层内部创建了一个接口,由适配器内部的类实现。在我们的六边形架构中,这个接口是一个端口。然后应用层调用这个端口接口来调用适配器的功能,如图 图 4.1 所示。

图 4.1 – 网络控制器调用一个由服务实现的外来端口,而服务调用一个由适配器实现的外出端口

图 4.1 – 网络控制器调用一个由服务实现的外来端口,而服务调用一个由适配器实现的外出端口

但谁为应用提供实现端口接口的实际对象呢?我们不希望在应用层手动实例化端口,因为我们不希望引入对适配器的依赖。

这就是依赖注入发挥作用的地方。我们引入了一个对所有层都有依赖的中立组件。这个组件负责实例化构成我们架构的大多数类。

在前面的示例图中,中立的依赖注入组件会创建SendMoneyControllerSendMoneyServiceAccountPersistenceAdapter类的实例。由于SendMoneyController需要一个SendMoneyUseCase,依赖注入机制将在构造过程中给它一个SendMoneyService类的实例。控制器不知道它实际上得到了一个SendMoneyService实例,因为它只需要知道接口。

类似地,在构建SendMoneyService实例时,依赖注入机制将以UpdateAccountStatePort接口的形式注入AccountPersistenceAdapter类的实例。服务永远不会知道接口背后的实际类。

我们将在第十章“组装*应用”中更详细地讨论使用 Spring 框架初始化应用,作为一个例子。

这如何帮助我构建可维护的软件?

我们研究了适用于六边形架构的包结构,使其尽可能接近目标代码结构。现在,在代码中找到架构元素变成了一件通过架构图上某些框的名称导航包结构的事情,这有助于沟通、开发和维护。

在接下来的章节中,我们将通过在应用层、网络适配器和持久化适配器中实现一个用例来展示这个包结构和依赖注入的实际应用。

第五章:实现用例

让我们最终看看我们如何能在实际代码中实现所讨论的架构。

由于在我们的架构中,应用程序、Web 和持久化层耦合得非常松散,我们可以完全自由地以我们觉得合适的方式对领域代码进行建模。我们可以进行领域驱动设计DDD),实现一个丰富的或贫血的领域模型,或者发明我们自己的做事方式。

本章描述了在之前章节中引入的六边形架构风格中实现用例的一种有见地的方法。

对于以领域为中心的架构来说,我们将从一个领域实体开始,然后围绕它构建一个用例。

实现领域模型

我们希望实现将资金从一个账户发送到另一个账户的用例。以面向对象的方式建模的一种方法是为允许我们从源账户取款并将资金存入目标账户创建一个Account实体:

代码 5.1

Account实体提供了实际账户的当前快照。每次从账户取款和存款都会在Activity实体中捕获。由于始终将账户的所有活动加载到内存中并不明智,Account实体只保留最近几天或几周的活动窗口,这些活动被捕获在ActivityWindow值对象中。

为了仍然能够计算当前账户余额,Account实体额外具有baselineBalance属性,表示活动窗口的第一个活动之前账户所拥有的余额。因此,总余额是基线余额加上窗口中所有活动的余额。

使用这个模型,将取款和存款到账户的操作变成在活动窗口中添加一个新的活动,就像在withdraw()deposit()方法中所做的那样。在我们能够取款之前,我们检查业务规则,该规则规定我们不能透支账户。

现在我们有了允许我们取款和存款的Account,我们可以向外扩展,围绕它构建一个用例。

用例的要点

首先,让我们讨论一下用例实际上做什么。通常,它遵循以下步骤:

  1. 获取输入。

  2. 验证业务规则。

  3. 操作模型状态。

  4. 返回输出。

用例从传入的适配器获取输入。你可能想知道为什么我没有将第一步称为“验证输入”。答案是,我相信用例代码应该只关注领域逻辑,我们不应该让它受到输入验证的污染。所以,我们将输入验证放在其他地方,就像我们很快就会看到的那样。

然而,用例负责验证业务规则。它与领域实体共同承担这一责任。我们将在本章后面讨论输入验证业务规则验证之间的区别。

如果业务规则得到满足,用例就会根据输入以某种方式操纵模型的状态。通常,它将改变领域对象的状态,并将这个新状态传递给由持久化适配器实现的端口以进行持久化。如果用例驱动其他比持久化更广泛的影响,它将调用适当的适配器以处理每个副作用。

最后一步是将输出适配器的返回值转换为输出对象,该对象将被返回给调用适配器。

考虑到这些步骤,让我们看看我们如何实现我们的 发送金钱 用例。

为了避免在 第二章 中讨论的广泛服务问题,我们将为每个用例创建一个单独的服务类,而不是将所有用例放入一个单一的服务类中。

这里有一个预告:

该服务实现了 SendMoneyUseCase 输入端口接口,并调用 Load AccountPort 输出端口接口来加载一个账户,以及 UpdateAccountState 端口 端口以在数据库中持久化更新的账户状态。

服务还设置了数据库事务的边界,如 @Transactional 注解所暗示的。更多关于这一点的内容请参阅 第七章**,实现 持久化适配器

图 5**.1 提供了相关组件的视觉概述:

图 5.1 – 一个服务实现一个用例,修改领域模型,并调用输出端口以持久化修改后的状态

图 5.1 – 一个服务实现一个用例,修改领域模型,并调用输出端口以持久化修改后的状态

注意

在这个例子中,UpdateAccountStatePortLoadAccountPort 是由持久化适配器实现的端口接口。如果它们经常一起使用,我们也可以将它们合并到一个更广泛接口中。我们甚至可以将该接口命名为 AccountRepository 以保持 DDD 语言的连贯性。在这个例子以及本书的其余部分,我选择仅在持久化适配器中使用“Repository”这个名字,但你也可以选择不同的名字!

让我们来处理我们在前面的代码中留下的那些 TODO 注释。

验证输入

现在,我们正在讨论验证输入,尽管我刚刚声称这不是用例类的责任。然而,我认为它属于应用层,所以这就是讨论它的地方。

为什么不让调用适配器在将输入发送到用例之前进行验证呢?好吧,我们是否希望信任调用者已经验证了用例所需的全部内容?此外,用例可能被多个适配器调用,因此验证必须由每个适配器实现,而且可能会出错或完全忘记。

应用程序层应该关心输入验证,因为,否则它可能会从应用程序核心外部获得无效输入。这可能会损害我们模型的状态。

但如果不在用例类中,我们将输入验证放在哪里?

我们将让输入模型来处理这个问题。对于 Send money 用例,输入模型是我们之前在代码示例中已经看到的 SendMoneyCommand 类。更确切地说,我们将在构造函数中进行验证:

要发送金钱,我们需要源账户和目标账户的 ID 以及要转移的金额。这些参数都不能为 null,金额必须大于零。如果任何这些条件被违反,我们将在构造过程中抛出异常,简单地拒绝对象创建。

通过使用 SendMoneyCommand,我们使其 不可变。因此,一旦成功构建,我们可以确信状态是有效的,并且不能被改变为无效状态。

由于 SendMoneyCommand 是用例 API 的一部分,它位于传入端口包中。因此,验证仍然位于应用程序的核心(在我们架构六边形的边缘),但不会污染神圣的用例代码。

但是,当有库可以为我们完成这项脏活时,我们真的想手动实现每个验证检查吗?我经常听到这样的说法:“你不应该在模型类中使用库。”当然,减少依赖到最小是明智的,但如果我们可以使用一个小型依赖项来节省我们的时间,那么为什么不使用它呢?让我们通过 Java 的 Bean Validation API 探索一下这可能会是什么样子。1

1 Bean Validation: beanvalidation.org/.

Bean Validation 允许我们在类的字段上使用注解来表示所需的验证规则:

Validator 类提供了 validate() 方法,我们只需在构造函数的最后一句调用它。这将评估字段上的 Bean Validation 注解(在这种情况下是 @NotNull),并在违反时抛出异常。如果默认的 Bean Validation 注解对于某些验证来说不够表达,我们可以像对 @PositiveMoney 注解那样实现我们自己的注解和验证器。2

2 你可以在 GitHub 仓库 github.com/thombergs/buckpal 中找到实现 @PositiveMoney 注解和验证器的完整代码。

Validator 类的实现可能看起来像这样:

通过将验证定位在输入模型中,我们在用例实现周围创建了一个反腐败层。这不是指分层架构中的层,而是指围绕我们的用例的一个薄薄的、保护性的屏幕,将不良输入弹回调用者。

注意,在SendMoneyCommand类中使用的“命令”一词并不符合“命令模式”的常见解释。3 在实际调用用例的execute()方法中。在我们的例子中,命令只是一个数据传输对象,它将所需的参数传递给执行命令的用例服务。我们可以称它为SendMoneyDTO,但我喜欢使用“命令”这个词,以使其非常清楚地表明我们正在通过这个用例改变模型状态。

3 命令模式:zh.wikipedia.org/wiki/命令模式

构造函数的力量

我们的SendMoneyCommand将很多责任放在了构造函数上。由于该类是不可变的,构造函数的参数列表包含了一个对应于类每个属性的参数。并且由于构造函数还验证参数,因此不可能创建一个无效状态的对象。

在我们的例子中,构造函数只有三个参数。如果我们有更多的参数会怎样?我们能不能使用构建器模式使其使用更加方便?我们可以将具有长参数列表的构造函数设为私有,并在构建器的build()方法中隐藏对其的调用。然后,我们就不必调用一个带有 20 个参数的构造函数,而可以构建一个像这样的对象:

我们仍然可以让构造函数进行验证,这样构建器就不能构建一个无效状态的对象。

听起来不错?想想如果我们不得不向SendMoneyCommandBuilder(在软件项目的生命周期中这种情况会发生很多次)添加另一个字段会发生什么。我们将新字段添加到构造函数和构建器中。然后,一个同事(或者一个电话,一封电子邮件,一只蝴蝶……)打断我们的思路。休息后,我们回到编码,忘记将新字段添加到调用构建器以创建对象的代码中。

当我们尝试创建一个无效状态的不变对象时,编译器不会给出任何警告!当然,在运行时——希望是在单元测试中——我们的验证逻辑仍然会起作用,并抛出一个错误,因为我们遗漏了一个参数。

但如果我们直接使用构造函数而不是通过构建器隐藏它,每次添加新字段或删除现有字段时,我们都可以通过跟踪编译错误来反映代码库中其他部分的更改。

长参数列表甚至可以格式化得很好,并且好的集成开发环境(IDE)可以帮助提供参数名称提示:

图 5.2 – IDE 在参数列表中显示参数名称提示,帮助我们避免迷失方向

图 5.2 – IDE 在参数列表中显示参数名称提示,帮助我们避免迷失方向

为了使前面的代码更加易于阅读和操作,我们可以引入不可变的Address值对象,例如,因为它们属于一起。我们甚至可以更进一步,创建CityZipCode值对象,例如。这将减少将一个String参数与另一个参数混淆的可能性,因为如果我们将City传递给ZipCode参数或反之亦然,编译器会报错。

虽然在某些情况下,构建器可能是更好的解决方案。例如,如果前一个示例中的ClassWithManyFields的一些参数是可选的,我们就必须将null值传递给构造函数,这最多是丑陋的。构建器允许我们仅定义所需的参数。但如果我们使用构建器,我们必须非常确保在忘记定义一个必需参数时,build()方法会大声失败,因为编译器不会为我们检查这一点!

不同用例的输入模型

我们可能会倾向于为不同的用例使用相同的输入模型。让我们考虑注册账户更新账户详情用例。这两个用例最初几乎需要相同的输入,即一些账户详情,例如用户名和电子邮件地址。

更新用例需要需要更新的账户的 ID,而注册用例则不需要。如果这两个用例使用相同的输入模型,我们总是必须将一个null账户 ID 传递给注册用例。这最多是令人烦恼的,最坏的情况是,因为这两个用例现在必须一起演变。

在我们的不可变命令对象中允许null作为字段的有效状态本身就是一种代码异味。但更重要的是,我们现在是如何处理输入验证的?由于一个需要 ID 而另一个不需要,验证对于注册更新用例必须不同。我们必须将自定义验证逻辑构建到用例本身中,这将污染我们神圣的业务代码,使其充满输入验证问题。

此外,如果在注册账户用例中账户 ID 字段意外地有一个非空值,我们该怎么办?我们是抛出错误吗?还是简单地忽略它?这些问题是维护工程师(包括未来的我们)在看到代码时会提出的问题。

为每个用例创建一个专门的输入模型可以使用例更加清晰,并且将其与其他用例解耦,防止出现不希望的结果。然而,这也有代价,因为我们必须将传入的数据映射到不同用例的不同输入模型。我们将在第九章“边界之间的映射”中讨论这种映射策略以及其他映射策略。

验证业务规则

虽然验证输入不是用例逻辑的一部分,但验证业务规则绝对是的。业务规则是应用程序的核心,应该得到适当的关注。但我们在什么时候处理输入验证,在什么时候处理业务规则呢?

两者之间一个非常实际的区分是,验证业务规则需要访问领域模型当前状态,而验证输入则不需要。输入验证可以声明式地实现,就像我们之前用@NotNull注解做的那样,而业务规则则需要更多的上下文。

我们也可以说,输入验证是一种语法验证,而业务规则在用例的上下文中是一种语义验证。

让我们以规则“源账户不得透支”为例。根据之前的定义,这是一个业务规则,因为它需要访问模型当前状态来检查源账户的余额。

相比之下,规则“转账金额必须大于零”可以在不访问模型的情况下进行验证,因此可以作为输入验证的一部分实现。

我知道这种区分可能会引起争议。你可能会争辩说,转账金额如此重要,验证它无论如何都应该被视为一项业务规则。

然而,这种区分有助于我们将某些验证放置在代码库中,并在以后轻松地再次找到它们。这就像回答验证是否需要访问当前模型状态的问题一样简单。这不仅帮助我们首先实施规则,而且也有助于未来的维护工程师再次找到它。这也是我第一章中提出的“可维护性”主张的一个很好的例子,即可维护性支持决策。

那么,我们如何实现业务规则呢?

最好的办法是将业务规则放入领域实体中,就像我们对规则“源账户不得透支”所做的那样:

这样,业务规则就很容易定位和推理,因为它紧挨着需要遵守此规则的业务逻辑。

如果在领域实体中验证业务规则不可行,我们可以在用例代码开始处理领域实体之前进行验证:

我们调用一个执行实际验证的方法,并在验证失败时抛出一个专门的异常。然后,与用户交互的适配器可以将此异常显示为错误消息或以任何其他它认为合适的方式处理。

在前面的情况下,验证只是检查源账户和目标账户是否实际上存在于数据库中。更复杂的业务规则可能需要我们首先从数据库中加载领域模型,然后对其状态进行一些检查。如果我们无论如何都必须加载领域模型,我们应该在领域实体本身中实现业务规则,就像我们之前对规则“源账户不得”透支”所做的那样。

丰富的领域模型与贫血领域模型

我们的建筑风格留出了如何实现我们的领域模型的空间。这既是福也是祸,因为我们可以在我们的环境中做我们认为正确的事情,但同时也因为没有任何指导方针来帮助我们而感到苦恼。

常见的讨论是是否根据 DDD 哲学实现一个丰富的领域模型或一个“贫血领域模型。让我们讨论每种方法如何适合我们的架构。

在一个丰富的领域模型中,尽可能多的领域逻辑都实现在应用程序核心的实体中。实体提供更改状态的方法,并且只允许根据业务规则进行更改。这是我们之前追求“账户”实体的方式。在这种情况下,我们的用例实现在哪里?

在这种情况下,我们的用例作为进入领域模型的入口点。用例仅仅代表了用户的意图,并将其转换为对领域实体的编排方法调用,这些实体实际完成工作。许多业务规则位于实体中而不是用例实现中。

“发送金钱”用例服务将加载源账户和目标账户实体,调用它们的withdraw()deposit()方法,并将它们发送回数据库。4

4 实际上,“发送金钱”用例还必须确保在源账户和目标账户之间没有同时进行其他资金转账,以避免透支账户。

在一个“贫血”的领域模型中,实体本身非常瘦。它们通常只提供用于存储状态的字段以及用于读取和更改状态的 getter 和 setter 方法。它们不包含任何领域逻辑。

这意味着领域逻辑是在用例类中实现的。它们负责验证业务规则,更改实体的状态,并将它们传递给负责将它们存储在数据库中的输出端口。“丰富性”包含在用例中而不是实体中。

无论是哪种风格,还是任何其他风格,都可以使用本书中讨论的架构方法来实现。请随意选择适合您需求的一种。

不同用例的不同输出模型

一旦用例完成了其工作,它应该向调用者返回什么?

与输入类似,如果输出尽可能具体到用例,那么它就有好处。输出应仅包括调用者实际需要的数据。

发送金钱用例的示例代码中,我们返回一个布尔值。这是我们在这个上下文中可能返回的最小和最具体的值。

我们可能会被诱惑将更新后的实体Account完整地返回给调用者。也许调用者对账户的新余额感兴趣。

但我们真的希望让发送金钱用例返回这些数据吗?调用者真的需要它吗?如果是这样,我们是否应该创建一个专门用于访问这些数据的用例,以便不同的调用者可以使用?

对于这些问题,没有唯一的正确答案。但我们应该提出这些问题,以尽量使我们的用例尽可能具体。如果有疑问,尽可能返回最少的。

在用例之间共享相同的输出模型也往往会紧密耦合这些用例。如果一个用例需要在输出模型中添加新字段,其他用例也必须处理这个字段,即使它与它们无关。共享模型由于多种原因,在长期运行中往往会肿瘤般地增长。应用单一责任原则并保持模型分离有助于解耦用例。

出于同样的原因,我们可能想要抵制使用我们的领域实体作为输出模型的诱惑。我们不希望我们的领域实体在不必要的情况下发生变化。然而,我们将在第十一章中更多地讨论将实体用作输入或输出模型,有意识地走捷径

那么,只读用例怎么办?

到目前为止,我们已经讨论了如何实现可能修改我们模型状态的用例。我们如何处理只读用例?让我们假设 UI 需要显示账户余额。我们是否为这个用例创建一个特定的用例实现?

对于这种只读操作,谈论其用例可能会显得有些尴尬。当然,UI 需要这些数据用于我们可能称之为查看账户余额的用例,但在某些情况下,将这称为“用例”可能有些人为。如果在这个项目的背景下,这被视为一个用例,那么我们应该像其他用例一样实施它。

然而,从应用程序核心的角度来看,这只是一个简单的数据查询。因此,如果在这个项目的背景下,这不被视为一个用例,我们可以将其实现为一个查询,以将其与真正的用例区分开来。

在我们的架构风格中,实现这一点的办法之一是为查询创建一个专门的输入端口,并在“查询服务”中实现它:

GetAccountBalanceUseCase调用输出端口LoadAccountPort,从数据库实际加载数据。它使用GetAccountBalanceQuery类型作为其输入模型。

这样,只读查询在我们的代码库中可以清楚地与修改用例(或“命令”)区分开来。我们只需查看输入类型的名称,就能知道我们正在处理什么。这与命令查询分离CQS)和命令查询责任分离CQRS)等概念相得益彰。

在前面的代码中,服务实际上并没有做任何工作,只是将查询传递到输出端口。如果我们跨层使用相同的模型,我们可以走捷径,让客户端直接调用输出端口。我们将在第十一章中讨论这个捷径,有意识地走捷径

这如何帮助我构建可维护的软件?

我们的架构允许我们根据需要实现领域逻辑,但如果我们独立地建模用例的输入和输出,我们可以避免不希望的副作用。

是的,这比在用例之间共享模型要复杂得多。我们必须为每个用例引入一个单独的模型,并将此模型与我们的实体进行映射。

但是,针对特定用例的模型可以清晰地理解用例,从而在长期内更容易维护。此外,它们还允许多个开发者并行工作在不同的用例上,而不会相互干扰。

与严格的输入验证相结合,针对特定用例的输入和输出模型对于构建可维护的代码库大有裨益。

在下一章中,我们将从应用程序的中心“向外迈出一步”,并探讨构建一个网络适配器,为用户提供与我们的用例进行交流的渠道。

第六章:实现 Web 适配器

今天的大多数应用程序都有某种形式的 Web 界面——要么是我们可以通过 Web 浏览器与之交互的 UI,要么是其他系统可以通过调用以与我们的应用程序交互的 HTTP API。

在我们的目标架构中,所有与外部世界的通信都通过适配器进行。因此,让我们讨论如何实现一个提供此类 Web 界面的适配器。

依赖倒置

图 6.1给出了一个放大视图,展示了与我们讨论 Web 适配器相关的架构元素——适配器本身以及它通过哪些端口与我们的应用程序核心交互:

图 6.1 – 一个传入适配器通过专用传入端口与应用层通信,这些端口是由领域服务实现的接口

图 6.1 – 一个传入适配器通过专用传入端口与应用层通信,这些端口是由领域服务实现的接口

Web 适配器是一个“驱动”或“传入”适配器。它从外部接收请求并将它们转换为对应用程序核心的调用,告诉它要做什么。控制流从 Web 适配器中的控制器流向应用层中的服务。

应用层通过特定的端口提供通信,这些端口是我在上一章中所称的“用例”,并由应用层中的领域服务实现。

如果我们仔细观察,会发现这是依赖倒置原则在起作用。由于控制流是从左到右的,我们同样可以让 Web 适配器直接调用用例,如图6.2所示。

图 6.2 – 我们可以移除端口接口并直接调用服务

图 6.2 – 我们可以移除端口接口并直接调用服务

那么为什么我们在适配器和用例之间添加另一层间接层呢?原因在于端口是外部世界可以与我们的应用程序核心交互的地点的规范。通过设置端口,我们确切地知道哪些与外部世界的通信发生了,这对于任何正在为您的遗留代码库工作的维护工程师来说都是宝贵的信息。

了解驱动应用程序的端口也让我们可以为应用程序构建一个测试驱动器。这个测试驱动器是一个适配器,它调用输入端口来模拟和测试某些使用场景——更多关于测试的内容请参阅第八章测试 架构元素

在讨论了输入端口的重要性之后,我们将在第十一章有意识地走捷径中讨论的一个捷径是直接跳过传入端口并直接调用应用程序服务。

尽管如此,还有一个问题与高度交互式应用程序相关。想象一个通过 WebSocket 向用户浏览器发送实时数据的服务器应用程序。应用程序核心如何将实时数据发送到网络适配器,而网络适配器再将数据发送到用户浏览器?

对于这个场景,我们肯定需要一个端口,因为没有端口,应用程序将不得不依赖于适配器实现,这会破坏我们使应用程序免受外部依赖的努力。这个端口必须由网络适配器实现,并由应用程序核心调用,如图图 6**.3所示:

图 6.3 – 如果应用程序必须主动通知网络适配器,我们需要通过输出端口来保持依赖关系的正确方向

图 6.3 – 如果应用程序必须主动通知网络适配器,我们需要通过输出端口来保持依赖关系的正确方向

左侧的WebSocketController实现了out包中的端口接口,应用程序核心可以通过这个端口调用以将实时数据发送到用户的浏览器。

从技术上来说,这将是一个输出端口,使网络适配器成为一个输入和输出适配器。但没有任何理由说同一个适配器不能同时是两者。在本章的其余部分,我们将假设网络适配器仅作为输入适配器,因为这是最常见的情况。

网络适配器的职责

网络适配器实际上做什么?假设我们想要为我们的 BuckPal 应用程序提供一个 REST API。网络适配器的职责从哪里开始,又在哪里结束?

网络适配器通常做以下事情:

  1. 将传入的 HTTP 请求映射到对象。

  2. 执行授权检查。

  3. 验证输入。

  4. 将请求对象映射到用例的输入模型。

  5. 调用用例。

  6. 将用例的输出映射回 HTTP。

  7. 返回 HTTP 响应。

首先,网络适配器必须监听符合某些标准(如 URL 路径、HTTP 方法和内容类型)的 HTTP 请求。然后,匹配的 HTTP 请求的参数和内容必须反序列化为我们可以处理的对象。

通常,网络适配器会进行身份验证和授权检查,如果失败则返回错误。

然后,可以验证传入对象的状态。但我们不是已经讨论过输入验证作为输入模型对用例的职责了吗?是的,输入模型对用例应该只允许在用例上下文中有效的输入。但在这里,我们谈论的是网络适配器的输入模型。它可能具有与用例输入模型完全不同的结构和语义,因此我们可能需要进行不同的验证。

我不主张在 Web 适配器中实现与我们在用例输入模型中已经实现的相同验证。相反,我们应该验证我们能否将 Web 适配器的输入模型转换为用例的输入模型。任何阻止我们进行这种转换的因素都是验证错误。

这将我们带到 Web 适配器的下一个责任:使用转换后的输入模型调用特定的用例。然后适配器获取用例的输出并将其序列化为 HTTP 响应,发送回调用者。

如果在过程中出现任何问题并抛出异常,网络适配器必须将错误转换为发送回调用者的消息。

这样,我们的 Web 适配器承担了大量的责任。但这也是应用层不应该关心的责任。任何与 HTTP 相关的事情都不应该泄露到应用层。如果应用核心知道我们在外部处理 HTTP,我们就失去了从其他不使用 HTTP 的传入适配器执行相同领域逻辑的选项。在一个可维护的架构中,我们希望保持选项开放。

注意,如果我们从领域层和应用层开始开发,而不是从网络层开始,那么网络适配器与应用层之间的边界就会自然而然地出现。如果我们首先实现用例,而不考虑任何特定的传入适配器,我们就不会倾向于模糊边界。

切片控制器

在大多数 Web 框架中——例如 Java 世界的 Spring MVC——我们创建执行我们之前讨论过的责任的控制器类。那么,我们是否需要构建一个回答所有指向我们应用程序的请求的单个控制器?我们不必这样做。Web 适配器可能由多个类组成。

然而,我们应该注意将这些类放入相同的包层次结构中,以标记它们属于一起,正如在第四章“组织代码”中讨论的那样。

那么,我们构建多少个控制器呢?我说我们宁愿构建太多也不愿太少。我们应该确保每个控制器实现尽可能窄的 Web 适配器切片,并且与其他控制器共享尽可能少的内容。

让我们以 BuckPal 应用程序中的账户实体操作为例。一种常见的方法是创建一个单独的AccountController,它接受与账户相关的所有操作的请求。

提供 REST API 的 Spring 控制器可能看起来像以下代码片段:

代码-6.1

与账户资源相关的一切都在一个类中,这感觉很好。但让我们讨论一下这种方法的缺点。

首先,每个类中代码更少是好事。我在一个遗留项目中工作过,其中最大的类有 30,000 行代码。1 这不是什么有趣的事情。即使控制器多年来只积累了 200 行代码,它也比 50 行代码更难理解,即使它被干净地分离成方法。

1 30,000 行代码:这实际上是我们前辈(请注意)的一个有意识的架构决策,导致这 30,000 行代码位于一个类中:为了在运行时更改系统,而不需要重新部署,它允许他们上传编译后的 Java 字节码到.class文件中。而且它只允许他们上传一个文件,所以这个文件必须包含所有代码。

同样的论点也适用于测试代码。如果控制器本身有很多代码,那么就会有大量的测试代码。而且,通常测试代码比生产代码更难理解,因为它往往更抽象。我们还想让特定生产代码的测试更容易找到,这在小型类中更容易实现。

同样重要的是,然而,将所有操作放入单个控制器类中鼓励了数据结构的重用。在先前的代码示例中,许多操作共享AccountResource模型类。它作为任何操作所需内容的容器。AccountResource可能有一个id字段。在create操作中不需要这个字段,而且可能会比它有帮助的地方更多造成混淆。想象一下,AccountUser对象之间存在一对一的关系。在创建或更新账户时,我们会包括那些User对象吗?用户会被列表操作返回吗?这是一个简单的例子,但在任何大型项目中,我们迟早都会问这些问题。

因此,我提倡为每个操作创建一个单独的控制器,可能是在一个单独的包中。我们还应该尽可能将方法和类名与我们的用例相匹配:

代码示例

我们可以将原始数据作为输入,就像我们在示例中处理sourceAccountIdtargetAccountIdamount时那样。但每个控制器也可以有自己的输入模型。我们可能有一个针对特定用例的模型,如CreateAccountResourceUpdateAccountResource,而不是一个通用的模型如AccountResource。这些专门的模型类甚至可以是控制器包私有的,以防止意外重用。控制器仍然可以共享模型,但使用来自另一个包的共享类让我们更多地思考它,也许我们会发现我们不需要一半的字段,最终会创建自己的。

此外,我们还应该仔细思考控制器和服务的命名。例如,对于CreateAccount,难道RegisterAccount不是一个更好的名字吗?在我们的 BuckPal 应用中,创建账户的唯一方式是用户注册它。因此,我们在类名中使用“register”这个词来更好地传达它们的含义。当然,有些情况下,常用的命名方式(Create...Update...Delete...)足以描述一个用例,但在实际使用之前,我们可能需要三思而后行。

这种切割风格的另一个好处是,它使得在不同操作上的并行工作变得轻而易举。如果两个开发者分别处理不同的操作,我们不会遇到合并冲突。

这如何帮助我构建可维护的软件?

当构建一个应用到 Web 的适配器时,我们应该记住,我们正在构建一个适配器,它将 HTTP 协议转换为应用用例的方法调用,将结果转换回 HTTP,并且不执行任何领域逻辑。

另一方面,应用层不应处理 HTTP,因此我们应该确保不要泄露 HTTP 细节。这使得 Web 适配器在需要时可以替换为另一个适配器。

当切割 Web 控制器时,我们不应害怕构建许多不共享模型的小类。它们更容易理解和测试,并且支持并行工作。最初设置这样细粒度的控制器需要更多的工作,但在维护期间会得到回报。

在查看我们应用的输入端之后,我们现在将来看看输出端以及如何实现持久化适配器。

第七章:实现持久化适配器

第二章,“层有什么问题?”中,我抱怨了传统的分层架构,并声称它促进了数据库驱动设计,因为最终,一切依赖于持久化层。在本章中,我们将探讨如何使持久化层成为应用程序层的插件,以反转这种依赖关系。

依赖倒置

我们将不讨论持久化层,而是讨论一个为领域服务提供持久化功能的持久化适配器。图 7.1展示了我们如何应用依赖倒置原则来实现这一点:

图 7.1 – 核心服务使用端口访问持久化适配器

图 7.1 – 核心服务使用端口访问持久化适配器

我们的领域服务调用端口接口以访问持久化功能。这些端口由一个持久化适配器类实现,该类执行实际的持久化工作,并负责与数据库通信。

在六边形架构术语中,持久化适配器是一个驱动输出适配器,因为它是由我们的应用程序调用的,而不是反过来。

端口在领域服务和持久化代码之间实际上是一个间接层。让我们提醒自己,我们添加这个间接层是为了能够在不思考持久化问题的情况下演进领域代码,也就是说,没有对持久化层的代码依赖。在持久化代码中的重构不会导致核心代码的改变。

自然地,在运行时,我们仍然有应用程序核心对持久化适配器的依赖。如果我们修改持久化层的代码并引入了一个错误,例如,我们仍然可能会破坏应用程序核心的功能。然而,只要端口合约得到满足,我们就可以在持久化适配器中自由地做我们想做的事情,而不会影响核心。

持久化适配器的职责

让我们看看持久化适配器通常都做什么:

  1. 接收输入。

  2. 将输入映射到数据库格式。

  3. 将输入发送到数据库。

  4. 将数据库输出映射到应用程序格式。

  5. 返回输出。

持久化适配器通过端口接口接收输入。输入模型可能是一个领域实体或一个专门用于特定数据库操作的对象,具体由接口指定。

然后它将输入模型映射到它可以处理以修改或查询数据库的格式。在 Java 项目中,我们通常使用Java 持久化 APIJPA)与数据库通信,因此我们可能会将输入映射到反映数据库表结构的 JPA 实体对象。根据上下文,将输入模型映射到 JPA 实体可能是一项工作量很大但收益甚微的工作,所以我们将在第九章边界之间的映射”中讨论不进行映射的策略。

我们可能不会使用 JPA 或其他对象关系映射框架,而是使用任何其他技术来与数据库通信。我们可能将输入模型映射到普通的 SQL 语句并将这些语句发送到数据库,或者我们将传入的数据序列化到文件中,然后从那里读取它们。

重要的是,持久化适配器的输入模型位于应用程序核心,而不是持久化适配器本身,这样持久化适配器的变化就不会影响核心。

接下来,持久化适配器查询数据库并接收查询结果。

最后,它将数据库答案映射到端口期望的输出模型,并返回它。同样,输出模型位于应用程序核心而不是持久化适配器中,这对于确保依赖关系指向正确的方向非常重要。

除了输入和输出模型位于应用程序核心而不是持久化适配器本身之外,责任实际上与传统持久层的责任没有太大区别。

然而,按照这里描述的方式实现持久化适配器不可避免地会引发一些问题,这些问题在我们实现传统持久层时可能不会提出,因为我们已经习惯了传统的方式,没有考虑这些问题。

端口接口切割

在实现服务时,人们可能会想到的一个问题是,如何切割定义应用程序核心可用的数据库操作的端口接口。

创建一个单一的存储库接口,为某个实体提供所有数据库操作,是一种常见的做法,如图 7.2所示。

图 7.2 – 将所有数据库操作集中到单个输出端口接口使所有服务都依赖于它们不需要的方法

图 7.2 – 将所有数据库操作集中到单个输出端口接口使所有服务都依赖于它们不需要的方法

每个依赖于数据库操作的服务都将随后依赖于这个单一的“广泛”端口接口,即使它只使用接口中的一个方法。这意味着我们在代码库中存在不必要的依赖。

在我们的环境中不需要的方法的依赖关系会使代码更难以理解和测试。想象一下,我们正在为前图中所示的RegisterAccountService编写单元测试。我们需要为AccountRepository接口的哪些方法创建模拟?我们必须首先找出服务实际调用的AccountRepository方法的哪些。只模拟接口的一部分可能会导致其他问题,因为下一个处理这个测试的人可能会期望接口被完全模拟并遇到错误。因此,他们又得进行一些研究。

用罗伯特·C·马丁的话来说,“依赖于携带你不需要的负担的东西可能会给你带来你意想不到的麻烦。”1

  1. 接口分离原则*:罗伯特·C·马丁的《Clean Architecture》,第 86 页。

接口分离原则为这个问题提供了一个答案。它指出,宽泛的接口应该被分割成具体的接口,这样客户端只知道他们需要的那些方法。如果我们将这个原则应用到我们的输出端口上,我们可能会得到如图图 7.3所示的结果。

图 7.3 – 应用接口分离原则消除了不必要的依赖,并使现有的依赖更加明显

图 7.3 – 应用接口分离原则消除了不必要的依赖,并使现有的依赖更加明显

现在,每个服务只依赖于它实际需要的那些方法。更重要的是,端口的名称清楚地说明了它们的内容。在测试中,我们不再需要考虑要模拟哪些方法,因为大多数情况下,每个端口只有一个方法。

拥有如此狭窄的端口使得编码成为一种即插即用的体验。当处理一个服务时,我们只需“插入”我们需要的端口。没有需要携带的负担。

当然,“每个端口一个方法”的方法可能并不适用于所有情况。可能有一些数据库操作组非常紧密且经常一起使用,我们可能希望将它们捆绑在一个单独的接口中。

持久化适配器的切片

在前面的图中,我们看到了一个实现所有持久化端口的单一持久化适配器类。然而,没有规则禁止我们创建超过一个持久化适配器,只要所有持久化端口都得到了实现。

例如,我们可能会选择为需要持久化操作(或在领域驱动设计术语中称为聚合)的每个领域实体组实现一个持久化适配器,如图图 7.4所示。

图 7.4 – 我们可以为每个聚合创建多个持久化适配器

图 7.4 – 我们可以为每个聚合创建多个持久化适配器

这样,我们的持久化适配器会自动沿着我们支持持久化功能的领域边界进行切片。

我们可能会将我们的持久化适配器分成更多类——例如,当我们想使用 JPA(或另一个对象关系映射器)实现几个持久化端口,并使用纯 SQL 实现一些其他端口以获得更好的性能时。然后我们可能会创建一个 JPA 适配器和一個纯 SQL 适配器,每个适配器实现持久化端口的子集。

记住,我们的领域代码不关心哪个类最终实现了持久化端口定义的契约。我们在持久化层中可以自由地按照我们的看法行事,只要所有端口都得到了实现。

每个聚合只有一个持久化适配器的方法也是为将来分离多个边界上下文的持久化需求打下良好基础。比如说,经过一段时间,我们确定了一个负责围绕计费用例的边界上下文。图 7**.5 将这个新领域添加到应用中。

图 7.5 – 如果我们想在边界上下文之间创建硬边界,每个边界上下文都应该有自己的持久化适配器(s)

图 7.5 – 如果我们想在边界上下文之间创建硬边界,每个边界上下文都应该有自己的持久化适配器(s)

每个 account 上下文可能无法访问 billing 上下文的持久化适配器,反之亦然。如果一个上下文需要另一个上下文的东西,它们可以调用对方的领域服务,或者我们可以引入一个应用服务作为边界上下文之间的协调者。我们将在 第十三章管理多个边界上下文 中更多地讨论这个话题。

Spring Data JPA 的示例

让我们看看一个代码示例,该示例实现了前面图中的 AccountPersistenceAdapter。这个适配器将需要在数据库中保存和加载账户。我们已经在 第五章实现用例 中看到了 Account 实体,但这里再次提供其结构以供参考:

注意

Account 类不是一个简单的数据类,带有获取器和设置器,而是试图尽可能不可变。它只提供工厂方法来创建一个处于有效状态的账户,并且所有修改方法都会进行一些验证,例如在取款前检查账户余额,这样我们就不能创建一个无效的领域模型。

我们将使用 Spring Data JPA 与数据库通信,因此我们还需要用 @Entity 注解的类来表示账户的数据库状态:

在这个阶段,账户的状态仅仅是一个 ID。稍后,可能会添加额外的字段,如用户 ID。更有趣的是ActivityJpaEntity,它包含了一个特定账户的所有活动。我们本可以使用 JPA 的@ManyToOne@OneToMany注解将ActivitiyJpaEntityAccountJpaEntity连接起来,以标记它们之间的关系,但我们选择现在不这样做,因为这会给数据库查询带来副作用。实际上,在这个阶段,使用比 JPA 更简单的对象关系映射器来实现持久化适配器可能更容易,但我们仍然会使用它,因为我们认为我们可能在将来需要它。2

  1. Java Persistence API:这对你来说熟悉吗?你选择 JPA 作为对象关系映射器,因为它正是人们用来解决这个问题的工具。开发了几个月后,你开始诅咒懒加载和缓存功能,希望有更简单的方法。JPA 是一个伟大的工具,但对于许多问题,更简单的解决方案可能确实更简单。看看 Spring Data JDBC 或 jOOQ 作为替代方案。

接下来,我们将使用 Spring Data 创建仓库接口,这些接口提供开箱即用的基本创建读取更新删除(CRUD)功能,以及自定义查询以从数据库中加载某些活动:

Spring Boot 将自动找到这些仓库,Spring Data 将执行其魔法,为仓库接口提供实现,该接口实际上会与数据库通信。

在有了 JPA 实体和仓库之后,我们可以实现一个持久化适配器,为我们的应用程序提供持久化功能:

持久化适配器实现了应用程序需要的两个端口,LoadAccountPortUpdateAccountStatePort

要从数据库中加载一个账户,我们首先从AccountRepository中加载它,然后通过ActivityRepository加载这个账户在特定时间窗口内的活动。

要创建一个有效的Account域实体,我们还需要这个账户在活动窗口开始前的余额,所以我们从数据库中获取这个账户的所有提款和存款的总和。

最后,我们将所有这些数据映射到Account域实体,并将其返回给调用者。

要更新账户的状态,我们遍历Account实体的所有活动,并检查它们是否有 ID。如果没有,它们是新的活动,然后我们通过ActivityRepository将它们持久化。

在之前描述的场景中,我们在AccountActivity域模型与AccountJpaEntityActivityJpaEntity数据库模型之间建立了双向映射。我们为什么还要做这种来回映射的努力?我们为什么不直接将 JPA 注解移动到AccountActivity类中,并将它们直接作为实体存储在数据库中呢?

这种无映射策略可能是一个有效的选择,正如我们将在第九章中看到的,边界之间的映射,当我们讨论映射策略时。然而,JPA 随后迫使我们对领域模型做出妥协。例如,JPA 要求实体有一个无参构造函数。或者,在持久化层中,从性能角度来看,“多对一”关系可能是有意义的,但在领域模型中,我们希望这种关系是相反的。

因此,如果我们想创建一个不向持久化层做出妥协的丰富领域模型,我们就必须在领域模型和持久化模型之间进行映射。

那么,数据库事务怎么办呢?

我们还没有涉及到数据库事务的话题。我们的事务边界在哪里?

事务应该跨越在某个用例中执行的所有数据库写操作,确保如果其中一个操作失败,所有这些操作都可以一起回滚。

由于持久化适配器不知道哪些其他数据库操作是同一用例的一部分,它不能决定何时打开和关闭事务。我们必须将这项责任委托给协调对持久化适配器调用服务的服务。

在 Java 和 Spring 中,最简单的方法是将@Transactional注解添加到领域服务类中,这样 Spring 就会将所有公共方法包装在一个事务中:

代码示例

但是,@Transactional注解不是引入了我们不想在我们的宝贵领域代码中拥有的框架的依赖吗?嗯,是的,我们对注解有依赖,但我们也为此获得了事务处理!我们不想为了保持代码“纯净”而构建自己的事务机制。

这如何帮助我构建可维护的软件?

构建一个作为领域代码插件的持久化适配器,可以将领域代码从持久化细节中解放出来,从而我们可以构建一个丰富的领域模型。

使用窄端口接口,我们可以灵活地以某种方式实现一个端口,以另一种方式实现另一个端口,甚至可能使用不同的持久化技术,而应用程序不会注意到。我们甚至可以替换整个持久化层,只要遵守端口合约。3

  1. 替换持久化层:虽然我见过几次这种情况(并且有很好的理由),但需要替换整个持久化层的概率通常相当低。即便如此,拥有专门的持久化端口仍然值得,因为它增加了可测试性。例如,我们可以轻松实现一个内存中的持久化适配器,用于测试。

现在我们已经构建了一个领域模型和一些适配器,让我们看看我们如何测试它们是否真的在按照我们期望的方式工作。

第八章:测试架构元素

在我见证的许多项目中,尤其是那些存在了一段时间并且随着时间的推移轮换了许多开发者的项目,自动化测试是一个谜。每个人都根据他们认为合适的方式编写测试,因为这是由某个尘封的规则所要求的,但没有人能回答关于团队测试策略的针对性问题。

本章提供了一种针对六边形架构的测试策略。对于我们架构的每个元素,我们将讨论覆盖它的测试类型。

测试金字塔

让我们从测试金字塔1 的讨论开始,如图 8.1 所示,这是一个帮助我们决定我们应该追求多少种类型测试的隐喻。

1 测试金字塔可以追溯到 2009 年迈克·科恩的书籍《敏捷成功》。

图 8.1 – 根据测试金字塔,我们应该创建许多低成本测试和较少的高成本测试

图 8.1 – 根据测试金字塔,我们应该创建许多低成本测试和较少的高成本测试

金字塔的基本陈述是我们应该有高覆盖率的细粒度测试,这些测试易于构建、易于维护、运行速度快且稳定。这些是验证单个单元(通常是一个类)按预期工作的单元测试。

一旦测试组合了多个单元并跨越了单元边界、架构边界甚至系统边界,它们往往变得更昂贵来构建、运行速度更慢、更脆弱(由于某些配置错误而不是功能错误而失败)。金字塔告诉我们,这些测试变得越昂贵,我们就越不应该追求对这些测试的高覆盖率,否则我们将花费太多时间构建测试而不是新功能。

根据上下文,测试金字塔通常以不同的层次展示。让我们看看我选择讨论测试我们的六边形架构的层次。

注意

单元测试集成测试系统测试的定义随着上下文的不同而变化。在一个项目中,它们可能意味着与另一个项目不同的事情。

以下是我们将在本章中使用的不同测试类型的解释:

  • 单元测试是金字塔的基础。单元测试通常实例化一个类并通过其接口测试其功能。如果被测试的类对其他类有非平凡的依赖,我们可以用模拟对象替换这些依赖,模拟对象模拟真实对象的行为,以满足测试的要求。

  • 集成测试构成了金字塔的下一层。这些测试实例化一个由多个单元组成的网络,并通过将一些数据通过入口类的接口发送到网络中,来验证这个网络是否按预期工作。在我们的解释中,集成测试将跨越两个层之间的边界,因此对象网络可能不完整或必须在某些点上与模拟对象交互。

  • 最后,系统测试启动构成我们应用程序的整个对象网络,并验证某个用例是否在应用程序的所有层中按预期工作。

在系统测试之上,可能有一层端到端测试,包括应用程序的用户界面。在这里我们不会考虑端到端测试,因为我们只在本书中讨论后端架构。

注意

测试金字塔,像任何其他指导一样,并不是你测试策略的万能钥匙。它是一个好的默认选择,但如果你在你的环境中可以廉价地创建和维护集成或系统测试,你可以也应该创建更多的这些测试,因为它们比单元测试更不容易受到实现细节变化的影响。这会使金字塔的侧面更陡峭,甚至可能颠倒它们。

现在我们已经定义了一些测试类型,让我们看看哪种类型的测试最适合我们六边形架构的每一层。

使用单元测试测试领域实体

我们将从查看我们架构中心的领域实体开始。让我们回顾一下来自第五章实现用例中的Account实体。Account的状态由一个账户在过去的某个时间点所拥有的余额(基线余额)以及自那时起所进行的存款和取款(活动)列表组成。

我们现在想验证withdraw()方法是否按预期工作:

前面的测试是一个简单的单元测试,它实例化了一个处于特定状态的Account,调用其withdraw()方法,并验证取款是否成功以及它对正在测试的Account对象的状态产生了预期的副作用。

测试设置相当简单,易于理解,并且运行非常快。测试没有比这更简单了。这样的单元测试是我们验证编码在我们领域实体中的业务规则的最佳选择。我们不需要任何其他类型的测试,因为领域实体的行为与其他类几乎没有任何依赖。

使用单元测试测试用例

向外扩展一层,下一个要测试的架构元素是作为领域服务实现的用例。让我们看看在第五章实现用例中讨论的SendMoneyService测试。发送金钱用例从源账户取款并将其存入目标账户。我们想验证当交易成功时,一切是否按预期工作:

为了使测试更易于阅读,它被结构化为given/when/then部分,这在行为驱动开发中是常用的。

given部分,我们创建源和目标Account对象,并使用以given...()开头的某些方法将它们置于正确的状态。我们还创建了一个SendMoneyCommand对象,作为用例的输入。在when部分,我们简单地调用sendMoney()方法来调用用例。在then部分,我们断言交易成功,并验证源和目标Account对象上是否调用了某些方法。

在底层,测试使用 Mockito 库来创建given...()方法。2 Mockito 还提供了then()方法来验证是否在模拟对象上调用过某个方法。

2 Mockito: site.mockito.org/.

注意

如果过度使用模拟,可能会产生虚假的安全感。模拟的行为可能与真实对象不同,即使在测试结果为绿色的情况下,也可能在生产中引发问题。如果你可以不花费太多额外努力就使用真实对象而不是模拟对象,那么你可能应该这样做。在前面的例子中,我们可能会选择与真实的Account对象而不是模拟对象一起工作,例如。这不应该需要更多的努力,因为Account类是一个领域模型类,它不依赖于其他类的任何复杂依赖。

由于被测试的使用案例服务是无状态的,我们无法在then部分验证某个状态。相反,测试验证服务是否与其(模拟的)依赖项上的某些方法进行了交互。这意味着测试容易受到被测试代码结构变化的影响,而不仅仅是其行为。这反过来意味着,如果被测试代码重构,测试需要修改的可能性更高。

考虑到这一点,我们应该仔细思考在测试中我们实际上想要验证哪些交互。可能一个好的主意不是像前面测试中那样验证所有交互,而是专注于最重要的那些。否则,我们必须随着被测试类的每一次更改而更改测试,这会削弱测试的价值。

虽然这个测试仍然是一个单元测试,但它接近于集成测试,因为我们测试了依赖项上的交互。然而,由于我们使用模拟并且不需要管理真实依赖项,所以它比完整的集成测试更容易创建和维护。

使用集成测试测试 Web 适配器

向外移动另一层,我们到达了我们的适配器。让我们讨论如何测试 Web 适配器。

回想一下,一个 Web 适配器通过 HTTP 接收输入,例如 JSON 字符串形式的输入,可能对其进行一些验证,将输入映射到用例期望的格式,然后将它传递给那个用例。然后它将用例的结果映射回 JSON,并通过 HTTP 响应将其返回给客户端。

在对 Web 适配器的测试中,我们想要确保所有这些步骤都按预期工作:

代码-8.3.jpg

前面的测试是对名为SendMoneyController的 Web 控制器的标准集成测试,该控制器是用 Spring Boot 框架构建的。在testSendMoney()方法中,我们向 Web 控制器发送一个模拟 HTTP 请求,以触发从一个账户到另一个账户的交易。

通过使用isOk()方法,我们验证 HTTP 响应的状态是200,并验证模拟的用例类已被调用。

这个测试覆盖了 Web 适配器的大多数职责。

我们实际上并没有通过 HTTP 协议进行测试,因为我们用MockMvc对象模拟了它。我们相信框架能够正确地将一切转换为 HTTP 协议。没有必要测试框架。

然而,从将输入从 JSON 映射到SendMoneyCommand对象的全过程都被覆盖了。如果我们像在第五章“实现用例”中解释的那样,将SendMoneyCommand对象构建为一个自我验证的命令,我们甚至可以确保这种映射产生了对用例而言语法上有效的输入。此外,我们还验证了用例确实被调用,并且 HTTP 响应具有预期的状态。

那么,为什么这是一个集成测试而不是单元测试呢?尽管在这个测试中似乎我们只测试了一个单一的 Web 控制器类,但在引擎盖下还有更多的事情在进行。通过使用@WebMvcTest注解,我们告诉 Spring 实例化一个负责响应特定请求路径、在 Java 和 JSON 之间进行映射、验证 HTTP 输入等的整个对象网络。在这个测试中,我们验证我们的 Web 控制器作为这个网络的一部分是否正常工作。

由于 Web 控制器与 Spring 框架紧密耦合,因此当它集成到这个框架中进行测试时是有意义的,而不是在隔离状态下进行测试。如果我们用普通的单元测试来测试 Web 控制器,我们就会失去对所有映射、验证和 HTTP 内容的覆盖,并且我们永远无法确定它是否在生产环境中实际工作,在那里它只是框架机械结构中的一个齿轮。

使用集成测试测试持久化适配器

由于同样的原因,用集成测试而不是单元测试来覆盖持久化适配器是有意义的,因为我们不仅想要验证适配器内部的逻辑,还要验证其映射到数据库的过程。

我们想测试我们在 第七章实现持久性适配器 中构建的持久性适配器。适配器有两个方法,一个是从数据库中加载 Account 实体,另一个是将新的账户活动保存到数据库中:

代码示例

使用 @DataJpaTest,我们告诉 Spring 实例化数据库访问所需的网络对象,包括连接到数据库的我们的 Spring Data 仓库。我们使用 @Import 注解导入一些额外的配置,以确保某些对象被添加到该网络中。这些对象是测试中的适配器所需的,以便将传入的领域对象映射到数据库对象,例如。

在对 loadAccount() 方法的测试中,我们使用名为 AccountPersistenceAdapterTest.sql 的 SQL 脚本将数据库置于某种状态。然后,我们简单地通过适配器 API 加载账户,并验证它具有我们根据 SQL 脚本中的数据库状态所期望的状态。

对于 updateActivities() 的测试,方向相反。我们创建一个带有新账户活动的 Account 对象,并将其传递给适配器以持久化。然后,我们检查该活动是否已通过 ActivityRepository 的 API 保存到数据库中。

这些测试的一个重要方面是我们没有模拟数据库。测试实际上击中了数据库。如果我们模拟了数据库,测试仍然会覆盖相同的代码行,产生相同的代码行高覆盖率。然而,尽管有这种高覆盖率,由于 SQL 语句中的错误或数据库表与 Java 对象之间意外的映射错误,测试在真实数据库的设置中仍然有相当高的失败概率。

注意,默认情况下,Spring 将启动一个内存数据库以在测试期间使用。这非常实用,因为我们不需要进行任何配置,测试将直接工作。然而,由于这个内存数据库很可能不是我们在生产中使用的数据库,即使在内存数据库上测试工作完美,仍然有相当大的可能性在真实数据库上出现问题。例如,数据库供应商喜欢实现他们自己的 SQL 版本。

因此,持久性适配器测试应该针对真实数据库运行。在这方面,Testcontainers 等库非常有帮助,可以根据需要启动一个包含数据库的 Docker 容器。3

3 Testcontainers: www.testcontainers.org/.

对真实数据库进行测试的好处是,我们不必处理两个不同的数据库系统。如果我们使用测试期间的内存数据库,我们可能需要以某种方式配置它,或者我们可能需要为每个数据库创建数据库迁移脚本的单独版本,这对我们测试的可维护性是一个很大的打击。

使用系统测试测试主要路径

在金字塔的顶部是我所说的系统测试。系统测试启动整个应用,并对其 API 运行请求,验证我们所有的层是否协同工作。

六角架构的核心是创建一个清晰定义的应用与外部世界之间的边界。这样做使得我们的应用边界在设计上非常易于测试。为了在本地测试我们的应用,我们只需按照图 8.2中概述的方法,用模拟适配器替换适配器即可。

图 8.2 – 通过用模拟替换适配器,我们可以运行和测试我们的应用,而不依赖于外部世界

图 8.2 – 通过用模拟替换适配器,我们可以运行和测试我们的应用,而不依赖于外部世界

在左侧,我们可以用测试驱动器替换输入适配器,该驱动器调用应用输入端口以与之交互。测试驱动器可以实施某些测试场景,模拟自动化测试期间的用户行为。

在右侧,我们可以用模拟适配器替换输出适配器,该适配器模拟真实适配器的行为并返回先前指定的值。4

4 模拟:根据你询问的对象和你在测试中做的事情,你最好将之称为“伪造”或“存根”,而不是“模拟”。每个术语似乎都有略微不同的语义,但最终,它们都是用“模拟”的事物来替换“真实”的事物,以便在测试中使用。我通常喜欢给事物取一个恰当的名字,但在这个情况下,我认为讨论模拟结束和存根开始之间的细微差别没有价值。或者,情况是否相反?

这样,我们可以创建“应用测试”,它覆盖了从输入端口到我们的领域服务和实体,再到输出端口的“六边形”应用。

然而,我认为,我们不应该编写模拟输入和输出适配器的“应用测试”,而应该旨在编写覆盖从真实输入适配器到真实输出适配器整个路径的“系统测试”。这些测试揭示了如果我们模拟输入和输出适配器,我们可能无法捕捉到的许多微妙错误。这些错误包括层之间的映射错误,或者简单地是应用与它所交流的外部系统之间的错误期望。

这样的“系统测试”要求我们能够在测试设置中启动应用所交流的真实外部系统。

在输入端,我们需要确保我们可以向应用程序发出真实的 HTTP 请求,例如,使请求通过我们的真实 Web 适配器。然而,这应该相当简单,因为我们只需在本地启动我们的应用程序,让它像在生产环境中一样监听 HTTP 请求。

在输出端,我们需要启动一个真实的数据库,例如,以便我们的测试可以通过真实持久化适配器进行。如今,大多数数据库都通过提供我们可以本地启动的 Docker 镜像来简化这一点。如果我们的应用程序与不是数据库的第三方系统通信,我们仍然应该尝试找到(或创建)一个包含该系统的 Docker 镜像,这样我们就可以通过启动本地 Docker 容器来测试我们的应用程序。

如果没有可用的 Docker 镜像来模拟外部系统,我们可以编写一个自定义的模拟输出适配器来模拟真实情况。六角架构使我们能够轻松地将真实输出适配器替换为这个模拟适配器,以便于我们的测试。如果 Docker 镜像变得可用,我们也可以轻松地切换到真实输出适配器。

当然,测试模拟适配器而不是真实适配器有合理的理由。例如,如果我们的应用程序在多个配置文件中运行,并且每个配置文件使用不同的(真实)输入或输出适配器,这些适配器针对相同的输入和输出端口实现,我们可能希望有隔离应用程序错误和适配器错误的测试。那么,只覆盖我们六角的应用程序测试正是我们想要的工具。然而,对于具有数据库的标准 Web 应用程序,其中输入和输出适配器相对静态,我们可能更希望专注于系统测试。

系统测试看起来会是什么样子?在一个针对 发送金钱 用例的系统测试中,我们向应用程序发送一个 HTTP 请求,并验证响应以及账户的新余额。

在 Java 和 Spring 世界中,它可能看起来是这样的:

代码 8.5a代码 8.5b

使用 @SpringBootTest,我们告诉 Spring 启动构成应用程序的对象网络。我们还配置应用程序在随机端口上公开自己。

在测试方法中,我们只需创建一个请求,将其发送到应用程序,然后检查响应状态和账户的新余额。

我们使用 TestRestTemplate 发送请求,而不是之前在 Web 适配器测试中使用的 MockMvc。这意味着测试会进行真实的 HTTP 调用,使测试更接近生产环境。

正如我们处理真实的 HTTP 一样,我们通过真实的输出适配器。在我们的案例中,这只是一个将应用程序连接到数据库的持久性适配器。在一个与其他系统通信的应用程序中,我们会放置额外的输出适配器。对于系统测试来说,并不是总是可行让所有这些第三方系统都运行起来,所以最终我们可能需要模拟它们。我们的六边形架构使我们能够尽可能容易地做到这一点,因为我们只需要模拟几个输出端口接口。

注意,我特意使测试尽可能易于阅读。我将所有丑陋的逻辑都隐藏在辅助方法中。这些方法现在形成了一个特定领域语言,我们可以用它来验证事物的状态。

尽管像这样的特定领域语言在任何类型的测试中都是一个好主意,但在系统测试中它更为重要。系统测试比单元测试或集成测试更好地模拟了应用程序的真实用户,因此我们可以从用户的角度验证应用程序。有了合适的词汇,这会容易得多。这个词汇库还使领域专家能够对测试进行推理并提供反馈,这些专家最适合体现应用程序的用户,而且可能不是程序员。有专门的库用于行为驱动开发,例如 JGiven5,它提供了一个框架来为您的测试创建词汇。

5 JGiven: jgiven.org/.

如果我们像前几节描述的那样创建单元测试和集成测试,系统测试将覆盖大量相同的代码。它们甚至提供任何额外的优势吗?是的,它们确实提供了。通常,它们会清除单元测试和集成测试之外的其他类型的错误。例如,某些层之间的映射可能不正确,而这仅凭单元测试和集成测试我们是无法注意到的。

如果系统测试结合多个用例来创建场景,它们就能发挥最大的优势。每个场景代表用户可能通过应用程序的某个特定路径。如果最重要的场景通过系统测试得到覆盖,我们可以假设我们最新的修改并没有破坏它们,并且可以准备发货。

多少测试才算足够?

我参与过的许多项目团队都无法回答的一个问题是我们应该进行多少测试。如果我们的测试覆盖了 80%的代码行数,这足够了吗?应该更高吗?

行覆盖率不是一个衡量测试成功的良好指标。除了 100%之外的其他任何目标都是完全没有意义的,因为代码库的重要部分可能根本未被覆盖。6 即使在 100%的情况下,我们仍然不能确定每个错误都已被消除。

6 测试覆盖率:如果你想了解更多关于 100%测试覆盖率的信息,请查看我那带有讽刺意味标题的文章《为什么你应该强制执行 100%代码覆盖率》,链接为reflectoring.io/100-percent-test-coverage/

我建议通过我们感觉有多舒服地发布软件来衡量测试的成功。如果我们对测试足够信任,在执行它们之后发布软件,那么我们就做得很好。我们发布得越频繁,我们对测试的信任就越多。如果我们一年只发布两次,没有人会信任测试,因为它们一年只证明自己两次。

这需要我们在前几次发布时跳出一个信仰的飞跃,但如果我们把修复和从生产中的错误中学习作为优先事项,我们就走上了正确的道路。对于每个生产错误,我们应该问自己,“为什么我们的测试没有捕捉到这个错误?”记录下答案,然后添加一个覆盖它的测试。随着时间的推移,这将使我们感到发布软件很舒服,而且文档甚至提供了一个衡量我们随着时间的推移改进的指标。

然而,从定义我们应该创建的测试的策略开始是有帮助的。我们六边形架构的一个这样的策略是:

  • 在实现领域实体时,用单元测试来覆盖它。

  • 在实现用例服务时,用单元测试来覆盖它。

  • 在实现适配器时,用集成测试来覆盖它。

  • 用系统测试覆盖用户可以通过应用程序采取的最重要路径。

注意到短语“while implementing”——当测试在开发功能期间完成而不是之后,它们就变成了一个开发工具,不再感觉像是一项任务。

然而,如果我们每次添加一个新字段时都必须花一个小时来修复测试,那么我们做错了。很可能,我们的测试对代码的结构变化过于脆弱,我们应该看看如何改进。如果我们不得不为每次重构修改测试,测试就会失去价值。

这如何帮助我构建可维护的软件?

六边形架构风格清晰地分离了领域逻辑和外部适配器。这有助于我们定义一个清晰的测试策略,用单元测试覆盖中心领域逻辑,用集成测试覆盖适配器。

输入和输出端口为测试提供了非常明显的模拟点。对于每个端口,我们可以决定模拟它或使用真实实现。如果端口每个都非常小且专注,模拟它们就像轻松完成任务一样,而不是一项任务。端口接口提供的方法越少,我们在测试中必须模拟的方法就越少混淆。

如果模拟某些事物变得过于繁重,或者如果我们不知道应该使用哪种测试来覆盖代码库的某个部分,那么这就是一个警告信号。在这方面,我们的测试有额外的责任,即充当一个“金丝雀”——提醒我们关于架构中的缺陷,并将我们引回创建可维护代码库的正确道路。

到目前为止,我们主要独立地讨论了我们的用例和适配器。它们之间是如何相互通信的呢?在下一章中,我们将探讨一些设计数据模型的方法,这些模型构成了它们之间的通用语言。

第九章:边界之间的映射

在前面的章节中,我们讨论了网络、应用、领域和持久层以及每个层对实现用例的贡献。

然而,我们几乎还没有触及到令人恐惧且无处不在的主题,即层之间模型之间的映射。我敢打赌,你可能在某个时候讨论过是否在两层中使用相同的模型以避免实现映射器。

争论可能如下进行:

支持映射的开发者:

如果我们不在层之间进行映射,我们不得不在两层中使用相同的模型,这意味着层将 紧密耦合!

反对映射的开发者:

但是如果我们确实在层之间进行映射,我们将产生大量的样板代码,这对于许多用例来说都是过度的,因为它们只进行 CRUD 操作,并且层之间已经使用相同的模型了!

在此类讨论中,通常双方的观点都有一定的真实性。让我们讨论一些映射策略及其优缺点,看看我们是否可以帮助这些开发者做出决定。

“无映射”策略

第一种策略实际上根本不进行映射。

图 9.1 – 如果端口接口使用领域模型作为输入和输出模型,我们可以选择不在层之间进行映射

图 9.1 – 如果端口接口使用领域模型作为输入和输出模型,我们可以选择不在层之间进行映射

图 9**.1显示了与我们 BuckPal 示例应用中的“发送金钱”用例相关的组件。

在网络层,网络控制器调用SendMoneyUseCase接口来执行用例。此接口接受一个Account对象作为参数。这意味着网络层和应用层都需要访问Account类——它们都在使用相同的模型。

在应用的反面,我们也有持久层和应用层之间相同的关系。

由于所有层都使用相同的模型,我们不需要在它们之间实现映射。

但这种设计的后果是什么?

网络层和持久层可能对其模型有特殊要求。例如,如果我们的网络层通过 REST 公开其模型,模型类可能需要一些注解来定义如何将某些字段序列化为 JSON。如果我们在使用对象关系映射ORM)框架时也是如此,这可能会要求一些注解来定义数据库映射。框架还可能要求类遵循某种契约。

在示例中,所有那些特殊要求都必须在Account领域模型类中处理,即使领域层和应用层对它们不感兴趣。这违反了单一职责原则,因为Account类必须因为与网络、应用和持久层相关的原因而更改。

除了技术要求之外,每一层可能还需要在Account类上某些自定义字段。这可能导致领域模型碎片化,某些字段仅在某一层中相关。

然而,这难道意味着我们永远不应该实施“无映射”策略吗?当然不是。尽管这可能感觉有些不妥,但“无映射”策略可以完全有效。

考虑一个简单的 CRUD 用例。我们真的需要将相同的字段从网络模型映射到领域模型,再从领域模型映射到持久化模型吗?我认为我们不需要。

那么那些在领域模型上的 JSON 或 ORM 注解呢?它们真的会困扰我们吗?即使我们必须在持久化层发生变化时更改领域模型中的一个或两个注解,那又如何呢?

只要所有层都需要在完全相同的结构中精确地获取相同的信息,那么“无映射”策略就是一个完全有效的选择。

然而,一旦我们在应用或领域层(除了注解之外)处理网络或持久化问题,我们应转向另一种映射策略。

从这里引入的教训对两位开发者来说很重要:尽管我们过去决定了一种特定的映射策略,但我们可以在以后更改它。

根据我的经验,许多用例最初都是简单的 CRUD 用例。后来,它们可能发展成为具有丰富行为和验证的完整业务用例,这证明了更昂贵的映射策略的合理性。或者它们可能永远保持 CRUD 状态,在这种情况下,我们很高兴我们没有投资于不同的映射策略。

“双向”映射策略

每一层都有自己的模型,这种映射策略我称之为“双向”映射策略,如图 9.2所示。2*。

图 9.2 – 每个适配器都有自己的模型,适配器负责将模型映射到领域模型并返回

图 9.2 – 每个适配器都有自己的模型,适配器负责将模型映射到领域模型,并返回

每一层都有自己的模型,其结构可能与领域模型完全不同。

网络层将网络模型映射到入站端口期望的输入模型。它还将入站端口返回的领域对象映射回网络模型。

持久化层负责在用于出站端口的领域模型和持久化模型之间进行类似的映射。

两个层都进行双向映射,因此命名为“双向”映射。

由于每一层都有自己的模型,它可以修改自己的模型而不影响其他层(只要内容没有改变)。网络模型可以有一个结构,允许最佳地展示数据。领域模型可以有一个结构,最好地实现用例。持久性模型可以有一个结构,满足 ORM 映射器将对象持久化到数据库所需的结构。

这种映射策略还导致了一个干净的领域模型,它不会受到网络或持久性问题的污染。它不包含 JSON 或 ORM 映射注解。满足了单一职责原则。

“双向”映射的另一个优点是,在“无映射”策略之后,它在概念上是最简单的映射策略。映射责任是清晰的:外层/适配器映射到内层模型的模型,并返回。内层只知道自己的模型,可以专注于领域逻辑而不是映射。

与每种映射策略一样,“双向”映射也有其缺点。

首先,它通常会导致大量的样板代码。即使我们使用许多映射框架之一来减少代码量,实现模型之间的映射通常也会占用我们大量的时间。这部分原因是调试映射逻辑很痛苦——尤其是在使用一个隐藏其内部工作原理在通用代码和反射层之后的映射框架时。

另一个潜在的缺点是,传入和传出的端口使用领域对象作为输入参数和返回值。适配器将这些映射到它们自己的模型,但这仍然比我们引入一个专门的“传输模型”(如我们接下来要讨论的“完全”映射策略)创建了更多的层间耦合。

就像“无映射”策略一样,“双向”映射策略也不是万能的。然而,在许多项目中,这种映射被认为是一条神圣的法律,我们必须在整个代码库中遵守,即使是对于最简单的 CRUD 用例。这无必要地减慢了开发速度。

不应该将任何单一的映射策略视为铁律。相反,我们应该为每个用例做出决定。

“完全”映射策略

另一种映射策略是我所说的“完全”映射策略,如图 9.3所示。

图 9.3 – 每个操作都需要自己的模型,网络适配器和应用层将各自的模型映射到它们想要执行的操作所期望的模型

图 9.3 – 每个操作都需要自己的模型,网络适配器和应用层将各自的模型映射到它们想要执行的操作所期望的模型

这种映射策略为每个操作引入了一个单独的输入和输出模型。我们不是使用域模型在层边界之间进行通信,而是使用针对每个操作的特定模型,例如SendMoneyCommand,它在图中充当SendMoneyUseCase端口的输入模型。我们可以将这些模型称为“命令”、“请求”或类似名称。

网络层负责将其输入映射到应用层的命令对象。这样的命令使得应用层的接口非常明确,几乎没有解释的空间。每个用例都有自己的命令,具有自己的字段和验证。我们不需要猜测哪些字段应该填写,哪些字段最好留空,因为它们可能会触发我们不希望针对当前用例的验证。

然后,应用层负责将命令对象映射到根据用例需要修改域模型所需的内容。

自然地,从一层映射到多个不同的命令需要比从单个网络模型到域模型之间的映射更多的映射代码。然而,这种映射比必须处理多个用例需求而不是单个用例的映射要容易实现和维护得多。

我不主张将这种映射策略作为全局模式。它在网络层(或任何其他传入适配器)和应用层之间发挥其优势,以清楚地界定应用的状态修改用例。由于映射开销,我不会在应用层和持久层之间使用它。

通常,我会将这种类型的映射限制在操作的输入模型上,并简单地使用域对象作为输出模型。例如,SendMoneyUseCase可能会返回一个带有更新后余额的Account对象。

这表明映射策略可以并且应该混合使用。不需要任何单一的映射策略成为所有层级的全局规则。

“单向”映射策略

还有一种映射策略,它有一套自己的优缺点:即图 9.4中展示的“单向”策略。

图 9.4 – 域模型和适配器模型实现相同的“状态”接口,每个层只需要映射从其他层接收到的对象一次

图 9.4 – 域模型和适配器模型实现相同的“状态”接口,每个层只需要映射从其他层接收到的对象一次

在这种策略中,所有层的模型实现相同的接口,通过在相关属性上提供 getter 方法来封装域模型的状态。

领域模型本身可以实现丰富的行为,我们可以从应用层的服务中访问这些行为。如果我们想将领域对象传递到外层,我们可以这样做,无需映射,因为领域对象实现了传入和传出端口所期望的状态接口。

外层可以决定它们是否可以使用该接口,或者是否需要将其映射到自己的模型。由于修改行为没有通过状态接口暴露,它们不能无意中修改领域对象的状态。

我们从外层传递到应用层的对象也实现了这个状态接口。然后应用层必须将其映射到真实领域模型,以便访问其行为。这种映射与领域驱动设计中的工厂概念相得益彰。在领域驱动设计(DDD)中,工厂负责从某种状态重新构建领域对象,这正是我们所做的。1

1 工厂:领域驱动设计,作者埃里克·埃文斯,Addison-Wesley,2004 年,第 158 页。

映射责任是明确的:如果一个层从另一个层接收对象,我们就将其映射为该层可以处理的东西。因此,每个层只映射一个方向,这使得这是一个“单向”映射策略。

然而,随着映射在层之间分布,这个策略在概念上比其他策略更难。

如果层之间的模型相似,这种策略最能发挥其优势。例如,对于只读操作,Web 层可能根本不需要映射到自己的模型,因为状态接口提供了它所需的所有信息。

何时使用哪种映射策略?

这确实是一个价值百万的问题,不是吗?

答案是通常令人不满意的“这取决于”。

由于每种映射策略都有不同的优缺点,我们应抵制将单一策略定义为整个代码库的硬性全局规则的冲动。这与我们的直觉相悖,因为它感觉在同一个代码库中混合模式是不整洁的。但为了满足我们对整洁的感觉,明知不是最适合某个工作的模式,却选择它,这是不负责任的,简单明了。

此外,随着软件随时间演变,昨天最适合工作的策略可能今天不再是最适合的。我们可能不是从固定的映射策略开始并保持它——无论发生什么——而是从一个简单的策略开始,这个策略允许我们快速演进代码,然后转向一个更复杂的策略,帮助我们更好地解耦层。

为了决定何时使用哪种策略,我们需要在团队内部达成一套指导原则。这些指导原则应该回答在何种情况下哪种映射策略应该是首选,以及为什么它们是首选,这样我们才能评估这些原因在一段时间后是否仍然适用。

例如,我们可能为修改用例定义不同的映射指南,而不是为查询定义。我们可能还希望在 Web 层和应用层之间以及应用层和持久层之间使用不同的映射策略。

这些情况的指导原则可能看起来是这样的:

  • 如果我们正在处理一个修改用例,那么在 Web 层和应用层之间,“完整”映射策略是首选,以便将用例彼此解耦。这为我们提供了清晰的每个用例验证规则,我们不必处理某个用例中不需要的字段。

  • 如果我们正在处理一个修改用例,那么在应用层和持久层之间,“无映射”策略是首选,以便能够快速进化代码而不需要映射开销。然而,一旦我们必须在应用层处理持久性问题,我们就转向“双向”映射策略以保持持久性问题在持久层。

  • 如果我们正在处理一个查询,那么在 Web 层和应用层之间以及应用层和持久层之间,“无映射”策略是首选,以便能够快速进化代码而不需要映射开销。然而,一旦我们必须在应用层处理 Web 或持久性问题,我们就转向 Web 层和应用层或应用层和持久层之间的“双向”映射策略。

为了成功应用这样的指南,它们必须存在于开发者的脑海中。因此,这些指南应该作为一个团队的努力持续讨论和修订。

这如何帮助我构建可维护的软件?

进出端口充当我们应用程序各层之间的守门人。它们定义了层与层之间如何相互通信,以及我们如何在不同层之间映射模型。

通过为每个用例设置狭窄的端口,我们可以为不同的用例选择不同的映射策略,甚至随着时间的推移进行进化,而不影响其他用例,从而在某个特定时间选择某种特定情况的最佳策略。

为每个用例选择不同的映射策略比简单地使用相同的映射策略更困难,需要更多的沟通,但只要映射指南是已知的,它将使团队能够获得一个只做它需要做的事情且更容易维护的代码库。

现在我们知道了构成我们应用程序的组件以及它们如何通信,我们可以探索如何将不同的组件组装成一个可工作的应用程序。

第十章:组装应用程序

现在我们已经实现了一些用例、Web 适配器和持久化适配器,我们需要将它们组装成一个可工作的应用程序。如第四章中所述,组织代码,我们依赖于依赖注入机制在启动时实例化我们的类并将它们连接起来。在本章中,我们将讨论一些使用纯 Java 和 Spring 以及 Spring Boot 框架来实现这一点的途径。

为什么甚至要关心组装?

为什么我们不在需要的时候和需要的地方实例化用例和适配器呢?因为我们希望保持代码依赖指向正确的方向。记住:所有依赖都应该指向内部,指向我们应用程序的领域代码,这样当外部层发生变化时,领域代码就不需要改变。

如果一个用例需要调用持久化适配器,并且自己实例化它,那么我们就创建了一个错误方向的代码依赖。

这就是为什么我们创建了输出端口接口。用例只知道接口,并在运行时提供了一个该接口的实现。

这种编程风格的一个很好的副作用是,我们创建的代码更容易测试。如果我们能够将一个类需要的所有对象传递给它的构造函数,我们可以选择传递模拟对象而不是真实对象,这使得为该类创建一个隔离的单元测试变得容易。

那么,谁负责创建我们的对象实例?我们如何在不违反依赖规则的情况下完成它?

答案是,必须有一个中立于我们架构的配置组件,并且它对所有类都有依赖关系,以便实例化它们,如图图 10**.1所示。

图 10.1 – 中立配置组件可以访问所有类以实例化它们

图 10.1 – 中立配置组件可以访问所有类以实例化它们

第三章中引入的反转依赖整洁架构中,这个配置组件会在最外层圆圈,可以访问所有内层,如依赖规则所定义。

配置组件负责将我们提供的各个部分组装成一个可工作的应用程序。它必须执行以下操作:

  • 创建 Web 适配器实例。

  • 确保 HTTP 请求实际上被路由到 Web 适配器。

  • 创建用例实例。

  • 提供带有用例实例的 Web 适配器。

  • 创建持久化适配器实例。

  • 提供带有持久化适配器实例的用例。

  • 确保持久化适配器实际上可以访问数据库。

此外,配置组件应该能够访问某些配置参数的来源,例如配置文件或命令行参数。在应用程序组装期间,配置组件将这些参数传递给应用程序组件,以控制行为,例如访问哪个数据库或使用哪个服务器发送电子邮件。

这些责任很多(即:改变的原因)。我们这里不是违反了单一职责原则吗?是的,我们是,但如果我们想保持应用程序的其他部分干净,我们需要一个外部组件来处理连接。而这个组件必须了解所有移动部件,以便将它们组装成一个可工作的应用程序。

通过纯代码进行组装

实现负责组装应用程序的配置组件有几种方法。如果我们正在构建一个没有依赖注入框架支持的应用程序,我们可以使用纯代码创建这样一个组件:

这段代码片段是一个简化的示例,展示了这样一个配置组件可能的样子。在 Java 中,应用程序是从main方法启动的。在这个方法中,我们实例化所有需要的类,从 Web 控制器到持久化适配器,并将它们连接在一起。

最后,我们调用神秘的方法startProcessingWebRequests(),它通过 HTTP 公开了 Web 控制器。1 然后,应用程序就准备好处理请求了。

1 startProcessingWebRequests()方法只是一个占位符,用于任何必要的引导逻辑,以便通过 HTTP 公开我们的 Web 适配器。我们并不真的想自己实现它。在实际的应用程序中,一个框架会为我们做这件事。

这种纯代码方法是最基本的组装应用程序的方式。然而,它也有一些缺点:

  • 首先,前面的代码是为只有一个 Web 控制器、用例和持久化适配器的应用程序编写的。想象一下,为了引导一个完整的商业应用程序,我们还需要编写多少类似的代码!

  • 其次,由于我们是从它们的包外部实例化所有类的,因此这些类都必须是公开的。这意味着,例如,Java 编译器不会阻止直接访问公开的持久化适配器,因为它公开了。如果我们能通过使用包私有可见性来避免这种不想要的依赖,那将很棒。

幸运的是,有一些依赖注入框架可以为我们做脏活,同时仍然保持包私有依赖。Spring 框架目前在 Java 世界中是最受欢迎的。Spring 还提供了许多其他功能,包括 Web 和数据库支持,因此我们最终不必实现神秘的startProcessingWebRequests()方法。

通过 Spring 的类路径扫描进行组装

如果我们使用 Spring 框架来组装我们的应用程序,结果被称为 应用程序上下文。应用程序上下文包含构成应用程序的所有对象(在 Java 术语中称为 bean)。

Spring 提供了多种方法来组装应用程序上下文,每种方法都有其自身的优缺点。让我们先从最流行(也是最方便)的方法开始讨论:类路径扫描

使用类路径扫描,Spring 会遍历类路径中某个片段中所有可用的类,并搜索带有 @Component 注解的类。框架随后会从这些类中创建对象。这些类应该有一个接受所有必需字段作为参数的构造函数,就像我们来自 第七章**,实现 持久化适配器 *的 AccountPersistenceAdapter 类一样:

在这种情况下,我们甚至没有自己编写构造函数,而是使用 Lombok 库的 @RequiredArgsConstructor 注解来为我们完成这项工作,该注解创建了一个接受所有 final 字段作为参数的构造函数。

Spring 会找到这个构造函数,并搜索带有 @Component 注解的、所需参数类型的类,并以类似的方式实例化它们,将它们添加到应用程序上下文中。一旦所有必需的对象都可用,它最终会调用 AccountPersistenceAdapter 的构造函数,并将生成的对象添加到应用程序上下文中。

类路径扫描是组装应用程序的一种非常方便的方法。我们只需要在代码库中添加一些 @Component 注解,并确保提供正确的构造函数。

我们也可以为 Spring 创建自己的 stereotypes 注解以便它能够识别。例如,我们可以创建一个 @PersistenceAdapter 注解:

这个注解通过 @Component 进行了元注解,以便 Spring 知道在类路径扫描期间应该选择它。现在,我们可以使用 @PersistenceAdapter 而不是 @Component 来标记我们的持久化适配器类作为应用程序的一部分。通过这个注解,我们已经使我们的架构对阅读代码的人更加明显。

然而,类路径扫描方法也有其缺点。首先,它是侵入性的,因为它要求我们在类中添加一个特定框架的注解。如果你是一个 Clean Architecture 的坚定支持者,你会说这是被禁止的,因为它将我们的代码绑定到了一个特定的框架。

我可以说,在通常的应用程序开发中,一个类上的单个注解并不是什么大问题,并且可以很容易地进行重构,如果确实需要的话。

然而,在其他上下文中,例如当为其他开发者构建库或框架时,这可能会是一个不可行的选择,因为我们不希望让我们的用户依赖于 Spring 框架。

类路径扫描方法的一个潜在缺点是可能会发生一些神奇的事情。这里的“神奇”指的是那种不良的魔法,它会导致难以解释的效果,如果你不是 Spring 专家,可能需要花费数天时间才能弄清楚。

由于类路径扫描是应用组装中一个非常钝的武器,所以会发生魔法。我们只需将 Spring 指向我们应用程序的父包,并告诉它在这个包内寻找带有@Component注解的类。

你能记住你应用程序中存在的每一个类吗?可能不会。肯定有一些我们实际上不希望在应用程序上下文中存在的类。也许这个类甚至以邪恶的方式操纵应用程序上下文,导致难以追踪的错误。

让我们看看另一种提供更多控制的方法。

通过 Spring 的 Java 配置进行组装

虽然类路径扫描是应用组装的棍棒,但 Spring 的 Java 配置是手术刀。2 这种方法与本章前面介绍的直接代码方法类似,但它更整洁,并为我们提供了一个框架,这样我们就不必手动编写所有代码。

2 棍棒与手术刀:如果你没有像我一样在角色扮演视频游戏中花费大量时间杀怪物,并且不知道什么是棍棒,那么棍棒是一种带有重锤头的棍子,可以用作武器。它是一种非常钝的武器,可以在不需要特别瞄准的情况下造成大量伤害。

在这种方法中,我们创建配置类,每个配置类负责构建一组要添加到应用程序上下文中的 bean。

例如,我们可以创建一个配置类,该类负责实例化我们所有的持久化适配器:

代码示例

@Configuration注解将此类标记为配置类,以便 Spring 的类路径扫描器可以识别。因此,在这种情况下,我们仍然使用类路径扫描,但我们只选择我们的配置类而不是每个单个 bean,这减少了发生邪恶魔法的机会。

这些 bean 本身是在配置类的@Bean注解的工厂方法中创建的。在前面的例子中,我们向应用程序上下文添加了一个持久化适配器。它需要两个仓库和一个映射器作为构造函数的输入。Spring 会自动将这些对象作为输入提供给工厂方法。

但 Spring 从哪里获取仓库对象呢?如果它们是在另一个配置类的工厂方法中手动创建的,那么 Spring 会自动将这些对象作为参数提供给前面代码示例中的工厂方法。然而,在这种情况下,它们是由 Spring 本身创建的,由@EnableJpaRepositories注解触发。如果 Spring Boot 发现这个注解,它将自动为我们定义的所有 Spring Data 仓库接口提供实现。

如果你熟悉 Spring Boot,你可能知道我们可以在主应用程序类中添加 @EnableJpa Repositories 注解,而不是我们的自定义配置类。是的,这是可能的,但每次启动应用程序时,它都会激活 JPA 存储库,即使我们在不需要持久化的测试中启动应用程序。因此,通过将此类“功能注解”移动到单独的配置“模块”,我们变得更加灵活,可以启动应用程序的某些部分,而无需总是启动整个应用程序。

通过 PersistenceAdapterConfiguration 类,我们创建了一个紧密作用域的持久化模块,它实例化了我们在持久化层中需要的所有对象。当我们在仍然完全控制哪些豆类被添加到应用程序上下文的同时,它将被 Spring 的类路径扫描自动识别。

类似地,我们可以为网络适配器或应用程序层中的某些模块创建配置类。现在,我们可以创建一个包含某些模块的应用程序上下文,同时模拟其他模块的豆类,这在测试中提供了很大的灵活性。我们甚至可以将每个模块的代码推送到其自己的代码库、包或 JAR 文件中,而无需进行太多重构。

此外,这种方法不会强迫我们在代码库中到处添加 @Component 注解,就像类路径扫描方法那样。因此,我们可以保持应用程序层的清洁,而不依赖于 Spring 框架(或任何其他框架)。

然而,这个解决方案有一个问题。如果配置类不在创建豆类(在这种情况下是持久化适配器类)的类所在的同一包中,那些类必须是公共的。为了限制可见性,我们可以使用包作为模块边界,并在每个包中创建一个专门的配置类。这样,我们无法使用子包,正如将在第十二章中讨论的,强制 架构边界

这如何帮助我构建可维护的软件?

Spring 和 Spring Boot(以及类似的框架)提供了许多使我们的工作更轻松的功能。其中一个主要功能是从我们作为应用程序开发者提供的部分(类)组装应用程序。

类路径扫描是一个非常方便的功能。我们只需将 Spring 指向一个包,它就会从找到的类中组装一个应用程序。这允许我们快速开发,无需考虑应用程序的整体结构。

然而,一旦代码库增长,这很快就会导致缺乏透明度。我们不知道哪些豆类确切地被加载到应用程序上下文中。此外,我们无法轻松启动应用程序上下文的孤立部分以用于测试。

通过创建一个专门负责组装我们应用程序的配置组件,我们可以将应用程序代码从这个责任中解放出来(读作:“变化的原因”——记得“SOLID”中的“S”吗?)。作为回报,我们得到了高度凝聚的模块,我们可以将它们彼此独立启动,并且可以轻松地在代码库中移动。通常,这需要我们额外花费一些时间来维护这个配置组件。

我们在这章和上一章中已经讨论了很多关于如何“正确地”做事的不同选项。然而,有时“正确的方式”并不可行。在下一章中,我们将讨论捷径、我们为此付出的代价,以及何时值得采取这些捷径。

第十一章:有意识地走捷径

在这本书的序言中,我诅咒了这样一个事实,那就是我们感觉被迫一直走捷径,积累了一大堆我们永远没有机会偿还的技术债务。

为了防止走捷径,我们必须能够识别它们。因此,本章的目标是提高人们对一些潜在捷径的认识,并讨论它们的影响。

有了这个信息,我们可以识别并修复意外的捷径。或者,如果合理的话,我们甚至可以有意选择捷径的影响。1

1 想象一下在一本关于土木工程的书或,甚至更可怕,在一本关于航空电子学的书中谈论捷径!然而,我们中的大多数人并不是在建造软件高楼大厦或飞机。软件是软的,比硬件更容易改变,所以有时(有意识地!)先走捷径,然后再修复(或永不修复)实际上是更经济的。

为什么捷径就像破窗

在 1969 年,心理学家菲利普·津巴多进行了一项实验,以测试后来被称为破窗 理论y的理论。2

2 破窗理论:www.theatlantic.com/magazine/archive/1982/03/broken-windows/304465/

他的团队在布朗克斯的一个街区停了一辆没有车牌的车,另一辆在帕洛阿尔托一个所谓的“更好”的街区。然后,他们等待。

布朗克斯的车在 24 小时内被洗劫一空,然后路人开始随意破坏它。

帕洛阿尔托的车一周内没有被触及,所以津巴多团队砸了一扇窗户。从那时起,这辆车和布朗克斯的那辆车有相似的命运,在很短的时间内被路过的行人破坏。

参与抢劫和破坏汽车的人来自所有社会阶层,包括那些在其他方面守法且行为良好的公民。

这种人类行为被称为“破窗理论”。用我自己的话说:

一旦某物看起来破旧、损坏、[插入负面形容词],或者总体上无人照管,人类大脑就会觉得让它变得更破旧、损坏、或者[插入负面形容词]是合理的。

这个理论适用于生活的许多领域:

  • 在一个常见的破坏行为的社区,掠夺或损坏无人照料的汽车的门槛很低。

  • 当一辆车有破窗时,即使在“好”的街区,进一步破坏它的门槛也很低。

  • 在一个杂乱的卧室里,把衣服扔在地上而不是放进衣柜的门槛很低。

  • 在一个学生经常打断课程的教室里,向同学讲一个笑话的门槛很低。

将其应用于代码工作,意味着以下内容:

  • 当在一个低质量的代码库上工作时,增加更多低质量代码的门槛很低。

  • 当在一个有很多编码违规的代码库上工作时,增加另一个编码违规的门槛很低。

  • 当在一个有很多捷径的代码库上工作时,增加另一个捷径的门槛很低。

考虑到所有这些,许多所谓的“遗留”代码库的质量随着时间的推移而严重下降,这真的令人惊讶吗?

从头开始的责任

虽然与代码打交道并不真的感觉像是抢劫一辆车,但我们所有人都在不知不觉中受到“破窗”心理的影响。这使得项目开始时尽可能干净、尽可能少地采取捷径和尽可能少的技术债务变得非常重要。这是因为,一旦出现捷径,它就会像破窗一样吸引更多的捷径。

由于软件项目通常是一项非常昂贵且持续时间长的努力,因此作为软件开发者,防止出现“破窗”现象是一项巨大的责任。我们甚至可能不是完成项目的最后一批人,其他人需要接手。对他们来说,这是一个他们还没有建立联系的遗留代码库,进一步降低了创建“破窗”现象的门槛。

然而,有时我们决定采取捷径是务实的选择,无论是由于我们正在工作的代码部分对整个项目来说并不那么重要,我们正在做原型设计,还是出于经济原因。

我们应该非常小心地记录这些有意识添加的捷径,例如,以架构决策记录ADRs)的形式,正如迈克尔·尼加德在他的博客[in hi]中提出的。3 我们应该对我们的未来自己和我们的继任者负责。如果团队中的每个成员都了解这份文档,它甚至可以减少“破窗”效应,因为团队将知道这些捷径是经过深思熟虑并且有充分的理由而采取的。

3 架构决策记录:thinkrelevance.com/blog/2011/11/15/documenting-architecture-decisions

以下各节将讨论一种可以被视为本书中提出的六边形架构风格中的捷径的模式。我们将探讨这些捷径的影响以及支持或反对采取这些捷径的论点。

在用例之间共享模型

第五章《实现用例》中,我论证了不同的用例应该有不同的输入和输出模型,这意味着输入参数的类型和返回值的类型应该是不同的。

图 11.1展示了两个用例共享相同输入模型的一个示例:

图 11.1 – 在用例之间共享输入或输出模型会导致用例之间的耦合

图 11.1 – 在用例之间共享输入或输出模型会导致用例之间的耦合

在这种情况下,共享的影响是SendMoneyUseCaseRevokeActivityUseCase相互耦合。如果我们对共享的SendMoneyCommand类中的某个部分进行更改,这两个用例都会受到影响。它们在单一责任原则(如第3 章**,反转依赖关系)的层面上共享一个改变的理由。如果两个用例共享相同的输出模型,情况也是如此。

如果用例在功能上是耦合的,即它们共享一定的需求,那么在用例之间共享输入和输出模型是有效的。在这种情况下,如果我们改变某些细节,我们实际上希望两个用例都受到影响。

然而,如果两个用例都应该能够独立于彼此进化,那么这是一个捷径。在这种情况下,我们应该从一开始就分离用例,即使这意味着如果它们一开始看起来相同,也需要复制输入和输出类。

因此,当围绕一个类似的概念构建多个用例时,定期询问用例是否应该独立于彼此进化是有意义的。一旦答案变成“是”,就是时候分离输入和输出模型了。

使用领域实体作为输入或输出模型

如果我们有一个Account领域实体和一个传入端口SendMoneyUseCase,我们可能会倾向于使用该实体作为传入端口的输入和/或输出模型,如图11**.2所示。

图 11.2 – 使用领域实体作为用例的输入或输出模型会将领域实体耦合到用例中

图 11.2 – 将领域实体用作用例的输入或输出模型会将领域实体耦合到用例中

传入端口依赖于领域实体。这种依赖的结果是,我们又为Account实体增加了一个改变的理由。

等一下,Account实体并不依赖于SendMoneyUseCase传入端口(情况相反),那么传入端口怎么能成为改变实体的原因呢?

假设我们在用例中需要一些关于账户的信息,而这些信息目前不在Account实体中。然而,这些信息最终不应该存储在Account实体中,而应该存储在不同的领域或边界上下文中。尽管如此,我们仍然倾向于向Account实体添加一个新字段,因为它已经在用例接口中可用。

对于简单的创建或更新用例,在用例接口中的领域实体可能就足够了,因为实体包含了我们持久化其状态在数据库中所需的确切信息。

一旦一个用例不仅仅是更新数据库中的几个字段,而是实现更复杂的领域逻辑(可能将部分领域逻辑委托给丰富的领域实体),我们就应该为用例接口使用专门的输入和输出模型,因为我们不希望用例的变化传播到领域实体。

这种快捷方式之所以危险,是因为许多用例最初只是作为一个简单的创建或更新用例开始,但随着时间的推移,它们变成了复杂的领域逻辑的怪物。这在敏捷环境中尤其如此,我们从一个最小可行产品开始,随着前进添加复杂性。因此,如果我们最初使用领域实体作为输入模型,我们必须找到在何时用独立于领域实体的专用输入模型替换它的时机。

跳过输入端口

虽然输出端口对于反转应用程序层和输出适配器之间的依赖关系(使依赖关系指向内部)是必要的,但我们不需要输入端口来实现依赖反转。我们可以决定让输入适配器直接访问我们的应用程序或领域服务,而不在之间使用输入端口,如图图 12.3所示。

图 11.3 – 没有输入端口,我们失去了对领域逻辑的清晰标记的入口点

图 11.3 – 没有输入端口,我们失去了对领域逻辑的清晰标记的入口点

通过移除输入端口,我们减少了输入适配器和应用程序层之间的抽象层。移除抽象层通常感觉相当不错。

然而,输入端口定义了进入我们应用程序核心的入口点。一旦我们移除了它们,我们必须更多地了解我们应用程序的内部结构,以找出我们可以调用以实现特定用例的服务方法。通过维护专门的输入端口,我们可以一眼就识别出应用程序的入口点。这使得新开发者更容易在代码库中找到方向。

保持输入端口的另一个原因是它们使我们能够轻松地强制执行架构。在第十二章“强制执行架构边界”中,我们将学习到的强制执行选项,我们可以确保输入适配器只调用输入端口,而不是应用程序服务。这使得每个进入应用程序层的入口点都是一个非常明智的决定。我们不能再意外调用一个本不应该从输入适配器调用的服务方法。

如果一个应用程序足够小,或者只有一个输入适配器,并且我们可以在不借助输入端口的情况下掌握整个控制流,我们可能希望不使用输入端口。然而,我们有多少时候可以说我们知道一个应用程序将始终保持小规模,或者在其整个生命周期中只会有一个输入适配器呢?

跳过服务

除了入站端口之外,对于某些用例,我们可能希望完全跳过服务层,如图11**.4所示。

图 11.4 – 没有服务,我们的代码库中不再有用例的表示

图 11.4 – 没有服务,我们的代码库中不再有用例的表示

在这里,出站适配器中的AccountPersistenceAdapter类直接实现了一个入站端口,并取代了通常实现入站端口的那个服务。

对于简单的 CRUD 用例来说,这样做是非常有诱惑力的,因为在这种情况下,服务通常只将创建、更新或删除请求转发给持久化适配器,而不添加任何领域逻辑。我们可以让持久化适配器直接实现用例,而不是转发。

然而,这需要入站适配器和出站适配器之间的共享模型,在这种情况下是Account领域实体,因此通常意味着我们正在使用之前描述的领域模型作为输入模型。

此外,我们不再在我们的应用程序核心中有用例的表示。如果 CRUD 用例随着时间的推移变得更加复杂,由于用例已经在那里实现,因此直接将领域逻辑添加到出站适配器中是有诱惑力的。这分散了领域逻辑,使其更难找到和维护。

最后,为了防止样板式的传递服务,我们最终可能选择在简单 CRUD 用例中跳过服务。然而,然后团队应该制定明确的指南,一旦用例预期要做的事情不仅仅是创建、更新或删除实体,就立即引入服务。

这如何帮助我构建可维护的软件?

有时候,从经济角度来看,走捷径是有意义的。本章提供了一些关于某些捷径可能带来的后果的见解,以帮助决定是否采取这些捷径。

讨论表明,对于简单的 CRUD 用例来说,引入捷径是有诱惑力的,因为对于它们来说,实现整个架构感觉像是过度杀鸡(而且捷径并不感觉像捷径)。然而,由于所有应用程序都是从小的开始的,因此对于团队来说,在用例超出其 CRUD 状态时达成一致非常重要。只有这样,团队才能用长期更易于维护的架构替换捷径。

有些用例永远不会超出它们的 CRUD 状态。对于它们来说,永远保留捷径可能更实用,因为它们实际上并不涉及维护开销。

在任何情况下,我们都应该记录架构和选择特定捷径的原因,以便我们(或我们的继任者)可以在未来重新评估这些决定。

即使有时捷径是可以接受的,我们仍然希望有意识地做出走捷径的决定。这意味着我们应该定义一种“正确”的做事方式,并强制执行这种方式,以便在有充分理由的情况下我们可以偏离这种方式。在下一章中,我们将探讨一些强制执行我们架构的方法。

第十二章:强制执行架构边界

在前面的章节中,我们讨论了很多关于架构的内容,现在有一个目标架构来指导我们如何编写代码以及将其放置在哪里,感觉很好。

然而,在每一个大型软件项目中,架构往往会随着时间的推移而侵蚀。层之间的边界减弱,代码变得难以测试,我们通常需要越来越多的时间来实现新功能。

在本章中,我们将讨论我们可以采取的一些措施来强制执行架构内的边界,从而对抗架构侵蚀。

边界和依赖关系

在我们讨论强制执行架构边界的不同方法之前,让我们讨论架构中的边界在哪里,以及强制执行边界实际上意味着什么。

图 12.1 – 强制执行架构边界意味着确保依赖关系指向正确的方向(虚线箭头表示根据我们的架构不允许的依赖关系)

图 12.1 – 强制执行架构边界意味着确保依赖关系指向正确的方向(虚线箭头表示根据我们的架构不允许的依赖关系)

图 12.1 展示了我们的六边形架构元素可能分布在四个层次上,类似于在第三章中介绍的通用 Clean Architecture 方法,“反转依赖”。

最内层包含领域实体和领域服务。围绕它的应用层可以访问这些实体和服务以实现一个用例,通常是通过应用服务。适配器通过输入端口访问这些服务,或者通过输出端口被这些服务访问。最后,配置层包含创建适配器和服务对象的工厂,并将它们提供给依赖注入机制。

在前面的图中,我们的架构边界变得非常清晰。每一层与其相邻的内层和外层之间都有一个边界。根据依赖规则,跨越此类层边界的依赖关系必须始终指向内层。

本章介绍如何强制执行依赖规则。我们希望确保没有非法的依赖关系指向错误的方向(图中用虚线箭头表示)。

可见性修饰符

让我们从面向对象语言(特别是 Java)提供给我们强制执行边界的最基本工具开始:可见性修饰符

可见性修饰符在过去几年中我进行的几乎所有入门级职位面试中都是一个话题。我会问应聘者 Java 提供了哪些可见性修饰符以及它们之间的区别是什么。

大多数受访者只列出publicprotectedprivate修饰符。只有少数人知道default修饰符。这总是我询问一些关于为什么这样的可见性修饰符有意义的绝佳机会,以便找出受访者是否能够从他们以前的知识中抽象出来。

那么,为什么包私有修饰符如此重要呢?因为它允许我们使用 Java 包将类分组到紧密的“模块”中。此类模块内的类可以相互访问,但不能从包外部访问。然后我们可以选择将特定的类设置为公共的,作为模块的入口点。这减少了因引入指向错误方向的依赖而意外违反依赖规则的风险。

让我们再次看看第四章中讨论的包结构,即组织代码,考虑到可见性修饰符:

代码-12.1.jpg

我们可以将persistence包中的类设置为包私有(在上述树中用o标记),因为它们不需要被外部世界访问。持久性适配器通过其实现的输出端口进行访问。出于同样的原因,我们也可以将SendMoneyService类设置为包私有。依赖注入机制通常使用反射来实例化类,因此即使它们是包私有的,它们仍然能够实例化这些类。

使用 Spring,这种方法只有在使用第第十章中讨论的类路径扫描方法时才有效,即组装应用程序,然而,因为其他方法要求我们自行创建这些对象的实例,这需要公共访问。

示例中的其余类必须根据我们的架构定义为公共的(用+标记):domain包需要被其他层访问,而application层需要被webpersistence适配器访问。

对于只有几个类的简单模块,包私有修饰符非常出色。然而,一旦包中的类达到一定数量,那么在同一个包中有这么多类就会变得令人困惑。在这种情况下,我喜欢创建子包,以便更容易找到代码(并且,我必须承认,这满足了我的审美感)。这就是包私有修饰符无法发挥作用的地方,因为 Java 将子包视为不同的包,我们无法访问子包的包私有成员。因此,子包中的成员必须是public的,这样它们就会暴露给外部世界,从而使我们的架构容易受到非法依赖的影响。

编译后适应度函数

一旦我们在类上使用公共修饰符,编译器就会允许任何其他类使用它,即使根据我们的架构,依赖关系的方向指向错误的方向。

由于编译器在这些情况下不会帮助我们,我们必须找到其他方法来检查依赖规则没有被违反。

一种方法是引入一个适应度函数——一个接受我们的架构作为输入并确定其在特定方面的适应度的函数。在我们的情况下,“适应度”定义为“依赖规则未被违反”。

理想情况下,编译器在编译过程中为我们运行一个适应度函数,但如果没有这个功能,我们可以在代码编译后运行此函数。这种运行时检查最好在持续集成构建的自动化测试中进行。

支持此类 Java 架构适应度函数的工具是ArchUnit。1 除了其他功能外,ArchUnit 提供了一个 API 来检查依赖是否指向预期的方向。如果它发现违规,它将抛出一个异常。最好在基于 JUnit 等单元测试框架的测试中运行,以便在依赖违规的情况下使测试失败。

1 ArchUnit: https://github.com/TNG/ArchUnit.

使用 ArchUnit,我们现在可以检查我们各层之间的依赖关系,假设每一层都有自己的包,正如前一小节中讨论的包结构所定义的。例如,我们可以检查领域模型没有对外部任何内容的依赖:

图片

此规则验证了图 12.2 中可视化的依赖规则。

图片

图 12.2 – 我们的领域模型可能访问自身和一些库包,但它可能不能访问任何其他包中的代码,例如包含我们的适配器的包(受 https://www.archunit.org/use-cases 中的图表启发)

前述规则的问题是,如果我们使用领域模型中的某些库代码,我们必须为每个引入的依赖(例如,我在示例中用lombokjava)添加一个例外。在第十四章《基于组件的软件架构方法》中,我们将看到一个没有这个问题的规则。

经过一些工作,我们甚至可以在 ArchUnit API 之上创建一种领域特定语言DSL),允许我们指定六边形架构中的所有相关包,然后自动检查这些包之间的所有依赖是否指向正确的方向:

图片

在前面的代码示例中,我们首先指定应用程序的父包。然后我们继续指定领域、适配器、应用程序和配置层的子包。最后的check()调用将执行一系列检查,验证包依赖是否根据依赖规则有效。如果您想尝试这个六边形架构 DSL 的代码,它可以在 GitHub 上找到。2

2 Hexagonal Architecture DSL for ArchUnit: github.com/thombergs/buckpal/blob/master/src/test/java/io/reflectoring/buckpal/archunit/HexagonalArchitecture.java.

虽然像之前的编译后检查这样的检查可以在对抗非法依赖方面大有帮助,但它们并不是万无一失的。例如,如果我们把前面的代码示例中的包名buckpal拼写错误,测试将找不到任何类,因此没有发现依赖违规。一个简单的拼写错误,或者更重要的是,一个重命名包的重构,可以使整个测试变得毫无用处。我们应该努力使这些测试重构安全,或者至少在重构破坏了它们时使它们失败。在前面的例子中,当提到的某个包不存在时,例如(因为它被重命名了),我们可以使测试失败。

构建工件

到目前为止,我们用于在代码库内划分架构边界的唯一工具是包。我们所有的代码都一直是同一个单体构建工件的一部分。

构建工件是(希望是自动化)构建过程的结果。在 Java 世界中,目前最流行的构建工具是 Maven 和 Gradle。因此,到目前为止,假设我们只有一个 Maven 或 Gradle 构建脚本,并且我们可以调用 Maven 或 Gradle 来编译、测试和打包我们应用程序的代码到一个单一的 JAR 文件中。

构建工具的一个主要功能是依赖关系解析。为了将某个代码库转换为构建工件,构建工具首先检查代码库所依赖的所有工件是否可用。如果不可用,它尝试从工件仓库中加载它们。如果这失败了,构建将在尝试编译代码之前因为错误而失败。

我们可以利用这一点来强制执行架构模块和层之间的依赖关系(以及因此强制执行边界)。对于这样的每个模块或层,我们创建一个独立的构建模块,它有自己的代码库和自己的构建工件(JAR 文件)作为结果。在每个模块的构建脚本中,我们只指定那些根据我们的架构允许的其他模块的依赖关系。开发者不能再无意中创建非法依赖,因为类甚至不在类路径上,他们将会遇到编译错误。

图 12.3 – 将我们的架构划分为多个构建工件的不同方式以禁止非法依赖

图 12.3 – 将我们的架构划分为多个构建工件的不同方式以禁止非法依赖

图 12**.3 展示了将我们的架构划分为单独构建工件的不完整选项集。

从左侧开始,我们看到一个基本的三个模块构建,包括配置、适配器和应用层的独立构建工件。配置模块可以访问适配器模块,而适配器模块又可以访问应用模块。由于它们之间存在隐式的传递依赖,配置模块也可以访问应用模块。适配器模块包含web适配器和持久化适配器。这意味着构建工具不会禁止这些适配器之间的依赖关系。虽然根据依赖规则,这些适配器之间的依赖关系并不是严格禁止的(因为这两个适配器都在同一个外部层中),但在大多数情况下,保持适配器相互隔离是明智的。毕竟,我们通常不希望持久化层的变化泄露到网络层,反之亦然(记住单一职责原则!)。同样的原则也适用于其他类型的适配器,例如连接我们的应用程序到某个第三方 API 的适配器。我们不希望通过在适配器之间添加意外的依赖关系,让该 API 的细节泄露到其他适配器中。

因此,我们可以将单个适配器模块拆分为多个构建模块,每个适配器一个,如图 12**.3的第二列所示。

接下来,我们还可以决定进一步拆分应用模块。它目前包含传入和传出端口到我们的应用程序,实现或使用这些端口的业务逻辑,以及应该包含大部分领域逻辑的领域实体。

如果我们决定我们的领域实体不应用于我们的端口内作为传输对象(即,我们希望禁止从第九章边界之间的映射)中提到的无映射策略),我们可以应用依赖倒置原则,并拉出一个单独的api模块,该模块只包含端口接口(图 12**.3中的第三列)。适配器模块和应用模块可以访问api模块,但反之则不行。api模块无法访问领域实体,也不能在端口接口中使用它们。此外,适配器也不再直接访问实体和服务,因此它们必须通过端口进行访问。

我们甚至可以更进一步,将api模块拆分为两个部分,一部分只包含传入端口,另一部分只包含传出端口(图 12**.3中的第四列)。这样,我们通过仅在输入或传出端口上声明依赖关系,就可以清楚地表明某个适配器是传入适配器还是传出适配器。

此外,我们甚至可以将应用程序模块进一步拆分,创建一个只包含服务的模块,另一个只包含领域模型。这确保了领域模型不会访问服务,并且它允许其他应用程序(具有不同的用例和因此不同的服务)通过简单地声明对领域构建实体的依赖来使用相同的领域模型。

图 12**.3 展示了将应用程序划分为构建模块的许多不同方法,当然,图中所描述的不仅仅是四种方法。关键是,我们划分的模块越细,我们就能越强有力地控制它们之间的依赖关系。然而,划分得越细,我们之间就需要进行更多的映射,强制执行在 第九章 中介绍的一种映射策略,即 边界之间的映射

此外,使用构建模块来划分架构边界与使用简单的包作为边界相比,具有许多优势:

  1. 首先,构建工具绝对讨厌循环依赖。循环依赖很糟糕,因为循环内某个模块的更改可能会意味着循环内所有其他模块的更改,这违反了单一职责原则。构建工具不允许循环依赖,因为它们在尝试解决它们时会陷入无限循环。因此,我们可以确信我们的构建模块之间没有循环依赖。

与此相反,Java 编译器根本不在乎两个或更多包之间是否存在循环依赖。

  1. 第二,构建模块允许在某些模块内进行独立的代码更改,而无需考虑其他模块。想象一下,如果我们必须在应用程序层进行重大重构,这会导致某个适配器暂时出现编译错误。如果适配器和应用程序层在同一个构建模块中,一些 IDE 会坚持要求在我们可以运行应用程序层的测试之前,必须修复适配器中的所有编译错误,即使测试不需要适配器来编译。然而,如果应用程序层在其自己的构建模块中,IDE 就不会关心适配器,我们可以随意运行应用程序层的测试。对于使用 Maven 或 Gradle 运行构建过程来说,也是如此:如果两个层都在同一个构建模块中,构建会因为任一层的编译错误而失败。

因此,多个构建模块允许每个模块进行独立的更改。我们甚至可以选择将每个模块放入其自己的代码仓库中,允许不同的团队维护不同的模块。

  1. 最后,在构建脚本中明确声明每个模块间的依赖关系后,添加新依赖关系就变成了一种有意识的行为,而不是意外。一个需要访问他们目前无法访问的类的开发者,在将其添加到构建脚本之前,可能会对依赖关系是否真正合理这个问题进行一些思考。

这些优势伴随着维护构建脚本的额外成本,因此,在将其拆分为不同的构建模块之前,架构应该相对稳定。

此外,构建模块随着时间的推移往往不太灵活。一旦选择,我们往往会坚持最初定义的模块。如果模块的切割一开始就不正确,由于重构的额外努力,我们不太可能后来纠正它。当所有代码都位于单个构建模块内时,重构更容易。

这如何帮助我构建可维护的软件?

软件架构基本上是关于管理架构元素之间的依赖关系。如果依赖关系变得一团糟,架构也会变得一团糟。

因此,为了随着时间的推移保持架构,我们需要不断确保依赖关系指向正确的方向。

在生成新代码或重构现有代码时,我们应该牢记包结构,并在可能的情况下使用包私有可见性,以避免对不应从包外部访问的类产生依赖。

如果我们需要在单个构建模块内强制执行架构边界,并且包私有修饰符不起作用,因为包结构不允许这样做,我们可以利用后编译工具,如 ArchUnit。

每当我们觉得架构足够稳定时,我们应该将架构元素提取到它们自己的构建模块中,因为这提供了对依赖关系的明确控制。

这三种方法可以结合起来强制执行架构边界,从而随着时间的推移保持代码库的可维护性。

在下一章中,我们将继续探讨架构边界,但从一个不同的角度:我们将思考如何在同一应用程序中管理多个领域(或边界上下文),同时保持它们之间的界限清晰。

第十三章:管理多个边界上下文

许多应用程序由多个领域组成,或者,按照领域驱动设计的语言来说,由多个边界上下文组成。术语“边界上下文”告诉我们,不同领域之间应该有边界。如果我们不同领域之间没有边界,那么这些领域中的类之间的依赖关系将没有限制。最终,领域之间的依赖关系会增长,将它们耦合在一起。这种耦合意味着领域不能再独立地进化,而只能一起进化。我们最初甚至可以不把代码分成不同的领域!

将代码分离到不同的领域中的整个原因是为了让这些领域能够独立进化。这是对第三章“反转依赖”中讨论的单一职责原则的应用。不过,这次我们不是在谈论单个类的职责,而是在谈论构成边界上下文的一组类的职责。如果一个边界上下文的职责发生变化,我们不希望改变其他边界上下文的代码!

管理边界上下文,即保持它们之间的边界清晰,是软件工程的主要挑战之一。许多开发者与所谓的“遗留软件”相关的痛苦都源于边界不明确。而且,事实证明,软件不需要很长时间就会变成“遗留”。

因此,不出所料(至少在回顾时是这样),这本书第一版的许多读者问我如何使用六边形架构管理多个边界上下文。不幸的是,答案并不简单。就像经常发生的那样,有几种方法可以做到这一点,而且它们本身并没有对错之分。让我们讨论一些分离边界上下文的方法。

每个边界上下文一个六边形?

当与六边形架构和多个边界上下文一起工作时,我们的本能是为每个边界上下文创建一个单独的“六边形”。结果看起来就像 图 13.1

图 13.1 – 如果每个边界上下文都作为自己的六边形实现,我们需要为边界上下文之间的每条通信线路提供一个输出端口、一个适配器和输入端口

图 13.1 – 如果每个边界上下文都作为自己的六边形实现,我们需要为边界上下文之间的每条通信线路提供一个输出端口、一个适配器和输入端口

每个边界上下文都生活在自己的六边形中,提供输入端口以与之交互,并使用输出端口与外界交互。

理想情况下,边界上下文之间根本不需要相互通信,因此两者之间没有任何依赖关系。然而,在现实世界中,这种情况很少见。让我们假设左侧的边界上下文需要调用右侧边界上下文的一些功能。

如果我们使用六边形架构提供的架构元素,我们在第一个限制上下文中添加一个输出端口,在第二个限制上下文中添加一个输入端口。然后,我们创建一个适配器,该适配器实现输出端口,进行任何必要的映射,并调用第二个限制上下文的输入端口。

问题解决,对吧?

事实上,在纸上这看起来是一个非常干净的解决方案。限制上下文之间被最优地分隔开来。它们之间的依赖关系以端口和适配器的形式清晰地结构化。新的限制上下文之间的依赖关系需要我们明确地将它们添加到现有的端口中,或者添加一个新的端口。由于创建这种依赖关系涉及许多仪式,因此依赖关系不太可能“意外”地出现。

然而,如果我们进一步思考,除了两个限制上下文之外,这种架构的扩展性并不好。对于具有一个依赖关系的两个限制上下文,我们需要实现一个适配器(如图中名为 领域适配器 的框所示)。如果我们排除了循环依赖,我们可能需要为三个限制上下文实现三个适配器,为四个限制上下文实现六个适配器,依此类推,如图 图 13.2 所示。1

1 我用来计算 n 个限制上下文之间潜在依赖关系的公式是 n-1 + n-2 + ... + 1。第一个限制上下文有 n-1 个潜在的、非循环的依赖关系,第二个有 n-2,依此类推。最后一个限制上下文不能依赖于另一个限制上下文,因为它的每个依赖关系都会是一个循环依赖,而我们不想允许循环依赖。

图 13.2 – 限制上下文之间的潜在依赖关系数量与限制上下文数量不成比例增长,即使我们排除了循环依赖

图 13.2 – 即使排除了循环依赖,限制上下文之间的潜在依赖关系数量与限制上下文数量不成比例增长

对于每个依赖关系,我们可能需要实现一个至少包含一个相关输入和输出端口的适配器。每个适配器都必须从一个领域模型映射到另一个领域模型。这很快就会变成一个开发和维护的繁琐工作。如果这是一个繁琐的工作,并且所需的努力超过了它带来的价值,团队将采取捷径来避免它,结果是一个乍一看像六边形架构但缺乏其承诺的架构。

如果我们查看介绍六边形架构的原始文章,六边形架构的意图从来不是在端口和适配器中封装单个限制上下文。2 相反,其意图是封装一个 应用程序。这个应用程序可能由许多限制上下文组成,也可能没有任何限制上下文。

2 关于六边形架构的原始文章:alistair.cockburn.us/hexagonal-architecture/.

当我们准备将它们提取到它们自己的应用程序(即它们自己的(微)服务)中时,将每个边界上下文包裹在其自己的六边形中是有意义的。这意味着我们应该非常确定我们放置在他们之间的边界是正确的边界,我们不期望它们会改变。

这里的要点是,六边形架构并不提供一种可扩展的解决方案来管理同一应用程序中的多个边界上下文。它不必这样做。我们可以从领域驱动设计中汲取灵感,以解耦我们的边界上下文,因为在六边形内部,我们可以做我们喜欢的事情。

解耦的边界上下文

在上一节中,我们了解到端口和适配器应该封装整个应用程序,而不是单独封装每个边界上下文。那么,我们如何保持边界上下文之间的分离呢?

在简单的情况下,我们可能会有不相互通信的边界上下文。它们通过代码提供完全独立的路径。在这种情况下,我们可以为每个边界上下文构建专门的输入和输出端口,就像在图 13.3中所示。

图 13.3 – 如果边界上下文(虚线)之间不需要相互通信,每个上下文都可以实现自己的输入端口并调用自己的输出端口

图 13.3 – 如果边界上下文(虚线)之间不需要相互通信,每个上下文都可以实现自己的输入端口并调用自己的输出端口

这个例子展示了一个具有两个边界上下文的六边形架构。网络适配器驱动应用程序,而数据库适配器由应用程序驱动。这些适配器代表任何其他输入和输出适配器 – 并非每个应用程序都是具有数据库的 Web 应用程序。

每个边界上下文通过一个或多个专门的输入端口公开其自己的用例。网络适配器知道所有输入端口,因此可以调用所有边界上下文的功能。

除了为我们的每个边界上下文设置专门的输入端口外,我们还可以实现一个“广泛”的输入端口,通过该端口网络适配器将请求路由到多个边界上下文。在这种情况下,上下文之间的边界将对外部我们的六边形隐藏。这可能是或可能不是根据情况而定的。

此外,每个边界上下文定义自己的输出端口到数据库,以便它可以独立于任何其他边界上下文存储和检索其数据。

虽然按边界上下文分割输入端口是可选的,但我强烈建议将存储和检索特定边界上下文领域数据的输出端口与其他边界上下文分开。如果一个边界上下文关注金融交易,而另一个关注用户注册,那么应该有一个(或多个)输出端口专门用于存储和检索交易数据,另一个则专门用于存储和检索注册数据。

每个边界上下文都应该有自己的持久化存储。如果边界上下文共享输出端口来存储和检索数据,它们会很快变得紧密耦合,因为它们都依赖于相同的数据模型。想象一下,如果我们需要将一个边界上下文从六边形应用中提取出来,成为一个独立的微服务,因为我们了解到它与其他应用的其他部分有不同的可扩展性要求。如果这个边界上下文与另一个边界上下文共享数据库模型,那么提取就会变得非常困难。我们不想让新的微服务触及另一个应用的数据库,对吧?出于同样的原因,我们希望保持每个边界上下文的数据库模型独立。

只要多个边界上下文在同一个运行时中执行,它们可能共享一个物理数据库并参与相同的数据库事务。但在那个数据库中,不同边界上下文的数据之间应该有清晰的边界,例如,以单独的数据库模式的形式,或者至少不同的数据库表。

将输入和输出端口这样分开,会有一个很好的效果,那就是边界上下文之间完全解耦。每个边界上下文都可以独立发展,而不会以任何方式影响其他上下文。但它们之所以解耦,仅仅是因为它们没有相互交流。如果我们有跨越多个边界上下文的使用案例,或者如果一个边界上下文需要与另一个上下文交流,会怎样呢?

适当地耦合的边界上下文

如果所有耦合都可以避免,软件架构将会容易得多。在现实世界的应用中,一个边界上下文很可能需要另一个边界上下文的支持来完成其工作。

举个例子,又是我们那个关注货币交易的边界上下文。出于安全考虑,我们希望记录哪个用户发起了一笔交易。这意味着我们的边界上下文需要一些关于用户的信息,这些信息存在于另一个边界上下文中。但我们的边界上下文不需要与用户管理上下文紧密耦合。

在我们的“事务管理”边界上下文中,不必知道整个用户对象,可能只需要知道用户的 ID。虽然“注册”上下文中的用户对象是一个具有许多属性的复杂对象,但在事务上下文中,用户的表示可能只是用户 ID 的包装。在 发送金钱 使用案例中,我们现在可以仅接受执行事务的用户 ID 作为输入,并将其记录下来。我们不需要将事务上下文耦合到用户的所有其他细节。

但我们可能希望验证用户没有被阻止进行交易。在这种情况下,我们可以使用领域事件。3 当用户管理上下文中用户的状态发生变化时,我们触发一个可以被其他边界上下文接收的领域事件。例如,我们的交易上下文可能会监听当用户新注册或被阻止时的事件。然后它可以将其存储在自己的数据库中,以便在 发送金钱 使用案例中稍后用于验证用户的状态。

3 领域驱动设计中的事件:Vaughn Vernon 著的《领域驱动设计》实施,Pearson,2013,第八章

另一个可能的解决方案是在用户管理和事务上下文之间引入一个应用服务作为协调者。4 应用服务实现了 发送金钱 输入端口。当被调用时,它首先向用户管理边界上下文询问用户的状态,然后将状态传递给由事务上下文提供的 发送金钱 使用案例 – 一种不同的实现,但效果与使用领域事件时相同。

4 领域驱动设计中的应用服务:Vaughn Vernon 著的《领域驱动设计》实施,Pearson,2013,第十四章

这些只是如何“适当地”耦合边界上下文的两个例子。如果您还没有这样做,我建议您阅读领域驱动设计的文献以获得灵感。

回到六边形架构,适当地耦合多个边界上下文可能看起来像 图 13.4。4。

图 13.4 – 如果我们有多边界上下文跨越的使用案例,我们可以引入一个应用服务来协调和领域事件,以便在上下文之间共享信息

图 13.4 – 如果我们有多边界上下文跨越的使用案例,我们可以引入一个应用服务来协调和领域事件,以便在上下文之间共享信息

我们已经引入了一个应用程序服务作为我们有限上下文之上的协调者。现在,输入端口由这个服务而不是由有限上下文本身实现。应用程序服务可以调用输出端口从其他系统获取所需信息,然后调用有限上下文提供的域服务之一或多个。除了协调对有限上下文的调用外,应用程序服务还充当事务边界,这样我们就可以在同一个数据库事务中调用多个域服务,例如。

有限上下文内的领域服务仍然使用它们自己的数据库输出端口来保持有限上下文之间的数据模型分离。我们可能会决定这种分离不是必要的,并使用单个数据库输出端口代替(但我们应该意识到共享数据模型会导致非常紧密的耦合)。

有限上下文可以访问一组共享的领域事件,它们可以分别发出和监听,以松散耦合的方式交换信息。

这如何帮助我构建可维护的软件?

管理领域之间的边界是软件开发中最困难的部分之一。在一个小的代码库中,边界可能不是必要的,因为整个代码库的心理模型仍然适合我们的大脑工作记忆。但是,一旦代码库达到一定规模,我们应该确保在领域之间引入边界,这样我们就可以单独推理每个领域。如果我们不这样做,依赖关系就会悄悄进入,使我们的代码库变成那些令人讨厌的“大泥球”之一。

六边形架构主要关注管理应用程序与外部世界之间的边界。这个边界由应用程序提供的某些输入端口和应用程序期望的某些输出端口组成。

六边形架构并不能帮助我们管理应用程序内部更细粒度的边界。在我们的“六边形”内部,我们可以做任何我们想做的事情。如果代码库太大,超出了我们的工作记忆,我们应该退回到领域驱动设计或其他概念,在代码库内部创建边界。

在下一章中,我们将探讨一种轻量级的方法来创建边界,我们可以使用或不用六边形架构。

第十四章:软件架构的组件化方法

当我们开始一个软件项目时,我们永远不知道用户在使用软件时将会一次性抛给我们哪些需求。软件项目总是伴随着冒险和做出有根据的猜测(我们喜欢称之为“假设”,以使其听起来更专业)。软件项目的环境变化无常,无法事先知道一切将如何展开。正是这种变化无常促成了敏捷运动的诞生。敏捷实践使组织足够灵活,能够适应变化。

但是,我们如何创建一个能够应对如此敏捷环境的软件架构?如果一切都可以随时改变,我们是否还应该关心架构?

是的,我们应该。如第一章中所述,可维护性,我们应该确保我们的软件架构能够实现可维护性。一个可维护的代码库可以随着时间的推移而演变,适应外部因素。

六边形架构在可维护性方面迈出了重要一步。它在我们应用程序和外部世界之间创建了一个边界。在我们应用程序的内部(六边形内),我们有我们的领域代码,它为外部世界提供了专门的端口。这些端口将应用程序连接到适配器,适配器与外部世界通信,在应用程序的语言和外部系统的语言之间进行翻译。这种架构提高了可维护性,因为应用程序可以主要独立于外部世界进行演变。只要端口不改变,我们就可以在应用程序内部演变任何内容,以应对敏捷环境中的变化。

但是,正如我们在第十三章中学习的,管理多个边界上下文,六边形架构并不能帮助我们在我们应用程序的核心内部创建边界。我们可能希望在应用程序核心内部应用不同的架构,以帮助我们在这方面。

此外,我多次听说六边形架构感觉很难,尤其是对于一个刚开始的软件项目来说。很难让团队接受,因为并不是每个人都理解依赖倒置的价值以及领域模型与外部世界之间的映射。对于初出茅庐的应用程序来说,六边形架构可能是一种过度设计。

对于这类情况,我们可能希望从一个更简单的架构风格开始,这种风格仍然提供了我们未来演变所需的模块化,同时足够简单,以便让每个人都参与其中。我建议组件化架构是一个好的起点,我们将使用本章来讨论这种架构风格。

通过组件实现模块化

可维护性的一个驱动因素是模块化。通过将软件系统划分为更简单的模块,模块化使我们能够征服软件系统的复杂性。我们不需要理解整个系统就能对某个特定的模块进行工作。相反,我们可以专注于那个模块,以及它可能交互的模块。只要模块之间的接口定义得清楚,模块可以相对独立地发展。我们可能能够将一个模块的心理模型放入我们的工作记忆中,但如果没有模块在代码库中,创建心理模型就非常困难了。我们会在代码中无助地跳来跳去。

只有模块化才使我们人类能够创建复杂的系统。在 Dave Farley 的《现代软件工程》一书中,他谈到了阿波罗太空计划的模块化:1

1 阿波罗太空计划的模块化:《现代软件工程》由 Dave Farley 著,Pearson 出版社,2022 年,第六章

“这种模块化有很多优点。这意味着每个组件都可以专注于解决问题的某个部分,并且在设计上需要妥协得更少。它允许不同的团队——在这种情况下,是完全不同的公司——在主要独立于其他团队的情况下工作在每个模块上。只要不同的团队就模块之间的接口达成一致,他们就可以努力解决他们模块的问题,而无需受到约束。”

模块化使我们能够登上月球!模块化使我们能够制造汽车、飞机和建筑物。它也帮助我们构建复杂的软件,这并不令人惊讶。

但什么是模块呢?我觉得在(面向对象)软件开发中,这个术语被过度使用了。所有东西,包括它的猫,都被称作“模块”,即使它只是一堆随意组合在一起以完成有用事情的课程。我更喜欢用“组件”来描述一组精心设计以实现某些功能,并且可以与其他课程组一起组合起来构建复杂系统的课程。组合方面意味着组件可以组合成更大的整体,甚至可以重新组合以应对环境的变化。可组合性要求组件定义一个清晰的接口,告诉我们它向外界提供什么以及它需要什么(输入和输出端口,对吧?)。想想乐高积木。乐高积木为其他积木提供了一定布局的凸点,并且需要一定布局的凸点来连接到其他积木。话虽如此,我不会因为您使用“模块”这个术语而评判您,但在这章的其余部分,我会使用“组件”这个术语。

为了本章的目的,一个组件是一组具有专用命名空间和明确定义的 API 的类。如果另一个组件需要此组件的功能,它可以通过其 API 调用它,但它可能无法访问其内部。一个组件可能由更小的组件组成。默认情况下,这些子组件位于父组件的内部,因此它们从外部不可访问。然而,如果它们实现了应从外部访问的功能,它们可以贡献给父组件的 API。

像任何其他架构风格一样,基于组件的架构完全是关于允许哪些依赖以及哪些依赖被劝阻。这在上图14.1中得到了说明。

图 14.1 – 对内部包的依赖无效,但只要 API 包不是嵌套在内部包中,对 API 包的依赖就有效

图 14.1 – 对内部包的依赖无效,但只要 API 包不是嵌套在内部包中,对 API 包的依赖就有效

在这里,我们有两个顶级组件,A 和 B。组件 A 由两个子组件 A1 和 A2 组成,而组件 B 只有一个子组件 B1。

如果 A1 需要访问 B 的功能,它可以通过调用 B 的 API 来获取。然而,它无法访问 B1 的 API,因为作为一个子组件,它是其父组件内部的一部分,因此对外部隐藏。尽管如此,B1 可以通过在父 API 中实现一个接口来向其父组件的 API 贡献功能。我们将在后面的案例研究中看到这一点。

相同的规则适用于兄弟组件 A1 和 A2 之间。如果 A1 需要访问 A2 的功能,它可以调用其 API,但它不能调用 A2 的内部。

这就是基于组件架构的全部内容。它可以总结为四条简单的规则:

  1. 一个组件有一个专用命名空间以便可寻址。

  2. 一个组件有一个专用的 API 和内部结构。

  3. 一个组件的 API 可以从外部调用,但它的内部结构不能。

  4. 一个组件可能包含作为其内部部分子组件。

为了使抽象具体化,让我们看看一个基于组件架构的真实代码示例。

案例研究 – 构建“检查引擎”组件

作为本章中介绍的基于组件架构的案例研究,我从我参与的一个真实软件项目中提取了一个组件到一个独立的 GitHub 仓库。2 仅仅是我相对容易地提取了组件,并且我们可以对这个组件进行推理,而无需了解它来自哪个软件项目,这表明我们通过应用模块化成功地征服了复杂性!

2 实现了基于组件架构的“检查引擎”的 GitHub 项目:github.com/thombergs/components-example

该组件是用面向对象的 Kotlin 编写的,但这些概念适用于任何其他面向对象的编程语言。

该组件被称为“检查引擎”。它原本打算是一种网络爬虫,它会遍历网页并对它们运行一系列检查。这些检查可以是“检查该网页上的 HTML 是否有效”到“返回该网页上的所有拼写错误”等。

由于抓取网页时可能会出现很多问题,我们决定异步运行检查。这意味着组件需要提供一个用于安排检查的 API,以及一个在检查执行后检索检查结果的 API。这暗示需要一个用于存储传入检查请求的队列和一个用于存储这些检查结果的数据库。

从外观上看,无论是将检查引擎“整体构建”还是将其拆分成子组件,都没有关系。只要组件有一个专门的 API,这些细节就会对外隐藏。然而,上述要求为检查引擎内部的子组件定义了某些自然的边界。沿着这些边界拆分检查引擎,使我们能够管理检查引擎组件内部的复杂性,因为每个子组件比整个问题更容易管理。

我们为检查引擎提出了三个子组件:

  • 一个队列组件,它封装了对队列的访问以排队和出队检查请求。

  • 一个数据库组件,它封装了对数据库的访问以存储和检索检查结果。

  • 一个检查运行器组件,它知道要运行哪些检查,并在队列中有检查请求时运行它们。

注意,这些子组件主要引入了技术边界。与六边形架构中的输出适配器非常相似,我们在子组件中隐藏了访问外部系统(队列和数据库)的细节。然而,检查引擎组件是一个非常技术性的组件,几乎没有领域代码。唯一可以考虑为“领域代码”的组件是检查运行器,它充当某种控制器。技术组件非常适合基于组件的架构,因为它们之间的边界比不同功能域之间的边界更清晰。

让我们看看检查引擎组件的架构图,以深入了解细节(图 14.2)。

图 14.2 – 检查引擎组件由三个子组件组成,这些子组件有助于父组件的 API

图 14.2 – 检查引擎组件由三个子组件组成,这些子组件有助于父组件的 API

图表反映了代码的结构。你可以把每个框想象成 Java 包(或在其他编程语言中的简单源代码文件夹)。如果一个框在更大的框内,它就是该更大框的子包。最后,最低层的框是类。

检查引擎组件的公共 API 由 CheckSchedulerCheckQueries 接口组成,分别允许安排网页检查和检索检查结果。

CheckScheduler 由位于队列组件内部的 SqsCheckScheduler 类实现。这样,队列组件就为父组件的 API 做出了贡献。只有当我们查看这个类的名称时,我们才会知道它正在使用亚马逊的简单队列服务(SQS)。这个实现细节并没有泄露到检查引擎组件的外部。甚至兄弟组件也不知道使用了哪种队列技术。你可能还会注意到,队列组件甚至没有 API 包,所以它所有的类都是内部的!

然后,CheckRequestListener 类监听来自队列的传入请求。对于每个传入请求,它调用检查运行器子组件 API 中的 CheckRunner 接口。DefaultCheckRunner 实现了该接口。它从传入请求中读取网页 URL,确定要对其运行的检查,然后运行这些检查。

当检查完成时,DefaultCheckRunner 类通过调用数据库子组件 API 的 CheckMutations 接口将结果存储在数据库中。这个接口由处理连接和与数据库通信的 CheckRepository 类实现。同样,数据库技术也没有泄露到数据库子组件的外部。

CheckRepository 类还实现了 CheckQueries 接口,这是检查引擎公共 API 的一部分。该接口提供了查询检查结果的方法。

通过将检查引擎组件拆分为三个子组件,我们已经将复杂性分解。每个子组件解决整体问题的一个更简单的部分。它主要可以自行进化。由于成本、可扩展性或其他原因,队列或数据库技术的变化不会渗透到其他子组件中。如果我们想的话,甚至可以用简单的内存实现来替换子组件进行测试。

所有这些,我们都是通过将代码结构化为组件,遵循拥有专用 API 和内部包的惯例来获得的。

强制组件边界

习惯是好的,但如果只有这些,有人会打破它们,架构就会逐渐退化。我们需要强制执行组件架构的规范。

组件架构的优点在于,我们可以应用一个相对简单的适应函数来确保没有意外的依赖关系渗透到我们的组件架构中:

任何不属于“内部”包的类都不应该访问该“内部”包中的类。

如果我们将组件的所有内部内容放入一个名为“internal”的包中(或以其他方式标记为“internal”的包),我们只需检查该包中的任何类都没有从包外部被调用。对于基于 JVM 的项目,我们可以使用 ArchUnit 来规范这个适应度函数:3

3 验证特定包内没有代码访问的 ArchUnit 规则:github.com/thombergs/components-example/blob/main/server/src/test/kotlin/io/reflectoring/components/InternalPackageTest.kt.

代码示例

我们只需要一种方法来识别每次构建过程中的内部包,并将它们全部输入到上面的函数中,如果意外地将依赖引入到内部类中,构建将会失败。

适应度函数甚至不需要了解我们架构中的组件。我们只需要遵循一个用于识别内部包的约定,然后将这些包输入到函数中。这意味着我们不需要在添加或从代码库中删除组件时更新运行适应度函数的测试。非常方便!

注意

这个适应度函数是我们之前在第十二章**,强制执行架构边界中引入的适应度函数的倒数形式。在第十二章中,我们验证了来自某个包的类不会访问该包之外的类。在这里,我们验证外部包的类不会访问包内部的类。这个适应度函数更加稳定,因为我们不需要为使用的每个库添加异常。

当然,我们仍然可以通过不遵循内部包的约定来引入不想要的依赖。规则仍然允许一个漏洞:如果我们直接将类放入顶级组件的“内部”包中,任何子组件的类都可以访问它。因此,我们可能还想引入另一条规则,禁止任何类直接位于顶级组件的“内部”包中。

这如何帮助我构建可维护的软件?

基于组件的架构非常简单。只要每个组件都有一个专门的命名空间、专门的 API 和内部包,并且内部包中的类不会被外部调用,我们就得到了一个非常可维护的代码库,由许多可组合和重新组合的组件组成。如果我们添加规则,允许组件由其他组件组成,我们可以构建一个由更小部分组成的应用程序,每个部分解决一个更简单的问题。

尽管存在绕过组件架构规则的漏洞,但架构本身非常简单,因此很容易理解和沟通。如果容易理解,那么维护起来也容易。如果维护起来容易,那么漏洞被利用的可能性就较小。

六边形架构关注应用层面的边界。基于组件的架构关注组件层面的边界。我们可以利用这一点在六边形架构中嵌入组件,或者我们可以选择从一个简单的基于组件的架构开始,并在需要时向任何其他架构演变。基于组件的架构是模块化的,设计上模块易于移动和重构。

在下一章和最后一章中,我们将结束关于架构的讨论,并尝试回答我们应该在何时选择哪种架构风格的问题。

第十五章:决定架构风格

到目前为止,本书提供了一种有见地的构建基于六边形架构风格的 Web 应用程序的方法。从组织代码到走捷径,我们回答了许多这种架构风格向我们提出的许多问题。

本书中的某些答案可以应用于传统的分层架构风格。某些答案只能在领域中心方法中实现,例如本书中提出的方法。还有一些答案你可能不同意,因为它们在你的经验中没有奏效。

然而,我们真正想要得到答案的最终问题是这些:我们应该何时实际使用六边形架构风格?我们应该何时而不是坚持传统的分层风格(或任何其他风格)?

从简单开始

一个我花了很长时间才意识到的重要观点是,软件架构不仅仅是我们在软件项目开始时定义的,之后就会自行处理的事情。在项目开始时,我们不可能知道我们设计优秀架构所需知道的一切!软件项目的架构可以也应该随着时间的推移而演化,以适应不断变化的需求。

这意味着我们可能不知道哪种架构风格在长期内将是软件项目的最佳选择,我们可能需要在将来改变架构风格!为了实现这一点,我们需要确保我们的软件易于更改。我们需要播下一颗可维护性的种子。

可维护性意味着我们需要使我们的代码模块化,这样我们就可以独立地工作在每个模块上,并在需要时将其在代码库中移动。我们的架构需要尽可能清晰地界定这些模块之间的边界,以防止那些模块之间意外地产生不想要的依赖,从而降低可维护性。

项目开始可能只涉及一组CRUD用例,而领域中心的架构,如六边形架构,可能过于复杂,因此我们选择更简单的方法,如基于组件的方法。或者,我们可能已经足够了解项目,开始构建一个丰富的领域模型,在这种情况下,六边形架构风格可能是开始的最佳选择。

领域演化

随着时间的推移,我们对我们软件的需求了解得越来越多,我们可以做出越来越好的关于最佳架构风格的决定。应用程序可能从一组简单的 CRUD 用例演变为具有许多业务规则的丰富领域中心应用程序。在这种情况下,六边形架构风格成为一个不错的选择。

在前面的章节中,应该已经很明显,六边形架构风格的主要特点是我们可以开发不受干扰的领域代码,例如持久性关注和对外部系统的依赖。在我看来,不受外部影响地发展领域代码是六边形架构风格最重要的论点。

这就是为什么这种架构风格与 DDD 实践如此匹配。显而易见的是,在 DDD 中,领域驱动开发,如果我们不必同时考虑持久性关注和其他技术方面,我们就能最好地推理领域。

我甚至可以说,像六边形架构这样的以领域为中心的架构风格是领域驱动设计(DDD)的推动者。如果没有一个将领域置于事物中心的架构,并且没有将依赖反转到领域代码,我们就没有真正实施 DDD 的机会。设计将始终由其他因素驱动。

因此,作为是否使用本书中介绍的架构风格的第一指标:如果你的应用程序中领域代码不是最重要的事情,你可能不需要这种架构风格

信任你的经验

我们是习惯的产物。习惯为我们自动化决策,所以我们不必在这些事情上浪费时间。如果有一只狮子朝我们跑来,我们就跑。如果我们构建一个新的 Web 应用程序,我们就使用分层架构风格。我们过去经常这样做,以至于它已经变成了一种习惯。

我并不是说习惯性的决策一定是错误的决策。习惯在帮助做出好决策方面和帮助做出坏决策方面一样有效。我的意思是,我们在做我们熟悉的事情。我们对过去所做的事情感到舒适,那么我们为什么要改变任何事情呢?

因此,唯一能够就架构风格做出明智决策的方法是拥有不同架构风格的经验。如果你对六边形架构风格不确定,可以在你目前正在构建的应用程序的一个小模块上尝试它。熟悉这些概念。感到舒适。应用这本书中的想法,修改它们,并加入你自己的想法,发展出你感到舒适的风格。

这种经验可以指导你下一个架构决策。

取决于

我很乐意提供一个多项选择题列表来决定架构风格,就像所有那些“你是哪种性格类型?”和“如果你是狗,你是什么品种的狗?”这样的测试,这些测试经常在社交媒体上出现。1

1 如果你想知道,我是“捍卫者”性格类型,而且如果我是狗,我显然会是一只比特犬。

然而,事情并没有那么简单。我对选择哪种架构风格的问题的回答仍然是专业顾问的——“视情况而定”。这取决于要构建的软件类型。这取决于领域代码的角色。这取决于团队的经验。最后,这取决于对做出决定的舒适度。

然而,我希望这本书能提供一些灵感的火花,帮助您解决架构问题。如果您有关于架构决策的故事要分享,无论是使用还是不使用六边形架构,我都非常愿意倾听。2

2 联系方式:您可以通过 tom@reflectoring.io 发送邮件给我。

posted @ 2025-09-12 13:57  绝不原创的飞龙  阅读(24)  评论(0)    收藏  举报