Java-六边形架构设计指南第二版-全-

Java 六边形架构设计指南第二版(全)

原文:zh.annas-archive.org/md5/508cef0b9ee715e675a8e519d9f5890b

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

有时候,通过代码解决问题和表达我们的想法是具有挑战性的。在我们心中看似明显的解决方案,在别人看来可能显得过于复杂。但如果我们愿意接受新想法和观点,那就没问题,因为当我们持有坚持不懈的态度,愿意接受一切时,我们就把心思放在学习意料之外的事物上了。

我最初接触到六边形架构时,并没有预料到。

我记得在一个公司工作,那里的大部分软件都是由顾问开发的。这些人来了,交付了他们的代码,然后离开了。尽管我和我的团队试图建立特定的标准来确保我们负责的应用程序的一致性,但残酷的现实是我们需要更多的知识来正确维护我们的应用程序。鉴于系统的数量以及构建它们所采用的不同方法和架构,维护和添加新功能需要大量的工作。就在那时,一个队友告诉我关于六边形架构以及它如何帮助我们解决难以维护的软件问题。

那时候,关于六边形架构的书籍并不多。大部分资源都散布在互联网上,通过视频课程和文章解释了某人如何实现六边形架构。资源的缺乏是一个相当大的障碍,但使用能够提高软件可维护性的架构对我来说非常吸引人。因此,我继续在自己的工作中研究和实验这些想法,这最终导致我撰写了这本书的第一版。

我很幸运有机会撰写关于六边形架构这样迷人主题的第一版,现在已经是第二版了。第二版让我能够使用更近期的 Java 和 Quarkus 版本来应用六边形架构的思想。这一版保留了前一版的基本内容,同时探索了现代 Java 的新酷特性。此外,这一版还探讨了如何将六边形架构与广受赞誉的 SOLID 原则相结合,以及它与广泛使用的分层架构的关系。

将诸如端口、适配器和用例等概念与领域驱动设计DDD)元素,如实体和价值对象相结合,提供了一本深入指南,解释了如何将这些概念组装起来,以解开使用六边形架构设计高度可变应用这一激动人心的难题。考虑到当今大多数企业开发所遵循的当代云原生实践,我们深入研究了 Quarkus,学习如何将六边形架构的思想与云原生开发相结合,这使我们能够创建适用于任何主要云提供商的企业级六边形应用程序。

因此,我鼓励你们保持持之以恒的态度,并愿意接受一切到来之物,与我一同踏上探索六边形架构这一迷人旅程。

本书面向的对象

这本书适合 Java 架构师和高级及中级 Java 开发者阅读。读者应具备面向对象语言原理的先验知识,并熟悉 Java 编程语言。此外,建议读者具备一定的 Java 专业经验,因为本书关注的是在 Java 企业级软件开发项目中通常会遇到的问题。

本书涵盖的内容

第一章为什么选择六边形架构?,首先讨论了软件如果组织不当且缺乏良好的架构原则,虽然可能运行良好,但会存在很高的技术债务风险。随着新功能的添加,软件往往变得难以维护,因为没有共同的基础来指导功能的添加或更改。基于这个问题,本章解释了为什么六边形架构通过建立一种将业务代码与技术代码解耦的方法来帮助解决技术债务问题,允许前者在没有依赖后者的前提下进行演变。

第二章将业务规则封装在领域六边形内,遵循领域驱动的方法,描述了领域实体是什么,它们在六边形架构中扮演什么角色,以及它们如何将业务规则和数据封装在简单的 Java POJO 中。它解释了为什么领域实体是代码中最重要的一部分,以及为什么它们不应该依赖于除其他领域实体之外的其他任何事物。最后,它解释了如何在领域实体内部使用规范设计模式实现业务规则。

第三章使用端口和用例处理行为,涵盖了用例是什么,解释说它们用于使用接口定义软件意图,接口描述了软件可以执行的事情。然后,它解释了输入端口是什么以及实现用例接口的类,并具体说明了如何实现软件意图。它讨论了输出端口及其在抽象定义需要从软件外部获取数据的操作行为中的作用。最后,本章解释了用例和端口是如何组合成所谓的应用六边形的。

第四章创建适配器以与外部世界交互,展示了适配器如何使软件能够与不同的技术集成。它解释说,同一个端口可以有多个适配器。绑定到输入端口的输入适配器,使应用程序能够通过不同的通信协议(如 REST、gRPC 或 WebSocket)暴露其功能。绑定到输出端口的输出适配器,允许应用程序与不同的数据源进行通信,无论是数据库还是消息队列或其他应用程序。最后,本章展示了所有适配器如何被分组在框架六边形中。

第五章探索驱动和被驱动操作的本质,解释了驱动操作通过启动其公开的某个功能来驱动软件行为。它详细说明了驱动操作的生命周期,展示了如何通过输入适配器在框架六边形上捕获请求,然后将其传递到应用程序六边形的输入端口,直到达到领域六边形的实体。它展示了当软件需要从外部获取数据时,用例从应用程序六边形开始驱动操作,从输出端口到输出适配器,以满足用例需求。

第六章构建领域六边形,展示了如何通过首先创建领域六边形作为 Java 模块来开始开发电信网络的拓扑库存应用程序。然后,本章展示了业务规则和数据如何映射到领域实体类和方法。业务规则以不同的算法排列,目的是实现规范设计模式。最后,它展示了如何对领域六边形进行单元测试。

第七章构建应用程序六边形,首先将应用程序六边形添加为应用程序的第二个 Java 模块。然后,它解释了如何创建用例接口,该接口描述了软件的操作以管理网络和拓扑库存。它展示了如何通过输入端口实现用例,详细说明了代码应该如何排列。它详细说明了输出端口接口的创建及其在从外部来源获取数据中的作用。最后,它解释了如何测试应用程序六边形。

第八章构建框架六边形,首先将框架六边形添加为应用程序的第三个 Java 模块。然后,它教你如何创建输入适配器,以及它将通过输入端口执行其操作。之后,通过实现输出端口创建输出适配器。输出适配器将展示如何从外部来源获取数据,并将其转换为在领域六边形术语中处理的数据。最后,本章解释了如何测试框架六边形。

第九章, 使用 Java 模块应用依赖反转,简要介绍了 Java 模块,解释了为什么它们对于强制执行与依赖反转相关的六边形架构原则很重要。它解释说 Java 模块不允许循环依赖,因此没有方法让两个模块同时依赖于对方。你将学习如何在六边形应用程序中配置模块描述符。

第十章, 将 Quarkus 添加到模块化的六边形应用程序中,简要介绍了 Quarkus 框架及其主要功能。然后,它进一步展示了如何将 Quarkus 添加到前几章中开发的六边形应用程序中。它介绍了创建一个名为 Bootstrap 的第四个模块,该模块用于启动应用程序,并用于将领域、应用程序和框架模块分组。

第十一章, 利用 CDI Bean 管理端口和用例,解释了如何将已开发的端口和用例转换为 CDI Bean,利用企业 Java 在六边形架构中的力量。它首先解释了 CDI Bean 是什么,然后展示了如何在输入和输出端口上实现它们。最后,本章描述了如何调整应用程序框架测试以使用 Quarkus CDI Bean 测试功能。

第十二章, 使用 RESTEasy Reactive 实现输入适配器,首先比较了用于 REST 端点的反应式和命令式方法,详细说明了为什么反应式方法表现更好。它解释了如何通过解释如何添加正确的注解和注入适当的依赖项来调用输入端口,使用 Quarkus RESTEasy Reactive 功能实现输入适配器。为了公开六边形应用程序 API,本章解释了如何添加 OpenAPI 和 Swagger UI。最后,它展示了如何使用 Quarkus 测试工具测试反应式输入端口。

第十三章, 使用输出适配器和 Hibernate Reactive 持久化数据,讨论了 Hibernate Reactive 及其如何帮助 Quarkus 提供数据持久化的反应式能力。它解释了如何创建一个反应式输出适配器以将数据持久化到 MySQL 数据库。最后,它展示了如何使用 Quarkus 测试工具测试反应式输出适配器。

第十四章, 设置 Dockerfile 和 Kubernetes 对象以进行云部署,解释了如何为基于 Quarkus 的六边形应用程序创建 Dockerfile。它详细解释了如何将所有模块和依赖项打包在一个单独的 Docker 镜像中。然后,它展示了如何为六边形应用程序创建 Kubernetes 对象,如 Deployment 和 Service,并在 minikube 本地 Kubernetes 集群中测试它们。

第十五章比较六边形架构与分层架构,描述了分层架构,并探讨了层如何处理特定的系统责任,如持久性和表示。然后,我们使用分层架构原则开发了一个应用程序。最后,为了突出分层架构和六边形架构之间的差异,我们将之前基于层的应用程序重构为六边形架构。

第十六章使用六边形架构与 SOLID 原则,首先回顾 SOLID 原则,并观察每个原则如何帮助构建具有改进可维护性的应用程序。然后,它探讨了 SOLID 原则如何应用于使用六边形架构开发的系统。最后,它介绍了在构建六边形系统时可以使用的常见设计模式。

第十七章为您的六边形应用程序制定良好设计实践,讨论了在创建应用程序的每个六边形时可以采用的一些良好实践。从领域六边形开始,我们关注领域驱动设计(DDD)方面,以明确应用程序应该解决的业务问题。然后,我们继续讨论在应用程序六边形中设置用例和端口的替代方法。最后,我们讨论了维护多个适配器的后果。

要充分利用本书

提供的示例基于 Java 17,但您应该能够使用更近期的 Java 版本运行它们。构建示例项目需要 Maven 3.8。还需要 Docker 来运行在容器内运行应用程序的示例。

本书涵盖的软件/硬件 操作系统要求
Maven 3.8 Windows、macOS 或 Linux
Java 17 SE 开发工具包 Windows、macOS 或 Linux
Docker Windows、macOS 或 Linux
Postman Windows、macOS 或 Linux
Newman Windows、macOS 或 Linux
Kafka macOS 或 Linux

您将需要 Postman、Newman 和 Kafka 来运行 第五章 中的示例。

如果您使用的是本书的数字版,我们建议您亲自输入代码或从本书的 GitHub 仓库(下一节中提供链接)获取代码。这样做将帮助您避免与代码复制粘贴相关的任何潜在错误。

下载示例代码文件

您可以从 GitHub 下载本书的示例代码文件,网址为 github.com/PacktPublishing/-Designing-Hexagonal-Architecture-with-Java---Second-Edition。如果代码有更新,它将在 GitHub 仓库中更新。

我们还有其他来自我们丰富的书籍和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们!

使用的约定

本书使用了多种文本约定。

文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“将下载的WebStorm-10*.dmg磁盘映像文件作为系统中的另一个磁盘挂载。”

代码块设置如下:

public interface RouterNetworkUseCase {
    Router addNetworkToRouter(RouterId,
    Network network);
    Router getRouter(RouterId routerId);
}

当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:

function getRouter() {
    const routerId = document.
    getElementById("routerId").value;
    var xhttp = new XMLHttpRequest();
    xhttp.onreadystatechange = function() {
        console.log(this.responseText);
        if (this.readyState == 4 && this.status == 200) {
            const json = JSON.parse(this.responseText)
            createTree(json)
        }

任何命令行输入或输出都如下所示:

java -jar target/topology-inventory-1.0-SNAPSHOT-jar-with-dependencies.jar rest
REST endpoint listening on port 8080...
Topology & Inventory WebSocket started on port 8887...

粗体:表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“从管理面板中选择系统信息。”

提示或重要注意事项

看起来是这样的。

联系我们

我们始终欢迎读者的反馈。

一般反馈:如果您对本书的任何方面有疑问,请通过 customercare@packtpub.com 给我们发邮件,并在邮件主题中提及书名。

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

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

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

分享您的想法

一旦您阅读了《使用 Java 设计六边形架构,第二版》,我们非常乐意听到您的想法!请点击此处直接转到此书的亚马逊评论页面并分享您的反馈。

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

下载此书的免费 PDF 副本

感谢您购买此书!

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

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

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

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

优惠不仅限于此,您还可以获得独家折扣、时事通讯和丰富的免费内容,每天直接发送到您的邮箱

按照以下简单步骤获取好处:

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

packt.link/free-ebook/9781837635115

  1. 提交您的购买证明

  2. 就这样!我们将直接将免费 PDF 和其他好处发送到您的邮箱

第一部分:架构基础

在本部分,您将深入了解六边形架构元素:领域实体、用例、端口和适配器。从讨论为什么我们会将六边形架构原则应用于我们的项目开始,我们通过学习如何使用领域驱动设计技术组织问题域代码来逐步推进我们的探索。

然后,我们探讨用例和端口在表达系统行为中的重要作用。接下来,我们探索适配器如何使六边形系统与不同的协议和技术兼容。最后,我们通过讨论驱动和被驱动操作如何影响六边形系统的行为来结束本部分。

本部分包含以下章节:

  • 第一章, 为什么选择六边形架构?

  • 第二章, 将业务规则包裹在领域六边形内

  • 第三章, 使用端口和用例处理行为

  • 第四章, 创建适配器以与外部世界交互

  • 第五章, 探索驱动和被驱动操作的本质

第一章:为什么选择六边形架构?

软件如果没有良好的组织结构和缺乏合理的软件架构原则,可能一开始运行良好,但会随着时间的推移积累技术债务。随着新功能的添加,软件可能变得更加难以维护,因为没有共同的基础来指导代码更改。基于这个问题,本章解释了六边形架构如何帮助构建能够适应意外需求的软件,通过这样做,我们可以提高软件的可维护性,并保持技术债务在可控范围内。

我们解决由于采取捷径来克服由于软件架构不灵活而引起的引入更改的困难而产生的技术债务问题。我们将看到六边形架构如何通过提供将业务逻辑(应纯粹表示业务问题的代码)与技术代码(将系统与数据库、消息队列和外部 API 等不同技术集成以支持业务逻辑的代码)解耦的原则,帮助我们提高可维护性。

我见过一些系统,其业务逻辑与技术代码紧密相关。这些系统中的某些很少改变,因此业务逻辑与技术代码之间的耦合永远不会成为问题。然而,对于经常和大量改变要求的其他系统,则需要重大的重构。那是因为业务逻辑与技术代码耦合得太紧密,以至于重写业务逻辑是唯一可行的解决方案。

使用六边形架构可能有助于你节省时间和精力,因为这种场景下经常和显著地改变要求会导致软件重写。

在本章中,我们将涵盖以下主题:

  • 审查软件架构

  • 理解六边形架构

到本章结束时,你将了解六边形架构的主要概念:实体、用例、端口和适配器。此外,你还将了解如何将六边形原则应用到你的项目中的基本技巧。

技术要求

要编译和运行本章中展示的代码示例,你需要在你的计算机上安装最新的 Java SE 开发工具包和 Maven 3.8。它们都适用于 Linux、macOS 和 Windows 操作系统。

你可以在 GitHub 上找到本章的代码文件,地址为github.com/PacktPublishing/-Designing-Hexagonal-Architecture-with-Java---Second-Edition/tree/main/Chapter01

审查软件架构

“架构”这个词很古老。它的起源可以追溯到人们使用原始工具建造东西的时代,通常是他们自己的手。然而,每一代人都会反复克服他们那个时代的限制,建造出至今仍屹立不倒的壮丽建筑。看看佛罗伦萨大教堂及其由菲利波·布鲁内莱斯基设计的圆顶——这是建筑的一个多么出色的例子!

架构师不仅仅是那些不假思索就建造事物的普通建造者。恰恰相反;他们是那些最关心美学、基础结构和设计原则的人。有时,他们通过推动利用现有资源的极限来扮演根本性的角色。正如已经提到的,佛罗伦萨大教堂证明了这一点。

我不会过分强调这个类比,因为软件不像一座物理建筑。尽管建筑和软件架构师之间存在一些相似之处,但后者由于软件工艺的活性和演变性质而存在相当大的差异。但我们都可以同意,他们有着相同的目标:正确地建造事物。

这个目标帮助我们理解什么是软件架构。如果我们旨在建造不仅能够工作而且易于维护和结构良好的软件,那么由于我们在建造过程中所投入的细致入微的关怀和注意力,它甚至可以在一定程度上被视为一件艺术品。因此,我们可以将建造易于维护和结构良好的软件的活动视为软件架构的高尚定义。

同样重要的是要指出,软件架构师的角色不应仅限于决定如何制造事物。正如佛罗伦萨大教堂的例子中,菲利波·布鲁内莱斯基本人帮助将砖块固定到建筑上以证明他的想法是合理的,软件架构师同样应该亲自动手以证明他们的架构是优秀的。

软件架构不应该是单个人思维的产物。尽管有些人通过提供指导和建立基础来敦促他人追求技术卓越的道路,但对于一个架构的演进和成熟来说,利用所有参与提高软件质量的人的合作和经验是必要的。

以下是我们可能在我们创建和演进软件架构以帮助我们应对混乱和不可战胜的复杂性的旅程中遇到的技术和组织挑战的讨论。

做出决策

所有关于软件架构关注点的讨论都是相关的,因为我们可能会削弱我们在长期内维护和演进软件的能力,如果我们忽视了这些关注点。当然,有些情况下,我们并不那么雄心勃勃地想要我们的软件有多么复杂、易于维护和功能丰富。对于这种情况,可能不值得花那么多时间和精力以正确的方式构建东西,因为需要的是尽可能快地交付可工作的软件。最终,这是一个优先级的问题。但我们应谨慎,不要陷入可以事后修复的陷阱。有时我们可能有足够的资金去做,但有时可能没有。项目开始时的不良决策可能会在未来给我们带来高昂的代价。

我们关于代码结构和软件架构所做的决策引导我们走向所谓的内部质量。软件代码组织得有多好、维护性有多强,与内部质量相对应。另一方面,从用户的角度来看,软件的价值感知,即软件有多有价值、有多好,与外部质量相对应。内部质量和外部质量并不直接相关。要找到代码库混乱的有用软件并不困难。

在内部质量上花费的精力应被视为一种投资,其回报不是立即且对用户可见的。投资回报随着软件的演进而到来。价值是通过不断向软件添加变化来感知的,而不会增加添加这些变化所需的时间和金钱,如下面的伪图所示:

图 1.1 – 显示变化影响的伪图

图 1.1 – 显示变化影响的伪图

但我们如何做出正确的决定?这是一个狡猾的问题,因为我们通常没有足够的信息来协助做出将导致最佳满足业务需求的软件架构的决策。有时,即使是用户也不知道他们完全想要什么,随着项目的演变,可能会导致新的或变化的需求。我们通过使用一种软件架构来应对这种不可预测性,这种架构可以帮助我们以可持续的方式添加变化,确保代码库增长而不增加复杂性并降低可维护性。

快速引入变化的能力是软件设计中的一个主要关注点,但我们应谨慎考虑我们在思考它上花费的时间。如果我们花太多时间设计,我们最终可能会得到一个过度工程化和可能过度昂贵的解决方案。另一方面,如果我们忽视或对设计关注点没有足够的反思,我们最终可能会得到一个复杂且难以维护的解决方案。正如《极限编程解释:拥抱变化》所指出的,在设计努力上投入的资源应与系统处理变化的速度和成本相匹配。

最后,我们希望在保持复杂性可控的同时,能够灵活地添加新功能。本着这个想法,本书关注的是软件架构思想,使我们能够处理软件设计决策以满足不断变化的业务需求。六边形架构帮助我们构建能够适应这些需求的容错系统。

不可见的事物

软件开发不是一项简单活动。要成为任何编程语言的熟练使用者需要相当大的努力,而使用这种技能来构建能产生利润的软件则需要更大的努力。令人惊讶的是,有时仅仅制作出盈利的软件可能还不够。

当我们谈论盈利的软件时,我们是在谈论解决现实世界问题的软件。或者,在大型企业的背景下,更精确地说,我们是指满足业务需求的软件。任何在大型企业工作过的人都知道,客户通常并不想知道软件是如何构建的。他们感兴趣的是他们能看到的东西:满足业务预期的运行软件。毕竟,这是最终支付账单的东西。

但客户看不到的事物也具有一定的意义。这些事物被称为非功能性需求。它们是与安全性、可维护性、可操作性、可扩展性、可靠性和其他能力相关的事物。如果不对这些客户视角中不可见的事物给予足够的关注,可能会损害软件的整体目的。这种妥协可能会微妙且逐渐地发生,导致出现包括技术债务在内的几个问题。

我之前提到过,软件架构是关于做正确的事情。所以,这意味着在其关注点中,我们应该包括未见和可见的事物。对于客户可见的事物,深入理解问题域是至关重要的。这就是诸如领域驱动设计等技术能帮助我们以结构化的方式处理问题的地方,这种结构化不仅对程序员有意义,对参与问题域的每个人也是如此。领域驱动设计还在塑造不可见部分中扮演着关键角色,通过紧密定义底层结构,这些结构将使我们能够解决客户需求,并以结构化和可维护的方式进行。

技术债务

由沃德·坎宁安提出的“技术债务”是一个术语,用来描述软件代码中存在多少不必要的复杂性。这种不必要的复杂性也可以称为冗余——也就是说,当前代码与理想状态之间的差异。我们将在下一节中看到技术债务如何在软件项目中出现。

开发仅仅能正常工作的软件是一回事。你以你认为足够满足业务需求的方式组装代码,然后将其打包并部署到生产环境中。在生产环境中,你的软件满足了客户的需求,所以一切正常,生活继续。过了一段时间,另一位开发者进入来为这个你创建的软件添加新功能。就像你一样,这位开发者以他们认为足够满足业务需求的方式组装代码,但你的代码中有些东西这位开发者并不清楚。因此,他们以与你略有不同的方式向软件中添加元素。软件最终进入生产环境,客户满意。因此,这个循环重复进行。

在之前的场景中,我们可以清楚地看到软件按预期工作。但我们无法如此清楚地看到的是,缺乏一个共同的基础来定义如何添加或修改功能,这留下了一个缺口,每当开发者不知道如何处理这些变化时,他们都会试图填补这个缺口。这个缺口为诸如技术债务之类的东西的增长留出了空间。

现实往往迫使我们陷入无法避免技术债务的情况。紧凑的时间表、糟糕的计划、缺乏技能的人员,当然,缺乏软件架构都是可能导致技术债务产生的因素。不用说,我们不应该相信强制执行软件架构会神奇地解决我们所有的技术债务问题。远非如此;在这里,我们只是在解决问题的一个方面。所有其他的技术债务因素将仍然存在,并且实际上可能会削弱我们构建可维护软件的努力。

恶性循环

财务债务往往会持续增长,无论你是否偿还。此外,如果你不及时偿还这些债务,银行和当局都可能追讨你的债务以及你的资产。与财务债务相反,技术债务如果不偿还,并不一定会增长。然而,决定其增长的是软件变化的速率和性质。基于这一点,我们可以假设频繁和复杂的变化有更高的潜力增加技术债务。

你始终有权利不偿还技术债务——有时这可能是根据情况下的最佳选择——但这样你会降低改变软件的能力。随着技术债务率的提高,代码变得越来越难以管理,导致开发者要么完全避免接触代码,要么找到尴尬的解决方案来解决问题。

我相信我们大多数人至少有过一次维护脆弱、极其复杂的系统的痛苦经历。在这种情况下,我们不是花时间在软件有价值的事情上,而是花更多的时间与技术债务作斗争,以腾出空间引入新功能。如果我们不控制技术债务,总有一天,向技术债务过载的系统添加新功能将不再值得。这就是人们决定放弃应用程序,开始一个新的,并重复这个周期的时候。因此,解决技术债务所需的努力应该被认为是值得的,以打破这个周期。

这并非适用于每个人

从任何严肃的架构工作中产生的对质量和正确性的热情并不总是存在的。有些情况下,公司中最能带来利润的软件就是一个绝对的大泥球。这是一种在没有任何秩序感的情况下成长起来的软件,难以理解和维护。敢于应对这种系统复杂性的开发者就像是在与九头蛇作战的战士。在这种复杂性中强加任何秩序所需的重构努力有时并不值得。

大泥球问题并非唯一的问题。还有文化和组织因素可能会破坏任何软件架构的努力。我常常遇到一些队友,他们根本不关心架构原则。在他们心中,将代码交付生产的最低努力路径是应该遵循的规范。在开发者周转率高的项目中,这种类型的人并不难找。由于在质量和高标准方面没有所有权感,因此没有动力去生产高质量的代码。

推动遵守软件架构的纪律是困难的。技术团队和管理层应该在遵守这种纪律的优势和影响上达成一致。重要的是要理解,在处理那些在客户功能方面价值不大的技术方面投入更多时间,可能会在长期内带来回报。所有的努力都会以更易于维护的软件得到回报,减轻了不再需要与九头蛇作斗争的开发者,以及现在能更好地满足业务截止日期的管理者。

在尝试推广,更不用说强制执行任何软件架构原则之前,最好评估情况,确保没有文化和组织因素在反对少数试图达到或提高标准以构建更完善系统的人的态度。

单体或分布式

在软件社区中,关于系统组件和责任的组织经常进行讨论。在过去,当昂贵的计算资源和网络带宽是影响软件架构的问题时,开发者倾向于将大量责任组合在一个软件单元中,以优化资源使用并避免在分布式环境中出现的网络开销。但是,一个可维护且紧密的单一架构与一个纠缠且难以维护的架构之间有一条微妙的界限。

越过这样一条线是一个红旗,表明系统已经积累了如此多的责任,变得如此复杂以至于任何改变都可能导致整个软件崩溃。我并不是说每个不断增长的单一架构都会变得混乱。我试图传达的是,当这样的责任聚合没有得到妥善处理时,责任积累会在单一架构系统中引起严重问题。除了这个问题之外,确保软件易于开发、测试和部署同样重要。如果软件太大,开发者可能难以在本地运行和测试它。它也可能对持续集成管道产生严重影响,影响这些管道的编译、测试和部署阶段,最终损害在 DevOps 环境中至关重要的反馈循环。

另一方面,如果我们知道系统积累了足够多的责任,我们可以重新思考整体软件架构,将大型单一架构分解成更小、更易于管理、有时是自主的软件组件,这些组件通常在其自己的运行时环境中隔离。这种方法在面向服务的架构SOA)中得到强烈采用,然后是它的演变:微服务架构。SOA 和微服务都可以被认为是分布式系统的不同风味。特别是微服务架构,主要是因为计算和网络资源不像以前那样昂贵,带来了许多与强解耦和更快软件交付相关的利益。然而,这也带来了成本,因为以前我们只需要在一个地方处理复杂性,而现在挑战在于处理网络中分散在多个服务周围的复杂性。

本书提出了可以应用于单一架构和分布式系统的六边形架构思想。对于单一架构,应用程序可能被前端消费,同时从数据库或其他数据源中获取数据。六边形方法可以帮助我们开发出更具变化容忍度的单一架构系统,即使没有前端和数据库也可以进行测试。以下图表展示了常见的单一架构系统:

图 1.2 – 带有单体系统的六边形架构

图 1.2 – 带有单体系统的六边形架构

在分布式系统中,我们可能会处理许多不同的技术。六边形架构在这些场景中表现出色,因为它的端口和适配器允许软件处理不断的技术变化。以下图表展示了一个典型的微服务架构,其中我们可以应用六边形原则:

图 1.3 – 带有微服务系统的六边形架构

图 1.3 – 带有微服务系统的六边形架构

微服务架构的一个巨大优势是我们可以使用不同的技术和编程语言来构建整个系统。我们可以使用 JavaScript 开发前端应用程序,用 Java 编写一些 API,以及用 Python 开发数据处理应用程序。六边形架构可以帮助我们在这种异构技术场景中。

既然我们已经意识到一些与软件架构相关的问题,我们就更有能力探索可能的解决方案来减轻这些问题。为了帮助我们在这方面取得进展,让我们首先研究六边形架构的基本原理。

理解六边形架构

创建你的应用程序,使其在没有 UI 或数据库的情况下也能工作,这样你就可以对应用程序进行自动回归测试,当数据库不可用时也能工作,并且可以在没有任何 用户参与 的情况下将应用程序连接在一起。

– 阿利斯泰尔·科克本。

这段话为理解六边形架构奠定了基础。我们可以进一步发展科克本的想法,使我们的应用程序在没有任何技术的情况下也能工作,而不仅仅是 UI 或数据库相关的技术。

六边形架构的主要思想之一是将业务代码与技术代码分离。不仅如此,我们还必须确保技术方面依赖于业务方面,这样后者就可以在没有关于用于实现业务目标的技术方面的担忧的情况下进行演变。使业务逻辑独立于任何技术细节,给系统带来了在不会破坏其业务逻辑的情况下更改技术的灵活性。从这个意义上说,业务逻辑代表了应用程序开发的基础,以及所有其他系统组件将从中派生出来的基础。

我们必须能够更改技术代码,而不会对其业务对应物造成损害。为了实现这一点,我们必须确定业务代码将存在的地方,它将独立并受到任何技术问题的保护。这将引发我们第一个六边形的创建:领域六边形。

在领域六边形中,我们组装了描述我们希望软件解决的核心问题的元素。实体和价值对象是领域六边形中使用的主体元素。实体代表我们可以赋予其身份的事物,而价值对象是我们可以用以组合实体的不可变组件。本书对实体和价值对象的使用意义来源于领域驱动设计原则。

我们还需要方法来使用、处理和编排来自领域六边形的业务规则。这正是应用六边形的作用所在。它位于业务和技术两边之间,作为中间人,与两部分进行交互。应用六边形利用端口和用例来执行其功能。我们将在下一节中更详细地探讨这些内容。

框架六边形提供了外部世界的接口。这就是我们有机会确定如何暴露应用功能的地方——这就是我们定义 REST 或 gRPC 端点的地方。为了从外部来源消费内容,我们使用框架六边形来指定从数据库、消息代理或任何其他系统获取数据的机制。在六边形架构中,我们通过适配器实现技术决策。以下图表提供了一个架构的高级视图:

图 1.4 – 六边形架构

图 1.4 – 六边形架构

接下来,我们将更深入地探讨每个六边形的组件、角色和结构。

领域六边形

领域六边形代表了一种理解和建模现实世界问题的努力。假设你正在从事一个需要为电信公司创建网络和拓扑清单的项目。这个清单的主要目的是提供一个全面视图,展示构成网络的全部资源。在这些资源中,我们有路由器、交换机、机架、货架和其他设备类型。我们的目标是利用领域六边形将识别、分类和关联这些网络和拓扑元素所需的知识建模成代码,并提供一个清晰有序的所需清单视图。这种知识应尽可能以技术无关的形式表示。

这个任务并非微不足道。参与此类项目的开发者可能对电信业务知之甚少,更不用说这个清单了。正如《领域驱动设计:解决软件核心的复杂性》所建议的,应咨询领域专家或其他已经了解问题域的开发者。如果没有人可用,你应该通过在书籍或其他教授问题域的材料中搜索来填补知识空白。

在领域六边形内部,我们有与关键业务数据和规则对应的实体。它们之所以关键,是因为它们代表了一个真实问题的模型。该模型可能需要一些时间来演变并始终如一地反映问题域。这通常是新软件项目的情况,在早期阶段,开发人员和领域专家都没有对系统目的的明确愿景。在这种情况下,尤其是在初创环境中,拥有一个初始的尴尬领域模型是很正常和可预测的,该模型只随着业务想法的演变和用户及领域专家的验证而演变。这是一个有趣的情况,即领域模型甚至对所谓的领域专家来说都是未知的。

另一方面,在问题域存在且在领域专家心中清晰的情况下,如果我们未能掌握该问题域及其如何转化为实体和其他领域模型元素(如价值对象),我们就有风险基于弱或不正确的假设来构建我们的软件。

弱假设可能是软件开始时简单,但随着代码库增长,积累技术债务并变得难以维护的原因之一。这些弱假设可能导致脆弱且难以表达的计算,虽然最初可以解决业务问题,但无法以连贯的方式适应变化。请记住,领域六边形由您认为有助于表示问题域的任何类型的对象类别组成。以下是基于实体和价值对象的表示:

图 1.5 – 领域六边形

图 1.5 – 领域六边形

让我们谈谈组成这个六边形的组件。

实体

实体帮助我们构建更具表达力的代码。实体所具有的特征是其连续性和身份感,正如《领域驱动设计:软件核心的复杂性处理》一书中所描述的。这种连续性与对象的生命周期和可变特性相关。例如,在我们的网络和拓扑库存场景中,我们提到了路由器的存在。对于一个路由器,我们可以定义其状态是启用还是禁用。

此外,我们还可以分配一些描述路由器与其他路由器和网络设备之间关系的属性。所有这些属性都可能随时间变化,因此我们可以看到路由器不是一个静态的东西,其问题域内的特性可以改变。正因为如此,我们可以断言路由器有一个生命周期。除此之外,每个路由器在库存中应该是唯一的,因此它必须有一个身份。因此,连续性和身份是决定实体的元素。

以下代码展示了由RouterType和价值对象RouterId组成的Router实体类:

//Router entity class
public class Router {
    private final Type type;
    private final RouterId id;
    public Router(Type type, RouterId id) {
        this.type = type;
        this.id = id;
    }
    public static List<Router> checkRouter(
    Type type, List<Router> routers) {
    var routersList = new ArrayList<Router>();
        routers.forEach(router -> {
        if(router.type == type ){
            routersList.add(router);
        }
    });
    return routersList;
    }
}

价值对象

当不需要唯一标识某物,或者我们更关注对象的属性而不是其标识时,值对象可以补充我们代码的表达性。我们可以使用值对象来组合实体对象,因此我们必须使它们不可变,以避免在领域内出现意外的不一致。在前面提到的路由器示例中,我们可以将 Type 路由器表示为 Router 实体的值对象属性:

public enum Type {
       EDGE,
    CORE;
}

应用六边形

到目前为止,我们一直在讨论如何通过实体和价值对象封装业务规则,使领域六边形得以实现。但有些情况下,软件不需要在领域级别直接操作。《整洁架构:软件结构和设计的工匠指南》 中提到,某些操作仅存在是为了允许软件提供的自动化。这些操作——尽管它们支持业务规则——在软件之外的环境中并不存在。我们谈论的是特定于应用的操作。

应用六边形是我们抽象处理特定于应用的任务的地方。我的意思是抽象,因为我们还没有直接处理技术问题。这个六边形基于领域六边形的业务规则表达了软件的用户意图和特性。

基于前面描述的相同拓扑和库存网络场景,假设你需要一种查询相同类型路由器的方法。这将需要一些数据处理来生成这样的结果。你的软件需要捕获一些用户输入以查询路由器类型。你可能想使用特定的业务规则来验证用户输入,并使用另一个业务规则来验证从外部来源获取的数据。如果没有违反任何约束,你的软件随后提供显示相同类型路由器列表的数据。我们可以将这些不同的任务分组在一个用例中。以下图展示了基于用例、输入端口和输出端口的 Application 六边形的高级结构:

图 1.6 – 应用六边形

图 1.6 – 应用六边形

下文将讨论这个六边形的组成部分。

用例

用例通过软件领域内存在的特定于应用的操作来表示系统的行为,这些操作支持领域的约束。用例可以直接与实体和其他用例交互,使它们成为相当灵活的组件。在 Java 中,我们通过接口表示用例的抽象,这些接口表达了软件能做什么。以下示例展示了一个提供获取过滤后的路由器列表操作的用例:

public interface RouterViewUseCase {
    List<Router> getRouters(Predicate<Router> filter);
}

注意到 Predicate 过滤器。我们将使用它来过滤在实现具有输入端口的该用例时的路由器列表。

输入端口

如果用例只是描述软件做什么的接口,我们仍然需要实现用例接口。这就是输入端口的作用。通过作为一个直接附加到用例的组件,在应用级别,输入端口允许我们以域术语实现软件意图。以下是一个提供实现以满足用例中声明的软件意图的输入端口:

public class RouterViewInputPort implements RouterViewUse
  Case {
    private RouterViewOutputPort routerListOutputPort;
    public RouterViewInput
      Port(RouterViewOutputPort  routerViewOutputPort) {
        this.routerListOutputPort = routerViewOutputPort;
    }
    @Override
    public List<Router> getRouters(Predicate<Router> fil
       ter) {
        var routers = routerListOutput
             Port.fetchRouters();
        return Router.retrieveRouter(routers, filter);
    }
}

这个例子向我们展示了我们如何可以使用域约束来确保我们正在过滤我们想要检索的路由器。从输入端口的实现中,我们也可以从应用程序外部获取东西。我们可以使用输出端口做到这一点。

输出端口

有时候,一个用例需要从外部资源获取数据以实现其目标。这就是输出端口的作用,它被表示为接口,以技术无关的方式描述用例或输入端口需要从外部获取什么类型的数据以执行其操作。我说无关是因为输出端口不在乎数据来自特定的关系型数据库技术或文件系统,例如。我们将这个责任分配给输出适配器,我们将在稍后讨论:

public interface RouterViewOutputPort {
    List<Router> fetchRouters();
}

现在,让我们讨论最后一种六边形。

框架六边形

在我们的关键业务规则被限制在域六边形内,随后是应用六边形通过用例、输入端口和输出端口处理一些特定应用操作的情况下,事情看起来组织得很好。现在是我们需要决定哪些技术应该允许与我们的软件进行通信的时候了。这种通信可以有两种形式,一种被称为驱动,另一种被称为被驱动。对于驱动端,我们使用输入适配器,而对于被驱动端,我们使用输出适配器,如下面的图所示:

图 1.7 – 框架六边形

图 1.7 – 框架六边形

让我们更详细地看看这个问题。

驱动操作和输入适配器

驱动操作是请求软件执行操作的那些操作。这可能是一个具有命令行客户端的用户或代表用户的客户端应用程序。可能有某些测试套件检查你软件暴露的事物的正确性。或者,它可能是大型生态系统中的其他应用程序需要与某些暴露的软件功能进行交互。这种通信通过建立在输入适配器之上的应用程序编程接口(API)进行。

这个 API 定义了外部实体如何与你的系统交互,并将它们的请求转换为你的域应用程序。术语驱动是因为那些外部实体正在驱动系统的行为。输入适配器可以定义应用程序支持的通信协议,如下所示:

图 1.8 – 驱动操作和输入适配器

图 1.8 – 驱动操作和输入适配器

假设你需要向仅使用 HTTP/1.1 上的 SOAP 的旧应用程序公开一些软件功能,同时还需要将这些相同的功能提供给可以利用 HTTP/2 优势的新客户端。使用六边形架构,你可以为这两种场景创建一个输入适配器,每个适配器连接到相同的输入端口,该端口将请求向下转换为领域术语。以下是一个使用用例引用调用输入端口操作的输入适配器示例:

public class RouterViewCLIAdapter {
    private RouterViewUseCase routerViewUseCase;
    public RouterViewCLIAdapter(){
        setAdapters();
    }
    public List<Router> obtainRelatedRouters(String type) {
        RelatedRoutersCommand relatedRoutersCommand =
           new RelatedRoutersCommand(type);
        return routerViewUseCase.getRelatedRouters
             (relatedRoutersCommand);
    }
    private void setAdapters(){
        this.routerViewUseCase = new  RouterViewInputPort
          (RouterViewFileAdapter.getInstance());
    }
}

此示例说明了创建一个从 STDIN 获取数据的输入适配器的过程。注意使用输入端口通过其用例接口。在这里,我们传递了封装在应用程序六边形上使用的输入数据的命令,以处理领域六边形的约束。如果我们想在我们的系统中启用其他通信形式,例如 REST,我们只需创建一个新的 REST 适配器,其中包含暴露 REST 通信端点的依赖项。我们将在以下章节中这样做,因为我们向我们的六边形应用程序添加更多功能。

驱动操作和输出适配器

在另一方面,我们有驱动操作。这些操作是从你的应用程序触发的,进入外部世界以获取数据以满足软件的需求。驱动操作通常是对某个驱动操作的响应。正如你可以猜到的,我们通过输出适配器定义驱动方面。这些适配器必须通过实现它们来符合我们的输出端口。记住,输出端口告诉系统它需要什么类型的数据来执行某些特定任务。输出适配器负责描述它将如何获取数据。以下是输出适配器和驱动操作的图示:

图 1.9 – 驱动操作和输出适配器

图 1.9 – 驱动操作和输出适配器

假设你的应用程序最初使用的是 Oracle 关系数据库,过了一段时间后,你决定改变技术,转向 NoSQL 方法,选择 MongoDB 作为你的数据源。一开始,你只有一个输出适配器,用于与 Oracle 数据库进行持久化。为了与 MongoDB 进行通信,你需要在框架六边形上创建一个输出适配器,同时保持应用程序和,最重要的是,领域六边形不受影响。因为输入和输出适配器都指向六边形内部,所以我们让它们依赖于应用程序和领域六边形,从而反转了依赖关系。

使用“驱动”这个术语是因为这些操作是由六边形应用程序本身驱动和控制的,触发在其他外部系统中的动作。注意,在以下示例中,输出适配器是如何实现输出端口接口来指定应用程序将如何获取外部数据的:

public class RouterViewFileAdapter implements Router
  ViewOutputPort {
    @Override
    public List<Router> fetchRouters() {
        return readFileAsString();
    }
    private static List<Router> readFileAsString() {
        List<Router> routers = new ArrayList<>();
        try (Stream<String> stream = new BufferedReader(
                new InputStreamReader(
                  Objects.requireNonNull(
                  RouterViewFileAdapter.class
                    .getClassLoader().
                  getResourceAsStream
                    ("routers.txt")))).lines()) {
            stream.forEach(line ->{
            String[] routerEntry = line.split(";");
            var id = routerEntry[0];
            var type = routerEntry[1];
            Router router = new Router
                   (RouterType.valueOf(type)
                      ,RouterId.of(id));
                routers.add(router);
            });
        } catch (Exception e){
           e.printStackTrace();
        }
        return routers;
    }
}

输出端口说明应用程序需要从外部获取哪些数据。上一个示例中的输出适配器提供了一种通过本地文件获取该数据的具体方式。

在讨论了该架构中的各种六边形之后,我们现在将探讨这种方法带来的优势。

六边形方法的优点

如果你正在寻找一种模式来帮助你标准化公司或个人项目中软件的开发方式,六边形架构可以作为创建这种标准化的基础,通过影响类、包和整体代码结构的组织方式来实现。

在我参与多个供应商的大型项目并带来许多新开发者共同贡献同一代码库的经验中,六边形架构帮助组织建立软件结构的基础原则。每当开发者切换项目时,他们理解软件结构的学习曲线都很浅,因为他们已经熟悉了在先前项目中学习的六边形原则。这个因素特别与具有轻微技术债务的软件的长期利益直接相关。

具有高度可维护性且易于更改和测试的应用程序总是受欢迎的。接下来,让我们看看六边形架构如何帮助我们获得这些优势。

适应变化

技术变革正在迅速发生。新的编程语言和无数复杂的工具每天都在涌现。为了击败竞争,仅仅坚持既定和经过时间考验的技术往往是不够的。使用尖端技术不再是一个选择,而是一种必要性,如果软件没有准备好适应这种变化,公司可能会因为软件架构不适应变化而损失金钱和时间。

因此,六边形架构的端口和适配器特性通过提供创建能够以较低摩擦度融入技术变化的架构原则,给我们带来了强大的优势。

可维护性

如果需要更改某些业务规则,我们知道唯一需要更改的是领域六边形。另一方面,如果我们需要允许现有功能被使用特定技术或协议(该协议尚未被应用程序支持)的客户端触发,我们只需创建一个新的适配器,仅在框架六边形上执行此更改。

这种关注点的分离看起来很简单,但当它作为架构原则被强制执行时,它赋予了一定程度的可预测性,足以减少在深入研究其复杂性之前掌握基本软件结构的心理负担。时间始终是一种稀缺资源,如果有机会通过一种能够消除一些心理障碍的架构方法来节省时间,我认为我们至少应该尝试一下。

可测试性

六边形架构的最终目标之一是允许开发者在外部依赖项(如 UI 和数据库)不存在的情况下测试应用程序,正如 Alistair Cockburn 所说。但这并不意味着这种架构忽略了集成测试。恰恰相反——它通过提供所需的灵活性来允许更松散的耦合方法,即使在没有数据库等依赖项的情况下,也能测试代码的最关键部分。

通过评估组成六边形架构的每个元素,并意识到这种架构可以为我们的项目带来的优势,我们现在具备了开发六边形应用程序的基础。

摘要

在本章中,我们学习了软件架构在建立开发稳健和高品质应用程序的基础中的重要性。我们探讨了技术债务的恶劣性质以及我们如何通过合理的软件架构来应对它。最后,我们概述了六边形架构的核心组件以及它们如何使我们能够开发出更具容错性、可维护性和可测试性的软件。

借助这些知识,我们现在能够将这些六边形原则应用于基于提议的领域、应用程序和框架六边形的构建应用程序,这将帮助我们建立业务代码和技术代码之间的边界,为完整六边形系统的开发奠定基础。

在下一章中,我们将通过查看其最重要的部分:领域六边形,来探讨如何开始开发六边形应用程序。

问题

  1. 构成六边形架构的三个六边形是什么?

  2. 领域六边形的作用是什么?

  3. 我们应该在什么时候利用用例?

  4. 输入和输出适配器存在于哪个六边形中?

  5. 驾驶和驱动操作有什么区别?

进一步阅读

  • 在干净的架构中动手实践(Hombergs,2019)

答案

  1. 领域、应用程序和框架。

  2. 它以实体、值对象和任何其他有助于建模问题域的对象类别(如)的形式提供业务规则和数据。它不依赖于其上的任何其他六边形。

  3. 当我们想要通过特定于应用程序的操作来表示一个系统的行为时。

  4. 框架六边形。

  5. 驱动操作是指从软件请求动作的操作。被驱动操作是由六边形应用程序本身启动的。这些操作超出六边形应用程序的范围,从外部来源获取数据。

第二章:在领域六边形内封装业务规则

在上一章中,我们学习了领域作为六边形架构中的第一个六边形。由于是内层六边形,领域不依赖于应用和框架六边形中的任何代码。我们还让所有其他六边形都依赖于领域来执行它们的操作。这种安排使领域六边形具有比其他六边形更高的责任和相关性。我们采用这种安排是因为在领域中,我们汇集了所有代表我们试图解决的问题的最具代表性的业务规则和数据。

在建模问题域的技术中,领域驱动设计DDD)在强调将软件代码作为传达业务知识媒介的项目中被广泛采用。一个持续的担忧是区分构成核心问题域的内容和次要内容,这使得 DDD 成为支持六边形架构目标——将技术代码与业务代码分离——的合适方法。

本章中我们将看到的原理和技术将作为构建领域六边形的基石。

本章我们将涵盖以下主题:

  • 使用实体建模问题域

  • 使用值对象增强描述性

  • 确保聚合的一致性

  • 与领域服务一起工作

  • 使用策略和规范模式处理业务规则

  • 将业务规则定义为普通 Java 对象POJOs

到本章结束时,你将学会 DDD 的基本构建块,并能够将所介绍的概念应用于六边形应用的开发。

技术需求

要编译和运行本章中提供的代码示例,你需要在你的计算机上安装最新的Java 标准版SE)开发工具包和Maven 3.8。它们都适用于 Linux、Mac 和 Windows 操作系统。

你可以在 GitHub 上找到本章的代码文件,链接为 github.com/PacktPublishing/-Designing-Hexagonal-Architecture-with-Java---Second-Edition/tree/main/Chapter02

使用实体建模问题域

在 DDD 中,在编写任何代码之前,开发者和领域专家之间必须进行大量的讨论——这些人对其业务有深刻的理解,可能包括其他开发者。这些讨论提供了宝贵的信息,这些信息是通过知识压缩过程获得的,该过程基于开发者和领域专家之间的头脑风暴。然后,这种知识被纳入通用语言。这种语言在所有参与项目的人之间作为通用语言使用,存在于文档、日常对话中——当然,也在代码中。

当我们处理实体时,我们必须始终注意仅通过阅读代码就能了解多少关于业务的信息。尽管仅仅阅读代码可能还不够。这时,像知识压缩这样的技术就变得至关重要,我们通过与领域专家交谈来了解更多关于业务的信息,这有助于我们不断演进通用语言并将业务知识转化为工作代码。这是真正捕捉相关行为并不仅仅是数据对象的丰富实体的基础。

要使一个实体被视为实体,它必须有一个身份;因此,我们将看到如何以与六边形架构目标一致的方式分配身份,以在业务和技术代码之间分离关注点。

领域实体的纯粹性

当我们建模问题域时,主要重点是尽可能精确地用代码捕捉现实生活中的场景。这个场景通常由几个协同工作的流程组成,以支持组织的目标,满足客户期望。满足客户需求的能力最终将决定组织的盈利能力。因此,问题域建模的努力对于确定任何依赖其软件赚钱的组织总体成功至关重要。未能理解和将业务需求转化为代码显然会导致客户期望得不到满足。

那个问题域建模工作的核心是实体的创建。由于实体与业务需求之间的邻近性,我们应该努力保护这些实体免受技术需求的影响。我们这样做是为了防止与业务相关的代码与技术相关的代码混淆。在这里,我指的是那些仅在软件上下文中存在并具有意义的那些事物。

如果我们只考虑业务需求而不考虑软件,那些相同的技术担忧就不再有意义。我们还得认识到,问题域可能并不总是指纯业务需求。问题域可能完全是技术性的,例如创建一个新的开发框架。我认为在这些情况下,六边形架构并不是最佳方法,因为它的重点在于试图解决传统业务问题的项目。

领域实体应该纯粹地处理业务关注点。对于特定于技术的事情,我们有选择使用端口、用例和适配器的选项,正如我们将在以下章节中看到的。

相关实体

一个相关的实体由两个要素的存在来表征——业务规则和业务数据。看到实体类几乎像数据库实体对象建模,只表达数据部分而忽略了通过实体类方法提供的行为所代表的业务规则,这是很常见的。这些业务规则可能最终出现在域六边形之外的代码部分。

这种泄露,即业务规则最终出现在领域六边形之外,可能是有害的,因为它可能使理解领域实体做什么变得困难。这种情况发生在业务规则在领域六边形之外定义,并依赖于例如处理数据库实体的代码时,这些数据库实体不是领域模型的一部分,而是支持领域模型的技术细节。这种现象在所谓的 贫血领域模型 中很普遍。来自贫血领域模型的实体对象通常有数据,但缺乏行为。通过不将数据与行为耦合,贫血领域模型违反了 面向对象编程OOP) 的本质。当领域对象中没有行为时,我们必须去其他地方才能完全理解实体应该做什么,从而产生一种随着代码库增长而迅速变成沉重负担的心理负担。

相反,我们不应该在实体类中添加与实体本身无关的逻辑。这并非易事,因为一开始我们可能认为某个操作是实体的一部分,但后来发现它并不是。

对于被认为不是实体行为固有属性的事物,我们有使用领域服务的选项。通过服务,我们可以容纳那些不适合平滑地集成到实体类中的操作。

在上一章中,我们创建了一个 retrieveRouter 方法来过滤和列出 Router 类中的路由器,如下代码片段所示:

public static List<Router> retrieveRouter(List<Router>
  routers, Predicate<Router> predicate){
     return routers.stream()
        .filter(predicate)
        .collect(Collectors.<Router>toList());
}

我们能否将这个列表路由器的行为视为现实世界中路由器的固有特征? 如果我们的问题域表示相反,那么我们应该从实体类中移除这种行为。那么,在我们将路由器添加到列表之前用于检查路由器类型的约束又如何呢? 如果我们认为这种验证是路由器固有的行为,我们有以下选项:

  • 将此约束直接嵌入到实体类中

  • 创建一个规范来断言约束

规范是我们将在本章后面讨论的主题,但到目前为止,你可以将规范视为谓词机制,以确保我们与正确的对象一起工作。以下代码片段提供了一个示例,展示了具有直接嵌入的 routerTypeCheck 约束的 Router 实体类:

public class Router {
/** Code omitted **/
     public static Predicate<Router> filterRouterByType
       (RouterType routerType){
          return routerType.equals(RouterType.CORE)
                ? Router.isCore() :
                Router.isEdge();
     }
     private static Predicate<Router> isCore(){
          return p -> p.getRouterType() == RouterType.CORE;
     }
     private static Predicate<Router> isEdge(){
          return p -> p.getRouterType() == RouterType.EDGE;
     }
/** Code omitted **/
}

为了适应领域服务方法,我们首先需要创建一个名为 RouterSearch 的领域服务类,并将 Router 类中的 retrieveRouter 方法移动到该类中,如下所示:

public class RouterSearch {
    public static List<Router> retrieveRouter(List<Router>
      routers, Predicate<Router> predicate){
          return routers.stream()
                .filter(predicate)
                .collect(Collectors.<Router>toList());
    }
}

isCoreisEdgefilterRouterByType 约束方法继续存在于 Router 实体类中。我们只是将 retrieveRouter 方法从 Router 移动到 RouterSearch。现在,retrieveRouter 方法可以作为服务被领域中的其他对象和其他六边形消费。在本章的后续部分,在 与领域服务一起工作 部分中,我们将更详细地探讨领域服务。

可能会出现的疑问是领域模型方法应该有多复杂,尤其是那些属于领域实体的方法。我的看法是,复杂性将由我们对问题域的了解以及我们将它转化为领域实体的能力所决定,通过适当的方法定义,仅捕获根据问题域提出的条件所必需的行为,以改变实体状态。薄弱的问题域知识可能导致不必要的复杂性。因此,随着我们对问题域的了解增加,我们也增加了为定义的领域实体提供正确复杂性的能力。

实体的一个基本特征是它们有一个唯一标识它们的身份。拥有一个身份机制对于确保我们的实体在整个系统中是唯一的至关重要。提供这种身份的一种方式是通过使用 UUID,我们将在本节中探讨这个主题。

使用 UUID 定义身份

你可能熟悉标识符ID)生成技术,这些技术依赖于数据库序列机制来生成和避免 ID 的重复。虽然将这项责任委托给数据库很方便,但这样做会将我们软件的一个关键方面与外部系统耦合起来。

假设我们旨在开发一个六边形应用,使我们能够以尽可能少的科技依赖性来演进业务代码。在这种情况下,我们需要找到一种方法将这种身份生成转化为一个独立的过程。

建立不依赖于中央权威的身份的常见方法是通过通用唯一标识符UUID)。这是一个广泛用于确保计算机系统普遍唯一性的 128 位数字。有四种不同的方法可以生成 UUID - 基于时间的、分布式计算机环境DCE)安全、基于名称和随机生成。以下代码片段显示了如何创建基于名称和随机生成的 UUID:

     // Name-based UUID
     var bytes = new byte[20];
     new Random().nextBytes(bytes);
     var nameBasedUUID = UUID.nameUUIDFromBytes(bytes);
     // Randomly generated UUID
     var randomUUID = UUID.randomUUID();

小心 UUID,如果你的数据源是关系型数据库,你可能会遇到性能问题。因为 UUID 是字符串,它们比关系型数据库提供的自动生成的 ID 消耗更多的内存。UUID 的使用可能会对数据库的大小和索引管理产生相当大的影响。没有免费的午餐。计算机资源是这种无差别的 ID 生成解决方案的代价。这取决于你决定这种方法的优点是否超过了缺点。

一旦定义,实体 ID 就不应该改变,因此它成为一个不可变的属性。这种不可变的特性使得实体 ID 属性成为建模为价值对象的合适候选。基于我们在上一章中处理过的拓扑和网络库存示例,以下代码片段展示了创建一个表示我们的 Router 实体 ID 的价值对象类的一个简单方法:

public class RouterId {
     private final UUID id;
     private RouterId(UUID id){
          this.id = id;
     }
     public static RouterId withId(String id){
          return new RouterId(UUID.fromString(id));
     }
     public static RouterId withoutId(){
          return new RouterId(UUID.randomUUID());
     }
}

withId 工厂方法允许我们在拥有 ID 的情况下重新构成 Router 实体。withoutId 工厂方法使我们能够为新的 Router 实体生成新的 ID。

withIdwithoutId 方法都是静态工厂模式的运用,这种模式允许我们封装对象创建。这些方法属于领域模型,因为它们通过 ID 在新的或现有的路由实体上提供身份验证。

实体是六边形架构中的第一类公民。它们是其他软件组件从中派生的基础元素。然而,仅凭它们本身不足以创建丰富的领域模型,因为领域中的并非所有事物都具有身份。我们需要某种东西来表达不需要唯一标识的对象。我们通过价值对象来满足这一需求,这是一种旨在帮助我们增加问题域描述性的对象类型。

使用价值对象增强描述性

在《实现领域驱动设计》这本书中,作者 Vernon Vaughn 指出,我们应该使用价值对象来衡量、量化或描述我们的问题域中的事物。例如,你可以用一个价值对象来描述 ID 属性,而不是用长或整数值。你可以将双精度或大十进制属性包装在一个特定的价值对象中,以更清晰地表达量化。

我们对仅仅使用内置语言类型来建模问题域并不完全满意。为了使系统更明确其性质和目的,我们将这些内置语言数据类型——甚至我们自己的创建类型——封装在定义良好的价值对象中。

这种传达意义的努力基于价值对象以下两个基本特征:

  • 它们是不可变的

  • 它们没有身份

假设你画了一幅画。想象一下,如果你完成作品后,出于某种原因,画中的部分神秘地改变了颜色,那会多么奇怪。在这个类比中,颜色就像我们用来创建画面的价值对象,每种颜色都可以是一个不同的价值对象。因此,为了确保我们的颜料持久,一旦使用,颜色就不能改变,一旦使用就必须是不可变的。我关于价值对象的论点基于这样一个观点:某些特征必须永远不变,因为它们是我们用来描述问题域的原始材料。

纯粹的原料本身既不表达很多意义,也没有多少价值。真正的价值在于当我们将原料结合并与之合作,形成相关且可辨识的事物时。因为值对象本身就像原料一样,我们不会费心去替换它们或丢弃它们。而且如果它们并不那么重要,我们为什么还要赋予它们一个身份,并像对待实体一样给予同样的关注呢?

最终,值对象应该是可丢弃的且易于替换的对象,我们使用它们来组合实体或其他类型的对象。

使用值对象来组合实体

当对实体类进行建模时,例如,我们有两种选择——在实体属性上使用或不使用值对象。以下是一个第二种方法的例子:

public class Event implements Comparable<Event> {
     private EventId id;
     private OffsetDateTime timestamp;
     private String protocol;
     private String activity;
     ...
}

考虑以下日志摘录作为我们想要解析到Event对象中的数据条目:

00:44:06.906367 100430035020260940012015 IPV6 casanova.58183 > menuvivofibra.br.domain: 64865+ PTR? 1.0.0.224.in-addr.arpa. (40)
00:44:06.912775 100430035020260940012016 IPV4 menuvivofibra.br.domain > casanova.58183: 64865 1/0/0 PTR all-systems.mcast.net. (75)

经过适当的解析后,我们会得到具有网络流量活动字符串字段的Event对象,如下所示:

casanova.58183 > menuvivofibra.br.domain

在大于号之前,我们有源主机,之后是目标主机。为了这个例子,让我们把它看作是一个表示数据包源和目标的活动。作为一个字符串,它给想要从其中检索源或目标主机的客户端留下了负担,如下所示:

var srcHost = event.getActivity().split(">")[0]
  //casanova.58183

让我们用一个Activity值对象来尝试,如下所示:

public class Activity {
     private final String description;
     private final String srcHost;
     private final String dstHost;
     public Activity (String description, String srcHost,
       String dstHost){
          this.description = description;
          this.srcHost = description.split(">")[0];
          this.dstHost = description.split(">")[1];
     }
     public String getSrcHost(){
          return this.srcHost;
     }
}

然后,我们更新Event实体类,如下所示:

public class Event implements Comparable<Event> {
     private EventId id;
     private OffsetDateTime timestamp;
     private String protocol;
     private Activity activity;
     ...
}

客户端代码变得更加清晰和表达性强,正如我们可以在以下代码片段中看到。此外,客户端不需要自己处理数据来检索源和目标主机:

var srcHost = event.getActivity().retrieveSrcHost()
//casanova.58183

使用值对象,我们对自己的数据有更多的灵活性和控制力,使我们能够以更一致的方式表达领域模型。

确保聚合的一致性

到目前为止,我们已经看到实体在表示问题域中的事物方面是多么有价值。我们还看到了值对象是如何对我们使用的模型的可描述性至关重要的。然而,当我们有一组相关的实体和值对象,当它们组合在一起时表达了一个整体概念时,我们该如何进行呢?对于这种情况,我们应该使用聚合。其理念是聚合内的对象以一致和隔离的方式操作。为了实现这种一致性,我们必须确保任何对任何聚合对象的更改都是基于该聚合施加的变体条件。

聚合体就像一个指挥者,它在其控制的对象上编排数据和行为。为了使这种方法有效,我们需要定义一个入口点来与聚合体领域交互。这个入口点也被称为聚合体根,它保留了对聚合体中实体和值对象的引用。有了聚合体提供的边界,我们更有能力确保在该边界内对象操作的一致性。通过正式建立概念边界以确保基于我们的问题域的活动的一致性,我们将更容易采用诸如乐观或悲观锁定等技术,以及诸如Java 事务 APIJava JTA)等技术来支持一致的事务操作。有了结构良好的聚合体,我们有更好的条件来应用我们认为好的任何方法,以使我们的系统能够进行事务处理。

从性能和可扩展性的角度来看,我们应该始终努力使我们的聚合体尽可能小。原因很简单——大的聚合体对象消耗更多的内存。同时实例化太多的聚合体对象可能会损害整体Java 虚拟机JVM)的性能。这条规则适用于 OOP 世界中的任何事物,但我们强调聚合体,因为它们能够集成对象。

一个小的聚合体通常只包含一个实体,该实体作为聚合体的根,以及其他值对象。使两个不同的聚合体相互交互的方式是通过它们的聚合体根,它恰好是一个具有唯一 ID 的实体根。聚合体根也用于持久化目的。因此,您将通过聚合体根对聚合体子对象进行更改,当您的更改完成后,您将使用相同的聚合体根将那些更改提交到您的持久化系统中。

相反,如果您不认为性能和可扩展性这样的非功能性需求是至关重要的,我认为,只要适当关注,聚合体可以增长到包含多个实体。

模型化一个聚合体

为了说明我们如何模型化一个聚合体,让我们回到我们的网络和拓扑库存场景。一个业务需求是对连接到特定边缘路由器的设备和网络进行编目。在下面的三层交换机下面,我们有一个负责为不同网络创建虚拟局域网VLANs)的三层交换机。结构可能像下面这样:

图 2.1 – 网络组件

图 2.1 – 网络组件

设备、网络和关系的目录被基础设施部门用来帮助他们计划和实施整体网络的变更。一个路由器或交换机单独并不能告诉我们太多关于网络的信息。真正的价值在于当我们聚合所有网络组件及其互连时。

这类信息将使基础设施部门有更多的可见性,并做出基于事实的决策。我们聚合的核心是边缘路由器实体,它恰好是我们的聚合根。交换机也是一个实体。我们将其 VLAN 网络建模为值对象。这里的上下文很清晰——一个由 HR、市场和工程 VLAN 网络组成的网络连接到一个交换机,而该交换机反过来又连接到边缘路由器。互联网或其他网络可以在不同的上下文中考虑。以下是一个类似于统一建模语言UML)的聚合根表示:

图 2.2 – 将所有网络组件聚集在一起

图 2.2 – 将所有网络组件聚集在一起

从最底层开始,我们有Network作为一个值对象,如下面的代码片段所示:

public record Network(IP address, String name, int cidr) {
    public Network {
        if (cidr < 1 || cidr > 32) {
            throw new IllegalArgumentException("Invalid
              CIDR
            value");
        }
    }
}

注意,互联网协议IP)地址属性也是一个值对象,如下面的代码片段所示:

public class IP {
     private final String address;
     private final Protocol protocol;
     public IP(String address) {
          if(address == null)
          throw new IllegalArgumentException("Null IP
            address");
          if(address.length()<=15) {
               this.protocol = Protocol.IPV4;
          } else {
               this.protocol = Protocol.IPV6;
          }
          this.address = address;
     }
}

你可能已经在IPNetwork值对象类的构造函数中注意到了一些验证规则。这些验证规则作为守护者,用于防止值对象被错误地构建。将这些守护者在实例创建中放置是一种让客户端摆脱验证值对象负担的方法。这正是Network类所发生的事情,我们只是验证了cidr属性,因为IP已经预先进行了验证。

此外,还有一个用于组合IP值对象的Protocol枚举值对象,如下面的代码片段所示:

public enum Protocol {
     IPV4,
     IPV6
}

在对IPNetworkProtocol值对象进行建模之后,我们现在有了建模Switch实体类的必要对象,如下所示:

public class Switch {
     private final SwitchType type;
     private final SwitchId switchId;
     private final List<Network> networkList;
     private final IP address;
    public Switch (SwitchType switchType, SwitchId
      switchId, List<Network> networks, IP address) {
        this.switchType = switchType;
        this.switchId = switchId;
        this.networks = networks;
        this.address = address;
    }
   public Switch addNetwork(Network network, Router rout
     er)
   {
      List<Network> newNetworks =
      new ArrayList<>(router.retrieveNetworks());
    newNetworks.add(network);
    return new Switch(
        this.switchType,
        this.switchId,
        newNetworks,
        this.address);
   }
    public List<Network> getNetworks() {
        return networks;
    }
}

因为网络直接连接到交换机,我们创建了一个addNetwork方法来支持向交换机添加更多网络的能力。此方法首先从路由器检索现有网络,将它们添加到列表中。然后,它将新网络添加到现有网络的列表中。请注意,addNetwork不会更改当前的Switch对象,而是创建一个新的Switch实例,其中包含我们添加的网络。

在我们迄今为止创建的所有值对象和Switch实体之上,我们需要与一个聚合根正式化一个边界。这就是我们的Router实体类的作用,如下面的代码片段所示:

public class Router {
    private final RouterType routerType;
    private final RouterId routerid;
    private Switch networkSwitch;
    public Router(RouterType, RouterId routerid) {
        this.routerType = routerType;
        this.routerid = routerid;
    }
    public static Predicate<Router>
      filterRouterByType(RouterType routerType) {
        return routerType.equals(RouterType.CORE)
                ? Router.isCore() :
                Router.isEdge();
    }
    public static Predicate<Router> isCore() {
        return p -> p.getRouterType() == RouterType.CORE;
   }
    public static Predicate<Router> isEdge() {
        return p -> p.getRouterType() == RouterType.EDGE;
   }
    public void addNetworkToSwitch(Network network) {
        this.networkSwitch =
          networkSwitch.addNetwork(network, this);
    }
    public Network createNetwork(IP address, String name,
      int cidr) {
        return new Network(address, name, cidr);
    }
    public List<Network> retrieveNetworks() {
        return networkSwitch.getNetworks();
    }
    public RouterType getRouterType() {
        return routerType;
    }
    @Override
    public String toString() {
        return "Router{" +
                "type=" + routerType +
                ", id=" + routerid +
          '}';
    }
}

除了RouterTypeRouterId值对象之外,还有一个代表交换机的实体。networkSwitch实体表示直接连接到该路由器的交换机。然后,我们添加了两个方法,一个用于创建新的网络,另一个用于将现有网络连接到交换机。

通过将这些方法放在聚合根上,我们将处理其上下文中所有对象的职责委托给它,从而在处理这种对象聚合时增强了一致性。此外,这也是防止贫血领域模型方法的一种努力,其中实体只是没有任何行为的数据对象。

接下来,我们将看到如何使用领域服务来调用聚合体中包含的操作。

与领域服务一起工作

当对问题域进行建模时,我们肯定会遇到手头的任务不适合领域六边形中我们迄今为止看到的任何对象类别的情况——实体、值对象和聚合体。在本章的早期,我们遇到了从Router实体中移除一个负责检索路由器列表的方法的情况。这个方法似乎位置不正确,因为在我们拓扑和网络库存场景中,路由器通常不会列出其他路由器。为了处理这种繁琐的情况,我们已经将路由器列表方法重构为一个单独的对象。埃里克·埃文斯称这样的对象为领域服务

我认为区分领域服务与其他任何类型的服务非常重要。例如,在模型-视图-控制器MVC)架构中,服务通常被视为连接应用程序不同方面的桥梁,处理数据和协调系统内外部的调用。它们的用法通常与软件开发框架相关联,例如 Spring,甚至有服务注解。然而,无论在何种情境下,我认为不同服务类型之间的主要区别不在于意义,而在于范围。

什么是服务? 它是执行某些有价值工作的能力。这种特性在任何服务中都是固有的,无论是在现实世界还是在计算机中。然而,在后一种情况下,我们应该关注关注点分离SoC)、模块化、解耦和其他与良好架构相关的内容。这些关注点是我们在领域六边形中将领域服务放入其中的基础。它们执行有价值的工作——就像任何其他服务一样——但仅限于我们的问题域的约束范围内。这意味着领域服务不应调用在应用程序或框架六边形中操作的服务或其他对象。相反,来自这些六边形的对象是客户端,它们调用领域服务。

在上一节中,我们在我们的Router实体类中创建了以下两个方法,它也是聚合根:

public void addNetworkToSwitch(Network network) {
    this.networkSwitch = networkSwitch.addNetwork(network,
      this);
}
public Network createNetwork(IP address, String name, long
  cidr) {
    return new Network(address, name, cidr);
}

在下面的代码片段中,我们有一个服务类操作这两个Router实体方法:

public class NetworkOperation {
     final private int MINIMUM_ALLOWED_CIDR = 8;
     public void createNewNetwork(Router router, IP
       address, String name, int cidr) {
     if(cidr < MINIMUM_ALLOWED_CIDR)
     throw new IllegalArgumentException("CIDR is
       below      "+MINIMUM_ALLOWED_CIDR);
     if(isNetworkAvailable(router, address))
     throw new IllegalArgumentException("Address already
       exist");
     Network =
       router.createNetwork(address,name,cidr);
     router.addNetworkToSwitch(network);
     }
     private boolean isNetworkAvailable(Router router, IP
       address){
          var availability = true;
          for (Network network : router.retrieveNetworks()) {
               if(network.getAddress().equals(address) &&
                 network.getCidr() == cidr)
                    availability = false;
                    break;
          }
          return availability;
     }
}

我们有一个名为createNewNetwork的方法,它负责创建一个新的网络对象并将其添加到与我们的路由器相连的交换机。为了能够创建一个网络,我们必须满足两个约束。第一个,简单的一个是检查是否没有违反最小无类别域间路由CIDR)。第二个约束稍微复杂一些。它验证网络地址是否在整个网络上已被使用。

采用这种方法,我们将处理那些不适合整齐地放入实体或值对象中的任务的职责委托给NetworkOperation域服务类。这也是防止实体和值对象类变得过于庞大、具有比问题域所需更多的特性的好方法。

到目前为止,我们一直在实体、值对象或服务类上直接处理不变量。接下来,我们将看到一种更有序、更组织化的方法来适应这些不变量。

使用策略和规范处理业务规则

一个系统拥有的最有价值的东西之一就是其编码的业务规则。这些规则代表了理解现实世界问题并将这种理解转化为工作软件的重要努力。这绝对不是一项微不足道的工作。在领域驱动设计中,我们了解到与领域专家紧密合作来正确地建模我们的问题域是多么关键。如果领域专家不可用,我们应该寻找了解业务的开发者。如果他们都不可用,我们就别无选择,只能通过书籍和其他任何可以帮助我们掌握问题域内部运作的资源开始知识寻求之旅。

一旦我们获得了业务知识,并且我们对问题域的步骤和过程有了足够的相关信息,我们就可以开始将那些知识转化为代码的冒险之旅。乍一看,这个过程似乎很简单,即理解业务需求并将它们转化为软件。然而,这实际上是经过非常好的辩论的结果,产生了各种方法论,甚至产生了一个重要的宣言,即敏捷宣言。在这里,我的目标不是讨论理解业务需求的最佳方法。相反,这里的想法是展示我们可以用来将业务知识转化为工作软件的一些技术。

我们总有选择以自己的方式做事,有时会忽略来自我们之前经验的知识。在处理业务规则时,这绝对没有不同。在先前的例子中,我们就是这样做的,不加思考地将业务规则散布在代码中。我们现在有机会修正这种方法,并利用我们之前的人的知识。

策略模式和规范模式是两种可以帮助我们更好地组织代码业务规则的模式。

策略,也称为策略模式,是一种封装问题域一部分的代码模式。对于那些熟悉策略模式(四人帮)的人来说,可以使用术语算法来描述封装的代码块。策略的主要特征是它在提供的数据上执行某些操作或处理。策略故意与实体和值对象保持分离,以避免耦合。这种解耦提供了众所周知的优点,即在不直接影响或产生副作用的情况下,可以独立地演进某个部分。

相反,规范就像用于确保对象属性的条件或谓词。然而,定义规范的是它以比简单的逻辑运算符更表达性的方式封装这些谓词。一旦封装,这些规范就可以被重用,甚至可以组合起来更好地表达问题域。

当政策和规范一起使用时,它们是提高我们业务规则在代码中鲁棒性和一致性的有效技术。规范确保只有合适的对象被我们的策略处理。我们有一系列不同且易于更改的算法可供策略使用。

为了更好地说明规范和策略的工作原理,我们现在将探讨如何实现它们。

创建规范

让我们先看看如何重构我们的NetworkOperation服务类以使用规范。我们将从创建一个Specification接口开始,如下所示:

public interface Specification<T> {
          boolean isSatisfiedBy(T t);
     Specification<T> and(Specification<T> specification);
}

通过isSatisfiedBy的实现,我们将定义我们的谓词。在接口之后,我们需要创建一个抽象类来实现and方法,以便我们可以组合规范,如下代码片段所示:

public abstract class AbstractSpecification<T> implements
  Specification<T> {
     public abstract boolean isSatisfiedBy(T t);
     public Specification<T> and(final Specification<T>
       specification) {
          return new AndSpecification<T>(this,
            specification);
     }
}

在这里,只有AND运算符的方法,因为我们不处理其他运算符,如ORNOT,尽管实现这些运算符的方法很常见。为了完成我们基础类型的创建,我们实现了AndSpecification类,如下所示:

public class AndSpecification<T> extends AbstractSpecifica
  tion<T> {
     private final Specification<T> spec1;
     private final Specification<T> spec2;
     public AndSpecification(final Specification<T> spec1,
       final Specification<T> spec2) {
          this.spec1 = spec1;
          this.spec2 = spec2;
     }
     public boolean isSatisfiedBy(final T t) {
          return spec1.isSatisfiedBy(t) &&
            spec2.isSatisfiedBy(t);
     }
}

现在我们已经准备好创建我们自己的规范了。第一个是关于限制创建新网络所允许的最小 CIDR 的业务规则。代码如下所示:

if(cidr < MINIMUM_ALLOWED_CIDR)
     throw new IllegalArgumentException("CIDR is
       below "+MINIMUM_ALLOWED_CIDR);

相应的规范将如下所示:

public class CIDRSpecification extends AbstractSpecifica
  tion<Integer> {
     final static public int MINIMUM_ALLOWED_CIDR = 8;
     @Override
     public boolean isSatisfiedBy(Integer cidr) {
          return cidr > MINIMUM_ALLOWED_CIDR;
     }
}

接下来,我们将处理检查网络地址是否已被使用的业务规则,如下所示:

if(isNetworkAvailable(router, address))
  throw new IllegalArgumentException("Address already ex
    ist");
private boolean isNetworkAvailable(Router router, IP ad
  dress) {
     var availability = true;
     for (Network network : router.retrieveNetworks()) {
          if(network.getAddress().equals(address) &&
            network.getCidr() == cidr)
                    availability = false;
                    break;
          }
          return availability;
     }

之前代码的重构基本上是将isNetworkAvailable方法从实体移动到规范类中,如下代码片段所示:

public class NetworkAvailabilitySpecification extends Ab
  stractSpecification<Router> {
     private final IP address;
     private final String name;
     private final int cidr;
     public NetworkAvailabilitySpecification(IP address,
       String name, int cidr) {
          this.address = address;
          this.name = name;
          this.cidr = cidr;
     }
     @Override
     public boolean isSatisfiedBy(Router router) {
          return router!=null &&
            isNetworkAvailable(router);
     }
     private boolean isNetworkAvailable(Router router) {
          return router.retrieveNetworks().stream()
             .noneMatch(
             network -> network.address().equals(address)
               &&
             network.name().equals(name) &&
             network.cidr() == cidr);
     }
}

为了说明如何使用and方法组合两个规范,我们将创建另外两个规范。第一个是确定允许的最大网络数量,如下代码片段所示:

public class NetworkAmountSpecification extends Ab
  stractSpecification<Router> {
     final static public int MAXIMUM_ALLOWED_NETWORKS = 6;
     @Override
     public boolean isSatisfiedBy(Router router) {
          return router.retrieveNetworks().size()
            <=MAXIMUM_ALLOWED_NETWORKS;
     }
}

第二个规范是确保我们只处理边缘或核心路由器。这在上面的代码片段中显示:

public class RouterTypeSpecification extends AbstractSpeci
  fication<Router> {
     @Override
     public boolean isSatisfiedBy(Router router) {
          return
          router.getRouterType().equals(RouterType.EDGE) ||
            router.getRouterType().equals(RouterType.CORE);
     }
}

现在我们已经定义了我们的规范,我们可以使用 Java 15 首次作为预览引入并随后在 Java 17 中作为最终版本引入的功能,允许我们约束哪些类被允许实现一个接口或类。我们称之为 密封类/接口。正如其名所示,这个特性密封了类/接口,因此除非实现类/接口的名称在密封类或接口上明确声明,否则它不能被实现。让我们看看这个特性如何与刚刚创建的规范一起工作。

我们希望限制谁可以实现 Specification 接口和 AbstractSpecification 抽象类。在下面的代码片段中,我们可以看到我们如何将其应用于 Specification 接口:

public sealed interface Specification<T> permits Ab
  stractSpecification {
/** Code omitted **/
}

注意,我们通过使用 permits 子句来限制哪些类可以实现该接口。让我们密封 AbstractSpecification 抽象类:

public abstract sealed class AbstractSpecification<T> im
  plements Specification<T> permits
        AndSpecification,
        CIDRSpecification,
        NetworkAmountSpecification,
        NetworkAvailabilitySpecification,
        RouterTypeSpecification
{
/** Code omitted **/
}

允许子句现在包括了所有实现 AbstractSpecification 的其他类。我们仍然需要确保实现这些类的 final 状态。因此,我们需要在每个这些类上添加 final 子句,如下例所示:

public final class NetworkAmountSpecification extends Ab
  stractSpecification<Router> {
/** Code omitted **/
}

一旦我们完成了对实现规范类的 final 关键字的调整,我们就有一个定义良好的密封类/接口集合,描述了哪些规范类可以用来定义系统的业务规则。

现在我们已经准备好重构我们的领域服务,该服务负责创建新的网络以使用这些规范,如下所示:

public class NetworkOperation {
     public void createNewNetwork(Router router, IP
       address, String name, int cidr) {
          var availabilitySpec = new
            NetworkAvailabilitySpecification(address, name,
              cidr);
          var cidrSpec = new CIDRSpecification();
          var routerTypeSpec = new
            RouterTypeSpecification();
          var amountSpec = new
            NetworkAmountSpecification();
          if(cidrSpec.isSatisfiedBy(cidr))
               throw new IllegalArgumentException("CIDR is
                 below
                 "+CIDRSpecification.MINIMUM_ALLOWED_CIDR);
          if(availabilitySpec.isSatisfiedBy(router))
               throw new IllegalArgumentException("Address
                 already exist");
          if(amountSpec.and(routerTypeSpec).isSatisfiedBy
            (router)) {
               Network network =
                 router.createNetwork(address, name, cidr);
               router.addNetworkToSwitch(network);
          }
     }
}

现在我们已经探讨了如何实现规范,让我们看看我们如何创建策略。

创建策略

为了理解策略是如何工作的,我们将创建一个服务类来帮助我们根据特定的算法检索基于原始事件数据的网络事件列表。这个解析算法可能或可能不被认为是问题域的一部分;通常情况下不是,但为了这个示例,让我们假设它是。

我们将创建两个策略——第一个是将字符串日志条目解析为 Event 对象,使用基于纯 正则表达式regex)的算法,我们明确地告知正则表达式模式,而第二个将使用基于分割的算法,仅使用空格作为分隔符来完成相同的事情。这两种策略之间的选择可以基于性能和定制解析机制的能力,以及其他因素。

首先,我们将创建一个 EventParser 接口,如下所示:

public interface EventParser {
     DateTimeFormatter formatter =
       DateTimeFormatter.ofPattern("yyyy-MM-dd
         HH:mm:ss.SSS").withZone(ZoneId.of("UTC"));
     Event parseEvent(String event);
}

我们将在两个事件解析实现类中使用 formatter 属性。

让我们开始实现正则表达式解析策略,如下所示:

public class RegexEventParser implements EventParser {
     @Override
     public Event parseEvent(String event) {
          final String regex = "(\\\"[^\\\"]+\\\")|\\S+";
          final Pattern pattern = Pattern.compile(regex,
            Pattern.MULTILINE);
          final Matcher matcher = pattern.matcher(event);
          var fields = new ArrayList<>();
          while (matcher.find()) {
               fields.add(matcher.group(0));
          }
          var timestamp =
            LocalDateTime.parse(matcher.group(0),
              formatter).atOffset(ZoneOffset.UTC);
          var id = EventId.of(matcher.group(1));
          var protocol =
            Protocol.valueOf(matcher.group(2));
          var activity = new Activity(matcher.group(3),
            matcher.group(5));
          return new Event(timestamp, id, protocol,
            activity);
     }
}

分割解析策略看起来更简单,如下所示:

public class SplitEventParser implements EventParser{
          @Override
     public Event parseEvent(String event) {
          var fields = Arrays.asList(event.split(" "));
          var timestamp =
            LocalDateTime.parse(fields.get(0),
              formatter).atOffset(ZoneOffset.UTC);
          var id = EventId.of(fields.get(1));
          var protocol = Protocol.valueOf(fields.get(2));
          var activity = new Activity(fields.get(3),
            fields.get(5));
          return new Event(timestamp,id, protocol,
            activity);
     }
}

就像我们之前处理规范一样,EventParser 接口可以被转换为一个密封接口:

public sealed interface EventParser permits RegexEvent
  Parser, SplitEventParser {
/** Code omitted **/
}

不要忘记在RegexEventParserSplitEventParser类中包含final关键字。

现在,回到解析策略实现,请注意Event构造函数是用解析后的属性调用的。我们需要更新我们的Event实体类,使其能够与我们的策略一起工作。我们可以通过以下代码来实现:

public class Event implements Comparable<Event> {
     private final OffsetDateTime timestamp;
     private final EventId id;
     private final Protocol protocol;
     private final Activity activity;
     public Event(OffsetDateTime timestamp, EventId id,
       Protocol protocol, Activity activity) {
          this.timestamp = timestamp;
          this.id = id;
          this.protocol = protocol;
          this.activity = activity;
     }
     public static Event parsedEvent(String
       unparsedEvent, ParsePolicyType policy) {
          switch (policy){
            case REGEX -> new
            RegexEventParser().parseEvent(unparsedEvent);
            case SPLIT -> new
            SplitEventParser().parseEvent(unparsedEvent);
          }
     }
...
}

允许我们在策略之间进行选择的开关依赖于以下枚举:

public enum ParsePolicyType {
    REGEX,
    SPLIT
}

我们现在准备好创建一个具有检索网络事件方法的EventSearch服务类。这个领域服务将允许我们在检索事件时选择使用哪种解析算法。以下是实现此功能所需的代码:

public class EventSearch {
     public List<Event> retrieveEvents(List<String>
       unparsedEvents, ParsePolicyType policyType){
          var parsedEvents = new ArrayList<Event>();
          unparsedEvents.forEach(event →{
               parsedEvents.add(Event.parsedEvent(event,
          policyType));
          });
          return parsedEvents;
     }
}

现在我们已经熟悉了策略和规范模式,让我们看看将我们的业务规则建模在 POJOs 上的好处。

将业务规则定义为 POJOs

在那个时代,当企业开发强烈受到 Java 2 Platform, Enterprise Edition (J2EE)(今天被称为Jakarta EE)的影响时,有一种称为Enterprise JavaBeans (EJBs)的技术,负责从开发者手中接管所有管理软件开发管道活动(涉及事务管理、安全和对象生命周期)的重型工作。EJB 的承诺是开发者可以集中精力开发业务功能,而 J2EE 容器将负责所有基础设施细节。EJBs 实现了这一承诺,但并非没有代价。在第一版中创建和维护 EJB 既耗时又无聊。有许多事情要做,涉及各种可扩展标记语言 (XML)配置和部署描述符,而且更糟糕的是,由于有太多的样板代码,几乎没有空间重用这些 EJB 对象。它们不像 POJOs 那样——简单且可重用。

第一版 EJB(特别是版本 2)的问题帮助我们激发了创建改进解决方案的动机,这些解决方案可以利用 POJOs 的简单性。在这些解决方案中,我们可以提到 EJB 3 以及从 Spring 和 Quarkus 等框架派生出来的技术。然而,所有这些技术共同的特点是激励和灵活性,以便与 POJOs 一起工作。

POJOs 很有吸引力,因为它们不过就是普通的 Java 对象。理解 POJO 很简单,因为我们只处理 Java 标准应用程序编程接口 (APIs),而不是自定义库和框架。这就是 POJOs 成为一类开发者友好对象的原因,它们更容易理解和在不同的应用程序部分之间重用。如果我们旨在开发容错性高的应用程序,那么使用 POJOs 总是推荐的做法,以减少与特定技术的耦合,允许应用程序在不同技术或框架之间切换而不会产生太多摩擦。

这种由 POJO 提供的灵活性允许它们在需要时同时参与不同的系统部门。例如,没有人阻止某人在事务性、持久性和用户展示上下文中使用相同的 POJO。我们还可以使用 POJO 来表示业务规则——本章中展示的实体、政策和规范对象是我们在 POJO 中体现业务规则的优秀例子。

通过使用 POJO 来建模业务规则,我们利用了与可重用性和简单性相关的所有好处。它们也与保持领域对象免受任何技术细节影响的重要目标相一致,这最终将有助于支持更灵活和清醒的设计的必要 SoC(分离关注点)努力。

摘要

本章中我们讨论的 DDD 主题对于开发六边形应用至关重要,因为正是通过使用 DDD 技术,我们才能塑造出一个解耦的、一致的、面向业务领域的六边形,这将成为应用和框架六边形的基础。

理解基础知识始终是至关重要的。通过更深入地研究主要的 DDD 概念,我们发现了一些基本技术,这些技术有助于我们开发领域六边形。我们讨论了如何创建纯净且相关的实体以及如何为它们分配身份。通过值对象,我们理解了它们在传达意义和增强问题域描述性方面的重要性。聚合体向我们展示了如何将相关的实体和值对象分组,以描述我们问题域中的整体操作。此外,我们还看到了聚合体如何确保与事务的一致性。

在学习聚合体之后,我们了解到领域服务让我们能够表达不适合放入实体或值对象中的行为,为了更好地组织业务规则,我们学习了策略和规范模式。最后,我们评估了 POJO 在定义业务规则时提供的可重用性和简单性的好处。通过本章探讨的思想和技术,我们现在可以构建一个领域六边形,它能够捕捉并正确地将影响整个应用行为的业务规则编码到代码中。

现在,我们已经准备好在阶梯上迈出更高的一步,进入应用六边形的领域,我们将看到如何通过用例和端口结合和编排业务规则来创建软件功能。

问题

  1. 实体与值对象相比,主要属性是什么?

  2. 值对象是否可以是可变的?

  3. 每个聚合体都必须有一个入口点对象,以便与其他由聚合体控制的对象进行通信。这个入口点对象的名称是什么?

  4. 领域服务是否允许调用其他六边形上的对象?

  5. 政策和规范之间有什么区别?

  6. 将业务规则定义为 POJO 有什么好处?

进一步阅读

  • 《实现领域驱动设计》(Vernon,2016 年)

  • 《领域驱动设计:软件核心的复杂性处理》(Evans,2003 年)

  • 《极限编程之道:拥抱变化》(Beck,1999 年)

答案

  1. 与值对象相反,实体具有一个身份。

  2. 不。值对象最重要的属性是其不可变性。

  3. 任何聚合的入口点对象被称为聚合根。

  4. 不,但来自其他领域和其他六边形的对象可以调用领域服务。

  5. 政策是一种模式,它将部分问题领域知识封装在代码块或算法中。规范是一种与谓词一起工作的模式,用于断言对象属性的合法性。

  6. 因为 POJO 不依赖于外部技术细节,例如外部库或框架提供的功能。相反,POJO 仅依赖于标准的 Java API,这使得 POJO 成为简单且易于重用的对象。POJO 对于创建不受技术细节模糊的业务规则对象很有帮助。

第三章:使用端口和用例处理行为

一旦我们在领域六边形中定义了业务规则,我们就可以开始考虑如何使用这些规则来创建软件功能,同时考虑系统将如何处理来自用户和其他应用程序的数据。端口和用例在六边形架构中解决了这些问题,我们需要协调系统数据和业务规则以提供有用的软件功能。

在本章中,我们将探讨如何使用用例来定义软件支持的行为。通过将输入和输出端口与用例相结合,我们将了解这些端口在建立六边形系统内部通信流程中的作用。

我们将涵盖以下主题:

  • 使用用例表达软件行为

  • 使用输入端口实现用例

  • 使用输出端口处理外部数据

  • 使用应用六边形自动化行为

“在本章结束时,您将能够使用端口和用例来协调六边形系统必须执行的所有操作以满足用户需求。”一旦您掌握了端口和用例的基本原理,您就可以利用它们将领域和应用六边形中的元素结合起来,构建强大的功能。

技术要求

要编译和运行本章中提供的代码示例,您需要在您的计算机上安装最新的Java SE 开发工具包Maven 3.8。它们都适用于 Linux、Mac 和 Windows 操作系统。

您可以在 GitHub 上找到本章的代码文件,链接为github.com/PacktPublishing/Designing-Hexagonal-Architecture-with-Java/tree/main/Chapter03

使用用例表达软件行为

软件系统不过是一组协同工作以实现用户或甚至其他软件系统定义的目标的行为。软件行为反过来又是一个值得的行动,单独或与其他软件行为结合,有助于实现一个值得的软件目标。这些目标与感兴趣的用户或系统表达出的愿望密切相关。

我们可以将那些感兴趣的各方归类为利益相关者或参与者,我们将从他们那里最终推导出将转化为目标的真实世界需求。这些参与者的目标将通过讨论的系统SuD)或简单地说是您正在开发的软件来实现。

从六边形架构的角度来看,我们可以将这些参与者与我们之前在第一章中看到的联系起来,即为什么选择六边形架构?,当时讨论了驱动和被驱动操作。同样,我们可以对 SuD 参与者进行分类:驱动参与者是一个触发 SuD 行为的人或系统,而被驱动参与者是 SuD 消耗的外部系统。

为了在功能和非功能术语中表达系统应该做什么,像 Ivar Jacobson 和 Alistair Cockburn 这样的人以及敏捷社区普遍做出了贡献,他们开发了有用的技术,将业务需求转化为有意义的书面描述,说明系统应该如何表现。在这些技术中,最突出的是用例技术。

与 UML 不同,UML 通过图之间的关系展示系统的高级视图,而用例通过提供对 SuD 行为的详细书面描述进行更深入的挖掘。用例是一种宝贵的技巧,用于设定 SuD 目标、实现这些目标的方法或行为、可能出现的失败场景以及出现时应该采取的措施。当与 DDD 技术结合使用时,用例在弥合处理特定于应用程序的活动方面的差距方面发挥着重要作用——这些活动对 SuD——以及应用程序六边形——比领域六边形中的问题域及其业务规则更为重要。通过从用例的角度思考,我们朝着在六边形架构中提高关注点分离迈出了重要的一步。

我们可以通过简单地写下关于它们的描述来创建用例,但也可以通过代码来表达它们。接下来,我们将学习如何以书面和代码的形式创建用例。

如何创建用例

有一些创建书面用例的详细方法,您可以指定有关输入数据、可能的行为和用例结果的详细和标准化信息。Cockburn 将这些详细用例归类为全装用例。全装用例在新团队中可能很有帮助,因为人们不习惯一起工作。全装方法强制执行的规范有助于提供一条清晰的路径,说明如何构建用例。它有助于防止出现一个人可能考虑某些用例方面,而这些方面在其他人的用例中并不存在的情况。以下是一个全装用例的例子:

  • 参与者:基础设施工程师

  • 目标:向边缘路由器添加新的网络

  • 范围:基础设施部门

  • 触发器:一个特定的原因,通过不同的网络隔离网络访问

  • 输入数据:路由器 ID、网络名称、地址和 CIDR

  • 操作

    1. 查找路由器 ID。

    2. 验证网络地址尚未存在。

    3. 验证 CIDR 是否不低于最低允许值。

    4. 如果前面的验证没有问题,将网络添加到已通知的路由器。

相反,我们还有不那么正式和随意的用例类型。非正式用例的主要特点是它们不遵循关于如何记录信息的标准。它们试图在一个或两个段落中传达尽可能多的意义,如下面的例子所述。

基础设施工程师向应用程序发送包含路由器 ID、网络名称、地址和 CIDR 的请求。应用程序在路由器 ID 中进行查找,然后验证网络尚未存在,接着验证 CIDR 值是否不低于最低允许值。如果所有验证都正常,则系统继续将网络添加到指定的路由器。

除了正式和非正式的书面技巧之外,还可以通过自动化测试直接在代码中表达用户意图。这种方法依赖于与发现、制定和自动化相关的行为驱动设计BDD)原则。在这种方法中,你开始与商业人士交谈,试图发现他们的需求。这个发现过程的输出包含描述业务需求的情况和行为示例。然后,你进入制定阶段,根据这些示例创建结构化文档。最后,自动化阶段是创建和执行测试以验证先前阶段中描述和结构化的行为。

在软件开发早期采用 BDD(行为驱动开发),我们有根据创建的示例和测试迭代创建用例的机会,以验证业务想法。

在 Cucumber 等工具的帮助下,我们可以在我们的六边形应用中采用 BDD 方法。为了将我们之前构建的书面用例转换为 Cucumber 功能文件,我们需要创建一个 Cucumber 功能文件:

@addNetworkToRouter
Feature: Add network to a router
I want to be able to add a network to an existent router
Scenario: Adding a network to an existent router
Given I provide a router ID and the network details
When I found the router
And The network address is valid and doesn't already exist
And The CIDR is valid
Given, When, And, and Then terms from the feature files, we need to create a test class to automate the validation of our use case steps:

public class AddNetworkStepsTest {

private RouterId routerId;

private Router router;

private RouterNetworkFileAdapter routerNetworkFileAdapter

= RouterNetworkFileAdapter.getInstance();

Network network = new Network(new IP("20.0.0.0"),

"营销", 8);

/** 代码省略 **/

}


 First, we have to declare the types and initialize the objects we will use to perform our tests. In the preceding code, we declared the `RouterId` and `Router` types. Then, we initialized the `RouterNetworkFileAdapter` and `Network` instances.
After preparing the resources we need to test, we can start by implementing the first step of our test:

@Given("我提供一个路由器 ID 和网络详情")

public void obtain_routerId() {

this.routerId = RouterId.withId(

"ca23800e-9b5a-11eb-a8b3-0242ac130003");

}


 The `@Given` annotation describes the retrieval of `RouterId`. We can use this ID to fetch a router:

@When("我找到了路由器")

public void lookup_router() {

router =

routerNetworkFileAdapter.fetchRouterById(routerId);

}


 By using `RouterNetworkFileAdapter` and `RouterId`, we retrieve a `Router` object. Next, we can check whether the `Network` object meets the desired requirements before adding it to the router:

@And(

"网络地址有效且尚未存在")

public void check_address_validity_and_existence() {

var availabilitySpec =

new NetworkAvailabilitySpecification(

network.getAddress(), network.getName(),

network.getCidr());

if(!availabilitySpec.isSatisfiedBy(router))

throw new IllegalArgumentException("地址已存在

exist");

}


 To ensure the network is valid, we must apply the rules from `NetworkAvailabilitySpecification`. Next, we must check the network CIDR:

@Given("CIDR 有效")

public void check_cidr() {

var cidrSpec = new CIDRSpecification();

if(cidrSpec.isSatisfiedBy(network.getCidr()))

throw new IllegalArgumentException(

"CIDR 低于"+CIDRSpecification.

MINIMUM_ALLOWED_CIDR);

}


 As the last verification step, we must apply the rules from `CIDRSpecification`. If everything is fine, then we can add the network to the router:

@Then("将网络添加到路由器")

public void add_network() {

router.addNetworkToSwitch(network);

}


 By calling the `addNetworkToSwitch` method from `Router`, we have added the network to the router.
The following is a visual representation of the formal, casual, and BDD-based types of use cases:
![Figure 3.1 – A use case for the topology and inventory network system](https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/dsn-hex-arch-java-2e/img/B19777_03_01.jpg)

Figure 3.1 – A use case for the topology and inventory network system
Fully dressed, casual, and BDD-based use cases express the same thing. The main difference lies not in the *what* but rather in *how* the three techniques achieve the same objective to describe system behavior. As we may expect, the best choice is conditioned to money, time, and organization constraints.
We could bypass this use case creation/process and go straight on to code the use case. Although I don’t consider the formal use case structuring part a required step, I certainly consider it a recommended one. By writing down and structuring the use case’s expected behaviors, we’re engaging in a valuable additional step to help us clarify and better organize our ideas regarding the use case’s arrangement. Once the structuring effort is made, we only need to translate that into its code counterpart.
What I propose in developing hexagonal applications is to design use cases as abstractions rather than implementations. I am using interfaces in these examples, but there is no problem using abstract classes. The following code shows a use case interface based on its written form:

public interface RouterNetworkUseCase {

路由器添加网络到路由器(RouterId routerId, 网络

network);

}


 We define use cases as interfaces for three reasons:

*   To provide different ways of fulfilling the use cases’ goals
*   To allow dependency on abstraction rather than implementation
*   For governance of APIs

The role of use cases in the hexagonal architecture is that they allow us to implement input ports. It’s through input ports that we construct the logic that will, for example, call Domain hexagon services, other use cases, and external resources through output ports. The UML representation of the use case and its input port is as follows:
![Figure 3.2 – A use case for the topology and inventory network system](https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/dsn-hex-arch-java-2e/img/B19777_03_02.jpg)

Figure 3.2 – A use case for the topology and inventory network system
Now that we know how to create use cases, both in written and code form, let’s explore the ways to implement use cases with input ports.
Implementing use cases with input ports
In the hexagonal architecture, there is this idea about driving and driven operations. We’ve seen that such classification is also valid to determine which actors interact with the hexagon system. Driving actors are the ones who send requests to the application, while the driven actors represent the external components accessed by the application. We use **input ports** – also known as **primary ports** – to allow the communication flow between driving actors and the driving operations exposed by a hexagonal system. Use cases tell us what behaviors the application will support, while input ports tell us how such behaviors will be performed.
Input ports play an integrating role because they are like pipes that allow the data to flow from driving actors when they hit the hexagonal system through one of its adapters on the Framework hexagon. In the same vein, input ports provide the pipes for communication with business rules from the Domain hexagon. Through input ports, we also orchestrate communication with external systems through output ports and adapters.
Input ports are at the crossroads of a hexagonal system, helping translate what comes from the outside and goes in the direction of the Domain and Application hexagons. Input ports are also essential in orchestrating communication with external systems. In the following diagram, we can see how **Application Hexagon** is the integration point between **Driving Actor** and **Driven Actor** and their respective input and output ports and adapters:
![Figure 3.3 – The various ports and the Application hexagon](https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/dsn-hex-arch-java-2e/img/B19777_03_03.jpg)

Figure 3.3 – The various ports and the Application hexagon
In the previous section, we defined a use case interface describing an operation that allowed us to add a network to a router. Let’s learn how to create an input port by implementing that use case:

public class RouterNetworkInputPort implements RouterNet

workUseCase {

private final RouterNetworkOutputPort

routerNetworkOutputPort;

public RouterNetworkInputPort(RouterNetworkOutputPort

routerNetworkOutputPort){

this.routerNetworkOutputPort =

routerNetworkOutputPort;

}

@Override

public Router addNetworkToRouter(RouterId routerId, Network

network) {

var router = fetchRouter(routerId);

return createNetwork(router, network);

}

private Router fetchRouter(RouterId routerId) {

return

routerNetworkOutputPort.fetchRouterById(routerId);

}

private Router createNetwork(Router router, Network net

work) {

var newRouter =

NetworkOperation.createNewNetwork(router, network);

return persistNetwork(router) ? newRouter : router;

}

private boolean persistNetwork(Router router) {

return routerNetworkOutputPort.persistRouter(router);

}

}


 With this input port implementation, we have a clear view of what actions the software must perform to fulfill the use case’s goal of adding a network to the router. Before we look closer at the input port methods, let’s consider the `RouterNetworkOutputPort` interface’s declaration:

public interface RouterNetworkOutputPort {

Router fetchRouterById(RouterId routerId);

boolean persistRouter(Router router);

}


 This output port states that the application intends to obtain and persist data from external sources. The hexagon system is not aware of whether the external source is a database, a flat file, or another system. Here, we only state the intention to get data from outside.
The `addNetworkToRouter` method, which returns a `Router` object, is the only public method that’s exposed by the input port. We make all other methods private because they are not supposed to be used outside the context of this input port. The input port starts its job by using `RouterId` to retrieve a `Router` object; then, it creates a new `Network` object on that `Router` object. Remember, the `Network` object comprises the address, name, and CIDR attributes, as expressed in the use case’s written form. The `fetchRouter` method will try to obtain a `Router` object by passing a `RouterId` ID to the output port’s `fetchRouterById` method. That’s when the input port will need to coordinate an external call that will be carried out by an output adapter that implements the output port.
If everything goes well, the input port will receive the desired `Router` object and will be able to create a network object and add it to the informed router. At this point, the input port is interacting with a Domain service called `createNewNetwork`. This service works under the constraints imposed by business rules from the Domain hexagon. Finally, the input port coordinates the persistence of the whole operation through the `persistRouter` method from the output port.
This input port does not contain anything specific to the problem domain. Its primary concern is to handle data by orchestrating internal calls with Domain services and external calls with output ports. The input port sets the operation’s execution order and provides the Domain hexagon with data in a format it understands.
External calls are interactions that are performed by the hexagonal application to get data from or persist data to external systems. This is the subject of the next section, where we’ll learn how to use output ports to deal with things living outside the application.
Using output ports to deal with external data
**Output ports**, also known as **secondary ports**, represent the application’s intent to deal with external data. It’s through output ports that we prepare the system to communicate with the outside world. By allowing this communication, we can associate output ports with driven actors and operations. Remember, driven actors are external systems, while driven operations are used to communicate with such systems.
I say that we’re preparing the hexagonal application to communicate with the outside world because, at the Application hexagon level, we don’t know how that communication will occur yet. This approach is based on Uncle Bob’s wise advice to postpone, as much as possible, any decisions concerned about which technologies will be used to fulfill the application’s needs. By doing that, we’re putting more emphasis on the problem domain than on technological details. I’m not saying that the persistence or messaging mechanisms, for example, are not relevant enough to influence the application’s design. Instead, the idea is to not let external technologies dictate how the application is designed.
In the early stages of a software project, it’s not uncommon to see people discussing whether to use PostgreSQL or Oracle databases for persistence, Kafka or Redis for pub-sub activities, and so on. Those types of discussions exert a strong influence on how the software solves business problems. Sometimes, it’s hard to imagine such software solving the same business problems but with different technologies. On certain occasions, it’s even inconceivable to consider such a thing because the whole application architecture is centered on specific technologies.
As people who work with technology, we’re always eager to use the hottest development framework or a modern programming language. That is a good attitude, and I think we should continuously pursue better techniques and sophisticated ways to solve problems. But prudence is advised to properly balance our focus between the technology and problem domain aspects of a system.
It’s not only about repositories
You may be used to using terms such as repository or **data access object** (**DAO**) to describe application behaviors related to persistence in a database. In hexagonal applications, we replace repositories with output ports.
Repositories are often associated with database operations, a fact that, by the way, is also enforced by some development frameworks that formalize this association through persistence features offered by the framework. A recurring example of this approach is similar to the following code:

public interface PasswordResetTokenRepository extends

JpaRepository<PasswordResetToken, Long> {

PasswordResetToken findByToken(String token);

PasswordResetToken findByUser(User user);

Stream

findAllByExpiryDateLessThan(Date now);

void deleteByExpiryDateLessThan(Date now);

@Modifying

@Query(«delete from PasswordResetToken t where

t.expiryDate <= ?1")

void deleteAllExpiredSince(Date now);

}


 The usage of the `JpaRepository` interface and the `@Query` annotation from the Spring framework reinforces the notion that the password data will come from a relational database. This situation could also be seen as a leaking abstraction condition because our `PasswordResetTokenRepository` interface would also contain all the methods inherited from the `JpaRepository` class that may not be relevant or provide behaviors that don’t suit the system’s needs.
The underlying idea about output ports is that we’re not inferring that persistence or any kind of external communication will occur with a database system. Instead, the output port’s scope is broader. Its concern is with communicating with any system, be it a database, a messaging system, or a local or network filesystem, for example.
A more hexagonal approach to the password reset interface shown previously would look something like the following code:

public interface PasswordResetTokenOutputPort {

PasswordResetToken findByToken(String token);

PasswordResetToken findByUser(User user);

Stream

findAllByExpiryDateLessThan(Date now);

void deleteByExpiryDateLessThan(Date now);

void deleteAllExpiredSince(Date now);

}


 By not extending types from a specific framework and avoiding the usage of annotations such as `@Query`, we’re turning the output port into a POJO. The usage of annotations per se is not a problem. The issue lies more in the purpose of their usage. If the aim is to use annotations to implement features that only exist in a particular framework, we are then coupling the software to that framework. Instead, if the purpose is to use annotations to implement features based on Java standard specifications, we are making a valuable effort to make the software more tolerant to change.
The data that’s obtained from an output port today may come directly from a relational database. Tomorrow, this same data can be obtained from the REST API of some application. Those details are not necessary from the Application hexagon’s perspective because the components in this hexagon are not concerned with how the data is obtained.
Their main concern is in expressing what kind of data they need to conduct their activities. The way those Application hexagon components define what data they need is based on the entity and value objects from the Domain hexagon. With this arrangement, where an output port states what type of data it needs, we can plug multiple adapters into the same output port. So, these adapters carry out the necessary tasks to obtain the data, as expressed by the output port. This flow is shown in the following diagram:
![Figure 3.4 – The output port and its adapters](https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/dsn-hex-arch-java-2e/img/B19777_03_04.jpg)

Figure 3.4 – The output port and its adapters
The output port’s main goal is to state what kind of data it needs without specifying how it will get that data. That’s the reason why we define them as interfaces and not implementations. The implementation part is reserved for output adapters, an essential hexagonal architecture component that we’ll look at in the next chapter. To conclude our analysis of output ports, let’s explore where they should be used.
Where to use output ports
At the beginning of this chapter, we learned how use cases establish the necessary actions to accomplish something useful in the application. Among these actions, there may be situations that require us to interact with external systems.
So, the reason to create and utilize output ports will be derived from the activities performed by use cases. In code, the reference for an output port will not appear in the use case’s interface declaration. The usage of output ports is made explicit when we implement the use case with an input port. That’s what we did when we implemented `RouterNetworkUseCase` and declared a `RouterNetworkOutputPort` attribute at the beginning of `RouterNetworkInputPort`:

public class RouterNetworkInputPort implements RouterNet

workUseCase {

private final RouterNetworkOutputPort

routerNetworkOutputPort;

public RouterNetworkInputPort(RouterNetworkOutputPort

routerNetworkOutputPort){

this.routerNetworkOutputPort =

routerNetworkOutputPort;

}

private Router fetchRouter(RouterId routerId) {

return routerNetworkOutputPort.fetchRouterById

(routerId);

}

private boolean persistNetwork(Router router) {

return routerNetworkOutputPort.persistRouter

(router);

}

}


 You may be wondering when and how the instance for an output port is created. The previous example shows one approach, where the input port constructor receives a reference for an output port object. This object will be an implementation provided by an output adapter.
Among the operations defined by a use case and implemented by an input port, some operations are responsible for getting data from or persisting data to external sources. That’s where output ports come in: to provide the data required to fulfill the use case’s goal.
In the same way that a use case goal is used to represent a piece of software’s intent, without saying how this intent will be realized, output ports do the same thing by representing what kind of data the application needs, without needing to know how that data will be obtained. Output ports, along with input ports and use cases, are the hexagonal architecture components that support the automation effort that characterizes the Application hexagon. We’ll examine this in the next section.
Automating behavior with the Application hexagon
**Automation** is one of the most valuable things software can do. The advent of computation brought radical changes to how people solve their problems. An interesting scenario is that of the credit card industry in its early years. When banks started to offer credit cards to their customers, most of the back-office activities were done manually. If you wanted to pay for something with a credit card, the person in the store would need to call their bank, who, in turn, would need to contact your card issuer to confirm you had credit. As the technology evolved, computer systems were able to automate this credit verification process.
If we decided to use the hexagonal architecture to build a credit card verification system, those required steps to confirm the cardholder’s credit could be expressed using a use case. With an input port, we could handle business rules and all the data necessary to achieve the use case goal, consuming, if necessary, external systems through an output port. When we put all those activities together, the fundamental role of the Application hexagon in automating those activities to fulfill the system’s intent becomes more apparent. Here’s a code example to illustrate how the credit verification process would look in the Application hexagon:

1.  We start by creating a `CreditCard` entity class:

    ```

    public class CreditCard {

    /** 代码省略 **/

    double availableCredit;

    public boolean

    isAvailableCreditGreaterOrEqualThan(

    double transactionAmount) {

    return  availableCredit>=transactionAmount;

    }

    }

    ```java

    The preceding code only emphasizes the credit availability aspect. So, we have the `availableCredit` attribute and the `isAvailableCreditGreaterOrEqualThan` method to check that there’s enough credit for a given transaction.

     2.  Then, we declare the `CreditCheckUseCase` interface:

    ```

    public interface CreditCheckUseCase {

    boolean hasEnoughCredit(String cardId, double

    transactionAmount);

    }

    ```java

    The goal is to check whether the credit card has enough credit for the transaction amount. To do so, we expect the `cardId` and `transactionAmount` attributes. We intend to use `cardId` to get credit card data from somewhere. So, having an output port is required to get data from other places.

     3.  Here, we declare `CreditCheckOutputPort`:

    ```

    public interface CreditCheckOutputPort {

    CreditCard getCreditCard(String cardId);

    }

    ```java

    This is a straightforward output port where we pass the `cardId` attribute and expect the `CreditCard` object to contain, among other things, how much credit is available.

     4.  Suppose credit card data is stored in a MySQL database. We would need an output adapter that implements the previously defined output port:

    ```

    public class CreditCheckMySQLOutputAdapter implements

    CreditCheckOutputPort {

    @Override

    public CreditCard getCreditCard(String cardId) {

    /** 代码省略 **/

    return creditCard;

    }

    }

    ```java

    Inside the `getCreditCard` method, we would probably have some sort of mapping mechanism to convert the data that’s retrieved from the database into the domain entity object – that is, `CreditCard`.

     5.  Finally, we can create the input port by implementing the `CreditCheckUseCase` interface:

    ```

    public class CreditCheckInputPort implements

    CreditCheckUseCase {

    CreditCheckOutputPort creditCheckOutputPort;

    @Override

    public boolean hasEnoughCredit(

    String cardId, double transactionAmount) {

    return

    getCreditCard(cardId)

    .isAvailableCreditGreaterOrEqualThan

    (transactionAmount);

    }

    private CreditCard getCreditCard(String cardId) {

    return creditCheckOutputPort

    .getCreditCard(cardId);

    }

    }

    ```java

    `CreditCheckInputPort` relies on `CreditCheckOutputPort` to get `CreditCard`, which is used in the `hasEnoughCredit` method, to check whether there is enough credit available.

One advantage of implementing the Application hexagon is that we don’t need to be specific about which technologies we should use to fulfill the automation needs of our system. Of course, it’s possible to add a fancy development framework to make our lives easier when handling certain activities–such as object life cycle management, which is provided by **Contexts and Dependency Injection** (**CDI**) mechanisms – but it’s that purist approach of not focusing on technological details that makes hexagon systems easier to integrate with different technologies.
As we continue exploring the possibilities offered by the hexagonal architecture, we’ll see that using a development framework is not a central point for software development. Instead, in hexagonal systems, frameworks are like ordinary utilitarian libraries that we use to strategically solve a specific problem.
Summary
In this chapter, we learned how to arrange the components that are responsible for organizing and building the features provided by the software. By looking into use cases, we grasped the fundamental principles to translate the behaviors that allow a system to meet users’ goals into code. We discovered how input ports play a central role by implementing use cases and acting as middlemen, intermediating the communication flow between internal and external things. With output ports, we can express the need for data from external sources without coupling the hexagonal system with specific technologies. Finally, by using use cases and input and output ports together, we saw how the Application hexagon supports the software’s automation effort.
By learning how to arrange things inside the Application hexagon, we can now combine business rules, entities, Domain services, use cases, and other components from both the Application and Domain hexagons to create fully fledged features in the hexagon application, ready to be integrated with different technologies. Such integration can be accomplished with the so-called adapters in the Framework hexagon. That’s what we will look at in the next chapter.
Questions
Answer the following questions to test your knowledge of this chapter:

1.  What is the purpose of use cases?
2.  Input ports implement use cases. Why do we have to do that?
3.  Where should output ports be used?
4.  What is the advantage of implementing the Application hexagon?

Further reading
To learn more about the topics that were covered in this chapter, take a look at the following resources:

*   *Writing Effective Use Cases* (Alistair Cockburn, 2000)
*   *Clean Architecture* (Robert Cecil Martin, 2017)

Answers
The following are the answers to this chapter’s questions:

1.  It’s to define software behaviors by establishing who the actors are and what features they expect from a system.
2.  Because in the hexagonal architecture, use cases are interfaces that state the supported software capabilities. Input ports, in turn, describe the actions that will enable those capabilities.
3.  Output ports appear inside input ports when it is necessary to interact with external systems.
4.  By implementing the Application hexagon, we’re supporting the overall hexagonal application’s effort to automate operations without relying on specific technologies to do so.

第四章:创建适配器以与外部世界交互

在软件开发过程中,存在一个时刻我们需要决定哪些技术将被系统所支持。在前面的章节中,我们讨论了技术选择不应该是开发六边形应用的主要驱动因素。实际上,基于这种架构的应用具有高度的可变性,使得系统可以尽可能少地摩擦与不同技术集成。这是因为六边形架构在代码的哪些部分与业务相关以及哪些部分与技术相关之间建立了一个清晰的边界。

在本章中,我们将探讨建立这种边界的六边形方法。我们将学习在需要设置技术或协议以使六边形应用能够与外部世界通信时,适配器所扮演的角色。

在本章中,我们将学习以下主题:

  • 理解适配器

  • 使用输入适配器以允许驱动操作

  • 使用输出适配器与不同的数据源进行通信

到本章结束时,你将了解如何使用输入适配器,结合输入端口,将相同的软件功能暴露给不同的技术进行工作。同样,你将学习到输出适配器在使应用在与不同的数据源技术通信时更加灵活方面的强大作用。

技术需求

要编译和运行本章中展示的代码示例,你需要最新的curljq。所有这些工具都可在LinuxMacWindows操作系统上使用。

你可以在 GitHub 上找到本章的代码文件,链接为github.com/PacktPublishing/-Designing-Hexagonal-Architecture-with-Java---Second-Edition/tree/main/Chapter04

理解适配器

在六边形架构中,适配器的作用与在面向对象语言中作为设计模式所使用的适配器不同。作为一个设计模式,我们使用适配器使两个不同类的接口兼容。在六边形架构中,我们使用适配器使系统能够与不同的技术或协议兼容。尽管适配器作为六边形架构概念或作为设计模式的作用可能不同,但可以正确地说,这两种方法具有相同的目的:使某物适应正确地嵌入到另一物中。

要理解适配器在六边形架构中扮演的角色,一个实用的类比是关于计算机的远程连接。每个现代操作系统都兼容远程连接协议。在过去(甚至在某些情况下,今天),使用Telnet打开到计算机的远程连接是常见的。随着时间的推移,出现了其他协议,如用于控制台连接的SSHRDP以及用于图形替代的虚拟网络计算VNC)。

这些协议仅定义了您如何访问操作系统,一旦您进入其中,您就可以执行命令并访问操作系统提供的功能。操作系统提供多个协议以允许远程连接并不罕见。这是好事,因为它扩大了通信的可能性。可能存在需要同时支持 Telnet 和 SSH 连接的情况,可能是因为有一个不寻常的客户,它只使用 Telnet。

通过使用前面的类比,我们可以将操作系统替换为使用Java(或任何其他编程语言)开发的程序,并将远程连接协议(如 SSH 和 Telnet)替换为基于 HTTP 的通信协议,如RESTgRPC。假设我们的 Java 应用程序是六边形的,这些应用程序提供的功能被组织为用例、端口和业务规则,这些规则来自应用程序领域六边形。如果您想使这些功能对 REST 和 gRPC 客户端都可用,您需要创建 REST 和 gRPC 适配器。用于公开应用程序功能的适配器称为输入适配器。为了将这些输入适配器连接到我们系统的其余部分,我们将输入端口与输入适配器关联,如下图所示:

图 4.1 – 适配器与端口之间的关系

图 4.1 – 适配器与端口之间的关系

我们可以定义输入适配器,以便用户和其他系统与应用程序进行交互。同样,我们也可以定义输出适配器,以将六边形应用程序生成的数据转换为外部系统可理解的形式。在这里,我们可以看到输入和输出适配器都位于框架六边形的端点:

图 4.2 – 输入和输出适配器的位置

图 4.2 – 输入和输出适配器的位置

在下一节中,我们将探讨如何使用输入适配器。

使用输入适配器来允许驱动操作

你可能之前听说过,如果我们能始终依赖的东西,那就是事情总是会变化。当我们谈论技术变革时,这个说法就更加明显了。我们生活在一个计算机不再像过去那样昂贵的时代。无论我们是在处理台式机、移动设备还是云计算,每年,计算机资源总体上变得更加便宜,并且对每个人来说都更加容易获得。这种可访问性意味着更多的人倾向于参与,并且可以参与软件开发项目。

这种不断增长的协作导致了新的编程语言、工具和开发框架的产生,以支持用更好和更现代的解决方案来解决人们问题的创造性努力。在这个创新和技术异构的背景下,大量的当前软件开发工作得以进行。在这种背景下开发软件时出现的一个问题是,面对不断的技术变革,一个系统将如何保持相关性和盈利性。如果一个系统被设计成将业务规则与技术细节交织在一起,那么在不进行重大重构的情况下,很难融入新技术。在六边形架构中,输入适配器是帮助我们使软件与不同技术兼容的元素。

输入适配器就像在前一节中提到的示例中提到的远程通信协议。这种比较是有效的,因为输入适配器的工作方式就像协议一样,定义了哪些技术被支持,作为访问六边形系统提供的功能的一种手段。输入适配器在六边形内部和外部之间划定了明确的边界,并执行我们所说的驱动操作。

从六边形外部来看,可能有用户或其他系统与六边形应用程序进行交互。我们已经了解到,这些用户和系统也被称为主要演员,在塑造应用程序用例方面发挥着关键作用。主要演员与六边形应用程序之间的交互是通过输入适配器进行的。这种交互由驱动操作定义。我们称它们为驱动操作,因为在六边形系统中,主要演员驱动着,即它们启动并影响系统的状态和行为。

当输入适配器组合在一起时,就形成了六边形应用程序的 API。因为输入适配器处于这个边界,它将六边形系统暴露给外部世界,因此它们自然成为任何希望与系统交互的人的接口。随着我们在本书的进展,我们将看到如何利用输入适配器的布局来结构和暴露应用程序 API,使用诸如Swagger等工具。

我们强调了适配器的特性,以使系统与不同的技术或协议兼容。一种更领域驱动设计(DDD)的方法建议使用适配器的其他目的。

在基于 DDD 的架构中,一个普遍关注的问题是关于将遗留系统的元素集成到新系统中。这种情况发生在遗留系统(其中包含相关知识的领域模型)解决了某些重要问题但同时也显示出其设计的不一致性时。你不想放弃遗留系统,但也不想让新系统的设计受到遗留系统设计的影响。为了应对这种情况,你可以采用 Vaughn Vernon 的 实现领域驱动设计 和 Eric Evans 的 领域驱动设计:软件核心的复杂性处理 中所提到的 反腐败层。这个层基于用于将遗留系统和新系统的有界上下文集成的适配器。在这种情况下,适配器负责防止新系统的设计受到遗留系统设计的影响。

虽然我们不在六边形架构中应用这种适配器的使用方式,但重要的是要意识到我们可以使用这种基于 DDD 的适配器方法来构建六边形系统。

我们了解到主要演员与六边形应用程序之间的连接是通过输入适配器实现的。现在让我们看看如何使输入适配器连接到系统中的其他六边形。

创建输入适配器

输入端口是我们实现用例的手段,它指定了输入端口如何执行操作以实现用例目标。输入端口对象需要接收 Jacobson (1992) 所称的 刺激 来执行其操作。这种刺激不过是一个对象调用另一个对象。输入端口对象通过输入适配器发送的刺激接收执行其操作所需的所有必要数据。然而,正是在这个阶段,可能会发生最终的转换,将输入数据转换为与领域六边形兼容的格式。

在上一章中,我们创建了一个用例来向路由器添加网络。为了实现用例目标,我们将创建两个输入适配器:一个用于通过 HTTP REST 进行通信的适配器,另一个用于命令行执行。在下面的 UML 图中,我们有一个 RouterNetworkAdapter 作为抽象父类,由 RouterNetworkRestAdapterRouterNetworkCLIAdapter 类扩展:

图 4.3 – 输入适配器的 UML 表示

图 4.3 – 输入适配器的 UML 表示

我们将定义一个适配器抽象基类,随后是两个实现,一个用于适配器从 HTTP REST 连接接收数据,另一个用于控制台 STDIN 连接。为了模拟对这些两个适配器的访问,我们将创建一个客户端类来启动应用程序。

基础适配器

让我们先定义 RouterNetworkAdapter 抽象基类:

public abstract class RouterNetworkAdapter {
    protected Router router;
    protected RouterNetworkUseCase;
    public Router addNetworkToRouter(
    Map<String, String> params){
        var routerId = RouterId.
               withId(params.get("routerId"));
        var network = new Network(IP.fromAddress(
               params.get("address")),
               params.get("name"),
               Integer.valueOf(params.get("cidr")));
        return routerNetworkUseCase.
               addNetworkToRouter(routerId, network);
    }
    public abstract Router processRequest(
                           Object requestParams);
}

这个基础适配器的想法是提供与适配器的对应输入端口通信的标准操作。在这种情况下,我们使用addNetworkToRouter适配器方法来接收构建RouterIDNetwork对象所需的参数,这些参数被用来启动用例操作,将网络添加到路由器。这些参数可能来自不同的来源,无论是通过 HTTP 请求还是通过 shell 控制台中的STDIN,但一旦到达addNetworkToRouter方法,它们都被以相同的方式处理。

我们不直接引用输入端口。相反,我们利用用例接口引用。这个用例引用由输入适配器的构造函数传递和初始化。

REST 输入适配器

现在我们已经定义了基础的RouterNetworkAdapter抽象类,我们可以继续创建 REST 适配器。我们首先定义RouterNetworkRestAdapter构造函数:

public RouterNetworkRestAdapter(RouterNetworkUseCase rout
  erNetworkUseCase){
    this.routerNetworkUseCase = routerNetworkUseCase;
}

我们使用RouterNetworkRestAdapter构造函数来接收和初始化RouterNetworkUseCase用例引用。

以下代码展示了客户端如何调用初始化这个RouterNetworkRestAdapter输入适配器:

RouterNetworkOutputPort outputPort = RouterNet
  workH2Adapter.getInstance();
RouterNetworkUseCase usecase = new RouterNetworkInput
  Port(outputPort);
RouterManageNetworkAdapter inputAdapter = new RouterNet
  workRestAdapter(usecase);

此处的意图是表达 REST 输入适配器需要一个 H2 内存数据库输出适配器。在这里,我们明确指出输入适配器需要哪个输出适配器对象来执行其活动。这可以被认为是一种纯方法,其中我们不使用基于框架的依赖注入技术,如CDI beans。稍后,所有这些适配器构造函数都可以移除,以使用来自框架如QuarkusSpring的依赖注入注解。

在定义了RouterNetworkAdapter构造函数之后,我们接着实现processRequest方法:

/**
* When implementing a REST adapter, the processRequest
  method receives an Object type parameter
* that is always cast to an HttpServer type.
*/
@Override
public Router (Object requestParams){
/** code omitted **/
    httpserver.createContext("/network/add", (exchange -> {
      if ("GET".equals(exchange.getRequestMethod())) {
       var query = exchange.getRequestURI().getRawQuery();
       httpParams(query, params);
       router = this.addNetworkToRouter(params);
       ObjectMapper mapper = new ObjectMapper();
       var routerJson = mapper.writeValueAsString(
       RouterJsonFileMapper.toJson(router));
       exchange.getResponseHeaders().
       set("Content-Type","application/json");
       exchange.sendResponseHeaders(
       200,routerJson.getBytes().length);
       OutputStream output = exchange.getResponseBody();
       output.write(routerJson.getBytes());
       output.flush();
      } else {
        exchange.sendResponseHeaders(405, -1);
      }
/** code omitted **/
}

此方法接收一个httpServer对象,用于创建接收/network/add路径的GET请求的 HTTP 端点。调用processRequest的客户端代码类似于以下摘录:

var httpserver = HttpServer.create(new InetSocket
  Address(8080), 0);
routerNetworkAdapter.processRequest(httpserver);

REST 适配器通过 HTTP 请求接收用户数据,解析请求参数,并使用它们来调用定义在RouterNetworkAdapter父类中的addNetworkToRouter

router = this.addNetworkToRouter(params);

记住,输入适配器负责将用户数据转换为触发输入端口所需的适当参数,这是通过使用其用例引用来完成的:

routerNetworkUseCase.addNetworkToRouter(routerId, network);

在这个时候,数据离开框架六边形,并进入STDIN

CLI 输入适配器

要创建第二个输入适配器,我们再次扩展基础适配器类:

public class RouterNetworkCLIAdapter extends RouterNetwork
  Adapter {
    public RouterNetworkCLIAdapter(
    RouterNetworkUseCase routerNetworkUseCase){
        this.routerNetworkUseCase = routerNetworkUseCase;
    }
/** code omitted **/
}

我们定义了RouterNetworkCLIAdapter构造函数,用于接收和初始化这个输入适配器所需的RouterNetworkUseCase用例。

对于 CLI 输入适配器,我们使用不同的输出适配器。而不是持久化内存数据库,这个输出适配器使用文件系统。

以下代码展示了客户端如何初始化RouterNetworkCLIAdapter输入适配器:

RouterNetworkOutputPort outputPort = RouterNetworkFileA
  dapter.getInstance();
RouterNetworkUseCase usecase = new RouterNetworkInput
  Port(outputPort);
RouterManageNetworkAdapter inputAdapter = new RouterNet
  workCLIAdapter(routerNetworkUseCase);

首先,我们获取一个 RouterNetworkOutputPort 输出端口引用。然后,使用该引用,我们检索一个 RouterNetworkUseCase 用例。最后,我们使用之前定义的用例获取 RouterNetworkAdapter

以下是我们为 CLI 适配器实现 processRequest 方法的步骤:

@Override
public Router processRequest(Object requestParams){
    var params = stdinParams(requestParams);
    router = this.addNetworkToRouter(params);
    ObjectMapper mapper = new ObjectMapper();
    try {
        var routerJson = mapper.writeValueAsString
                     (RouterJsonFileMapper.toJson(router));
        System.out.println(routerJson);
    } catch (JsonProcessingException e) {
        e.printStackTrace();
    }
    return router;
}

在 REST 适配器中,我们有 httpParams 方法从 HTTP 请求中检索数据。现在,在 CLI 适配器的 processRequest 中,我们有 stdinParams 方法从控制台检索数据。

来自 REST 和 CLI 适配器的 processRequest 方法在处理输入数据方面存在差异,但它们有一个共同点。一旦它们将输入数据捕获到 params 变量中,它们都会调用从适配器基类继承的 addNetworkToRouter 方法:

router = this.addNetworkToRouter(params);

从这一点开始,数据遵循与 REST 适配器场景中描述的相同流程,其中输入适配器通过用例接口引用调用输入端口。

现在我们已经完成了 REST 和 CLI 输入适配器的创建,让我们看看如何调用这些适配器。

调用输入适配器

这里是控制选择哪个适配器的客户端代码:

public class App {
/** code omitted **/
    void setAdapter(String adapter) {
        switch (adapter){
            case "rest" -> {
                outputPort =
                RouterNetworkH2Adapter.getInstance();
                usecase =
                new RouterNetworkInputPort(outputPort);
                inputAdapter =
                new RouterNetworkRestAdapter(usecase);
                rest();
            }
            default -> {
                outputPort =
                RouterNetworkFileAdapter.getInstance();
                usecase =
                new RouterNetworkInputPort(outputPort);
                inputAdapter =
                new RouterNetworkCLIAdapter(usecase);
                cli();
            }
        }
    }
}

如果我们在执行程序时传递 rest 作为参数,switch-case 条件将创建一个 REST 适配器实例并调用 rest 方法:

private void rest() {
    try {
        System.out.println("REST endpoint listening on
                           port 8080...");
        var httpserver = HttpServer.create(
        new netSocketAddress(8080), 0);
        routerNetworkAdapter.processRequest(httpserver);
    } catch (IOException e){
        e.printStackTrace();
    }
}

然后,rest 方法反过来调用 REST 输入适配器的 processRequest 方法。

否则,如果我们在执行程序时传递 cli 参数,switch-case 将默认创建一个 CLI 适配器并调用 cli 方法:

private void cli() {
    Scanner = new Scanner(System.in);
    routerNetworkAdapter.processRequest(scanner);
}

cli 方法随后调用 CLI 输入适配器的 processRequest 方法。

调用输入适配器的步骤如下:

  1. chapter4 目录中的 GitHub 代码示例中,你可以通过运行以下命令来编译应用程序:

    .jar file with the rest parameter:
    
    

    $ java -jar target/chapter04-1.0-SNAPSHOT-jar-with-dependencies.jar rest

    发送 GET 请求以创建和添加网络:

    .jar file with no parameters:
    
    

    $ java -jar target/chapter04-1.0-SNAPSHOT-jar-with-dependencies.jar cli

    请告知路由器 ID:

    ca23800e-9b5a-11eb-a8b3-0242ac130003

    请告知 IP 地址:

    40.0.0.0

    请告知网络名称:

    金融

    请告知 CIDR:

    8

    
    
    
    

应用程序将要求你指定路由器 ID 和其他网络附加详细信息以调用 CLI 适配器。在这里,我们使用了与调用 REST 适配器相同的数据。

在本节中,我们学习了如何使用输入适配器来公开六边形应用程序功能。通过首先定义一个基本输入适配器,我们扩展了它以创建用于 HTTP 请求的 REST 适配器和用于控制台/STDIN 请求的 CLI 适配器。这种安排帮助我们理解输入适配器在探索以不同方式访问六边形系统中相同功能的基本作用。

输入适配器是我们进入所有六边形应用程序可以提供的功能的门户。通过输入适配器,我们可以轻松地使系统通过不同的技术变得可访问,而不会干扰业务逻辑。同样地,我们可以使六边形应用程序与不同的数据源进行通信。我们将在下一节中看到,这是通过输出适配器实现的。

使用输出适配器与不同的数据源进行通信

面向对象系统的特点在于其将数据和行为视为紧密相关的事物。这种紧密性恰好模仿了现实世界中的事物。无论是生物还是非生物,都具有属性,并能执行或成为某些动作的目标。对于刚开始学习面向对象编程的人来说,我们提供了例如汽车这样的例子,它有四个轮子并能驾驶——轮子是数据,驾驶是行为。这样的例子表达了这样一个基本原理:数据和行为不应被视为独立的事物,而应在我们所说的对象内部统一。

这种对象理念为过去几十年中庞大而复杂的系统的发展奠定了基础。这些系统中的很大一部分是在企业环境中运行的商业应用程序。面向对象范式征服了企业开发,因为其高级方法使得人们在创建软件以解决商业问题时更加高效和精确。过程范式对于企业的需求来说既繁琐又过于底层。

除了面向对象的语言之外,企业软件还依赖于获取和持久化数据的方法。很难想象一个没有与数据库、消息队列或文件服务器等数据源集成的系统,例如。存储东西的需求始终存在于计算中。然而,问题在于这种需求如何影响和决定了整个软件结构。随着关系数据库管理系统(RDBMS)的出现,也出现了通过模式正式化数据的要求。然后,这些模式作为建立数据关系以及应用程序如何处理这些关系的参考。过了一段时间,人们开始寻找替代方案以避免 RDBMSs 强加的正式化和严格的规范化原则。问题本身并不在于正式化,而在于在不必要的情况下使用 RDBMSs。

作为关系型数据库管理系统(RDBMS)的替代,出现了NoSQL数据库,提出了一种不依赖于表、列和模式作为数据组织手段的数据存储方式。NoSQL 方法提供了基于文档、键值存储、宽列存储和图的不同数据存储技术。不受 RDBMS 方法的限制,软件开发者开始使用这些 NoSQL 技术以更好地满足业务需求,并避免依赖于 RDBMS 的繁琐解决方案,因为当时没有替代品。

除了数据库之外,其他数据源也被用来满足软件处理数据的需求。文件系统、消息代理、基于目录的存储(LDAP)和主机存储,仅举几例,这些都是软件可以处理数据的方式。在云计算的世界里,将系统与不同的技术集成以发送或接收数据变得越来越自然。这种集成在软件开发中带来了一些挑战,因为系统现在需要理解并使自己在异构技术环境中易于理解。这种情况在如微服务这样的架构中变得更加严重,这些架构促进了这种异构性。为了应对这一挑战,我们需要克服技术异构环境挑战的技术。

在上一节中,我们看到了我们可以将多个输入适配器插入到同一个输入端口。这也适用于输出适配器和端口。接下来,我们将看到如何创建输出适配器并将它们插入到六边形系统的输出端口。

创建输出适配器

与输入适配器一起,输出适配器是构成框架六边形的第二个组件。在六边形架构中,输出适配器的角色是处理驱动操作。记住,驱动操作是由六边形应用程序本身发起的,以与外部系统交互发送或接收一些数据。这些驱动操作通过用例来描述,并由用例输入端口实现中的操作触发。每当用例声明需要处理存在于外部系统中的数据时,这意味着六边形应用程序至少需要一个输出适配器和端口来满足这些要求。

我们了解到,存在于应用程序六边形中的输出端口以抽象的方式表达与外部系统的交互。反过来,输出适配器有责任具体描述这些交互将如何发生。通过输出适配器,我们决定系统将使用哪些技术来实现数据持久性和其他类型的外部集成。

到目前为止,我们只讨论了基于我们在领域六边形中创建的领域模型表达的需求的数据。毕竟,是来自领域六边形的领域模型驱动了整个六边形系统的形状。技术问题只是必须遵守领域模型的细节,而不是相反。

通过在应用六边形中使用输出端口作为接口,以及在框架六边形中使用输出适配器作为该接口的实现,我们正在构建一个支持不同技术的六边形系统。在这个结构中,框架六边形中的输出适配器必须符合应用六边形中的输出端口接口,而应用六边形反过来必须依赖于来自领域六边形的领域模型。

在上一节中,你可能已经注意到了两种不同的输出适配器的使用 – RouterNetworkH2Adapter 适配器用于处理内存数据库中的数据,以及 RouterNetworkFileAdapter 适配器用于从本地文件系统中读取和持久化文件。这两个输出适配器是我们创建在应用六边形中的输出端口的实现:

图 4.4 – 输出适配器的 UML 表示

图 4.4 – 输出适配器的 UML 表示

我们将首先实现 RouterNetworkH2Adapter。它使用 H2 内存数据库来设置所有必需的表和关系。这个适配器实现展示了如何将领域模型数据适配到关系型数据库中。然后,我们继续实现 RouterNetworkFileAdapter,它使用基于 JSON 文件的数据库结构。H2 和 JSON 文件实现都是基于我们一直在工作的拓扑和库存样本系统提供的数据。这两个适配器将允许以两种方式将附加网络连接到现有的交换机:

图 4.5 – 带有财务网络的拓扑和库存系统

图 4.5 – 带有财务网络的拓扑和库存系统

使用上一节中相同的输入数据,我们将使用两种可用的输出适配器之一,将 Finance Network 连接到来自 Edge RouterLayer 3 Switch

H2 输出适配器

在实现 H2 输出适配器之前,我们首先需要定义拓扑和库存系统的数据库结构。为了确定这个结构,我们创建了一个包含以下 SQL 语句的 resources/inventory.sql 文件:

CREATE TABLE routers(
    router_id UUID PRIMARY KEY NOT NULL,
    router_type VARCHAR(255)
);
CREATE TABLE switches (
    switch_id UUID PRIMARY KEY NOT NULL,
    router_id UUID,
    switch_type VARCHAR(255),
    switch_ip_protocol VARCHAR(255),
    switch_ip_address VARCHAR(255),
    PRIMARY KEY (switch_id),
    FOREIGN KEY (router_id) REFERENCES routers(router_id)
);
CREATE TABLE networks (
    network_id int NOT NULL PRIMARY KEY AUTO_INCREMENT,
    switch_id UUID,
    network_protocol VARCHAR(255),
    network_address VARCHAR(255),
    network_name VARCHAR(255),
    network_cidr VARCHAR(255),
    PRIMARY KEY (network_id),
    FOREIGN KEY (switch_id) REFERENCES switches(switch_id)
);
INSERT INTO routers(router_id, router_type) VALUES('ca23800e-9b5a-11eb-a8b3-0242ac130003', 'EDGE');
INSERT INTO switches(switch_id, router_id, switch_type, switch_ip_protocol, switch_ip_address)
VALUES('922dbcd5-d071-41bd-920b-00f83eb4bb46', 'ca23800e-9b5a-11eb-a8b3-0242ac130003', 'LAYER3', 'IPV4', '9.0.0.9');
INSERT INTO networks(switch_id, network_protocol, network_address, network_name, network_cidr)
VALUES('922dbcd5-d071-41bd-920b-00f83eb4bb46', 'IPV4', '10.0.0.0', 'HR', '8');
INSERT INTO networks(switch_id, network_protocol, network_address, network_name, network_cidr)
VALUES('922dbcd5-d071-41bd-920b-00f83eb4bb46', 'IPV4', '20.0.0.0', 'Marketing', '8');
INSERT INTO networks(switch_id, network_protocol, network_address, network_name, network_cidr)
VALUES('922dbcd5-d071-41bd-920b-00f83eb4bb46', 'IPV4', '30.0.0.0', 'Engineering', '8');

虽然 switchesnetworks 有主键,但我们把交换机视为实体,而 networks 视为领域模型中 Router 实体的组成部分。我们正在将我们的模型强加于技术安排,而不是相反。

我们在领域模型中不使用来自switchesnetworks表的这些主键作为参考。相反,我们使用router_id值来关联Router实体与其SwitchNetwork值对象及其相应的数据库表。这种关联使得可以形成一个聚合,其中Router是聚合根,而SwitchNetwork是用于组成聚合的对象。

现在,我们可以继续实现RouterNetworkOutputPort来创建RouterNetworkH2Adapter类:

public class RouterNetworkH2Adapter implements RouterNet
  workOutputPort {
     private static RouterNetworkH2Adapter instance;
     @PersistenceContext
     private EntityManager em;
     private RouterNetworkH2Adapter(){
          setUpH2Database();
     }
     @Override
     public Router fetchRouterById(RouterId routerId) {
          var routerData = em.
              getReference(RouterData.class,
              routerId.getUUID());
          return RouterH2Mapper.toDomain(routerData);
     }
     @Override
     public boolean persistRouter(Router router) {
          var routerData = RouterH2Mapper.toH2(router);
          em.persist(routerData);
          return true;
     }
     private void setUpH2Database() {
          var entityManagerFactory = Persistence.
          createEntityManagerFactory("inventory");
          var em = entityManagerFactory.
          createEntityManager();
          this.em = em;
     }
/** code omitted **/
}

我们首先重写的方法是fetchRouterById,在这里我们接收routerId来使用我们的实体管理器引用从 H2 数据库中获取一个路由器。我们不能直接使用Router领域实体类映射到数据库。同样,我们也不能使用数据库实体作为领域实体。这就是为什么我们在fetchRouterById上使用toDomain方法来将 H2 数据库中的数据映射到领域。

我们执行相同的映射过程,使用persistRouter上的toH2方法将数据从领域模型实体转换为 H2 数据库实体。setUpH2Database方法在应用程序启动时初始化数据库。为了创建 H2 适配器的单个实例,我们使用getInstance方法定义一个单例:

public static RouterNetworkH2Adapter getInstance() {
    if (instance == null) {
        instance = new RouterNetworkH2Adapter();
    }
    return instance;
}

instance字段用于提供一个 H2 输出适配器的单例对象。请注意,构造函数调用setUpH2Database方法使用EntityManagerFactory创建数据库连接。为了正确配置实体管理器,我们创建一个包含设置 H2 数据库属性的resources/META-INF/persistence.xml文件:

<?xml version="1.0" encoding="UTF-8" ?>
<!-- code omitted -->
<property
     name="jakarta.persistence.jdbc.url"
          value="jdbc:h2:mem:inventory;
          MODE=MYSQL;
          DB_CLOSE_DELAY=-1;
          DB_CLOSE_ON_EXIT=FALSE;
          IGNORECASE=TRUE;
          INIT=CREATE SCHEMA IF NOT EXISTS inventory\;
          RUNSCRIPT FROM 'classpath:inventory.sql'" />
<!-- code omitted -->

记住,我们的领域模型是首要的,所以我们不想将系统与数据库技术耦合。这就是为什么我们需要创建一个直接映射到数据库类型的RouterData ORM类。在这里,我们使用EclipseLink,但你也可以使用任何JPA 兼容的实现:

@Getter
@AllArgsConstructor
@NoArgsConstructor
@Entity
@Table(name = "routers")
@SecondaryTable(name = "switches")
@MappedSuperclass
@Converter(name="uuidConverter", converterClass=
  UUIDTypeConverter.class)
public class RouterData implements Serializable {
    @Id
    @Column(name="router_id",
            columnDefinition = "uuid",
            updatable = false )
    @Convert("uuidConverter")
    private UUID routerId;
    @Embedded
    @Enumerated(EnumType.STRING)
    @Column(name="router_type")
    private RouterTypeData routerType;
    @OneToOne(cascade = CascadeType.ALL)
    @JoinColumn(table = "switches",
            name = "router_id",
            referencedColumnName = "router_id")
    private SwitchData networkSwitch;
}

我们使用@Getter@NoArgsConstructor@AllArgsConstructor注解来减少类的冗长性。我们将在以后使用获取器和构造函数将数据类转换为领域模型类。

使用@Table@SecondaryTable注解的目的是表示routersswitches表之间的关系。这种关系通过@OntToOne@JoinColumn注解进行映射,指定两个表必须通过router_id属性相互链接。

要在 EclipseLink 中使用UUID作为 ID,我们需要创建以下转换类:

public class UUIDTypeConverter implements Converter {
     @Override
     public UUID convertObjectValueToDataValue(Object
                    objectValue, Session session) {
          return (UUID) objectValue;
     }
     @Override
     public UUID convertDataValueToObjectValue(Object
                    dataValue, Session session) {
          return (UUID) dataValue;
     }
     @Override
     public boolean isMutable() {
          return true;
     }
     @Override
     public void initialize(
     DatabaseMapping mapping, Session session){
          DatabaseField field = mapping.getField();
          field.setSqlType(Types.OTHER);
          field.setTypeName("java.util.UUID");
          field.setColumnDefinition("UUID");
     }
}

这是我们在 RouterData 类顶部的 @Converter 注解中使用的类。如果没有这个转换器,将会抛出一个异常,指出在映射 routerId 属性时存在问题。在 routerId 声明之后,有一个名为 routerTypeRouterTypeData 属性。对于每个 ORM 属性,我们都在类名后添加 Data 后缀。除了 RouterData 之外,我们还将 RouterTypeDataSwitchData 做同样处理。记住,在领域模型中,等效的类型是 RouterRouterTypeSwitch

RouterTypeData 是我们存储路由类型的枚举:

@Embeddable
public enum RouterTypeData {
     EDGE,
     CORE;
}

@Embeddable 注解允许使用 @Embedded 注解将枚举数据映射到数据库中的 router_type 字段:

@Embedded
@Enumerated(EnumType.STRING)
@Column(name="router_type")
private RouterTypeData routerType;

作为最后一个 RouterData 字段,我们在 networkSwitch 变量中引用 SwitchData,我们使用它来创建路由和交换机之间的关系。让我们看看 SwitchData 类是如何实现的:

@Getter
@AllArgsConstructor
@NoArgsConstructor
@Entity
@Table(name = "switches")
@SecondaryTable(name = "networks")
@MappedSuperclass
@Converter(name="uuidConverter", converterClass=
  UUIDTypeConverter.class)
public class SwitchData implements Serializable {
    @Id
    @Column(name="switch_id",
            columnDefinition = "uuid",
            updatable = false )
    @Convert("uuidConverter")
    private UUID switchId;
    @Column(name="router_id")
    @Convert("uuidConverter")
    private UUID routerId;
    @Enumerated(EnumType.STRING)
    @Embedded
    @Column(name = "switch_type")
    private SwitchTypeData switchType;
    @OneToMany
    @JoinColumn(table = "networks",
            name = "switch_id",
            referencedColumnName = "switch_id")
    private List<NetworkData> networks;
    @Embedded
    @AttributeOverrides({
            @AttributeOverride(
                    name = "address",
                    column = @Column(
                            name = "switch_ip_address")),
            @AttributeOverride(
                    name = "protocol",
                    column = @Column(
                            name = "switch_ip_protocol")),
    })
    private IPData ip;
}

我们将应用于 RouterData 的相同技术也应用于 SwitchData。不过,有一个细微的差别,那就是 switchesnetworks 表之间建立的关系。为了创建这种关系,我们使用 @OneToMany@JoinColumn 注解,通过 switch_id 属性在 SwitchDataNetworkData 类型之间创建链接。由于 @OneToMany 注解,需要一个 NetworkData 对象列表的引用。

RouterDataType 类似,我们有 SwitchDataType,它是领域模型中 SwitchType 的枚举等效:

@Embeddable
public enum SwitchTypeData {
     LAYER2,
     LAYER3;
}

在拓扑和库存系统中,我们直接将网络附加到交换机上。要将领域值对象映射到 H2 数据库实体,我们实现 NetworkData 类:

@Getter
@AllArgsConstructor
@NoArgsConstructor
@Entity
@Table(name = "networks")
@MappedSuperclass
@Converter(name="uuidConverter", converterClass=
  UUIDTypeConverter.class)
public class NetworkData implements Serializable {
    @Id
    @Column(name="network_id")
    private int id;
    @Column(name="switch_id")
    @Convert("uuidConverter")
    private UUID switchId;
    @Embedded
    @AttributeOverrides({
            @AttributeOverride(
                    name = "address",
                    column = @Column(
                            name = "network_address")),
            @AttributeOverride(
                    name = "protocol",
                    column = @Column(
                            name = "network_protocol")),
    })
    IPData ip;
    @Column(name="network_name")
    String name;
    @Column(name="network_cidr")
    Integer cidr;
/** code omitted **/
}

NetworkData 中我们拥有的所有属性都与它的领域值对象对应物中存在的属性相同。唯一的区别是我们添加的注解,将其转换为数据库实体。

SwitchDataNetworkData 类都声明了 IPData 字段。在领域模型中,我们遇到类似的行为,其中 SwitchNetwork 类有一个 IP 属性。以下是实现 IPData 类的方法:

@Embeddable
@Getter
public class IPData {
    private String address;
    @Enumerated(EnumType.STRING)
    @Embedded
    private ProtocolData protocol;
    private IPData(String address){
        if(address == null)
            throw new IllegalArgumentException("Null IP
                          address");
        this.address = address;
        if(address.length()<=15) {
            this.protocol = ProtocolData.IPV4;
        } else {
            this.protocol = ProtocolData.IPV6;
        }
    }
    public IPData() {}
    public static IPData fromAddress(String address){
        return new IPData(address);
    }
}

ProtocolData 遵循其他基于枚举的类型所使用的相同模式:

@Embeddable
public enum ProtocolData {
     IPV4,
     IPV6;
}

我们可以争论说,创建所有这些类以将系统与数据库集成存在一些重复。这是真的。这是一个权衡,我们牺牲了可重用性以换取可变性,使应用程序能够更好地与 RDBMS 和其他数据源集成。

现在我们已经创建了所有 ORM 类以允许与 H2 数据库集成,我们需要将数据库对象转换为领域模型对象,反之亦然。我们通过创建一个具有映射方法的映射类来完成此操作。让我们从我们将数据库实体转换为领域实体的方法开始:

public static Router toDomain(RouterData routerData){
/** code omitted **/
    return new Router(routerType, routerId, networkSwitch);
}

toDomain 方法接收一个表示数据库实体的 RouterData 类型,并返回一个 Router 领域实体。

NetworkData 数据库实体对象列表转换为 Network 领域值对象列表时,我们使用 getNetworksFromData 方法:

private static List<Network> getNetworksFromData(List<Net
  workData> networkData){
    return networkData
            .stream()
            .map(network -> new Network(
                      IP.fromAddress(
                      network.getIp().getAddress()),
                      network.getName(),
                      network.getCidr()))
            .collect(Collectors.toList());
}

它接收一个 NetworkData 数据库实体对象列表并返回一个 Network 领域实体对象列表。然后,为了将领域模型实体转换为 H2 数据库实体,我们创建 toH2 映射方法:

public static RouterData toH2(Router router){
/** code omitted **/
return new RouterData(routerId, routerTypeData,
  switchData);
}

toH2 方法接收一个 Router 领域实体对象作为参数,以进行适当的映射,然后返回一个 RouterData 对象。

最后,为了将 Network 领域值对象列表转换为 NetworkData 数据库实体对象列表,我们有 getNetworksFromDomain 方法:

private static List<NetworkData> getNetworksFromDo
  main(List<Network> networks, UUID switchId){
    return  networks
             .stream()
             .map(network -> new NetworkData(
                    switchId,
                    IPData.fromAddress(
                    network.getAddress().getIPAddress()),
                    network.getName(),
                    network.getCidr()))
             .collect(Collectors.toList());
}

getNetworksFromDomain 方法接收一个 Network 领域值对象列表和一个 UUID 类型的开关 ID 作为参数。有了这些数据,该方法能够进行适当的映射,返回一个 NetworkData 数据库实体对象列表。

当我们需要将 H2 数据库对象转换为它的领域模型对应物时,使用 toDomain 静态方法:

@Override
public Router fetchRouterById(RouterId routerId) {
     var routerData = em.getReference(
     RouterData.class, routerId.getUUID());
     return RouterH2Mapper.toDomain(routerData);
}

当将领域模型实体作为 H2 数据库实体持久化时,我们使用 toH2 静态方法:

@Override
public boolean persistRouter(Router router) {
     var routerData = RouterH2Mapper.toH2(router);
     em.persist(routerData);
     return true;
}

fetchRouterByIdpersistRouter 方法是通过 RouterNetworkInputPort 对象调用的,使用 RouterNetworkOutputPort 接口引用:

private Router fetchRouter(RouterId routerId) {
     return routerNetworkOutputPort.
     fetchRouterById(routerId);
}
/** code omitted **/
private boolean persistNetwork(Router router) {
     return routerNetworkOutputPort.
     persistRouter(router);
}

记住,RouterNetworkOutputPort 是根据我们传递给 RouterNetworkInputPort 构造函数的参数在运行时解析的。使用这种技术,我们使六边形系统对它需要去哪里获取数据一无所知。它可以是关系数据库或 .json 文件,我们将在下一节中看到。

文件适配器

要创建文件适配器,我们可以应用创建 H2 数据库适配器时使用的相同想法,只需对适应基于文件的数据库源进行一些小的调整。这个数据源是一个包含用于创建先前数据库的相同数据的 .json 文件。因此,首先,你可以在 resources/inventory.json 创建一个 .json 文件,其内容如下:

[{
    "routerId": "ca23800e-9b5a-11eb-a8b3-0242ac130003",
    "routerType": "EDGE",
    "switch":{
      "switchId": "922dbcd5-d071-41bd-920b-00f83eb4bb46",
      "ip": {
        "protocol": "IPV4", "address": "9.0.0.9"
      },
      "switchType": "LAYER3",
      "networks":[
        {
          "ip": {
            "protocol": "IPV4", "address": "10.0.0.0"
          },
          "networkName": "HR", "networkCidr": "8"
        },
        {
          "ip": {
            "protocol": "IPV4", "address": "20.0.0.0"
          },
          "networkName": "Marketing", "networkCidr": "8"
        },
        {
          "ip": {
            "protocol": "IPV4", "address": "30.0.0.0"
          },
          "networkName": "Engineering", "networkCidr": "8"
        }
      ]
    }
}]

添加网络以满足我们的用例目标的目的保持不变,因此我们再次实现 RouterNetworkOutputPort 接口以创建 RouterNetworkFileAdapter

public class RouterNetworkFileAdapter implements RouterNet
  workOutputPort {
/** code omitted **/
    @Override
    public Router fetchRouterById(RouterId routerId) {
var router = new Router();
        for(RouterJson: routers){
              if(routerJson.getRouterId().
              equals(routerId.getUUID())){
                    router =  RouterJsonFileMapper.
                    toDomain(routerJson);
              break;
           }
        }
        return router;
    }
    @Override
    public boolean persistRouter(Router router) {
        var routerJson = RouterJsonFileMapper.
                         toJson(router);
        try {
            var localDir = Paths.get("").
                              toAbsolutePath().toString();
            var file = new File(localDir+
                        "/inventory.json");
            file.delete();
            objectMapper.writeValue(file, routerJson);
        } catch (IOException e) {
            e.printStackTrace();
        }
        return true;
    }
/** code omitted **/
}

fetchRouterById 方法通过使用 RouterId 参数解析 .json 文件来返回一个 Router 对象。persistRouter 方法将 inventory.json 文件中的更改持久化。

我们不使用实体管理器和 EclipseLink,而是使用 Jackson 库来序列化和反序列化 JSON 数据。为了将 inventory.json 文件加载到内存中,我们使用适配器构造函数调用 readJsonFile 方法将 inventory.json 文件加载到一个 RouterJson 对象列表中:

private void readJsonFile(){
    try {
        this.routers = objectMapper.readValue(
             resource,
             new TypeReference<List<RouterJson>>(){});
        } catch (Exception e) {
           e.printStackTrace();
        }
}
private RouterNetworkFileAdapter() {
    this.objectMapper = new ObjectMapper();
    this.resource = getClass().getClassLoader().
    getResourceAsStream("inventory.json");
    readJsonFile();
}

在 H2 的情况下,与 JSON 一样,我们还需要创建特殊类来在 JSON 对象和领域模型对象之间进行映射。这些类的结构类似于 H2 ORM 类,主要区别在于用于创建适当映射的注解。让我们看看如何实现 RouterJson 类:

/** Code omitted **/
@JsonInclude(value = JsonInclude.Include.NON_NULL)
public class RouterJson {
     @JsonProperty("routerId")
     private UUID routerId;
     @JsonProperty("routerType")
     private RouterTypeJson routerType;
     @JsonProperty("switch")
     private SwitchJson networkSwitch;
}

我们使用 @JsonInclude@JsonProperty 注解将类属性映射到 JSON 字段。这些 JSON 映射比 H2 映射更直接,因为我们不需要处理数据库关系。RouterTypeJsonSwitchJson 以及所有其他 JSON 映射类都类似,它们使用相同的注解来转换 JSON 和领域模型对象。

要将 RouterJson 转换为 Router,我们使用 RouterJsonFileMapper 映射器类中的 toDomain 方法:

RouterJsonFileMapper.toDomain(routerJson);

我们使用 toJson 方法将 Router 转换为 RouterJson

RouterJsonFileMapper.toJson(router);

RouterJsonFileMapper 与其 H2 对应物类似,但更简单,因为我们不需要处理 一对多一对一 的关系。让我们从将 JSON 对象转换为领域对象的方法开始:

public static Router toDomain(RouterJson routerJson){
    /** code omitted **/
    return new Router(routerType, routerId, networkSwitch);
}

这里 toDomain 方法接收一个 RouterJson 对象作为参数,执行适当的映射,然后返回一个 Router 对象。当我们需要将一系列 NetworkJson JSON 对象转换为一系列 Network 领域对象时,发生类似的流程:

private static List<Network> getNetworksFromJson(List<Net
  workJson> networkJson){
    return networkJson
            .stream()
            .map(json ->  new Network(
                    IP.fromAddress(
                    json.getIp().getAddress()),
                    json.getNetworkName(),
                    Integer.valueOf(json.getCidr())))
            .collect(Collectors.toList());
}

getNetworksFromJson 方法接收一系列 NetworkJson 对象作为参数,并返回一个适当映射的 Network 对象列表。

让我们看看将领域对象转换为 JSON 对象所使用的方法:

public static RouterJson toJson(Router router){
    /** code omitted **/
    return new RouterJson(
               routerId,
               routerTypeJson,
               switchJson);
}

toJson 方法与 toDomain 方法相反。这里 toJson 方法接收一个 Router 领域对象作为参数,执行适当的映射,并返回一个 RouterJson 对象。

最后,我们有一个需要将一系列 Network 领域对象转换为一系列 NetworkJson JSON 对象的情况:

private static List<NetworkJson>  getNetworksFromDo
  main(List<Network> networks){
     return networks
             .stream()
             .map(network -> new NetworkJson(
                    IPJson.fromAddress(
                    network.getAddress().getIPAddress()),
                    network.getName(),
                    String.valueOf(network.getCidr())))
              .collect(Collectors.toList());
}

getNetworksFromDomain 方法通过接收一系列 Network 对象作为参数,可以继续映射所需的属性,并返回一系列 NetworkJson 对象。

现在我们已经完成了文件输出适配器的实现,让我们尝试调用文件和 H2 输出适配器。

调用输出适配器

在调用适配器之前,让我们编译应用程序。导航到 Chapter04 目录并运行以下命令:

mvn clean package

要调用 H2 输出适配器,我们需要使用 REST 输入适配器。我们可以在执行 .jar 文件时提供 rest 参数:

$ java -jar target/chapter04-1.0-SNAPSHOT-jar-with-dependencies.jar rest
$ curl -vv "http://localhost:8080/network/add?routerId=ca23800e-9b5a-11eb-a8b3-0242ac130003&address=40.0.0.0&name=Finance&cidr=8" | jq

文件输出适配器可以通过 CLI 输入适配器访问:

$ java -jar target/chapter04-1.0-SNAPSHOT-jar-with-dependencies.jar
Please inform the Router ID:
ca23800e-9b5a-11eb-a8b3-0242ac130003
Please inform the IP address:
40.0.0.0
Please inform the Network Name:
Finance
Please inform the CIDR:
8

调用 H2 和文件输出适配器的结果将是相同的:

{
  "routerId": "ca23800e-9b5a-11eb-a8b3-0242ac130003",
  "routerType": "EDGE",
  "switch": {
    "switchId": "922dbcd5-d071-41bd-920b-00f83eb4bb46",
    "ip": {
      "address": "9.0.0.9", "protocol": "IPV4"
    },
    "switchType": "LAYER3",
    "networks": [
      {
        "ip": {
          "address": "10.0.0.0", "protocol": "IPV4"
        },
        "networkName": "HR",
        "networkCidr": "8"
      },
      {
        "ip": {
          "address": "20.0.0.0", "protocol": "IPV4"
        },
        "networkName": "Marketing",
        "networkCidr": "8"
      },
      {
        "ip": {
          "address": "30.0.0.0", "protocol": "IPV4"
        },
        "networkName": "Engineering",
        "networkCidr": "8"
      },
      {
        "ip": {
          "address": "40.0.0.0", "protocol": "IPV4"
        },
        "networkName": "Finance",
        "networkCidr": "8"
      }
    ]
  }
}

注意输出末尾的 Finance 网络块,这确认了数据已被正确持久化。

通过创建这两个输出适配器,我们使六边形应用程序能够与不同的数据源进行通信。最好的部分是,我们不需要在领域或应用程序六边形中做任何更改。

创建输出适配器的唯一要求是从应用程序六边形实现一个输出端口接口。这些输出适配器示例展示了六边形方法如何保护业务逻辑不受技术问题的干扰。当然,当我们决定走这条路时,会有一定的权衡。然而,如果我们旨在创建以领域模型为中心的容变系统,六边形架构提供了实现这一目标的必要技术。

摘要

在本章中,我们了解到适配器用于定义六边形应用程序支持的技术。我们创建了两个输入适配器以允许驱动操作,即一个 REST 适配器用于接收来自 HTTP 连接的数据,一个 CLI 适配器用于接收来自STDIN的数据。这两个输入适配器连接到相同的输入端口,允许六边形系统使用相同的逻辑来处理以不同格式传入的请求。

然后,我们创建了一个 H2 数据库输出适配器和 JSON 文件输出适配器,以便六边形应用程序能够与不同的数据源进行通信。这两个输出适配器连接到相同的输出端口,使得六边形系统能够从外部源持久化和获取数据,这样数据源技术就不会影响业务逻辑。

通过了解输入和输出适配器的目的,并理解如何实现它们,我们现在可以创建能够容忍重大技术变化而无需大量重构的系统。这种好处是通过所有系统组件,包括适配器,都是围绕领域模型开发的来实现的。

为了全面理解适配器与其他六边形架构元素之间的动态关系,我们将在下一章探讨驱动和被驱动操作的生命周期。

问题

  1. 我们应该在什么时候创建输入适配器?

  2. 将多个输入适配器连接到同一输入端口的好处是什么?

  3. 我们必须实现哪个接口来创建输出适配器?

  4. 输入和输出适配器属于哪个六边形?

答案

  1. 当我们需要将软件功能暴露给驱动操作者访问时,我们会创建输入适配器。这些操作者可以使用不同的技术或协议,如 HTTP REST 或通过命令行,来访问六边形应用程序。

  2. 主要好处是,包含在输入端口中的相同逻辑可以用来处理来自不同输入适配器的数据。

  3. 输出适配器必须始终实现输出端口。通过这样做,我们可以确保适配器符合领域模型所表达的要求。

  4. 它们都来自框架六边形。

进一步阅读

  • 《动手实践清洁架构:使用 Java 代码示例创建清洁 Web 应用程序的实用指南》,Tom Hombergs,Packt Publishing Ltd.,2019

  • 《面向对象软件工程:一种用例驱动的方法》,Ivar Jacobson,Pearson Education,1992

  • 领域驱动设计:软件核心的复杂性处理,埃里克·埃文斯,Pearson Education,2003

  • 实现领域驱动设计,沃恩·弗农,Pearson Education,2013

  • 六边形架构 (alistair.cockburn.us/hexagonal-architecture/),阿利斯泰尔·科克本

第五章:探索驾驶和被驱动操作的本质

在前面的章节中,我们分析了构成每个六边形的六边形架构的元素。我们学习了实体、值对象和业务规则,以及如何将它们安排在领域六边形中,以创建一个有意义的领域模型。之后,当处理应用六边形时,我们学习了如何利用用例和端口在领域模型之上创建完整的软件功能。最后,我们学习了如何创建适配器,以将六边形应用功能与不同技术集成。

为了更好地理解六边形系统,我们还需要了解其周围环境。这就是为什么在本章中,我们探讨了驱动和被驱动操作的本质,因为它们代表了与六边形应用交互的外部元素。在驱动方面,我们将看到前端应用如何作为主要演员,驱动六边形系统的行为。在被驱动方面,我们将学习使基于消息的系统能够被六边形系统驱动所必需的条件。

在本章中,我们将涵盖以下主题:

  • 通过驱动操作达到六边形应用

  • 将 Web 应用与六边形系统集成

  • 运行测试代理并从其他应用程序调用六边形系统

  • 使用驱动操作处理外部资源

到本章结束时,您将了解最常见的驱动和被驱动操作。一旦您理解了这些操作以及它们如何影响六边形系统的内部结构,您就将学会六边形架构的所有构建块,这将使您能够在利用迄今为止所展示的所有技术的同时开发完整的六边形应用。

技术要求

要编译和运行本章中展示的代码示例,您需要在您的计算机上安装Java SE 开发工具包JDK)(版本 17 或更高)和Maven 3.8。它们都适用于LinuxMacWindows操作系统。您还需要下载以下工具:PostmanNewman(来自npm)和Kafka。我们建议使用 Linux 来正确运行 Kafka。如果您使用的是 Windows 系统,您可以使用Windows 子系统(WSL)来运行 Kafka。

您可以从kafka.apache.org/downloads.html下载 Kafka 的最新版本。

您可以从www.postman.com/downloads下载 Postman 的最新版本。

您可以从www.npmjs.com/package/newman下载 Newman 的最新版本。

您可以在 GitHub 上找到本章的代码文件:

github.com/PacktPublishing/-Designing-Hexagonal-Architecture-with-Java---Second-Edition/tree/main/Chapter05

使用驾驶操作到达六边形应用程序

我们可能认为一个系统可以自给自足,即没有人与之交互,并且这个系统不与其他用户或系统交互,这是不可想象的。这种安排违反了计算机架构的基本原则(冯·诺伊曼,1940 年),它假定任何计算机系统都存在输入和输出操作。事实上,很难想象一个有用的软件程序不会接收任何数据或产生任何结果。

通过六边形架构的视角,系统的输入侧由驾驶操作控制。我们称它们为驾驶操作,因为它们实际上启动并驱动六边形应用程序的行为。

第三章 使用端口和用例处理行为中,我们将驾驶操作与主要演员联系起来。这些演员负责在六边形系统中触发驾驶操作。驾驶操作可以采取不同的方面:它们可以是直接通过命令行控制台与系统交互的用户,是一个请求数据以在浏览器中展示的用户界面UI)应用程序,是一个想要验证特定测试用例的测试代理,或者任何对六边形应用程序公开的功能感兴趣的任何系统。

所有这些不同的方面都被归类在驾驶侧,如下面的图所示:

图 5.1 – 驾驶侧和六边形应用程序

图 5.1 – 驾驶侧和六边形应用程序

在上一章中,我们看到了如何使用命令行界面CLI)和通过HTTP REST与六边形应用程序交互。现在,我们将探讨如何集成其他类型的驾驶操作,以与我们迄今为止一直在开发的拓扑和库存系统进行通信。

一旦我们建立了这些集成,我们将分析请求需要经过的路径,以便穿越所有六边形直到到达领域。这项练习将帮助我们理解每个六边形及其组件在处理驾驶操作请求中所扮演的角色。因此,让我们首先将一个网络 UI 与六边形系统集成起来。

将 Web 应用程序与六边形系统集成

现在,随着HTML 5、现代JavaScript和不断改进的 Web 开发技术的出现,可以直接从 Web 浏览器运行高度复杂的系统。更快的互联网连接、更多的计算资源以及更好和更稳定的 Web 标准都为 Web 应用程序的改进做出了贡献。例如,旧的杂乱无章的FlashJava 小程序系统已经被基于AngularReactVue等花哨框架的前端应用程序所取代。

不仅技术已经发展和变化,围绕 Web 开发的实践也发生了演变。受到.ear.war包文件的鼓励,业务逻辑泄漏到表示代码中并不罕见。

Java EE(现在称为Jakarta EE)和其他框架,如Struts,利用了ServletsJSPJSF等技术,以实现表示层和业务代码之间的完全集成。过了一段时间,人们开始意识到,将前端和后端代码放得太近可能会成为他们软件项目的熵源。

作为对这些做法的回应,行业转向了解耦架构,其中前端系统是一个独立的、独立的应用程序,通过网络与一个或多个后端系统交互。

因此,我们将创建一个简单、独立的客户端应用程序,该应用程序从我们的拓扑和库存系统中获取数据。我们的应用程序将仅基于 HTML 5、CSS 和vanilla JavaScript。该应用程序旨在允许用户将网络添加到路由器,并从系统数据库中检索现有路由器。我们还将重构部分六边形应用程序,以实现与前端应用程序更好的集成。结果将是一个集成了六边形系统的网络浏览器应用程序,如下面的截图所示:

图 5.2 – 前端拓扑与库存应用程序

图 5.2 – 前端拓扑与库存应用程序

前端应用程序将允许用户将网络添加到现有路由器,并查看路由器和其网络的图形表示。

让我们从向RouterNetworkUseCase接口添加getRouter方法开始,增强六边形应用程序。

public interface RouterNetworkUseCase {
    Router addNetworkToRouter(RouterId,
    Network network);
    Router getRouter(RouterId routerId);
}

getRouter方法签名很简单。它接收RouterId并返回一个Router对象。我们需要这种行为,以便前端应用程序能够显示一个路由器。

接下来,我们需要为getRouter方法提供一个实现。我们通过使用RouterNetworkInputPort类实现RouterNetworkUseCase接口来完成这一点:

public class RouterNetworkInputPort implements RouterNet
  workUseCase {
/** code omitted **/
    @Override
    public Router getRouter(RouterId routerId) {
        return fetchRouter(routerId);
    }
    private Router fetchRouter(RouterId routerId) {
        return routerNetworkOutputPort.
               fetchRouterById(routerId);
    }
/** code omitted **/
}

注意到fetchRouter已经在输入端口实现中存在,但我们没有暴露的操作来检索路由器。然后fetchRouter方法不仅被addNetworkToRouter方法使用,现在也被getRouter使用。

有必要将输入端口的变化传播到输入适配器。我们通过在RouterNetworkAdapter抽象类中定义的基输入适配器上创建一个getRouter方法来完成这一点:

public Router getRouter(Map<String, String> params) {
    var routerId = RouterId.
    withId(params.get("routerId"));
    return routerNetworkUseCase.getRouter(routerId);
}

记住RouterNetworkAdapterRouterNetworkCLIAdapterRouterNetworkRestAdapter两个适配器的基输入适配器。

为了允许前端应用程序与六边形系统通信,我们将使用 REST 适配器。因此,我们需要在RouterNetworkRestAdapter中进行一些更改,以允许这种通信:

@Override
public Router processRequest(Object requestParams){
/** code omitted **/
    if (exchange.
      getRequestURI().getPath().equals("/network/add")) {
        try {
            router = this.addNetworkToRouter(params);
        } catch (Exception e) {
            exchange.sendResponseHeaders(
            400, e.getMessage().getBytes().length);
            OutputStream output = exchange.
            getResponseBody();
            output.write(e.getMessage().getBytes());
            output.flush();
        }
    }
    if (exchange.
      getRequestURI().getPath().contains("/network/get")) {
        router = this.getRouter(params);
    }
/** code omitted **/
}

processRequest 方法的更改是为了使其能够正确处理来自 /network/add/network/get 路径的请求。

现在,我们可以转向拓扑和库存系统前端部分的开发。我们的重点将是 HTML 和 JavaScript 元素。我们将创建两个页面:第一个页面允许用户添加网络,第二个页面将允许用户获取路由器和其网络的图形视图。

创建添加网络页面

让我们从创建第一个 HTML 页面开始,如下面的截图所示:

图 5.3 – 前端应用拓扑和库存的添加网络页面

图 5.3 – 前端应用拓扑和库存的添加网络页面

添加网络 页面包含一个表单,用户被要求输入添加现有路由器所需的数据。以下是表单的代码:

<html>
  <head>
    <title>Topology & Inventory | Add Network</title>
    /** code omitted **/
  </head>
  <body>
      /** code omitted **/
      <form name="addNetwork" onsubmit="return
       false;">
      /** code omitted **/
      </form>
    <script src="img/networkTools.js"></script>
  </body>
</html>

为了处理 networkTools.js 文件中存在的 addNetworkToRouter,需要进行以下操作:

function addNetworkToRouter() {
    const routerId = document.
    getElementById("routerId").value;
    const address = document.
    getElementById("address").value;
    const name = document.getElementById("name").value;
    const cidr = document.getElementById("cidr").value;
    const xhttp = new XMLHttpRequest();
    xhttp.open("GET",
    "http://localhost:8080/network/add?
        routerId=" + routerId + "&" +
        "address=" + address + "&" +
        "name=" + name + "&" +
        "cidr=" + cidr, true);
    xhttp.onload = function(
        if (xhttp.status === 200) {
            document.
            getElementById("message").
            innerHTML = "Network added with success!"
        } else {
            document.
            getElementById("message").
            innerHTML = "An error occurred while
            trying to add the network."
        }
    };
    xhttp.send();
}

我们使用 XMLHttpRequest 对象在六边形应用中 REST 适配器公开的 /network/add 端点中处理 GET 请求。这是一段简短的 JavaScript 代码,它捕获在 HTML 表单中输入的值,处理它们,然后如果一切顺利则显示成功消息,如果不顺利则显示错误消息,就像我们在这里看到的那样:

图 5.4 – 在拓扑和库存应用中添加新的网络

图 5.4 – 在拓扑和库存应用中添加新的网络

现在,让我们继续创建 获取 路由器 页面。

创建获取路由器页面

获取路由器 页面包含一个 HTML 表单来处理用户请求,但它还基于从六边形应用获得的 JSON 响应提供图形视图。让我们首先考虑 HTML 表单:

图 5.5 – 前端应用拓扑和库存的获取路由器页面

图 5.5 – 前端应用拓扑和库存的获取路由器页面

获取路由器 HTML 页面与我们之前在 添加网络 页面上使用的结构相同,但这个表单只使用一个参数从六边形应用中查询路由器。

为了创建基于 JSON 的路由器和其网络的图形视图,我们将使用一个名为 D3 的 JavaScript 库,该库消费 JSON 数据并生成图形视图。JavaScript 代码处理表单,然后使用 D3 库和 JSON 响应:

function getRouter() {
    const routerId = document.
    getElementById("routerId").value;
    var xhttp = new XMLHttpRequest();
    xhttp.onreadystatechange = function() {
        console.log(this.responseText);
        if (this.readyState == 4 && this.status == 200) {
            const json = JSON.parse(this.responseText)
            createTree(json)
        }
    };
    xhttp.open(
    "GET",
    "http://localhost:8080/network/get?routerId="+routerId,
    true);
    xhttp.send();
}
function createTree(json) {
    const container = document.getElementById("container");
    const vt = new VTree(container);
    const reader = new Vtree.reader.Object();
    var data = reader.read(json);
    vt.data(data).update();
}

在这里,我们正在传递之前在六边形应用中定义的 /network/get 端点。getRouter 函数处理 GET 请求,并使用 JSON 响应作为 createTree 函数的参数,该函数将构建网络的图形视图。

如果我们填写表单中的路由器 ID,ca23800e-9b5a-11eb-a8b3-0242ac130003,以检索路由器,我们得到的结果如下所示:

图 5.6 – Get Router 页面提供的网络图形视图

图 5.6 – Get Router 页面提供的网络图形视图

记住,前面截图中的数据最终来源于我们直接附加到前端应用程序在此处使用的 REST 输入适配器的 H2 内存数据库。

现在,让我们看看测试代理如何与总线和库存系统集成。

运行测试代理

除了前端应用程序外,另一种常见的驱动操作来自测试和监控代理与六边形系统交互,以验证其功能是否运行良好。使用 Postman 等工具,我们可以创建全面的测试用例来验证应用程序在面临特定请求时的行为。

此外,我们可以定期向某些应用程序端点发出请求以检查它们是否健康。这种做法已经通过Spring Actuator等工具普及,这些工具在应用程序中提供了一个特定的端点,允许您检查其是否健康。还有一些技术涉及使用探针机制定期向应用程序发送请求以查看其是否存活。例如,如果应用程序不活跃或导致超时,则可以自动重启。在基于Kubernetes的云原生架构中,使用探针机制的系统很常见。

本节将探讨如何运行一个简单的测试用例以确认应用程序是否按我们的预期行为。我们不需要更改迄今为止一直在开发的总线和库存系统。在这里,我们将使用一个名为 Postman 的工具创建测试用例。在 Postman 中,测试用例被称为测试集合。一旦创建了这些测试集合,我们就可以使用Newman执行它们,Newman 是一个专门用于运行 Postman 集合的命令行工具。

要开始,您必须遵循以下步骤:

  1. 下载 Postman 和 Newman。下载链接可在技术要求部分找到。本章使用的集合也存在于该章节的 GitHub 仓库中(github.com/PacktPublishing/-Designing-Hexagonal-Architecture-with-Java---Second-Edition/blob/main/Chapter05/topology-inventory.postman_collection.json)。

  2. 将集合导入 Postman。

  3. 导入后,集合将显示两个请求。一个请求是针对getRouter端点,另一个是针对addNetwork。以下截图显示了将集合导入 Postman 后两个请求的显示方式:

图 5.7 – Postman 中的拓扑和库存集合

图 5.7 – Postman 中的拓扑和库存集合

  1. 在 Postman 上运行测试之前,请确保通过从项目的root目录运行以下命令来启动拓扑和库存应用程序:

    getRouter request is to confirm whether the application returns an HTTP 200 response code when we try to retrieve a router by passing a router ID.
    
  2. 然后,我们想要验证返回的值是否是我们所期望的。在这种情况下,我们期望在系统中只遇到三个网络:HRMarketingEngineering。在 Postman 中,我们为每个请求创建测试。因此,我们将为导入的集合中现有的两个请求创建测试。让我们先为getRouter请求创建一个测试:

    pm.test("Status code is 200", () => {
      pm.expect(pm.response.code).to.eql(200);
    });
    pm.test("The response has all properties", () => {
      const responseJson = pm.response.json();
      pm.expect(
          responseJson.switch.networks).
          to.have.lengthOf(3);
      pm.expect(
          responseJson.switch.networks[0].networkName).
          to.eql('HR');
      pm.expect(
          responseJson.switch.networks[1].networkName).
          to.eql('Marketing');
      pm.expect(
          responseJson.switch.networks[2].networkName).
          to.eql('Engineering');
    });
    

    在前面的代码中,我们首先检查 HTTP 响应代码是否为200。然后,我们继续解析和预览 JSON 响应数据,以查看它是否与我们所期望的匹配。在这个测试中,我们期望一个包含由三个网络组成的交换机的路由器的响应。

  3. 来自addNetwork请求的测试与getRouter请求的测试类似。然而,这次预期的响应中包含额外的Finance网络,正如我们在以下测试代码中所看到的:

    pm.test("Status code is 200", () => {
      pm.expect(pm.response.code).to.eql(200);
    });
    pm.test("The response has all properties", () => {
      const responseJson = pm.response.json();
      pm.expect(
          responseJson.switch.networks).
          to.have.lengthOf(4);
      pm.expect(
          responseJson.switch.networks[3].networkName).
          to.eql('Finance');
    });
    

    该集合中的addNetwork请求添加了一个名为Finance的网络。这就是为什么我们只检查是否正确添加了Finance网络。此外,我们期望在添加Finance网络后,网络的列表长度为4

  4. 如果你想在 Postman 外部运行这些测试,你可以通过首先将集合导出为.json文件,然后使用 Newman 从该集合执行测试来实现:

    newman run topology-inventory.postman_collection.json
    

    结果类似于以下截图所示:

图 5.8 – 使用 Newman 运行拓扑和库存应用程序测试

图 5.8 – 使用 Newman 运行拓扑和库存应用程序测试

使用 Newman 进行此类测试执行非常适合将六边形应用程序集成到持续集成CI)管道中。开发者使用 Postman 创建集合及其相应的测试,然后通过 CI 工具(如Jenkins)触发和验证这些相同的集合,这些工具可以使用 Newman 执行测试。

既然我们已经熟悉了前端应用程序和测试代理作为驱动操作的手段,让我们再看看另一种驱动操作类型。接下来,我们将讨论在分布式或微服务架构中发生的驱动操作,其中来自同一系统的不同应用程序通过网络进行通信。

从其他应用程序调用六边形系统

关于是否开发单体或微服务系统,有一个反复出现的争论。在单体中,数据直接在对象和方法调用之间流动。所有软件指令都组在同一个应用程序中,减少了通信开销并集中了系统生成的日志。

在微服务和分布式系统中,部分数据会在独立、自包含的应用程序之间通过网络流动,这些应用程序合作提供整个系统的功能。这种方法解耦了开发,允许更模块化的组件。它还因为包更小而提高了编译时间,有助于在 CI 工具中形成更快的反馈循环。然而,微服务也带来了一些挑战,因为日志不再集中,网络通信开销可能成为限制因素,这取决于系统的目的。

在分布式方法中,两个或更多六边形自包含系统可以组成整个基于六边形的系统。在这种情况下,启动请求的六边形系统 A充当主要参与者,并在六边形系统 B上触发驱动操作,如下所示:

图 5.9 – 多个六边形应用程序

图 5.9 – 多个六边形应用程序

注意系统 A通过其输出适配器之一触发请求。这个请求直接从系统 B的一个输入适配器接收。分布式架构的一个令人兴奋之处在于,你不需要使用相同的编程语言来开发所有系统组件。

在分布式架构场景中,我们可以用 Java 编写系统 A,用 Python 编写系统 B。只要它们就通信的公共媒介达成一致——例如 JSON 和 HTTP——它们就可以在同一系统中协作。随着容器技术如 Docker 和 Kubernetes 的出现,拥有一个技术混合系统并不是什么大问题。

本节探讨了驱动操作是什么以及我们如何使用它们与六边形系统交互。在下一节中,我们将看到硬币的另一面:驱动操作。

使用驱动操作处理外部资源

商业应用程序的一般特征是它们需要从其他系统发送或请求数据。我们已经看到,输出端口和适配器是我们用来允许六边形系统与外部资源交互而不损害业务逻辑的六边形架构组件。这些外部资源也被称为次要参与者,并提供六边形应用程序请求的数据或能力。

当六边形应用程序向一个次要参与者发送请求——通常是代表首先从一个六边形应用程序的使用案例中触发驱动操作的原始参与者——我们称这样的请求为驱动操作。这是因为这些操作由六边形系统控制和驱动。

因此,驱动操作来自主要演员的请求,这些请求驱动六边形系统的行为,而驱动操作是六边形应用程序本身向次要演员(如数据库或其他系统)发起的请求。以下图显示了驱动端和一些驱动操作的示例:

图 5.10 – 驱动端和六边形应用程序

图 5.10 – 驱动端和六边形应用程序

本节将探讨六边形应用程序可以执行的一些可能的驱动操作,如图中所示。

数据持久性

基于数据持久性的驱动操作是最常见的。我们在第四章,“创建与外部世界交互的适配器”中创建的 H2 输出适配器是一个处理数据持久性的驱动操作的例子,它通过使用内存数据库来实现。这类驱动操作通常利用对象关系映射ORM)技术来处理和转换六边形系统与数据库之间的对象。在 Java 世界中,HibernateEclipseLink提供了具有 ORM 功能的强大Java 持久性 APIJPA)实现。

事务机制也是基于持久性驱动的操作的一部分。当处理事务时,我们可以让六边形系统直接处理事务边界,或者将这项责任委托给应用程序服务器。

消息和事件

并非每个系统都只依赖于同步通信。根据情况,您可能希望在不妨碍应用程序运行时流程的情况下触发有关您的东西的事件。

有一些架构类型受到异步通信技术的影响很大。通过采用这些技术,这些系统变得更加松散耦合,因为它们的组件不再依赖于其他应用程序提供的接口。我们不再仅仅依赖于阻塞连接的 API,而是让消息和事件以非阻塞的方式驱动应用程序的行为。

通过阻塞,我们指的是那些需要等待响应以允许应用程序流程继续进行的连接。非阻塞方法允许应用程序发送请求并继续前进,而无需立即响应。也存在一些情况下,应用程序会响应消息或事件来采取某些行动。

基于消息的系统是受六边形应用驱动的次要参与者。与数据库不同,通信将从六边形应用开始,但在某些场景中,基于消息的系统将首先与六边形应用开始通信。但是,为了接收或发送消息,六边形系统始终需要首先与消息系统建立通信流程。在处理 Kafka 等技术时,这种情况很常见,其中应用可以是消息的消费者和生产者。为了与 Kafka 等消息系统集成,六边形应用需要通过加入 Kafka 主题 来表达其意图。

为了更好地理解基于消息的系统如何与六边形应用集成,我们将在我们的拓扑和库存系统中实现一个功能,以便我们可以看到应用产生的事件。系统的后端六边形部分将发送事件到 Kafka,而前端将实时消费这些事件并在网页浏览器中显示它们。我们将通过执行以下步骤来实现此功能:

  1. 让我们先启动 Kafka 并为我们的应用程序创建一个主题。Kafka 下载 URL 可在 技术要求 部分找到。一旦你下载了最新的 Kafka 版本,提取它:

    $ curl "https://downloads.apache.org/kafka/3.4.0/kafka_2.12-3.4.0.tgz" -o ./kafka_2.12-3.4.0.tgz
    $ tar -xzf kafka_2.12-3.4.0.tgz
    zookeeper service:
    
    

    代理服务:

    $ bin/kafka-server-start.sh config/server.properties
    
    
    
  2. 到目前为止,Kafka 已在你的环境中启动并运行。现在,让我们在第三个 shell 会话或标签中创建我们的应用程序的主题:

    NotifyEventOutputPort output port:
    
    

    public interface NotifyEventOutputPort {

    void sendEvent(String Event);

    String getEvent();

    }}

    
    
  3. 接下来,我们实现输出端口,使用 NotifyEventKafkaAdapter 输出适配器。我们首先通过定义 Kafka 连接属性来启动 NotifyEventKafkaAdapter 适配器实现:

    public class NotifyEventKafkaAdapter implements Noti
      fyEventOutputPort {
        private static String KAFKA_BROKERS =
          "localhost:9092";
        private static String
          GROUP_ID_CONFIG="consumerGroup1";
        private static String CLIENT_ID="hexagonal-
          client";
        private static String TOPIC_NAME=
        "topology-inventory-events";
        private static String
          OFFSET_RESET_EARLIER="earliest";
        private static Integer
          MAX_NO_MESSAGE_FOUND_COUNT=100;
        /** code omitted **/
    }
    

    注意,KAFKA_BROKERS 变量的值设置为 localhost:9092,对应于启动 Kafka 主题所使用的宿主机和端口。TOPIC_NAME 变量的值设置为 topology-inventory-events,代表我们用于产生和消费消息的主题。

  4. 让我们继续创建将消息发送到我们的 Kafka 主题的方法:

    private static Producer<Long, String> getProducer(){
        Properties properties = new Properties();
        properties.put(ProducerConfig.
        BOOTSTRAP_SERVERS_CONFIG, KAFKA_BROKERS);
        properties.put(ProducerConfig.
        CLIENT_ID_CONFIG, CLIENT_ID);
        properties.put(ProducerConfig.
        KEY_SERIALIZER_CLASS_CONFIG,
        LongSerializer.class.getName());
        properties.put(ProducerConfig.
        VALUE_SERIALIZER_CLASS_CONFIG,
        StringSerializer.class.getName());
        return new KafkaProducer<>(properties);
    }
    

    getProducer 方法通过在 ProducerConfig 类中设置所需的属性来配置生产者属性。然后,它返回一个 KafkaProducer 实例,我们使用该实例在 Kafka 主题中产生消息。

  5. 另一方面,我们有 getConsumer 方法,它消费由 Producer 方法生成的消息:

    public static Consumer<Long, String> getConsumer(){
        Properties properties = new Properties();
        properties.put(ConsumerConfig.
        BOOTSTRAP_SERVERS_CONFIG,KAFKA_BROKERS);
        properties.put(ConsumerConfig.
        GROUP_ID_CONFIG, GROUP_ID_CONFIG);
        properties.put(ConsumerConfig.
        KEY_DESERIALIZER_CLASS_CONFIG,
        LongDeserializer.class.getName());
        properties.put(ConsumerConfig.
        VALUE_DESERIALIZER_CLASS_CONFIG,
        StringDeserializer.class.getName());
        properties.put
          (ConsumerConfig.MAX_POLL_RECORDS_CONFIG,
                  1);
        properties.put(ConsumerConfig.
        ENABLE_AUTO_COMMIT_CONFIG,"false");
        properties.put(ConsumerConfig.
        AUTO_OFFSET_RESET_CONFIG, OFFSET_RESET_EARLIER);
        Consumer<Long, String> consumer =
        new KafkaConsumer<>(properties);
        consumer.
        subscribe(Collections.singletonList(TOPIC_NAME));
        return consumer;
    }
    

    使用 getConsumer 方法,我们使用 ConsumerConfig 类设置所需的属性。此方法返回一个 KafkaConsumer 实例,我们使用该实例从 Kafka 主题中消费和读取消息。

  6. 接下来,我们覆盖在 NotifyEventOutputPort 中声明的第一个方法 sendEvent。我们将通过此方法将消息发送到 Kafka Producer 实例:

    @Override
    public void sendEvent(String eventMessage){
        var record = new ProducerRecord<Long, String>(
                TOPIC_NAME, eventMessage);
        try {
            var metadata = producer.send(record).get();
            System.out.println("Event message " +
                    "sent to the topic "+TOPIC_NAME+": "
                    +eventMessage+".");
            getEvent();
        }catch (Exception e){
            e.printStackTrace();
        }
    }
    

    sendEvent方法的第一行创建了一个ProducerRecord实例,该实例向构造函数参数传达了主题名称和我们打算作为事件发送的消息。在接近结尾的地方,我们调用了getEvent方法。

  7. 正如我们将在下一部分更详细地看到的那样,我们调用这个方法来从 Kafka 消费消息并将它们转发到运行在端口8887上的WebSocket服务器:

    @Override
    public String getEvent(){
        int noMessageToFetch = 0;
        AtomicReference<String> event =
        new AtomicReference<>("");
        while (true) {
        /** code omitted **/
            consumerRecords.forEach(record -> {
                event.set(record.value());
            });
        }
        var eventMessage = event.toString();
        if(sendToWebsocket)
        sendMessage(eventMessage);
        return eventMessage;
    }
    

    getEvent方法依赖于分配给consumer变量的KafkaConsumer实例。通过这个实例,它从 Kafka 主题中检索消息。

  8. 在检索到消息后,getEvent方法调用sendMessage方法将那条消息转发到WebSocket服务器:

    public void sendMessage(String message){
        try {
            var client = new WebSocketClientAdapter(
            new URI("ws://localhost:8887"));
            client.connectBlocking();
            client.send(message);
            client.closeBlocking();
        } catch (URISyntaxException |
                 InterruptedException e) {
            e.printStackTrace();
        }
    }
    

    sendMessage方法接收一个字符串参数,其中包含消费的 Kafka 主题消息。然后,它将那条消息转发到运行在端口8887上的WebSocket服务器。

让我们简要地看看这个WebSocket服务器是如何实现的:

public class NotifyEventWebSocketAdapter extends WebSock
  etServer {
/** code omitted **/
public static void startServer() throws IOException, Inter
  ruptedException {
    var ws = new NotifyEventWebSocketAdapter(
    new InetSocketAddress("localhost", 8887));
    ws.setReuseAddr(true);
    ws.start();
    System.out.println("Topology & Inventory" +
    " webSocket started on port: " + ws.getPort());
    BufferedReader sysin =
    new BufferedReader(new InputStreamReader(System.in));
    while (true) {
        String in = sysin.readLine();
        ws.broadcast(in);
        if (in.equals("exit")) {
            ws.stop();
            break;
        }
    }
}
/** code omitted **/
}

startServer方法创建了一个NotifyEventWebSocketAdapter实例,其中包含了WebSocket服务器的地址和端口。当我们启动六边形应用时,最早发生的事情之一就是调用startServer方法,在端口8887上启动 WebSocket 服务器:

void setAdapter(String adapter) throws IOException, Inter
  ruptedException {
    switch (adapter) {
        case "rest" -> {
            routerOutputPort =
            RouterNetworkH2Adapter.getInstance();
            notifyOutputPort =
            NotifyEventKafkaAdapter.getInstance();
            usecase =
            new RouterNetworkInputPort(routerOutputPort,
            notifyOutputPort);
            inputAdapter =
            new RouterNetworkRestAdapter(usecase);
            rest();
            NotifyEventWebSocketAdapter.startServer();
        }
        default -> {
            routerOutputPort =
            RouterNetworkFileAdapter.getInstance();
            usecase =
            new RouterNetworkInputPort(routerOutputPort);
            inputAdapter =
            new RouterNetworkCLIAdapter(usecase);
            cli();
        }
    }
}

除了 WebSocket 服务器类,我们还需要实现一个 WebSocket 客户端类来处理来自 Kafka 的事件:

public class WebSocketClientAdapter extends
  org.java_websocket.client.WebSocketClient {
    public WebSocketClientAdapter(URI serverUri) {
        super(serverUri);
    }
    @Override
    public void onMessage(String message) {
        String channel = message;
    }
    @Override
    public void onOpen(ServerHandshake handshake) {
        System.out.println("Connection has opened");
    }
    @Override
    public void onClose(int code, String reason,
    boolean remote) {
        System.out.println("Connection has closed");
    }
    @Override
    public void onError(Exception e) {
        System.out.println(
        "An error occurred. Check the exception below:");
        e.printStackTrace();
    }
}

当从 Kafka 主题中消费消息时,六边形应用使用WebSocketClientAdapter将消息转发到 WebSocket 服务器。onMessageonOpenonCloseonError方法代表了WebSocketClientAdapter类需要支持的 WebSocket 协议操作。

在六边形应用中,我们需要做的最后一件事是让addNetworkToRoutergetRouter方法使用我们刚刚创建的端口和适配器发送事件:

public class RouterNetworkInputPort implements RouterNet
  workUseCase {
    /** Code omitted **/
    @Override
    public Router addNetworkToRouter(
    RouterId routerId,  Network network) {
        var router = fetchRouter(routerId);
        notifyEventOutputPort.
        sendEvent("Adding "+network.getName()
        +" network to router "+router.getId().getUUID());
        return createNetwork(router, network);
    }
    @Override
    public Router getRouter(RouterId routerId) {
        notifyEventOutputPort.
        sendEvent(
        "Retrieving router ID"+routerId.getUUID());
        return fetchRouter(routerId);
    }
    /** Code omitted **/
}

注意,现在我们在两个方法(addNetworkToRoutergetRouter)上都调用了sendEvent,所以无论何时添加网络或检索路由器,六边形应用都会发送一个事件来通知我们发生了什么。

现在,我们可以添加一个事件页面,以便前端应用能够从六边形应用连接到 WebSocket 服务器。以下截图显示了我们将要创建的事件页面:

图 5.11 – 拓扑和库存应用事件页面

图 5.11 – 拓扑和库存应用事件页面

事件页面遵循我们在之前页面中使用的相同结构。这个页面的重要部分是用于将用户连接到我们六边形应用暴露的 WebSocket 服务器的 JavaScript 代码:

var wsocket;
function connect() {
    wsocket = new WebSocket("ws://localhost:8887");
    wsocket.onopen = onopen;
    wsocket.onmessage = onmessage;
    wsocket.onclose = onclose;
}
    function onopen() {
    console.log("Connected!");
}
    function onmessage(event) {
    console.log("Data received: " + event.data);
    var tag = document.createElement("div");
    tag.id = "message";
    var text = document.createTextNode(">>"+event.data);
    tag.appendChild(text);
    var element = document.getElementById("events");
    element.appendChild(tag);
}
    function onclose(e) {
    console.log("Connection closed.");
}
window.addEventListener("load", connect, false);

onmessage 方法为从 WebSocket 连接接收到的每条新消息创建并附加一个新的 div HTML 元素。因此,由六边形应用程序生成的每个事件都将发送到 Kafka,并在前端应用程序中实时打印。前端、具有 WebSocket 的六边形应用程序和 Kafka 消息系统之间的通信在以下流程中表示:

图 5.12 – 前端、具有 WebSocket 的六边形应用程序和消息系统之间的流程

图 5.12 – 前端、具有 WebSocket 的六边形应用程序和消息系统之间的流程

要测试此流程,请确保您的本地 Kafka 实例正在运行。然后,启动六边形应用程序:

java -jar target/topology-inventory-1.0-SNAPSHOT-jar-with-dependencies.jar rest
REST endpoint listening on port 8080...
Topology & Inventory WebSocket started on port 8887...

要在您的浏览器和应用程序之间创建 WebSocket 连接,您需要打开 ca23800e-9b5a-11eb-a8b3-0242ac130003 ID。事件条目将在 事件 页面上显示如下:

图 5.13 – 前端应用程序通过 WebSocket 连接接收来自 Kafka 的事件

图 5.13 – 前端应用程序通过 WebSocket 连接接收来自 Kafka 的事件

使用 Kafka 和 WebSocket 的这种集成向我们展示了六边形应用程序如何处理消息驱动操作。我们不需要修改业务逻辑来添加这些技术。我们只需创建更多的端口和适配器来增强系统的功能。

现在,让我们简要地看看六边形应用程序可以处理的另一种驱动操作类型。

模拟服务器

软件开发的典型方法是有多个环境,例如开发、QA 和生产。第一个工作的软件版本开始进入开发环境,然后逐步进入生产环境。这种进入生产的过程通常由 CI 管道执行,它们持续验证并确保软件运行良好。

在 CI 验证中,单元和集成测试可能在管道执行期间发生。特别是集成测试,它依赖于外部组件,如其他应用程序、系统、数据库和服务,所有这些都在不同的环境中提供。

在开发环境中执行集成测试风险较低,但如果存在资源并发使用的情况,可能会引起问题。这种并发问题可能导致测试结果不一致。对于 QA 来说,情况稍微复杂一些,因为我们必须确保在处理专门针对特定场景量身定制的数据时的一致性。如果测试数据意外更改,我们可能会在测试结果中找到不一致性。我们需要小心,因为 QA 中测试失败的成本甚至高于开发环境。

为了克服测试障碍,一些工具模拟应用程序端点和它们的响应。这些工具被称为模拟解决方案,它们以各种形状和形式存在。你可以手动模拟应用程序所需服务的响应和端点;然而,这并不总是简单的事情,可能需要相当大的努力。此外,还有一些复杂的工具会做脏活,让你只需关注逻辑。这就是模拟服务器的角色。

由于模拟服务器充当一个提供有用资源的对外实体,我们也认为它们是六边形系统中的次要角色,该系统希望通过利用模拟服务器功能而不是实际系统。

我们绝对没有穷尽六边形系统可能拥有的所有可能的驱动操作。但,在本节中,我们窥视了六边形应用程序中存在的一些相关驱动操作。

摘要

在本章中,我们有深入了解驱动和驱动操作本质的机会。尽管我们已经在之前的章节中处理过它们,但我们对这些操作进行了更深入的探讨。

驱动操作开始,我们了解到它们通过调用其输入适配器来驱动六边形应用程序的行为。为了说明驱动操作,我们首先创建了一个前端应用程序,扮演主要角色,通过拓扑和库存六边形应用程序提供的输入适配器请求数据。然后,为了探索作为驱动操作的测试工具,我们创建了一个基于六边形应用程序公开的 API 端点的 Postman 集合进行测试。

驱动操作方面,我们看到了如何使六边形应用程序能够与基于消息的系统(如 Kafka)一起工作。为了更好地理解基于消息的系统对六边形应用程序的影响,我们创建了端口和适配器,使应用程序能够从 Kafka 发送和消费消息。此外,我们还创建了一个 WebSocket 服务器,让前端应用程序实时检索六边形系统生成的事件。

通过处理不同类型的驱动和驱动操作,我们现在可以更好地理解六边形系统的内部运作、其环境以及驱动和驱动操作如何影响六边形应用程序。

从本章和前几章获得的基本原理为使用六边形架构工具开始开发健壮、可容忍变化的系统提供了所有构建块。

在下一章中,我们将应用我们所学到的知识来启动构建一个生产级六边形系统的过程,该系统将结合 Java 模块系统和Quarkus框架的功能。

问题

  1. 驱动操作是什么?

  2. 请举一个驱动操作的例子。

  3. 驱动操作是什么?

  4. 请举一个驱动操作的例子。

答案

  1. 驱动操作是由驱动六边形应用程序行为的主体角色发起的请求。

  2. 一个前端应用程序通过其输入适配器之一调用六边形系统是驱动操作的例子。

  3. 驱动操作是由六边形应用程序本身发起的请求,通常代表用例需求,向由六边形系统驱动的次要角色发起。

  4. 当六边形应用程序访问数据库时。在这种情况下,数据库由六边形应用程序驱动。

第二部分:使用六边形创建坚实基础

通过跟随一个管理电信网络和拓扑库存的系统的真实世界示例,在本部分中,你将学习如何使用六边形架构思想构建创建此类系统的构建块。

这是一个实践部分,我们将有机会在应用六边形架构原则的同时亲自动手。我们首先实现领域六边形,其中包含拓扑和库存系统的领域模型。然后,我们通过用例和端口来表示系统行为,实现应用程序六边形。为了启用和暴露六边形系统提供的功能,我们使用适配器实现框架六边形。在本部分的结尾,我们学习如何使用 Java 模块在我们的六边形系统中应用依赖倒置。

本部分包含以下章节:

  • 第六章, 构建领域六边形

  • 第七章, 构建应用程序六边形

  • 第八章, 构建框架六边形

  • 第九章, 使用 Java 模块实现依赖倒置

第六章:构建领域六边形

在前面的章节中,我们有机会使用领域驱动设计DDD)技术,如实体和值对象,来创建领域模型。然而,直到现在,我们还没有涉及到组织包、类和模块以适应六边形架构的目的。

领域六边形是开始开发六边形应用程序的地方。基于领域,我们推导出所有其他六边形。我们可以这样说,领域六边形是六边形系统的核心,因为核心的基本业务逻辑就驻留在这样的六边形中。

因此,在本章中,我们将从底层开始探索如何使用 Java 模块方法来构建六边形应用程序项目。这将帮助我们确保更好的封装和单元测试,以验证我们在开发领域六边形组件时的代码。

本章我们将涵盖以下主题:

  • 引导构建领域六边形

  • 理解问题域

  • 定义值对象

  • 定义实体和规范

  • 定义领域服务

  • 测试领域六边形

到本章结束时,你将获得关于所有领域六边形组件开发的实际操作视角。这些知识将使你能够处理领域六边形中关于类和包的结构和排列的所有细节。

技术要求

要编译和运行本章中展示的代码示例,你需要在你的计算机上安装最新的Java SE 开发工具包Maven 3.8。它们都适用于 Linux、Mac 和 Windows 操作系统。

你可以在 GitHub 上找到本章的代码文件,地址为github.com/PacktPublishing/-Designing-Hexagonal-Architecture-with-Java---Second-Edition/tree/main/Chapter06

引导构建领域六边形

本章我们将开始构建的六边形应用程序项目实际上是我们在上一章中开发的拓扑和库存系统的延续。然而,这里的区别在于我们将增强系统的一些功能,并使用Java 平台模块系统JPMS)来封装领域六边形为一个 Java 模块。

要开始引导构建领域六边形,让我们创建一个多模块 Maven 项目,如下所示:

  1. 首先,我们将通过执行以下代码创建一个名为topology-inventory的父项目:

    mvn archetype:generate \
    -DarchetypeGroupId=org.codehaus.mojo.archetypes \
    -DarchetypeArtifactId=pom-root \
    -DarchetypeVersion=RELEASE \
    -DgroupId=dev.davivieira \
    -DartifactId=topology-inventory \
    -Dversion=1.0-SNAPSHOT \
    archetype:generate Maven goal to generate a Maven root project for the system. It creates a pom.xml file with the coordinates we pass in the command’s parameters, such as groupId and artifactId.
    
  2. 然后,我们为领域六边形创建一个模块,如下所示:

    cd topology-inventory
    mvn archetype:generate \
      -DarchetypeGroupId=de.rieckpil.archetypes  \
      -DarchetypeArtifactId=testing-toolkit \
      -DarchetypeVersion=1.0.0 \
      -DgroupId=dev.davivieira \
      -DartifactId=domain \
      -Dversion=1.0-SNAPSHOT \
      -Dpackage=dev.davivieira.topologyinventory.domain \
    archetype:generate Maven goal. The result is a Maven module called domain that is part of the topology-inventory Maven project.
    
  3. 在执行mvn命令创建topology-inventory Maven 根项目和domain模块之后,你将拥有一个类似于以下所示的目录树:

图 6.1 – 领域六边形的目录结构

图 6.1 – 领域六边形的目录结构

自从将module-info.java模块描述符文件发布到 Java 项目根目录以来。当你使用此文件创建 Java 模块时,你将关闭对该模块中所有公共包的访问。为了使公共包对其他模块可访问,你需要在模块描述符文件中导出所需的包。关于 Java 模块还有其他有趣的事情要说,但我们已经将它们留给了第九章使用 Java 模块应用依赖倒置

要将领域六边形转换为 Java 模块,你需要在topology-inventory/domain/src/java/module-info.java创建一个模块描述符文件,如下所示:

module domain {
}

由于我们尚未允许访问任何公共包,也没有依赖于其他模块,我们将module-info.java文件留空。

为了使领域以及所有其他具有更简洁类别的六边形都更加简洁,我们将lombok库添加到pom.xml项目根目录,如下所示:

<dependencies>
  <dependency>
      <groupId>org.projectlombok</groupId>
      <artifactId>lombok</artifactId>
      <version>1.18.20</version>
      <scope>compile</scope>
  </dependency>
</dependencies>

同样,配置lombok的注解处理路径也很重要;否则,将出现编译错误。你可以通过运行以下代码来完成此操作:

<plugins>
  <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-compiler-plugin</artifactId>
      <version>3.8.1</version>
      <configuration>
          <source>17</source>
          <target>17</target>
          <annotationProcessorPaths>
              <path>
                   <groupId>org.projectlombok</groupId>
                   <artifactId>lombok</artifactId>
                   <version>1.18.26</version>
              </path>
          </annotationProcessorPaths>
      </configuration>
  </plugin>
</plugins>

maven-compile-plugin插件块内部,我们添加了annotationProcessorPaths的配置。

由于我们添加了 lombok 依赖项,我们需要更新域的module-info.java文件,如下所示:

module domain {
    requires static lombok;
}

我们现在已准备好开始在完全模块化的结构之上开发领域六边形。让我们继续了解我们增强拓扑和库存系统的问题域。

理解问题域

我们将通过考虑以下事实来开始建模问题域:核心路由器可以连接到核心路由器和边缘路由器。反过来,边缘路由器连接到交换机和它们的网络。以下图表描述了这一场景:

图 6.2 – 拓扑和库存网络系统的用例

图 6.2 – 拓扑和库存网络系统的用例

核心路由器速度更快,处理高流量负载,并且它们不直接处理来自交换机和其网络的流量。相反,边缘路由器直接处理来自交换机和其网络的流量。在我们的场景中,边缘路由器不允许连接到其他边缘路由器;它只能连接到核心路由器和交换机。交换机可以有多个网络。

请记住,这是一个为我们的场景建立的特定安排。这绝对不代表组织网络组件的严格规则。以下是我们的场景安排的图表:

图 6.3 – 拓扑和库存网络系统的用例(续)

图 6.3 – 拓扑和库存网络系统的用例(续)

拓扑和库存系统的目的是允许用户查看和管理网络资产。通过网络资产,我们指的是路由器、交换机和网络——路由器和交换机是物理资产,而网络是由交换机提供的逻辑资产。这些资产分布在不同的位置,系统应显示资产及其站点之间的互连性。位置由完整的地址以及其纬度和经度组成。

管理部分仅基于类似于创建、读取、更新、删除CRUD)的操作,使用户能够控制拓扑和库存系统数据。

我们构建此类系统的方法是首先创建一个领域六边形,使用包含实现系统最高层目的所需操作和规则的领域模型。在最高层,我们的意图是直接在领域六边形上验证商业想法,而不需要应用和框架六边形上现有事物的帮助。随着事物移动到这些六边形,它们往往会变得更加技术特定,处于较低层次,因为技术特定的事物离领域六边形很远。我们在领域六边形内保持核心系统功能程度的程度,极大地影响了六边形系统的松散耦合程度。

为了验证领域六边形的方法和类,我们将创建单元测试以确保领域操作按预期工作。这将给我们一定的信心继续前进,并在应用六边形上使用这些操作。

接下来,我们将开始使用价值对象构建六边形系统的基础,这些是架构组件,使我们能够创建领域模型以更好地表达问题域。

定义价值对象

正如我们在第二章中已经看到的,“在领域六边形内封装业务规则”,实体是我们用来分类具有身份的系统组件的元素。相反,价值对象没有身份。我们使用价值对象来描述那些不需要定义身份的系统部分。然后,我们有聚合体,用于封装对象的关联实体和值。

我建议首先创建价值对象,因为它们就像构建块,是我们将用来构建更复杂价值对象和——最重要的是——实体的原材料。现在,我们将添加在上一节中当我们启动领域六边形时创建的所有领域六边形模块上的体积对象类。我们将使用以下步骤来定义价值对象:

  1. 让我们从Id价值对象类开始,如下所示:

    package dev.davivieira.topologyinventory.domain.vo;
    import lombok.EqualsAndHashCode;
    import lombok.Getter;
    import lombok.ToString;
    import java.util.UUID;
    @Getter
    @ToString
    @EqualsAndHashCode
    public class Id {
        private final UUID id;
        private Id(UUID id){
            this.id = id;
        }
        public static Id withId(String id){
            return new Id(UUID.fromString(id));
        }
        public static Id withoutId(){
            return new Id(UUID.randomUUID());
        }
    }
    

    上述代码非常直接,只有一个UUID属性,我们用它来存储id值。我们将使用withId静态方法来创建具有给定字符串的Id实例。如果我们想创建新的东西,我们应该使用withoutId静态方法,它随机生成 ID。

  2. 如我们在定义实体和规范部分中将要看到的,Vendor enum值对象类在路由器和交换机实体类中都使用。您可以在以下代码片段中看到此类:

    package dev.davivieira.topologyinventory.domain.vo;
    public enum Vendor {
        CISCO,
        NETGEAR,
        HP,
        TPLINK,
        DLINK,
        JUNIPER
    }
    

    我们将Vendor类建模为enum,这样我们可以轻松地展示系统功能。

  3. 我们将对Model enum做同样的事情,如下所示:

    package dev.davivieira.topologyinventory.domain.vo;
    public enum Model {
        XYZ0001,
        XYZ0002,
        XYZ0003,
        XYZ0004
    }
    
  4. 对于Protocol,我们创建一个enum值对象来表示互联网协议版本 4IPv4)和IP 版本 6IPv6)协议,如下所示:

    package dev.davivieira.topologyinventory.domain.vo;
    public enum Protocol {
        IPV4,
        IPV6;
    }
    
  5. 为了帮助我们清楚地定义我们正在处理哪种类型的路由器,我们将创建一个RouterType enum,如下所示:

    package dev.davivieira.topologyinventory.domain.vo;
    public enum RouterType {
        EDGE,
        CORE;
    }
    
  6. 同样的想法也应用于可用的交换机类型,如下所示:

    package dev.davivieira.topologyinventory.domain.vo;
    public enum SwitchType {
        LAYER2,
        LAYER3;
    }
    
  7. 由于每个路由器和交换机都有一个位置,我们必须创建一个Location值对象类,如下所示:

    package dev.davivieira.topologyinventory.domain.vo;
    public record Location (
        String address,
        String city,
        String state,
        int zipCode,
        String country,
        float latitude,
        float longitude
    ) {}
    

    我们引入了具有允许我们唯一识别地址的属性的Location值对象。这就是为什么我们也有latitudelongitude作为类属性。

我们刚刚创建的值对象是最重要的,因为它们是其他值对象和实体的基本构建块,这些实体构成了整个系统。接下来,我们可以根据我们刚刚创建的创建更复杂的价值对象,如下所示:

  1. 让我们从以下代码片段中所示的IP值对象开始:

    /** Code omitted **/
    public class IP {
        private final String ipAddress;
        private final Protocol;
        public IP(String ipAddress){
          if(ipAddress == null)
              throw new IllegalArgumentException(
              "Null IP address");
             this.ipAddress = ipAddress;
          if(ipAddress.length()<=15) {
              this.protocol = Protocol.IPV4;
          } else {
            this.protocol = Protocol.IPV6;
          }
        }
    /** Code omitted **/
    }
    

    使用IP值对象类,我们可以创建 IPv4 和 IPv6 地址。检查使用哪个协议的约束在值对象构造函数内部。我们用来验证 IP 地址的逻辑非常简单,只是为了我们的示例。为了更全面的验证,我们可以使用commons-validator库中的InetAddressValidator类。

  2. 然后,我们创建一个值对象来表示将被添加到交换机中的网络,如下所示:

    package dev.davivieira.topologyinventory.domain.vo;
    import lombok.Builder;
    import lombok.EqualsAndHashCode;
    import lombok.Getter;
    import lombok.ToString;
    @Builder
    @Getter
    @ToString
    @EqualsAndHashCode
    public class Network {
        private IP networkAddress;
        private String networkName;
        private int networkCidr;
        public Network(IP networkAddress,
        String networkName, int networkCidr){
            if(networkCidr <1 || networkCidr>32){
                throw new IllegalArgumentException(
                "Invalid CIDR value");
            }
            this.networkAddress = networkAddress;
            this.networkName = networkName;
            this.networkCidr = networkCidr;
        }
    }
    

    我们将Network值对象建模为存储 IP 地址、网络名称和10.0.0.0是网络基本 IP 地址。第二个数字(例如,24)用于确定网络子网掩码以及在这个网络中可用的 IP 地址数量。在Network类中,我们引用第二个 CIDR 数字。

    Network构造函数内部,我们添加了约束来验证 CIDR 值是否有效。

    最后,你将有一个类似于以下屏幕截图所示的包和类结构:

图 6.4 – 值对象的目录结构

图 6.4 – 值对象的目录结构

现在我们已经了解了值对象,它们是我们领域六边形的构建块,我们可以继续创建实体及其规范。

定义实体和规范

一旦我们创建了所有值对象,我们就可以开始考虑如何在具有身份的实体中表示元素。此外,我们需要开发规范来定义规范业务规则,这些规则控制实体应遵守的约束。

记住,定义实体的特征是其身份、业务规则和数据的存在。在拓扑和库存系统中,我们有EquipmentRouterSwitch作为实体。

在我们之前创建的domain Java 模块中,我们将在名为entity的包内添加实体类。

设备和路由器抽象实体

路由器和交换机是不同类型的网络设备,因此我们将首先创建一个Equipment抽象类,如下所示:

package dev.davivieira.topologyinventory.domain.entity;
import dev.davivieira.topologyinventory.domain.vo.IP;
import dev.davivieira.topologyinventory.domain.vo.Id;
import dev.davivieira.topologyinventory.domain.vo.Location;
import dev.davivieira.topologyinventory.domain.vo.Model;
import dev.davivieira.topologyinventory.domain.vo.Vendor;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public abstract sealed class Equipment
permits Router, Switch {
    protected Id id;
    protected Vendor vendor;
    protected Model model;
    protected IP ip;
    protected Location location;
    public static Predicate<Equipment>
    getVendorPredicate(Vendor vendor){
        return r -> r.getVendor().equals(vendor);
    }
}

在上一节中创建的大多数值对象都包含在Equipment实体中。我们使用getVendorTypePredicate提供的谓词来应用过滤器,只检索特定供应商的设备。

Equipment派生,我们创建了一个Router抽象类,如下所示:

package dev.davivieira.topologyinventory.domain.entity;
import dev.davivieira.topologyinventory.domain.vo.IP;
import dev.davivieira.topologyinventory.domain.vo.Id;
import dev.davivieira.topologyinventory.domain.vo.Location;
import dev.davivieira.topologyinventory.domain.vo.Model;
import dev.davivieira.topologyinventory.domain.
  vo.RouterType;
import dev.davivieira.topologyinventory.domain.vo.Vendor;
import lombok.Getter;
import java.util.function.Predicate;
@Getter
public abstract sealed class Router extends Equipment
permits CoreRouter, EdgeRouter {
    protected final RouterType routerType;
    public static Predicate<Router>
    getRouterTypePredicate(RouterType routerType){
        return r -> r.getRouterType().equals(routerType);
    }
    /** Code omitted **/
}

Router抽象类定义了核心或边缘路由器共有的谓词。我们使用getRouterTypePredicate提供的谓词来应用过滤器,只检索特定类型的路由器。

在这里,我们有来自Router抽象类的两个更多谓词:

public static Predicate<Equipment>
  getModelPredicate(Model model){
    return r -> r.getModel().equals(model);
}
public static Predicate<Equipment>
  getCountryPredicate(Location location){
    return p ->
     p.location.country().equals(location.country());
}

我们使用getModelPredicategetCountryPredicate谓词来检索特定型号或特定国家的路由器。

Router抽象类提供了核心和边缘路由器共享的常见属性。在Router类中,我们引入了谓词,用作查询路由器列表时的过滤器。

核心路由器实体及其规范

接下来,让我们实现CoreRouter实体类,如下所示:

/** Imports omitted **/
public final class CoreRouter extends Router {
    /** Code omitted **/
    public Router addRouter(Router anyRouter){
        var sameCountryRouterSpec =
        new SameCountrySpec(this);
        var sameIpSpec =
        new SameIpSpec(this);
        sameCountryRouterSpec.check(anyRouter);
        sameIpSpec.check(anyRouter);
        return this.routers.put(anyRouter.id, anyRouter);
    }
/** Code omitted **/
}

核心路由器可以连接到其他核心和边缘路由器。为了在CoreRouter类中允许这种行为,我们创建了一个接收Router抽象类型作为参数的addRouter方法。我们还使用SameCountrySpec规范来确保边缘路由器与核心路由器位于同一国家。当我们尝试将核心路由器连接到另一个核心路由器时,此规则不适用。

接下来,我们有SameIPSpec规范来确认路由器没有相同的 IP 地址。通过使用规范,我们使业务规则更加明确,代码更容易阅读和理解。你可以不使用任何规范,只使用必要的变量抛出if-else条件,但对于不熟悉它的人来说,理解代码所需的认知负荷可能会更高。

在这里,我们有removeRouter方法:

public Router removeRouter(Router anyRouter){
    var emptyRoutersSpec = new EmptyRouterSpec();
    var emptySwitchSpec = new EmptySwitchSpec();
    switch (anyRouter.routerType) {
        case CORE → {
            var coreRouter = (CoreRouter)anyRouter;
            emptyRoutersSpec.check(coreRouter);
        }
        case EDGE → {
            var edgeRouter = (EdgeRouter)anyRouter;
            emptySwitchSpec.check(edgeRouter);
        }
    }
    return this.routers.remove(anyRouter.id);
}

对于removeRouter方法,我们有EmptyRouterSpec规范,它防止我们移除任何其他路由器连接到它的路由器。EmptySwitchSpec规范检查路由器是否连接了任何交换机。

核心路由器只处理其他路由器。这就是为什么在 CoreRouter 实体类中没有交换机的引用。

注意,两个方法 addRouterremoveRouter 直接在 Router 类型参数上操作,使用域规范来检查在做出任何更改之前没有约束违规。让我们仔细检查 CoreRouter 实体使用的规范,从 SameCountrySpec 规范开始。这个规范确保边缘路由器始终来自与其核心路由器相同的国家。

package 规范是我们将放置所有规范的地方,因此我们将 SameCountrySpec 规范放在这个包中,如下所示:

/** Imports omitted **/
public final class SameCountrySpec extends AbstractSpecifi
  cation<Equipment> {
    private final Equipment equipment;
    public SameCountrySpec(Equipment equipment){
        this.equipment = equipment;
    }
/** Code omitted **/
}

SameCountrySpec 构造函数接收一个 Equipment 对象,我们使用它来初始化 equipment 私有字段。

继续实现 SameCountrySpec,我们重写 isSatisfiedBy 方法,如下所示:

@Override
public boolean isSatisfiedBy(Equipment anyEquipment) {
    if(anyEquipment instanceof CoreRouter) {
        return true;
    } else if (
    anyEquipment != null && this.equipment != null) {
        return this
        .equipment
        .getLocation()
        .country()
        .equals(
           anyEquipment.getLocation().country());
    } else{
        return false;
    }
}

SameCountrySpec 实现不适用于核心路由器。这就是为什么当对象是 CoreRouter 实体时,我们总是返回 true。否则,我们继续进行验证,检查设备是否不在不同的国家。

接下来,我们重写 check 方法,如下所示:

@Override
public void check(Equipment equipment) {
    if(!isSatisfiedBy(equipment))
        throw new GenericSpecificationException(
        "The equipments should be in the same country");
}

我们使用 check 方法来运行规范。其他类可以调用此方法来验证规范是否得到满足。

可以连接来自不同国家的两个核心路由器。然而,如前所述,不可能连接不在同一国家的边缘和核心路由器。请注意,这个规范基于 Equipment 类型,允许我们不仅与路由器,而且与交换机重用这个规范。

以下 SameIpSpec 规范确保没有设备具有相同的 IP 地址:

/** Imports omitted **/
public final class SameIpSpec extends AbstractSpecification
  <Equipment>{
    private final Equipment equipment;
    public SameIpSpec(Equipment equipment){
        this.equipment = equipment;
    }
    @Override
    public boolean isSatisfiedBy(Equipment anyEquipment) {
        return
       !equipment.getIp().equals(anyEquipment.getIp());
    }
    @Override
    public void check(Equipment equipment) {
        if(!isSatisfiedBy(equipment))
            throw new GenericSpecificationException("It's
              not possible to attach routers with the same
              IP");
    }
}

SameCountrySpecSameIpSpec 规范被 addRouter 方法使用,以确保在向核心路由器添加任何路由器之前不会违反任何约束。

继续前进,我们有 EmptyRouterSpecEmptySwitchSpec 规范。在删除路由器之前,我们必须确保没有其他路由器或交换机连接到这样的路由器。这些规范非常简单。让我们首先查看 EmptyRouterSpec 规范,如下所示:

/** Imports omitted **/
public final class EmptyRouterSpec extends AbstractSpecification
  <CoreRouter> {
    @Override
    public boolean isSatisfiedBy(CoreRouter coreRouter) {
        return coreRouter.getRouters()==null||
                coreRouter.getRouters().isEmpty();
    }
    @Override
    public void check(CoreRouter coreRouter) {
        if(!isSatisfiedBy(coreRouter))
            throw new GenericSpecificationException("It
              isn't allowed to remove a core router with
              other routers attached to it");
    }
}

这个规范基于 CoreRouter 类型,因为只有核心路由器可以连接到其他核心和边缘路由器。

EmptySwitchSpec 类如下所示:

/** Imports omitted **/
public final class EmptySwitchSpec extends AbstractSpecification
  <EdgeRouter> {
    @Override
    public boolean isSatisfiedBy(EdgeRouter edgeRouter) {
        return edgeRouter.getSwitches()==null ||
                edgeRouter.getSwitches().isEmpty();
    }
    @Override
    public void check(EdgeRouter edgeRouter) {
        if(!isSatisfiedBy(edgeRouter))
            throw new GenericSpecificationException("It
              isn't allowed to remove an edge router with a
              switch attached to it");
    }
}

EmptySwitchSpec 类与 EmptyRouterSpec 类非常相似。然而,区别在于只有边缘路由器可以拥有交换机。这就是为什么这个规范基于 EdgeRouter 类型。

边缘路由实体及其规范

现在我们已经完成了 CoreRouter 实体及其规范,我们可以继续创建 EdgeRouter 实体类,如下所示:

/** Imports omitted **/
public final class EdgeRouter extends Router {
    /**Code omitted **/
    private final Map<Id, Switch> switches;
    public void addSwitch(Switch anySwitch){
        var sameCountryRouterSpec =
        new SameCountrySpec(this);
        var sameIpSpec = new SameIpSpec(this);
        sameCountryRouterSpec.check(anySwitch);
        sameIpSpec.check(anySwitch);
        this.switches.put(anySwitch.id,anySwitch);
    }
    /** Code omitted **/
}

addSwitch方法的目的是将开关连接到边缘路由器。此外,在EdgeRouter类中,我们重用了在实现CoreRouter类时使用的相同的SameCountrySpecSameIpSpec规范。

接下来,我们有removeSwitch方法,如下面的代码片段所示:

public Switch removeSwitch(Switch anySwitch){
    var emptyNetworkSpec = new EmptyNetworkSpec();
    emptyNetworkSpec.check(anySwitch);
    return this.switches.remove(anySwitch.id);
}

对于removeSwitch方法,我们有EmptyNetworkSpec规范来确保开关没有连接任何网络。

正如我们在CoreRouter类中所做的那样,我们使用了SameCountrySpecSameIpSpec规范。然而,上下文是不同的,因为我们正在将一个开关添加到路由器中。在EdgeRouter类中使用的唯一新规范是EmptyNetworkSpec规范,它用于确保在从边缘路由器中删除之前,所有网络都已从开关中删除。

开关实体及其规范

现在剩下的是Switch实体类及其相关规范的实现。我们在这里使用的方法与我们在核心和边缘路由器实体中应用的方法类似。让我们首先创建一个Switch实体类,如下所示:

/** Imports omitted **/
public final class Switch extends Equipment {
    private final SwitchType switchType;
    private final     List<Network> switchNetworks;
    /** Code omitted **/
    public static Predicate<Switch>getSwitchTypePredicate
      (SwitchType switchType){
        return s -> s.switchType.equals(switchType);
    }
    /** Code omitted **/
}

我们通过创建一个getSwitchTypePredicate方法谓词来开始Switch类的实现,我们使用这个谓词来根据开关类型过滤开关集合。

接下来,我们创建一个addNetworkToSwitch方法,如下所示:

public boolean addNetworkToSwitch(Network network) {
    var availabilitySpec =
    new NetworkAvailabilitySpec(network);
    var cidrSpec = new CIDRSpecification();
    var amountSpec = new NetworkAmountSpec();
    cidrSpec.check(network.getNetworkCidr());
    availabilitySpec.check(this);
    amountSpec.check(this);
    return this.switchNetworks.add(network);
}

addNetworkToSwitch方法接收一个Network类型参数,我们使用它将网络添加到开关中。然而,在添加网络之前,我们需要检查由规范表达的一些约束。第一个是NetworkAvailabilitySpec规范,它验证网络是否已经存在于开关上。然后,我们使用CIDRSpecification规范来检查网络 CIDR 是否有效。最后,我们使用NetworkAmountSpec规范来验证我们是否已经超过了开关上允许的最大网络数量。

接下来,我们有removeNetworkFromSwitch方法,如下面的代码片段所示:

public boolean removeNetworkFromSwitch(
  Network network){
    return this.switchNetworks.remove(network);
}

由于没有约束来从开关中删除网络,这个方法没有使用任何规范。

总结一下,在Switch类的开头,我们声明了一个谓词,以便我们可以根据开关类型(LAYER2LAYER3)过滤开关集合。addNetworktoSwitch方法使用了我们在第二章在领域六边形内封装业务规则中已经定义的NetworkAvailabilitySpecNetworkAmountSpecCIDRSpecification规范。如果这些规范的约束没有被违反,一个Network对象将被添加到开关中。

最后,我们有removeNetworkFromSwitch方法,它不会查看任何规范来从开关中删除网络。

通过Switch实体实现,我们完成了满足拓扑和库存系统目的的实体和规范的建模。

对于所有实体,你应该有一个类似于以下的包和类结构:

图 6.5 – 实体的目录结构

图 6.5 – 实体的目录结构

正如前一个屏幕截图所示,我们将所有实体放在了entity包中。

对于所有规范,包和类结构应该如下所示:

图 6.6 – 规范的目录结构

图 6.6 – 规范的目录结构

顶点和库存系统中使用的某些规范已在第二章中创建,将业务规则封装在领域六边形内。其余规范是我们在本节中创建的。

基于我们刚刚创建的实体,我们现在可以思考与这些实体不直接相关的任务。这就是作为提供领域实体外能力的替代方案工作的服务的情况。现在让我们看看如何实现允许我们查找、过滤和从系统中检索数据的服务。

定义领域服务

拓扑和库存系统是关于网络资产的可视化和管理,因此我们需要允许用户处理此类网络资产的集合。一种方法是通过服务来实现。通过服务,我们可以定义处理系统实体和值对象的行为。

我们将在本节中创建的所有服务都位于service包中。

让我们先创建一个服务来处理路由器集合。

路由器服务

在上一节中,在实现RouterCoreRouterEdgeRouter实体时,我们还创建了一些方法来返回谓词,以帮助我们过滤路由器集合。通过领域服务,我们可以使用这些谓词来过滤此类集合,如下所示:

package dev.davivieira.topologyinventory.domain.service;
import dev.davivieira.topologyinventory.domain.
  entity.Equipment;
import dev.davivieira.topologyinventory.domain.
  entity.Router;
import dev.davivieira.topologyinventory.domain.vo.Id;
import java.util.List;
import java.util.Map;
import java.util.function.Predicate;
import java.util.stream.Collectors;
public class RouterService {
    public static List<Router>
    filterAndRetrieveRouter(List<Router> routers,
    Predicate<Equipment> routerPredicate){
        return routers
                .stream()
                .filter(routerPredicate)
                .collect(Collectors.<Router>toList());
    }
    public static Router findById(
    Map<Id,Router> routers, Id id){
        return routers.get(id);
    }
}

对于filterAndRetrieveRouter方法,我们传递一个路由器列表和一个谓词作为参数,以过滤列表。然后,我们定义一个findById方法,使用Id类型参数检索路由器。

现在,让我们看看我们可以使用的服务操作来处理交换机。

交换机服务

此服务遵循我们应用于路由器服务相同的思想。它主要基于getSwitchTypePredicate方法提供的谓词来根据类型过滤交换机集合。随着新谓词的出现,我们可以将它们用作新的标准来过滤交换机集合。此外,请注意,findById方法再次被用来允许根据Id类型参数检索交换机。以下是代码:

package dev.davivieira.topologyinventory.domain.service;
import dev.davivieira.topologyinventory.domain.
  entity.Switch;
import dev.davivieira.topologyinventory.domain.vo.Id;
import java.util.List;
import java.util.Map;
import java.util.function.Predicate;
import java.util.stream.Collectors;
public class SwitchService {
    public static List<Switch> filterAndRetrieveSwitch
      (List<Switch>   switches, Predicate<Switch>
       switchPredicate){
     return switches
                .stream()
                .filter(switchPredicate)
                .collect(Collectors.<Switch>toList());
    }
    public static Switch findById(Map<Id,Switch> switches,
      Id id){
     return switches.get(id);
    }
}

虽然我们没有在领域模型中将网络建模为实体,但创建处理网络值对象集合的服务类没有问题。

让我们为拓扑和库存系统创建最后一个服务类。

网络服务

此服务主要基于根据 IP 协议过滤网络集合的需求。我们可以有 IPv4 和 IPv6 网络的集合。此服务提供了根据网络 IP 协议过滤此类集合的能力。以下代码用于创建NetworkService类:

package dev.davivieira.topologyinventory.domain.service;
import dev.davivieira.topologyinventory.domain.vo.Network;
import java.util.List;
import java.util.function.Predicate;
import java.util.stream.Collectors;
public class NetworkService {
    public static List<Network> filterAndRetrieveNetworks
      (List<Network> networks, Predicate<Network>
       networkPredicate){
        return networks
                .stream()
                .filter(networkPredicate)
                .collect(Collectors.<Network>toList());
    }
}

filterAndRetrieveNetworks 方法接收一个网络列表和一个谓词,作为参数来过滤列表。它返回一个过滤后的网络列表。

使用NetworkService,我们完成了领域服务的创建。

在创建所有这些服务之后,你将拥有一个类似于以下所示的包和类结构:

图 6.7 – 领域服务的目录结构

图 6.7 – 领域服务的目录结构

为了推动值对象、实体、规范和服务的发展,你可以采用测试驱动开发TDD)的方法,其中你可以开始创建失败的测试,然后实现正确的类和方法来使这些测试通过。我们在这里做了相反的事情,以提供一个我们需要创建以构建拓扑和库存系统领域六边形的组件的大图景。

在本节中,我们创建了在领域六边形级别下运行的服务。我们不是直接将更多行为放在实体上,而是创建了单独的服务类,以实现我们不认为本质上是实体一部分的行为。这些服务使我们能够处理路由器、交换机和网络的集合。

在我们继续开发应用六边形之前,我们需要确保在领域六边形中创建的操作按预期工作;否则,当执行这些操作时,上游六边形将会崩溃。因此,在下一节中,我们将看到如何测试领域六边形。

测试领域六边形

为了适当地测试领域六边形,我们应该只依赖其组件,忽略来自其他六边形的任何内容。毕竟,这些六边形应该依赖于领域六边形,而不是相反。正如我们之前看到的,领域六边形专注于核心系统逻辑。正是从这个逻辑中,我们推导出应用和框架六边形的结构和行为。通过构建一个健壮且经过良好测试的领域六边形,我们为整个系统构建了一个坚实的基础。

在拓扑和库存系统执行的操作中,我们可以将添加、删除和搜索网络资产视为最重要的操作。我们将使用以下步骤来测试这些操作:

  1. 让我们先看看如何测试网络设备的添加,如下所示:

    @Test
    public void addNetworkToSwitch(){
        var location = createLocation("US");
        var newNetwork = createTestNetwork("30.0.0.1", 8);
        var networkSwitch =
        createSwitch("30.0.0.0", 8, location);
        assertTrue(
        networkSwitch.addNetworkToSwitch(newNetwork));
    }
    

    addNetworkToSwitch 方法检查当系统可以添加网络到交换机时的成功路径。以下测试检查了该路径的不愉快情况:

    @Test
    public void
    addNetworkToSwitch_failBecauseSameNetworkAddress(){
        var location = createLocation("US");
        var newNetwork = createTestNetwork("30.0.0.0", 8);
        var networkSwitch = createSwitch(
        "30.0.0.0", 8, location);
        assertThrows(GenericSpecificationException.class,
          () ->
          networkSwitch.addNetworkToSwitch(newNetwork));
    }
    

    addNetworkToSwitch_failBecauseSameNetworkAddress方法检查当我们尝试添加一个已经存在于交换机中的网络时的失败路径。

  2. 然后,我们有测试场景,其中我们想要向边缘路由器添加一个交换机,如下面的代码片段所示:

    @Test
    public void addSwitchToEdgeRouter(){
        edgeRouter.addSwitch(networkSwitch);
        assertEquals(1,edgeRouter.getSwitches().size());
    }
    addSwitchToEdgeRouter method:
    
    @Test
    public void addSwitchToEdgeRouter
      _failBecauseEquipmentOfDifferentCountries(){
        var locationUS = createLocation("US");
        var locationJP = createLocation("JP");
        var networkSwitch =
        createSwitch("30.0.0.0", 8, locationUS);
        var edgeRouter =
        createEdgeRouter(locationJP,"30.0.0.1");
        assertThrows(GenericSpecificationException.class,
        () -> edgeRouter.addSwitch(networkSwitch));
    }
    

    当我们尝试添加一个针对与边缘路由器不同国家的交换机时,addSwitchToEdgeRouter 方法检查成功的路径,而 addSwitchToEdgeRouter_failBecauseEquipmentOfDifferentCountries 方法检查不成功的路径。

  3. 然后,我们有测试场景,其中我们想要将一个边缘路由器添加到核心路由器,如下面的代码片段所示:

    @Test
    public void addEdgeToCoreRouter(){
        coreRouter.addRouter(edgeRouter);
        assertEquals(1,coreRouter.getRouters().size());
    }
    addEdgeToCoreRouter method:
    
    @Test
    public void addEdgeToCoreRouter
      _failBecauseRoutersOfDifferentCountries(){
        var locationUS = createLocation("US");
        var locationJP = createLocation("JP");
        var edgeRouter =
        createEdgeRouter(locationUS,"30.0.0.1");
        var coreRouter =
        createCoreRouter(locationJP, "40.0.0.1");
        assertThrows(GenericSpecificationException.class,
        () -> coreRouter.addRouter(edgeRouter));
    }
    

    addEdgeToCoreRouter_failBecauseRoutersOfDifferentCountries 方法检查当边缘路由器和核心路由器位于不同国家时,不成功的路径。

  4. 然后,我们有测试场景,其中我们想要将一个核心路由器添加到另一个核心路由器,如下面的代码片段所示:

    @Test
    public void addCoreToCoreRouter(){
        coreRouter.addRouter(newCoreRouter);
        assertEquals(2,coreRouter.getRouters().size());
    }
    

    addCoreToCoreRouter 方法检查当我们能够将一个核心路由器添加到另一个路由器时,成功的路径。在下面的代码片段中,我们有这个方法的“不愉快”路径:

    @Test
    public void addCoreToCoreRouter
      _failBecauseRoutersOfSameIp(){
        var location = createLocation("US");
        var coreRouter = createCoreRouter(
        location, "30.0.0.1");
        var newCoreRouter = createCoreRouter(
        location, "30.0.0.1");
        assertThrows(GenericSpecificationException.class,
        () -> coreRouter.addRouter(newCoreRouter));
    }
    

    addCoreToCoreRouter_failBecauseRoutersOfSameIp 方法检查当我们尝试添加具有相同 IP 地址的核心路由器时,不成功的路径。

    通过这些测试,我们还可以检查规格是否按预期工作。

  5. 然后,还有其他场景需要从核心路由器中移除任何路由器,从边缘路由器中移除任何交换机,以及从交换机中移除任何网络,如下面的代码片段所示:

    @Test
    public void removeRouter(){
        var location = createLocation("US");
        var coreRouter = createCoreRouter(
        location, "30.0.0.1");
        var edgeRouter = createEdgeRouter(
        location, "40.0.0.1");
        var expectedId = edgeRouter.getId();
        coreRouter.addRouter(edgeRouter);
        var actualId =
        coreRouter.removeRouter(edgeRouter).getId();
        assertEquals(expectedId, actualId);
    }
    

    removeRouter 测试方法检查我们是否可以从核心路由器中移除一个边缘路由器。在下面的代码片段中,我们使用交换机进行移除测试:

    @Test
    public void removeSwitch(){
        var location = createLocation("US");
        var network = createTestNetwork("30.0.0.0", 8);
        var networkSwitch =
        createSwitch("30.0.0.0", 8, location);
        var edgeRouter = createEdgeRouter(
        location, "40.0.0.1");
        edgeRouter.addSwitch(networkSwitch);
        networkSwitch.removeNetworkFromSwitch(network);
        var expectedId =
        Id.withId(
        "f8c3de3d-1fea-4d7c-a8b0-29f63c4c3490");
        var actualId=
        edgeRouter.removeSwitch(networkSwitch).getId();
        assertEquals(expectedId, actualId);
    }
    

    removeSwitch 测试方法检查我们是否可以从边缘路由器中移除一个交换机。在下面的代码片段中,我们使用网络进行移除测试:

    @Test
    public void removeNetwork(){
        var location = createLocation("US");
        var network = createTestNetwork("30.0.0.0", 8);
        var networkSwitch =
        createSwitch("30.0.0.0", 8, location);
        assertEquals(
        1, networkSwitch.getSwitchNetworks().size());
        assertTrue(
        networkSwitch.removeNetworkFromSwitch(network));
        assertEquals(
        0, networkSwitch.getSwitchNetworks().size());
    }
    

    removeNetwork 测试方法检查我们是否可以从交换机中移除一个网络。

    在添加和移除操作之后,我们必须测试过滤和检索操作。

  6. 要按类型过滤路由器,我们实现以下测试:

    @Test
    public void filterRouterByType(){
        List<Router> routers = new ArrayList<>();
        var location = createLocation("US");
        var coreRouter = createCoreRouter(
        location, "30.0.0.1");
        var edgeRouter = createEdgeRouter(
        location, "40.0.0.1");
        routers.add(coreRouter);
        routers.add(edgeRouter);
        var coreRouters =
        RouterService.filterAndRetrieveRouter(routers,
        Router.getRouterTypePredicate(RouterType.CORE));
        var actualCoreType =
        coreRouters.get(0).getRouterType();
        assertEquals(RouterType.CORE, actualCoreType);
        var edgeRouters =
        RouterService.filterAndRetrieveRouter(routers,
        Router.getRouterTypePredicate(RouterType.EDGE));
        var actualEdgeType =
        edgeRouters.get(0).getRouterType();
        assertEquals(RouterType.EDGE, actualEdgeType);
    }
    

    filterRouterByType 方法测试 RouterService 类上的可用操作。在前面的案例中,我们检查 filterAndRetrieveRouter 方法是否真的可以从包含不同类型路由器的列表中过滤和检索 COREEDGE 路由器。

  7. 要按供应商过滤路由器,我们有以下测试:

    @Test
    public void filterRouterByVendor(){
        List<Router> routers = new ArrayList<>();
        var location = createLocation("US");
        var coreRouter = createCoreRouter(
        location, "30.0.0.1");
        var edgeRouter = createEdgeRouter(
        location, "40.0.0.1");
        routers.add(coreRouter);
        routers.add(edgeRouter);
        var actualVendor =
        RouterService.
          filterAndRetrieveRouter(routers,
        Router.getVendorPredicate(
        Vendor.HP)).get(0).getVendor();
        assertEquals(Vendor.HP, actualVendor);
        actualVendor =
        RouterService.filterAndRetrieveRouter(routers,
        Router.getVendorPredicate(
        Vendor.CISCO)).get(0).getVendor();
        assertEquals(Vendor.CISCO, actualVendor);
    }
    

    通过使用 getVendorPredicate 方法提供的谓词,我们从 RouterService 类调用 filterAndRetrieveRouter。然后,我们检查检索到的路由器型号是否是我们想要的。

  8. 接下来,我们测试相同的 filterRouterByLocation 方法,但使用不同的谓词,如下所示:

    @Test
    public void filterRouterByLocation(){
        List<Router> routers = new ArrayList<>();
        var location = createLocation("US");
        var coreRouter = createCoreRouter(
        location, "30.0.0.1");
        routers.add(coreRouter);
        var actualCountry =
        RouterService.filterAndRetrieveRouter(routers,
        Router.getCountryPredicate(
        location)).get(0).getLocation().getCountry();
        assertEquals(
        location.getCountry(), actualCountry);
    }
    

    通过调用 getCountryPredicate 方法,我们接收用于按国家过滤路由器的谓词。此方法的结果存储在 actualCountry 变量中,我们在测试断言中使用它。

  9. 接下来,我们测试 filterRouterByModel 方法,如下所示:

    @Test
    public void filterRouterByModel(){
        List<Router> routers = new ArrayList<>();
        var location = createLocation("US");
        var coreRouter = createCoreRouter(
        location, "30.0.0.1");
        var newCoreRouter = createCoreRouter(
        location, "40.0.0.1");
        coreRouter.addRouter(newCoreRouter);
        routers.add(coreRouter);
        var actualModel=
        RouterService.filterAndRetrieveRouter(routers,
        Router.getModelPredicate(
        Model.XYZ0001)).get(0).getModel();
        assertEquals(Model.XYZ0001, actualModel);
    }
    

    这里的目标是确认当我们需要根据路由器模型过滤路由器列表时,filterAndRetrieveRouter方法是否按预期工作。

  10. 这里,我们对SwitchService类中的filterAndRetrieveSwitch方法进行了测试:

    @Test
    public void filterSwitchByType(){
        List<Switch> switches = new ArrayList<>();
        var location = createLocation("US");
        var networkSwitch = createSwitch(
        "30.0.0.0", 8, location);
        switches.add(networkSwitch);
        var actualSwitchType =
        SwitchService.filterAndRetrieveSwitch(switches,
        Switch.getSwitchTypePredicate(
        SwitchType.LAYER3)).get(0).getSwitchType();
        assertEquals(
        SwitchType.LAYER3, actualSwitchType);
    }
    

    这里的目标是检查是否可以使用getSwitchTypePredicate方法提供的谓词来过滤开关列表。这是我们用来按类型过滤开关列表的谓词。最后,assertEquals方法检查预期的开关类型是否与我们期望的一致。

  11. 然后,我们通过使用它们的 ID 来检索路由器和开关的操作进行测试,如下所示:

    @Test
    public void findRouterById() {
        List<Router> routers = new ArrayList<>();
        Map<Id, Router> routersOfCoreRouter =
        new HashMap<>();
        var location = createLocation("US");
        var coreRouter = createCoreRouter(
        location, "30.0.0.1");
        var newCoreRouter = createCoreRouter(
        location, "40.0.0.1");
        coreRouter.addRouter(newCoreRouter);
        routersOfCoreRouter.put(
        newCoreRouter.getId(), newCoreRouter);
        var expectedId = newCoreRouter.getId();
        var actualId =
        RouterService.findById(
        routersOfCoreRouter, expectedId).getId();
        assertEquals(expectedId, actualId);
    }
    

    使用findRouterById,我们测试RouterService中的findById方法。

  12. 最后,我们实现了findSwitchById方法,如下所示:

    @Test
    public void findSwitchById(){
        List<Switch> switches = new ArrayList<>();
        Map<Id, Switch> switchesOfEdgeRouter =
        new HashMap<>();
        var location = createLocation("US");
        var networkSwitch = createSwitch(
        "30.0.0.0", 8, location);
        switchesOfEdgeRouter.put(
        networkSwitch.getId(), networkSwitch);
        var expectedId =
        Id.withId("f8c3de3d-1fea-4d7c-a8b0-29f63c4c3490");
        var actualId =
        SwitchService.findById(
        switchesOfEdgeRouter, expectedId).getId();
        assertEquals(expectedId, actualId);
    }
    

    使用findSwitchById,我们测试SwitchService中的findById方法。

在实现和执行这些测试之后,你应该看到以下输出,显示19个测试成功执行:

[INFO] -------------------------------------------------------
[INFO]  T E S T S
[INFO] -------------------------------------------------------
[INFO] Running dev.davivieira.topologyinventory.domain.DomainTest
[INFO] Tests run: 19, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.04 s - in dev.davivieira.topologyinventory.domain.DomainTest
[INFO]
[INFO] Results:
[INFO]
[INFO] Tests run: 19, Failures: 0, Errors: 0, Skipped: 0

这些测试的成功执行确保我们最基本的功能从领域六边形按预期工作。

这是我们需要继续前进并开始开发应用六边形的绿灯。

摘要

基于我们在前几章中开发的拓扑和库存系统,本章提供了一种动手方法来开发六边形系统的早期步骤。我们首先通过将领域六边形作为一个模块化的 Maven 项目并使用 JPMS 来启动它。

我们简要分析了并理解了与网络资产管理相关的问题域。然后,我们基于值对象、实体、规范和服务将问题域转换成领域模型。最后,我们测试了我们所做的一切,以确保当我们开始在领域六边形之上开发应用六边形时,一切都不会出错。

通过学习如何开发一个健壮的领域六边形,我们为应用和框架六边形提供了一个坚实的基础。在下一章中,我们将学习如何通过组装在领域六边形上创建的有用功能和一切其他内容来构建应用六边形。

问题

  1. 用于启动领域六边形作为模块化应用的哪些技术?

  2. 为什么我们首先通过创建值对象来开始开发领域六边形?

  3. 一旦我们理解了问题域,下一步是什么?

  4. 为什么开发一个健壮且经过良好测试的领域六边形如此重要?

答案

  1. Maven 和 JPMS。

  2. 因为值对象被用来组合其他值对象和实体。

  3. 我们需要将那个问题域转换成一个领域模型。

  4. 因为一个健壮的领域六边形为开发应用和框架六边形提供了一个坚实的基础。

第七章:构建应用程序六边形

一旦我们有了领域六边形提供的基础,我们就可以在这个基础上构建系统的剩余部分。现在是时候考虑系统将如何协调处理不同的数据和行为以满足不同角色的需求了,我们将通过讨论用例示例来探讨这一点。为了实现这一点,我们需要在领域六边形定义的基础上创建应用程序六边形。

为了继续构建前一章中启动的模块化结构,其中我们将领域六边形配置为Java模块,我们将通过定义应用程序六边形作为我们六边形系统的第二个 Java 模块来继续使用模块化方法。

为了更好地展示系统的功能,一个推荐的方法是使用Cucumber,这是一种知名的行为驱动开发技术,它使用诸如特性和场景等概念来描述系统的行为。因此,对于应用程序六边形,我们将使用 Cucumber 来帮助我们塑造六边形系统的用例。

Cucumber 使我们能够以非技术的方式测试应用程序六边形并解释用例的结构。

在本章中,我们将学习以下主题:

  • 引导应用程序六边形

  • 定义用例

  • 使用输入端口实现用例

  • 测试应用程序六边形

在本章结束时,你将了解如何利用用例作为蓝图来驱动整个应用程序六边形的开发。通过通过用例表达用户意图并从中推导出对象以实现端口,你将能够以结构化的方式开发代码以实现用例目标。

技术要求

为了编译和运行本章中展示的代码示例,你需要在你的计算机上安装最新的Java SE 开发工具包JDK)和Maven 3.8。它们都适用于LinuxMacWindows操作系统。

你可以在 GitHub 上找到本章的代码文件,链接为github.com/PacktPublishing/-Designing-Hexagonal-Architecture-with-Java---Second-Edition/tree/main/Chapter07

引导应用程序六边形

应用程序六边形通过领域六边形协调内部请求,通过框架六边形协调外部请求。我们根据领域六边形提供的领域模型构建系统的特性,包括端口和用例。在应用程序六边形中,我们不指定任何约束或业务规则。相反,我们对于应用程序六边形的目的是定义和控制六边形系统中的数据流。

为了继续开发拓扑和库存系统,我们必须将应用程序六边形作为 Maven 和 Java 模块进行引导。让我们从 Maven 配置开始:

mvn archetype:generate \
  -DarchetypeGroupId=de.rieckpil.archetypes  \
  -DarchetypeArtifactId=testing-toolkit \
  -DarchetypeVersion=1.0.0 \
  -DgroupId=dev.davivieira \
  -DartifactId=application \
  -Dversion=1.0-SNAPSHOT \
  -Dpackage=dev.davivieira.topologyinventory.application \
  -DinteractiveMode=false

之前的命令为应用程序六边形创建了基本的 Maven 项目结构。在这里,我们将模块的 groupId 坐标设置为 dev.davivieiraversion 设置为 1.0-SNAPSHOT,与父项目使用相同。我们将 artifactId 设置为 application 以在 Maven 项目中唯一标识此模块。

您需要通过使用以下命令在 Maven 项目根目录中运行前面的 mvn 命令:

$ cd topology-inventory
$ mvn archetype:generate ...

这为应用程序六边形创建了基本的项目结构。目录结构将类似于以下截图:

图 7.1 – 应用程序六边形的目录结构

图 7.1 – 应用程序六边形的目录结构

pom.xml 文件应包含 applicationdomain Maven 模块:

<modules>
    <module>domain</module>
    <module>application</module>
</modules>

在创建 Maven 模块项目之后,我们需要通过在 application/src/java/module-info.java 中创建 module 描述符文件来将应用程序六边形配置为 Java 模块:

module application {
    requires domain;
    requires static lombok;
}

注意第一个 requires 条目 – 它声明 application 模块依赖于 domain 模块。我们需要在 application/pom.xml 中添加领域六边形的依赖项:

<dependency>
    groupId>dev.davivieira</groupId>
    <artifactId>domain</artifactId>
    <version>1.0-SNAPSHOT</version>
    <scope>compile</scope>
</dependency>

Maven 坐标 groupIdartifactIdversion 指定了获取领域六边形 Maven 模块的正确参数。

由于我们将使用 Cucumber 提供书面描述并测试我们的用例,因此我们需要将其依赖项添加到 application/pom.xml 文件中:

<dependency>
    <groupId>io.cucumber</groupId>
    <artifactId>cucumber-java</artifactId>
    <version>6.10.4</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>io.cucumber</groupId>
    <artifactId>cucumber-junit</artifactId>
    <version>6.10.4</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>io.cucumber</groupId>
    <artifactId>cucumber-picocontainer</artifactId>
    <version>6.10.4</version>
    <scope>test</scope>
</dependency>

如本章引言所述,我们将使用 Cucumber 来构建和测试用例。在之前的代码示例中声明的 Maven 依赖项是启用应用程序六边形中 Cucumber 的必需条件。

一旦为拓扑和库存系统正确配置了应用程序六边形的 Maven 模块和 Java 模块,我们就可以继续并开始定义系统的用例。

定义用例

拓扑和库存系统允许用户管理网络资源,如路由器、交换机和网络。为了启用此管理,我们在上一章创建了一个表示这些资源之间关系的领域模型。我们现在必须从领域模型的角度构建系统的特性。这些特性代表了用户与系统交互时的意图。

为了能够以书面和代码形式表达用例,我们使用 Cucumber,这是一个有价值的工具,使非技术人员能够理解代码中存在的用例。

通过依赖 Cucumber 的概念,如特性和场景,我们可以创建易于遵循的用例描述。使用 Cucumber 形成的用例描述可以作为开发用例接口的参考。

在创建拓扑和库存系统的用例接口之前,我们首先需要结构化 Cucumber 使用功能文件中的用例。功能文件是我们将描述一系列书面语句以定义用例的地方。这个相同的书面描述随后在实现类以测试用例时使用。

为路由器管理用例编写描述

要开始,让我们创建RouterAdd.feature文件,该文件描述了将路由器添加到系统中的用例:

@RouterAdd
Feature: Can I add an edge router to a core router?
  Scenario: Adding an edge router to a core router
    Given I have an edge router
    And I have a core router
    Then I add an edge router to a core router
  Scenario: Adding a core router to another core router
    Given I have a core router
    And I have another core router
    Then I add this core router to the core router

这个功能文件描述了两个场景——第一个是当用户想要将边缘路由器添加到核心路由器时;第二个是当用户想要将核心路由器添加到另一个核心路由器时。

之后,我们有RouterCreate.feature文件:

@RouterCreate
Feature: Can I create a new router?
  Scenario: Creating a new core router
    Given I provide all required data to create a core
          router
    Then A new core router is created
  Scenario: Creating a new edge router
    Given I provide all required data to create an edge
      router
    Then A new edge router is created

这里,我们有两个场景,描述了核心路由器和边缘路由器的创建。

最后,是RouterRemove.feature文件:

@RouterRemove
Feature: Can I remove routers?
  Scenario: Removing an edge router from a core router
    Given The core router has at least one edge
          router connected to it
    And The switch has no networks attached to it
    And The edge router has no switches attached to it
    Then I remove the edge router from the core router
  Scenario: Removing a core router from another core router
    Given The core router has at least one core router
          connected to it
    And The core router has no other routers connected to
        it
    Then I remove the core router from another core router

对于描述的两个场景中的每一个,我们定义一组特定的约束以允许移除路由器。一旦我们有 Cucumber 场景描述了关于路由器管理的支持行为,我们就可以定义将允许实现这些操作的用例接口。这些操作将启用这些行为。

定义路由器管理用例接口

一个好的路由器管理用例接口应该包含允许系统实现由RouterAdd.featureRouterCreate.featureRouterRemove.feature文件描述的场景的操作。以下用例接口是根据我们在 Cucumber 功能文件中描述的场景定义的:

package dev.davivieira.topologyinventory.
  application.usecases;
import dev.davivieira.topologyinventory.domain.
  entity.CoreRouter;
import dev.davivieira.topologyinventory.domain.
  entity.Router;
import dev.davivieira.topologyinventory.domain.vo.IP;
import dev.davivieira.topologyinventory.domain.vo.Id;
import dev.davivieira.topologyinventory.domain.vo.Location;
import dev.davivieira.topologyinventory.domain.vo.Model;
import dev.davivieira.topologyinventory.domain.
  vo.RouterType;
import dev.davivieira.topologyinventory.domain.vo.Vendor;
public interface RouterManagementUseCase {
    Router createRouter(
            Vendor vendor,
            Model model,
            IP ip,
            Location location,
            RouterType routerType);
    CoreRouter addRouterToCoreRouter(
            Router router, CoreRouter coreRouter);
    Router removeRouterFromCoreRouter(
            Router router, CoreRouter coreRouter);
    Router retrieveRouter(Id id);
    Router persistRouter(Router router);
}

createRouter方法基于RouterCreate.feature Cucumber 文件。addRouterToCoreRouterremoveRouterFromCoreRouter方法分别对应于RouterAdd.featureRouterRemove.feature文件。现在,让我们继续创建交换机管理用例的书面描述。

为交换机管理用例编写描述

我们将首先创建SwitchAdd.feature文件:

@SwitchAdd
Feature: Can I add a switch to an edge router?
  Scenario: Adding a switch to an edge router
    Given I provide a switch
    Then I add the switch to the edge router

这是一个非常直接的用例场景。鉴于我们提供了一个有效的交换机,我们可以将其添加到边缘路由器中。没有提到核心路由器,因为它们不应该接收交换机连接。

然后,我们创建SwitchCreate.feature文件:

@SwitchCreate
Feature: Can I create new switches?
  Scenario: Creating a new switch
    Given I provide all required data to create a switch
    RouterCreate.feature file, in the sense that if we provide all the required data, a new Switch object is created.
Finally, we create the `SwitchRemove.feature` file:

@SwitchRemove

Feature: 我能否从边缘路由器中移除一个交换机?

Scenario: 从边缘路由器中移除交换机

Given I know the switch I want to remove

And The switch has no networks

然后我从边缘路由器中移除了交换机


 So, to remove a switch from an edge router, we have to make sure the switch has no networks connected to it. This is what the preceding scenario asserts.
Now, let’s define the use case interface for switch management, based on the Cucumber scenarios we just created.
Defining the use case interface for switch management
As we did with routers, we will do the same for switches by creating a use case interface to define the switch management operations, based on the written descriptions we made previously in our Cucumber feature files:

package dev.davivieira.topologyinventory.

application.usecases;

import dev.davivieira.topologyinventory.domain.

entity.EdgeRouter;

import dev.davivieira.topologyinventory.domain.

entity.Switch;

import dev.davivieira.topologyinventory.domain.vo.IP;

import dev.davivieira.topologyinventory.domain.vo.Location;

import dev.davivieira.topologyinventory.domain.vo.Model;

import dev.davivieira.topologyinventory.domain.

vo.SwitchType;

import dev.davivieira.topologyinventory.domain.vo.Vendor;

public interface SwitchManagementUseCase {

Switch createSwitch(

Vendor vendor,

Model model,

IP ip,

Location location,

SwitchType switchType

);

EdgeRouter addSwitchToEdgeRouter(Switch networkSwitch,

EdgeRouter edgeRouter);

EdgeRouter removeSwitchFromEdgeRouter(Switch

networkSwitch,

EdgeRouter edgeRouter);

}


 The `createSwitch`, `addSwitchToEdgeRouter`, and `removeSwitchFromEdgeRouter` methods correspond to the Cucumber `SwitchCreate.feature`, `SwitchAdd.feature`, and `SwitchRemove.feature` feature files, respectively. The `createSwitch` method receives all the required parameters to construct a `Switch` object. Both the `addSwitchToEdgeRouter` and `removeSwitchFromEdgeRouter` methods receive a switch and an edge router as parameters, and both methods return `EdgeRouter`.
To finish the definition of use cases, we still need to create the Cucumber feature files and interfaces for networks. Let’s do that!
Creating written descriptions for network management use cases
For networks, we will continue to follow the same pattern of the add, create, and remove operations previously used on routers and switches. Let’s start with the `NetworkAdd.feature` file:

@NetworkAdd

功能:能否向交换机添加网络?

场景:向交换机添加网络

Given 我有一个网络

并且我有一个交换机来添加网络

然后,我将网络添加到交换机


 This is a simple scenario to ensure that we’re able to add networks to a switch.
Following the addition of networks, we have the `NetworkCreate.feature` file:

@NetworkCreate

功能:能否创建新的网络?

场景:创建一个新的网络

Given 我提供了创建网络所需的所有数据

然后,创建了一个新的网络


 For network creation, as we did with routers and switches, we make sure that all required data is properly provided so that a new network is created.
Finally, we have the `NetworkRemove.feature` file:

@NetworkRemove

功能:能否从一个交换机中删除网络?

场景:从交换机中删除网络

Given 我知道我想要删除的网络

并且我有一个交换机来删除网络

然后,我从交换机中删除网络


 It follows the same structure as the adding scenario but checks the system’s capability to remove networks from a switch.
Now that we have Cucumber scenarios for network management, let’s define a use case interface to perform such scenarios.
Defining the use case interface for network management
The `NetworkManagementUseCase` interface follows the same structure as previously defined interfaces, where we declared methods for creation, addition, and removal operations:

package dev.davivieira.topologyinventory.

application.usecases;

import dev.davivieira.topologyinventory.domain.

entity.Switch;

import dev.davivieira.topologyinventory.domain.vo.IP;

import dev.davivieira.topologyinventory.domain.vo.Network;

public interface NetworkManagementUseCase {

Network createNetwork(

IP networkAddress,

String networkName,

int networkCidr);

Switch addNetworkToSwitch(Network network,

Switch networkSwitch);

Switch removeNetworkFromSwitch(Network network,

Switch networkSwitch);

}


 Here, again, we declare the `createNetwork`, `addNetworkToSwitch`, and `removeNetworkFromSwitch` methods based on the written descriptions from the Cucumber feature files. These three method declarations in the `NetworkManagementUseCase` interface represent the first step in implementing the capabilities that will allow us to manage networks, as described in the scenarios we created using Cucumber.
In this section, we learned about an approach to start use case development by first describing the behaviors and scenarios expected from the system. Once the scenarios were thoroughly explored, we then utilized them as a reference to define the use case interfaces that will allow the system to perform the behaviors described in the scenarios.
Now that we have all the use case interfaces to manage routers, switches, and networks, we can provide an input port implementation for each of these use case interfaces.
Implementing use cases with input ports
Input ports are a central element of the Application hexagon. They play a crucial integration role because it is through them that we bridge the gap between the Domain and Framework hexagons. We can get external data from an output port and forward that data to the Domain hexagon by using output ports. Once the Domain hexagon’s business logic is applied to the data, the Application hexagon moves that data downstream until it reaches one of the output adapters in the Framework hexagon.
When creating the Application hexagon, you’re able to define output port interfaces, but because there is no Framework hexagon yet to provide an output adapter as an implementation, you’re not able to use these output ports.
You’ll see output port declarations in the following code, but they are not being used yet. We’re just preparing the Application hexagon to work when we have the Framework hexagon to provide the implementations.
The following steps will help us to implement use cases with input ports:

1.  We start by creating a `RouterManagementOutputPort` field in the `RouterManagementInputPort` class:

    ```

    package dev.davivieira.topologyinventory.application.

    ports.input;

    import dev.davivieira.topologyinventory.application.

    ports.output.RouterManagementOutputPort;

    import dev.davivieira.topologyinventory.application.

    usecases.RouterManagementUseCase;

    import dev.davivieira.topologyinventory.domain.entity.

    CoreRouter;

    import dev.davivieira.topologyinventory.domain.

    entity.Router;

    import dev.davivieira.topologyinventory.domain.entity.

    factory.RouterFactory;

    import dev.davivieira.topologyinventory.domain.vo.IP;

    import dev.davivieira.topologyinventory.domain.vo.Id;

    import dev.davivieira.topologyinventory.domain.

    vo.Location;

    import dev.davivieira.topologyinventory.domain.

    vo.Model;

    import dev.davivieira.topologyinventory.domain.

    vo.RouterType;

    import dev.davivieira.topologyinventory.domain.

    vo.Vendor;

    import lombok@NoArgsConstructor;

    @NoArgsConstructor

    public class RouterManagementInputPort implements

    RouterManagementUseCase {

    RouterManagementOutputPort

    routerManagementOutputPort;

    /** 代码省略

    }

    ```java

    We created this `RouterManagementOutputPort` interface field because we don’t want to depend directly on its implementation. Remember, output adapters implement output ports.

     2.  Next, we implement the `createRouter` method:

    ```

    @Override

    public Router createRouter(Vendor vendor,

    Model model,

    IP ip,

    Location location,

    RouterType routerType) {

    return RouterFactory.getRouter(

    vendor,model,ip,location,routerType);

    }

    ```java

    With the `createRouter` method, we’ll receive all the required parameters to construct a `Router` object. Object creation is delegated to the `getRouter` method from the `RouterFactory` class.

     3.  Next, we implement the `retrieveRouter` method:

    ```

    @Override

    public Router retrieveRouter(Id id) {

    return

    routerManagementOutputPort.retrieveRouter(id);

    }

    ```java

    It’s a very straightforward method that uses `Id` to obtain the `Router` objects, using the `retrieveRouter` method from the `RouterManagementOutputPort` output port.

     4.  Next, we implement the `persistRouter` method:

    ```

    @Override

    public Router persistRouter(Router router) {

    return

    routerManagementOutputPort.persistRouter(router);

    }

    ```java

    To persist a router, we need to pass the `Router` object we want to persist. This method is generally used after any operation that creates new `Router` objects or causes changes in existing ones.

     5.  Next, we implement the `addRouterToCoreRouter` method:

    ```

    @Override

    public CoreRouter addRouterToCoreRouter(Router router,

    CoreRouter coreRouter) {

    var addedRouter = coreRouter.addRouter(router);

    //persistRouter(addedRouter);

    return addedRouter;

    }

    ```java

    To add `Router` to `CoreRouter`, we call the `addRouter` method from `CoreRouter`. We’re not persisting `Router` because we don’t have an adapter to allow us to do that. So, we just return the added `Router` object.

     6.  Finally, we implement `removeRouterFromCoreRouter`:

    ```

    @Override

    public Router removeRouterFromCoreRouter(Router rout

    er,CoreRouter coreRouter) {

    var removedRouter =

    coreRouter.removeRouter(router);

    //persistRouter(removedRouter);

    return removedRouter;

    }

    ```java

    Again, we use one of the methods present in the `CoreRoute` class. Here, we call the `removeRouter` method to remove `Router` from `CoreRouter`. Then, we return `removedRouter`, instead of actually removing it from an external data source.

The first method we implemented, `createRouter`, can produce either core or edge routers. To accomplish this, we need to provide a factory method directly in the Domain hexagon, in a class called `RouterFactory`. The following is how we implement this `getRouter` factory method:

public static Router getRouter(Vendor vendor,

Model model,

IP ip,

Location location,

RouterType routerType){

switch (routerType){

case CORE → { return CoreRouter.builder().

id(Id.withoutId()).

vendor(vendor).

model(model).

ip(ip).

location(location).

routerType(routerType).

build();

}

/** 代码省略 **/


 The `RouterType` parameter, which we pass to the `getRouter` method, has only two possible values – `CORE` and `EDGE`. The switch looks into one of these two values to determine which `builder` method to use. If `RouterType` is `CORE`, then the `builder` method from `CoreRouter` is called. Otherwise, the `builder` method from `EdgeRouter` is used, as we can see here:

case EDGE → {  return EdgeRouter.builder().

id(Id.withoutId()).

vendor(vendor).

model(model).

ip(ip).

location(location).

routerType(routerType).

build();

}

default → throw new UnsupportedOperationException(

"No valid router type informed");


 If neither `CORE` nor `EDGE` is informed, the default behavior is to throw an exception saying that no valid router type was informed.
Let’s implement the `SwitchManagementUseCase` interface with `SwitchManagementInputPort`:

1.  We will start by implementing the `createSwitch` method:

    ```

    package dev.davivieira.topologyinventory.application.

    ports.input;

    import dev.davivieira.topologyinventory.application.

    usecases.SwitchManagementUseCase;

    import dev.davivieira.topologyinventory.domain.

    entity.EdgeRouter;

    import dev.davivieira.topologyinventory.domain.

    entity.Switch;

    import dev.davivieira.topologyinventory.domain.vo.IP;

    import dev.davivieira.topologyinventory.domain.vo.Id;

    import dev.davivieira.topologyinventory.domain.

    vo.Location;

    import dev.davivieira.topologyinventory.domain.

    vo.Model;

    import dev.davivieira.topologyinventory.domain.

    vo.SwitchType;

    import dev.davivieira.topologyinventory.domain.

    vo.Vendor;

    public class SwitchManagementInputPort implements

    SwitchManagementUseCase {

    @Override

    public Switch createSwitch(

    Vendor vendor,

    Model model,

    IP ip,

    Location location,

    SwitchType switchType) {

    return Switch.builder()

    .id(Id.withoutId())

    .vendor(vendor)

    .model(model)

    .ip(ip)

    .location(location).switchType

    (switchType).build();

    }

    /** 代码省略 **/

    }

    ```java

    For the `createSwitch` method, we don’t need a factory method to create objects because there are no `Switch` object variations as compared to routers. Instead, we generate `Switch` objects, using the `builder` method directly from the `Switch` class.

     2.  Next, we implement the `addSwitchToEdgeRouter` method:

    ```

    @Override

    public EdgeRouter addSwitchToEdgeRouter(

    Switch networkSwitch, EdgeRouter edgeRouter) {

    edgeRouter.addSwitch(networkSwitch);

    return edgeRouter;

    }

    ```java

    Then, we have `addSwitchToEdgeRouter`, which receives `Switch` and `EdgeRouter` as parameters, to add switches to an edge router. There is no way to persist switches without persisting routers as well. That’s why we did not put a persistence method here. By doing that, we enforce all switch persistence operations to occur only when we persist routers.

    Remember that `Router` is an aggregate (a cluster of domain objects) that controls the life cycle of other entities and value objects, including `Switch`-type objects.

     3.  Finally, we implement the `removeSwitchFromEdgeRouter` method:

    ```

    @Override

    public EdgeRouter removeSwitchFromEdgeRouter(

    Switch networkSwitch,EdgeRouter edgeRouter) {

    edgeRouter.removeSwitch(networkSwitch);

    return edgeRouter;

    }

    ```java

    The last method, `removeSwitchFromEdgeRouter`, receives the same parameters, `Switch` and `EdgeRouter`, and removes switches from edge routers using the `removeSwitch` method present in an `EdgeRouter` instance.

Now, let’s see how we can implement the `NetworkManagementUseCase` interface with `NetworkManagementInputPort`:

1.  We start by implementing the `createNetwork` method:

    ```

    package dev.davivieira.topologyinventory.

    application.ports.input;

    import dev.davivieira.topologyinventory.application.

    usecases.NetworkManagementUseCase;

    import dev.davivieira.topologyinventory.domain.

    entity.Switch;

    import dev.davivieira.topologyinventory.domain.vo.IP;

    import dev.davivieira.topologyinventory.domain.

    vo.Network;

    import lombokNoArgsConstructor;

    @NoArgsConstructor

    public class NetworkManagementInputPort implements

    NetworkManagementUseCase {

    @Override

    public Network createNetwork(

    IP networkAddress, String networkName,

    int networkCidr) {

    return  Network

    .builder()

    .networkAddress(networkAddress)

    .networkName(networkName)

    .networkCidr(networkCidr).build();

    }

    /** 代码省略 **/

    }

    ```java

    To create a new network, we use all the received method parameters in conjunction with the `builder` method from the `Network` class.

     2.  Next, we implement `addNetworkToSwitch`:

    ```

    @Override

    public Switch addNetworkToSwitch(

    Network network, Switch networkSwitch) {

    networkSwitch.addNetworkToSwitch(network);

    return networkSwitch;

    }

    ```java

    Here, we receive the `Network` and `Switch` objects. Then, we call the `addNetworkToSwitch` method on `Switch` by passing the `Network` object as a parameter. Then, we return a `Switch` object with the added `Network` object.

     3.  Finally, we implement the `removeNetworkFromSwitch` method:

    ```

    @Override

    public Switch removeNetworkFromSwitch(

    Network network, Switch networkSwitch) {

    networkSwitch.removeNetworkFromSwitch(network);

    return networkSwitch;

    }

    ```java

    We receive the `Network` and `Switch` objects as parameters, like in the `addNetworkToSwitch` method. However, to remove the network from a switch, we call `removeNetworkFromSwitch` from the `Switch` object.

That completes implementing input ports for router, switch, and network management. To ensure everything works as expected, let’s create Cucumber tests based on the written use case descriptions and the input ports we just created.
Testing the Application hexagon
An interesting and useful thing about Cucumber is that we can use the written scenario description provided in the feature file to tailor unit tests. In addition, these written scenarios provide an easy way to understand and implement the hexagonal system’s use cases. We’re also laying the groundwork for the development of unit tests in the Application hexagon.
So, the tests we’re about to build in this section are a continuation of the written scenario descriptions we created for the router, switch, and network management operations. Our goal here is to test input port implementations to ensure these ports work as expected when input adapters call them.
To get started, we need to create the `ApplicationTest` test class to enable Cucumber:

package dev.davivieira.topologyinventory.application;

import io.cucumber.junit.Cucumber;

import io.cucumber.junit.CucumberOptions;

import org.junit.runner.RunWith;

@RunWith(Cucumber.class)

@CucumberOptions(

plugin = {"pretty", "html:target/cucumber-result"}

)

public class ApplicationTest {

}


 The important part is the `@RunWith` annotation, which triggers the initialization of the Cucumber engine.
Let’s start by creating tests to check whether the system is capable of adding routers.
In the same way that we created a `RouterAdd.feature` file, we’ll create its counterpart as a `RouterAdd.java` test class. The location for both files will resemble the following:

*   `src/test/java/dev/davivieira/topologyinventory/application/RouterAdd.java`
*   `src/test/resources/dev/davivieira/topologyinventory/application/routers/RouterAdd.feature`

The following steps walk you through adding an edge router to a core router:

1.  The first step is to get an edge router:

    ```

    @Given("我有一个边缘路由器")

    public void assert_edge_router_exists(){

    edgeRouter = (EdgeRouter)

    this.routerManagementUseCase.createRouter(

    Vendor.HP,

    Model.XYZ0004,

    IP.fromAddress("20.0.0.1"),

    locationA,

    EDGE

    );

    assertNotNull(edgeRouter);

    }

    ```java

    Here, we use the `createRouter` method from `RouterManagementUseCase` to create edge router objects. We need to cast the returned object to an `EdgeRouter` type because the `createRouter` method returns `Router`. Then, to make sure that we received a proper router object, we call `assertNotNull` on `edgeRouter`.

     2.  Now that we have `EdgeRouter`, we need to create `CoreRouter` by using the `createRouter` method again:

    ```

    @And("我有一个核心路由器")

    public void assert_core_router_exists(){

    coreRouter = (CoreRouter)

    this.routerManagementUseCase.createRouter(

    Vendor.CISCO,

    Model.XYZ0001,

    IP.fromAddress("30.0.0.1"),

    locationA,

    CORE

    );

    assertNotNull(coreRouter);

    }

    ```java

    This code follows the exact same pattern as the first step. The only difference is that we pass `CORE` as `RouterType` to the `createRouter` method from `RouterManagementUseCase`.

     3.  With these two objects, `EdgeRouter` and `CoreRouter`, we can now test adding the former to the latter:

    ```

    @Then("我添加了一个边缘路由器到核心路由器")

    public void add_edge_to_core_router(){

    var actualEdgeId = edgeRouter.getId();

    var routerWithEdge =

    (CoreRouter)  this.routerManagementUseCase.

    addRouterToCoreRouter(edgeRouter, coreRouter);

    var expectedEdgeId =

    routerWithEdge.getRouters().get(actualEdgeId).

    getId();

    assertEquals(actualEdgeId, expectedEdgeId);

    }

    ```java

    The `addRouterToCoreRouter` method receives `EdgeRouter` and `CoreRouter` as parameters. At the end of the method, we compare the actual and expected edge router IDs to confirm whether the edge router has been added correctly to the core router.

To test the execution of the Cucumber scenario steps from `RouterAdd.feature`, we have to run the following Maven command:

mvn test


 The output will be similar to the one shown here:

@RouterAdd

Scenario: 将边缘路由器添加到核心路由器 # dev/davivieira/topologyinventory/application/routers/RouterAdd.feature:4

Given 我有一个边缘路由器 # dev.davivieira.topologyinventory.application.RouterAdd.assert_edge_router_exists()

And 我有一个核心路由器 # dev.davivieira.topologyinventory.application.RouterAdd.assert_core_router_exists()

然后我将一个边缘路由器添加到一个核心路由器上 # dev.davivieira.topologyinventory.application.RouterAdd.add_edge_to_core_router()


 The Cucumber test passes through the testing methods in the `RouterAdd.java` file in the same order as they were declared in the `RouterAdd.feature` file.
Now, let’s see how we can implement the `RouterCreate.java` test class for the `RouterCreate.feature` file. Their file locations will resemble the following:

*   `RouterCreate.java` file: `src/test/java/dev/davivieira/topologyinventory/application/RouterCreate.java`
*   `RouterCreate.feature` file: `src/test/resources/dev/davivieira/topologyinventory/application/routers/RouterCreate.feature`

The following scenario steps walk through creating a new core router in the system:

1.  The first step is to create a new core router:

    ```

    @Given("我提供创建核心路由器所需的所有数据")

    router")

    public void create_core_router(){

    router =  this.routerManagementUseCase.

    createRouter(

    Vendor.CISCO,

    Model.XYZ0001,

    IP.fromAddress("20.0.0.1"),

    locationA,

    CORE

    );

    }

    ```java

    We provide all the required data to the `createRouter` method from `RouterManagementUseCase` in order to create the new core router.

     2.  Then, we proceed to confirm whether the router created was indeed a core router:

    ```

    @Then("创建了一个新的核心路由器")

    public void a_new_core_router_is_created(){

    assertNotNull(router);

    assertEquals(CORE, router.getRouterType());

    }

    ```java

    The first assertion checks whether we received a null pointer. The second assertion looks into the router’s type to confirm that it’s a core router.

The following scenario steps involve checking whether we can simply create an edge router by using the `createRouter` method from `RouterManagementUseCase`:

1.  First, we create an edge router:

    ```

    @Given("我提供创建边缘路由器所需的所有数据")

    router")

    public void create_edge_router(){

    router =

    this.routerManagementUseCase.createRouter(

    Vendor.HP,

    Model.XYZ0004,

    IP.fromAddress("30.0.0.1"),

    locationA,

    EDGE

    );

    }

    ```java

    We follow the same procedure for creating the core router objects, but now, we set the `EDGE` parameter as `RouterType` for object creation.

     2.  In the last scenario step, we just execute the assertions:

    ```

    @Then("创建了一个新的边缘路由器")

    public void a_new_edge_router_is_created(){

    assertNotNull(router);

    assertEquals(EDGE, router.getRouterType());

    }

    ```java

    The first assertion checks with the `assertNotNull` method whether the router reference is not `null`. Then, it proceeds by executing `assertEquals` to check whether the router created is `EdgeRouter`.

To run the tests related to the creation of routers, we will execute the following Maven command in the project root directory:

mvn test


 The test result should contain the following output:

@RouterCreate

Scenario: 创建一个新的边缘路由器 # dev/davivieira/topologyinventory/application/routers/RouterCreate.feature:8

Given 我提供创建边缘路由器所需的所有数据 # dev.davivieira.topologyinventory.application.RouterCreate.create_edge_router()

然后创建一个新的边缘路由器 # dev.davivieira.topologyinventory.application.RouterCreate.a_new_edge_router_is_created()


 Now that we’re done with the scenario to create routers, let’s see how to implement the `RouterRemove.java` test class for the `RouterRemove.feature` file. The file locations are as follows:

*   `src/test/java/dev/davivieira/topologyinventory/application/RouterRemove.java`
*   `src/test/resources/dev/davivieira/topologyinventory/application/routers/RouterRemove.feature`

We have to create the methods to test a scenario where we want to remove an edge router from a core router:

1.  To get started, we first need to know whether the core router we are working with has at least an edge router connected to it:

    ```

    @Given("核心路由器至少有一个边缘路由器

    连接到它")

    public void 核心路由器至少有一个边缘路由器连接到它

    router_connected_to_it(){

    var predicate =

    Router.getRouterTypePredicate(EDGE);

    edgeRouter = (EdgeRouter)

    this.coreRouter.getRouters().

    entrySet()

    stream().

    map(routerMap -> routerMap.getValue()).

    filter(predicate).

    findFirst().get();

    assertEquals(EDGE, edgeRouter.getRouterType());

    }

    ```java

    From a core router, we search for an edge router connected to it. Then, we store the returned edge router in the `edgeRouter` variable. Following that, we assert the type of router to confirm whether we have an edge router.

     2.  Next, we have to check that there are no networks attached to the switch connected to the edge router. We have to check this; otherwise, we will not be able to remove the switch from the edge router:

    ```

    @And("交换机没有连接到它的网络")

    public void 交换机没有连接到它的网络

    attached_to_it(){

    var networksSize =

    networkSwitch.getSwitchNetworks().size();

    assertEquals(1, networksSize);

    networkSwitch.removeNetworkFromSwitch(network);

    networksSize =

    networkSwitch.getSwitchNetworks().size();

    assertEquals(0, networksSize);

    }

    ```java

    To assert a switch has no networks connected to it, we first check the size of the networks on the switch. It should return `1`. Then, we remove the network and check the size again. It should return `0`.

    We must ensure that the switch has no networks attached to it to make that switch eligible for removal.

     3.  Next, we can proceed to check that there are no switches connected to the edge router:

    ```

    @And("边缘路由器没有连接到它的交换机")

    public void 边缘路由器没有连接到它的交换机

    attached_to_it(){

    var switchesSize =

    edgeRouter.getSwitches().size();

    assertEquals(1, switchesSize);

    edgeRouter.removeSwitch(networkSwitch);

    switchesSize = edgeRouter.getSwitches().size();

    assertEquals(0, switchesSize);

    }

    ```java

    Here, we remove the switch using the `removeSwitch` method, followed by an assertion to confirm that the edge router has no more switches connected.

     4.  Now, we can test the removal of the edge router from the core router:

    ```

    @Then("我从核心路由器中移除边缘路由器")

    public void 从核心路由器中移除边缘路由器(){

    var actualID = edgeRouter.getId();

    var expectedID = this.routerManagementUseCase.

    removeRouterFromCoreRouter(

    edgeRouter, coreRouter).

    getId();

    assertEquals(expectedID, actualID);

    }

    ```java

    To test the removal of an edge router from the core router, we first get the edge router ID of the router we intend to remove. We store this ID in the `actualID` variable. Then, we proceed to the actual removal. The `removeRouterFromCoreRouter` method returns the removed router. So, we can use the removed router ID, stored in the `expectedID` variable, to check with the `assertEquals` method whether the router was really removed.

To confirm the tests related to router removal are working, we execute the Maven test goal in the project root directory:

mvn test


 The results you get after executing the tests should be similar to the following output:

@RouterRemove

场景:从核心路由器中移除边缘路由器 # dev/davivieira/topologyinventory/application/routers/RouterRemove.feature:4

Given 核心路由器至少有一个边缘路由器连接到它 # dev.davivieira.topologyinventory.application.RouterRemove.the_core_router_has_at_least_one_edge_router_connected_to_it()

并且交换机没有连接到它的网络 # dev.davivieira.topologyinventory.application.RouterRemove.the_switch_has_no_networks_attached_to_it()

并且边缘路由器没有连接到它的交换机 # dev.davivieira.topologyinventory.application.RouterRemove.the_edge_router_has_no_switches_attached_to_it()

然后我从核心路由器中移除边缘路由器 # dev.davivieira.topologyinventory.application.RouterRemove.edge_router_is_removed_from_core_router()


 The preceding output provides the execution details of the four testing methods involved in removing the edge router from the core router.
We have completed the testing part of router management. For switch and network management, we follow the same ideas. In the book’s GitHub repository, you can access the topology and inventory code with all its tests.
Summary
In this chapter, on top of the Domain hexagon, we built the Application hexagon with use cases and ports. For use cases, we heavily relied on a behavior-driven development tool called Cucumber. With Cucumber, we can express use cases supported by the system not only in code terms but also in written text.
We started by creating Cucumber feature files containing the use case written descriptions, and then we used them as a reference to create use case interfaces. These interfaces were then implemented by input ports that provided a concrete way to achieve the use case goals. Finally, we built use case tests, based again on the written description provided by Cucumber.
By implementing and testing the Application hexagon in this way, we leveraged the special capabilities of Cucumber to express the system’s behavior in a declarative and straightforward form, and we used these same capabilities to implement and test the entire Application hexagon.
On top of the Application hexagon and the features it provides, we need to decide how such features will be exposed. Also, some of these require access to external data sources. We’ll address all these concerns by developing the Framework hexagon in the next chapter.
Questions

1.  What do we call files where we declare Cucumber scenarios?
2.  On which other Java module does the Application hexagon depend?
3.  Which hexagonal architecture component is used to implement use cases?

Answers

1.  They are called feature files.
2.  It depends on the Domain hexagon Java module.
3.  Input ports are utilized to implement use cases.

第八章:构建框架六边形

当构建六边形应用时,最后一步是通过将输入适配器连接到输入端口来公开应用功能。此外,如果需要从外部系统获取数据或将其持久化,则需要将输出适配器连接到输出端口。框架六边形是我们组装所有适配器以使六边形系统工作的地方。

我们首先在领域六边形中创建了领域模型,包括实体、值对象和规范。然后,在应用六边形中,我们使用用例和端口表达用户的意图。现在,在框架六边形中,我们必须使用适配器来公开系统功能并定义将用于启用这些功能的技术。在组装了领域、应用和框架六边形之后,我们将拥有一个类似于以下图示的架构:

图 8.1 – 领域、应用和框架六边形组合

图 8.1 – 领域、应用和框架六边形组合

六边形架构之所以引人入胜,在于我们可以在不担心改变领域六边形包裹的核心系统逻辑的情况下添加和删除适配器。当然,这需要付出数据在领域实体和外部实体之间转换的代价。然而,作为交换,我们获得了一个更加解耦的系统,其责任领域之间有清晰的边界。

在本章中,我们将涵盖以下主题:

  • 引入框架六边形

  • 实现输出适配器

  • 实现输入适配器

  • 测试框架六边形

到本章结束时,您将学会创建输入适配器,使六边形应用功能可供其他用户和系统使用。您还将学习如何实现输出适配器,以使六边形系统能够与外部数据源通信。

技术要求

要编译和运行本章中展示的代码示例,您需要在计算机上安装最新的Java SE 开发工具包Maven 3.8。它们都适用于 Linux、Mac 和 Windows 操作系统。

您可以在 GitHub 上找到本章的代码文件,地址为github.com/PacktPublishing/-Designing-Hexagonal-Architecture-with-Java---Second-Edition/tree/main/Chapter08

引入框架六边形

当使用六边形架构构建系统时,你不需要一开始就决定系统 API 将使用 REST 还是 gRPC,也不需要决定系统的主要数据源将是 MySQL 数据库还是 MongoDB。相反,你需要做的是从领域六边形开始建模你的问题域,然后在应用六边形中设计和实现用例。然后,只有在创建了前两个六边形之后,你才需要开始考虑哪些技术将使六边形系统的功能得以实现。

领域驱动设计为中心的六边形方法使我们能够推迟关于六边形系统内部或外部底层技术的决策。六边形方法的另一个优点是适配器的可插拔性。如果你想通过 REST 公开某个系统功能,你可以在输入端口创建并插入一个 REST 输入适配器。稍后,如果你想通过 gRPC 向客户端公开相同的功能,你可以在同一个输入端口创建并插入一个 gRPC 输入适配器。

在处理外部数据源时,我们使用输出适配器具有相同的可插拔优势。你可以将不同的输出适配器插入到同一个输出端口,无需对整个六边形系统进行大规模重构即可更改底层数据源技术。

为了进一步探索输入适配器,我们将在第十二章使用 RESTEasy Reactive 实现输入适配器中进行更深入的讨论。我们还将调查在第十三章使用输出适配器和 Hibernate Reactive 持久化数据中输出适配器的更多可能性。

让我们坚持基础,为输入和输出适配器创建一个坚实的基础结构。在这个结构之上,稍后我们将能够添加 Quarkus 框架提供的令人兴奋的功能。

继续开发拓扑和库存系统,我们需要将框架六边形作为 Maven 和 Java 模块启动。

在拓扑和库存 Maven 根项目中,我们必须运行以下命令:

mvn archetype:generate \
    -DarchetypeGroupId=de.rieckpil.archetypes  \
    -DarchetypeArtifactId=testing-toolkit \
    -DarchetypeVersion=1.0.0 \
    -DgroupId=dev.davivieira \
    -DartifactId=framework \
    -Dversion=1.0-SNAPSHOT \
    -Dpackage=dev.davivieira.topologyinventory.framework \
    -DinteractiveMode=false

如果你使用 Windows,我们建议直接在 CMD 中运行前面的命令,而不是 PowerShell。如果你需要使用 PowerShell,你需要将命令的每一部分用双引号括起来。

mvn archetype:generate目标在topology-inventory内部创建一个名为framework的 Maven 模块。此模块包含基于我们传递给mvn命令的groupIdartificatId的骨架目录结构。此外,它还包括framework目录内的子pom.xml文件。

执行mvn命令创建framework模块后,根项目的pom.xml文件将更新以包含新的模块:

<modules>
  <module>domain</module>
  <module>application</module>
  <module>framework</module>
</modules>

framework模块作为最新添加的模块被插入到末尾。

由于 framework 模块依赖于 domainapplication 模块,我们需要将它们添加为依赖项到 framework 模块的 pom.xml 文件中:

<dependencies>
  <dependency>
    <groupId>dev.davivieira</groupId>
    <artifactId>domain</artifactId>
    <version>1.0-SNAPSHOT</version>
  </dependency>
  <dependency>
    <groupId>dev.davivieira</groupId>
    <artifactId>application</artifactId>
    <version>1.0-SNAPSHOT</version>
  </dependency>
<dependencies>

运行 Maven 命令创建 framework 模块后,你应该看到一个类似于下面所示的目录树:

图 8.2 – 框架六边形的目录结构

图 8.2 – 框架六边形的目录结构

framework 目录中应该有一个子 pom.xml 文件,在 topology-inventory 目录中应该有一个父 pom.xml 文件。

一旦我们完成了 Maven 配置,我们就可以创建将 framework Maven 模块转换为 Java 模块的描述符文件。我们通过创建以下文件来完成此操作,topology-inventory/framework/src/java/module-info.java

module framework {
    requires domain;
    requires application;
}

由于我们已经将 domainapplication 作为 Maven 依赖项添加到框架的 pom.xml 文件中,我们也可以将它们作为 Java 模块依赖项添加到 module-info.java 描述符文件中。

在为框架六边形正确配置了 Maven 和 Java 模块之后,我们可以继续创建拓扑和库存系统的输出适配器。

实现输出适配器

我们将首先实现输出适配器,以设置我们的拓扑和库存系统与底层数据源技术(一个 H2 内存数据库)之间的集成。首先实现输出适配器也很重要,因为我们实现输入适配器时会引用它们。

拓扑和库存系统允许外部检索路由器和交换机实体的数据。因此,在本节中,我们将回顾获取与这些实体相关的外部数据的输出端口接口。我们还将为每个输出端口接口提供输出适配器实现。

路由管理输出适配器

我们需要创建的路由管理输出适配器应该实现这个 RouterManagementOutputPort 接口:

package dev.davivieira.topologyinventory.application.
  ports.output;
import
  dev.davivieira.topologyinventory.domain.entity.Router;
import dev.davivieira.topologyinventory.domain.vo.Id;
public interface RouterManagementOutputPort {
    Router retrieveRouter(Id id);
    Router removeRouter(Id id);
    Router persistRouter(Router router);
}

retrieveRouterremoveRouter 方法的签名都包含 Id 参数。我们使用 Id 来识别底层数据源中的路由器。然后,我们有 persistRouter 方法的签名接收一个 Router 参数,它可以代表核心路由器和边缘路由器。我们使用该 Router 参数在数据源中持久化数据。

对于拓扑和库存系统,目前我们只需要实现一个输出适配器,以便系统可以使用 H2 内存数据库。

我们从 RouterManagementH2Adapter 类开始实现:

package dev.davivieira.topologyinventory.framework.
  adapters.output.h2;
import dev.davivieira.topologyinventory.application.ports.
  output.RouterManagementOutputPort;
import dev.davivieira.topologyinventory.domain.
  entity.Router;
import dev.davivieira.topologyinventory.domain.vo.Id;
import dev.davivieira.topologyinventory.framework.adapters.
  output.h2.data.RouterData;
import dev.davivieira.topologyinventory.framework.adapters.
  output.h2.mappers.RouterH2Mapper;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import jakarta.persistence.Persistence;
import jakarta.persistence.PersistenceContext;
public class RouterManagementH2Adapter implements
  RouterManagementOutputPort {
    private static RouterManagementH2Adapter instance;
    @PersistenceContext
    private EntityManager em;
    private RouterManagementH2Adapter(){
        setUpH2Database();
    }
    /** Code omitted **/
}

H2 数据库连接由 EntityManager 控制。此连接由 setUpH2Database 方法配置,我们在调用类的空构造函数时执行此方法。我们使用名为 instance 的变量来提供单例,以便其他对象可以触发数据库操作。

让我们实现输出端口接口上声明的每个方法:

  1. 我们从retrieveRouter方法开始,它接收Id作为参数:

    @Override
    public Router retrieveRouter(Id id) {
        var routerData = em.getReference(
                         RouterData.class, id.getUuid());
        return RouterH2Mapper.
          routerDataToDomain(routerData);
    }
    

    使用EntityManagergetReference方法调用,并使用RouterData.class作为参数,UUID 值从Id对象中提取。RouterData是我们用来将来自数据库的数据映射到Router域实体类的数据库实体类。这种映射是通过RouterH2Mapper类的routerDataToDomain方法完成的。

  2. 然后,我们实现removeRouter方法,它从数据库中删除路由器:

    @Override
    public Router removeRouter(Id id) {
        var routerData = em.getReference(
                         RouterData.class, id.getUuid());
        em.remove(routerData);
        return null;
    }
    

    要删除一个路由器,我们首先需要通过调用getReference方法来检索它。一旦我们有了表示数据库实体的RouterData对象,我们就可以从EntityManager调用remove方法,从而从数据库中删除路由器。

  3. 最后,我们实现persistRouter方法:

    @Override
    public Router persistRouter(Router router) {
        var routerData = RouterH2Mapper.
                         routerDomainToData(router);
        em.persist(routerData);
        return router;
    }
    

    它接收一个需要转换为RouterData数据库实体对象以便使用EntityManagerpersist方法持久化的Router域实体对象。

通过实现retrieveRouterremoveRouterpersistRouter方法,我们提供了拓扑和库存系统所需的基本数据库操作。

让我们继续查看交换机输出适配器的实现。

交换机管理输出适配器

我们为交换机实现的输出适配器更简单,因为我们不需要直接持久化交换机或删除它们。交换机输出适配器的唯一目的是从数据库中检索交换机。我们只允许通过路由器输出适配器进行持久化。

要开始,让我们定义SwitchManagementOutputPort接口:

package dev.davivieira.topologyinventory.application.
  ports.output;
import dev.davivieira.topologyinventory.domain.
  entity.Switch;
import dev.davivieira.topologyinventory.domain.vo.Id;
public interface SwitchManagementOutputPort {
    Switch retrieveSwitch(Id id);
}

我们只有一个名为retrieveSwitch的方法,它接收Id并返回Switch

SwitchManagementH2Adapter输出适配器的实现非常简单,与其路由器对应部分类似。所以,我们只需评估retrieveSwitch方法的实现:

/** Code omitted **/
public class SwitchManagementH2Adapter implements
  SwitchManagementOutputPort {
    /** Code omitted **/
    @Override
    public Switch retrieveSwitch(Id id) {
        var switchData = em.getReference(
                         SwitchData.class, id.getUuid());
        return
        RouterH2Mapper.switchDataToDomain(switchData);
    }
    /** Code omitted **/
}

我们使用EntityManagergetReference方法,并使用SwitchData.class和从Id对象中提取的 UUID 值作为参数来检索一个SwitchData数据库实体对象。然后,当我们从RouterH2Mapper类调用switchDataToDomain方法时,该对象被转换为Switch域实体。

现在我们已经正确实现了RouterManagementH2AdapterSwitchManagementH2Adapter,我们可以继续实现输入适配器。

实现输入适配器

当构建应用程序六边形时,我们需要创建用例和输入端口来表示系统能力。为了使这些能力对用户和其他系统可用,我们需要构建输入适配器并将它们连接到输入端口。

对于拓扑和库存系统,我们将实现一组通用输入适配器作为 Java POJO。这些通用输入适配器是技术特定实现的基础,该实现发生在 第十二章使用 RESTEasy Reactive 实现输入适配器。在该章中,我们将重新实现通用输入适配器,作为基于 RESTEasy 的输入适配器,使用 Quarkus 框架。

输入适配器的核心作用是从六边形系统外部接收请求,并使用输入端口来满足这些请求。

继续开发拓扑和库存系统,让我们实现接收与路由器管理相关的请求的输入适配器。

路由器管理输入适配器

我们首先创建 RouterManagementGenericAdapter 类:

public class RouterManagementGenericAdapter {
    private RouterManagementUseCase
      routerManagementUseCase;
    public RouterManagementGenericAdapter(){
        setPorts();
    }
    /** Code omitted **/
}

我们通过声明一个 RouterManagementUseCase 类属性来开始 RouterManagementGenericAdapter 的实现。我们不是使用输入端口类引用,而是利用用例接口引用 RouterManagementUseCase 来连接到输入端口。

RouterManagementGenericAdapter 构造函数中,我们调用 setPorts 方法,该方法使用 RouterManagementH2Adapter 参数作为输出端口来连接到输入端口使用的 H2 内存数据库。

下面是我们应该如何实现 setPorts 方法:

private void setPorts(){
    this.routerManagementUseCase =
            new RouterManagementInputPort(
            RouterManagementH2Adapter.getInstance()
    );
}
/** Code omitted **/

setPorts 方法将一个 RouterManagementInputPort 对象存储在我们之前定义的 RouterManagementUseCase 属性中。

在类初始化之后,我们需要创建暴露六边形系统支持的操作的方法。这里的意图是在输入适配器中接收请求,并通过使用其用例接口引用将其转发到输入端口:

  1. 这里是检索和从系统中删除路由器的操作:

    /**
     * GET /router/retrieve/{id}
     * */
    public Router retrieveRouter(Id id){
        return routerManagementUseCase.retrieveRouter(id);
    }
    /**
     * GET /router/remove/{id}
     * */
    public Router removeRouter(Id id){
        return routerManagementUseCase.removeRouter(id);
    }
    

    注释是为了提醒我们,这些操作在将 Quarkus 集成到六边形系统时将被转换为 REST 端点。retrieveRouterremoveRouter 都接收 Id 作为参数。然后,使用用例引用将请求转发到输入端口。

  2. 然后,我们有创建新路由器的操作:

    /**
     * POST /router/create
     * */
    public Router createRouter(Vendor vendor,
                                   Model,
                                   IP,
                                   Location,
                                   RouterType routerType){
        var router = routerManagementUseCase.createRouter(
                null,
                vendor,
                model,
                ip,
                location,
                routerType
       );
       return routerManagementUseCase.
         persistRouter(router);
    }
    

    RouterManagementUseCase 引用中,我们首先调用 createRouter 方法创建一个新的路由器,然后使用 persistRouter 方法将其持久化。

  3. 记住,在拓扑和库存系统中,只有核心路由器可以接收来自核心路由器和边缘路由器的连接。为了允许将路由器添加到或从核心路由器中移除,我们首先定义以下操作来添加路由器:

    /**
     * POST /router/add
     * */
    public Router addRouterToCoreRouter(
        Id routerId, Id coreRouterId){
        Router = routerManagementUseCase.
        retrieveRouter(routerId);
        CoreRouter =
            (CoreRouter) routerManagementUseCase.
            retrieveRouter(coreRouterId);
        return routerManagementUseCase.
                addRouterToCoreRouter(router, coreRouter);
    }
    

    对于addRouterToCoreRouter方法,我们传递路由器的Id实例作为参数,我们打算添加的目标核心路由器的Id。有了这些 ID,我们调用retrieveRouter方法从我们的数据源获取路由器对象。一旦我们有了RouterCoreRouter对象,我们通过使用情况引用来处理对输入端口的请求,通过调用addRouterToCoreRouter将一个路由器添加到另一个路由器。

    然后,我们定义从核心路由器中移除路由器的操作:

    /**
     * POST /router/remove
     * */
    public Router removeRouterFromCoreRouter(
        Id routerId, Id coreRouterId){
        Router =
        routerManagementUseCase.
        retrieveRouter(routerId);
        CoreRouter =
             (CoreRouter) routerManagementUseCase.
             retrieveRouter(coreRouterId);
        return routerManagementUseCase.
                removeRouterFromCoreRouter(router,
                  coreRouter);
    }
    

    对于removeRouterFromCoreRouter方法,我们遵循与addRouterToCoreRouter方法相同的步骤。然而,唯一的区别是,在最后,我们从使用情况中调用removeRouterFromCoreRouter以从另一个路由器中移除一个路由器。

让我们现在创建处理交换机相关操作的适配器。

交换机管理输入适配器

在我们定义暴露交换机相关操作的函数之前,我们需要配置SwitchManagementGenericAdapter类的适当初始化:

package dev.davivieira.topologyinventory.framework.
  adapters.input.generic;
import dev.davivieira.topologyinventory.application.
  ports.input.*
import dev.davivieira.topologyinventory.application.
  usecases.*;
import dev.davivieira.topologyinventory.domain.entity.*;
import dev.davivieira.topologyinventory.domain.vo.*;
import dev.davivieira.topologyinventory.framework.
  adapters.output.h2.*;
public class SwitchManagementGenericAdapter {
    private SwitchManagementUseCase
      switchManagementUseCase;
    private RouterManagementUseCase
      routerManagementUseCase;
    public SwitchManagementGenericAdapter(){
        setPorts();
    }

SwitchManagementGenericAdapter连接到两个输入端口——第一个输入端口是来自SwitchManagementUseCaseSwitchManagementInputPort,第二个输入端口是来自RouterManagementUseCaseRouterManagementInputPort。这就是为什么我们开始类实现时声明了SwitchManagementUseCaseRouterManagementUseCase的属性。我们将交换机适配器连接到路由器输入端口,因为我们想强制任何持久化活动只通过路由器发生。Router实体作为一个聚合,控制与其相关的对象的生命周期。

接下来,我们实现setPorts方法:

private void setPorts(){
    this.switchManagementUseCase =
            new SwitchManagementInputPort(
            SwitchManagementH2Adapter.getInstance()
    );
    this.routerManagementUseCase =
            new RouterManagementInputPort(
            RouterManagementH2Adapter.getInstance()
    );
}
** Code omitted **

通过setPorts方法,我们使用SwitchManagementH2AdapterRouterManagementH2Adapter适配器初始化两个输入端口,以允许访问 H2 内存数据库。

让我们看看如何实现暴露交换机相关操作的函数:

  1. 我们从一个简单的操作开始,这个操作只是检索一个交换机:

    /**
     * GET /switch/retrieve/{id}
     * */
    public Switch retrieveSwitch(Id switchId) {
        return switchManagementUseCase.
          retrieveSwitch(switchId);
    }
    

    retrieveSwitch方法接收Id作为参数。然后,它利用使用情况引用将请求转发到输入端口。

  2. 接下来,我们有一个方法,允许我们创建并添加一个交换机到边缘路由器:

    /**
     * POST /switch/create
     * */
    public EdgeRouter createAndAddSwitchToEdgeRouter(
           Vendor,
           Model,
           IP,
           Location,
           SwitchType, Id routerId
    ) {
        Switch newSwitch = switchManagementUseCase.
        createSwitch(vendor, model, ip, location,
          switchType);
        Router edgeRouter = routerManagementUseCase.
        retrieveRouter(routerId);
        if(!edgeRouter.getRouterType().equals
          (RouterType.EDGE))
            throw new UnsupportedOperationException(
        "Please inform the id of an edge router to add a
         switch");
        Router = switchManagementUseCase.
        addSwitchToEdgeRouter(newSwitch, (EdgeRouter)
          edgeRouter);
        return (EdgeRouter)
        routerManagementUseCase.persistRouter(router);
    }
    

    我们通过传递createAndAddSwitchToEdgeRouter方法接收到的参数调用交换机输入端口的createSwitch方法来创建一个交换机。通过routerId,我们通过调用router输入端口的retrieveRouter方法检索边缘路由器。一旦我们有了SwitchEdgeRouter对象,我们可以调用addSwitchToEdgeRouter方法将交换机添加到边缘路由器。作为最后一步,我们调用persistRouter方法在数据源中持久化操作。

  3. 最后,我们有removeSwitchFromEdgeRouter方法,它允许我们从边缘路由器中移除一个交换机:

    /**
     * POST /switch/remove
     * */
    public EdgeRouter removeSwitchFromEdgeRouter(
    Id switchId, Id edgeRouterId) {
        EdgeRouter =
                (EdgeRouter) routerManagementUseCase.
                             retrieveRouter(edgeRouterId);
        Switch networkSwitch = edgeRouter.
                               getSwitches().
                               get(switchId);
        Router = switchManagementUseCase.
                        removeSwitchFromEdgeRouter(
                        networkSwitch, edgeRouter);
        return (EdgeRouter) routerManagementUseCase.
        persistRouter(router);
    }
    

    removeSwitchFromEdgeRouter接收交换机的Id参数以及边缘路由器的另一个Id。然后,它通过调用retrieveRouter方法检索路由器。使用交换机 ID,它从边缘路由器对象中检索switch对象。一旦获取到SwitchEdgeRouter对象,它就调用removeSwitchFromEdgeRouter方法从边缘路由器中移除交换机。

现在剩下的是实现处理拓扑和库存网络的适配器。

网络管理输入适配器

正如我们在路由器和交换机适配器中所做的那样,让我们首先定义它需要的端口来实现NetworkManagementGenericAdapter类:

package dev.davivieira.topologyinventory.framework.
  adapters.input.generic;
import dev.davivieira.topologyinventory.application.
  ports.input.*;
import dev.davivieira.topologyinventory.application.
  usecases.*;
import dev.davivieira.topologyinventory.domain.
  entity.Switch;
import dev.davivieira.topologyinventory.domain.vo.*;
import dev.davivieira.topologyinventory.framework.
  adapters.output.h2.*;
public class NetworkManagementGenericAdapter {
    private SwitchManagementUseCase
      switchManagementUseCase;
    private NetworkManagementUseCase
    networkManagementUseCase;
    public NetworkManagementGenericAdapter(){
        setPorts();
    }

除了NetworkManagementUseCase,我们还使用SwitchManagementUseCase。我们需要从NetworkManagementGenericAdapter的构造函数中调用setPorts方法,以正确初始化输入端口对象并将它们分配给相应的用例引用。以下是如何实现setPorts方法的示例:

private void setPorts(){
    this.switchManagementUseCase =
             new SwitchManagementInputPort(
             SwitchManagementH2Adapter.getInstance());
    this.networkManagementUseCase =
             new NetworkManagementInputPort(
             RouterManagementH2Adapter.getInstance());
}
/** Code omitted **/

正如我们在之前的输入适配器实现中所做的那样,我们配置setPorts方法以初始化输入端口对象并将它们分配给用例引用。

让我们实现与网络相关的函数:

  1. 首先,我们实现addNetworkToSwitch方法以将网络添加到交换机:

    /**
     * POST /network/add
     * */
    public Switch addNetworkToSwitch(Network network, Id
      switchId) {
        Switch networkSwitch = switchManagementUseCase.
                               retrieveSwitch(switchId);
        return networkManagementUseCase.
               addNetworkToSwitch(
               network, networkSwitch);
    }
    

    addNetworkToSwitch方法接收NetworkId对象作为参数。为了继续操作,我们需要通过调用retrieveSwitch方法来检索Switch对象。然后,我们可以调用addNetworkToSwitch方法将网络添加到交换机。

  2. 然后,我们实现从交换机中移除网络的方法:

    /**
     * POST /network/remove
     * */
    public Switch removeNetworkFromSwitch(
    String networkName, Id switchId) {
        Switch networkSwitch = switchManagementUseCase.
                               retrieveSwitch(switchId);
        return networkManagementUseCase.
               removeNetworkFromSwitch(
               networkName, networkSwitch);
    }
    

    首先,我们通过使用Id参数调用retrieveSwitch方法来获取Switch对象。要从交换机中移除网络,我们使用网络名称从交换机附加的网络列表中找到它。我们通过调用removeNetworkFromSwitch方法来完成这个操作。

管理网络的适配器是我们必须实现的最后一个输入适配器。有了这三个适配器,我们现在可以从框架六边形管理路由器、交换机和网络。为了确保这些适配器工作良好,让我们为它们创建一些测试。

测试框架六边形

通过测试框架六边形,我们不仅有机会检查输入和输出适配器是否工作良好,还可以测试其他六边形(域和应用)是否在响应来自框架六边形的请求时履行其职责。

为了测试它,我们调用输入适配器以触发下游六边形中所有必要操作的执行,以满足请求。我们首先实现路由器管理适配器的测试。交换机和网络的测试遵循相同的模式,并在本书的 GitHub 仓库中可用。

对于路由器,我们将把我们的测试放入RouterTest类中:

public class RouterTest extends FrameworkTestData {
    RouterManagementGenericAdapter
    routerManagementGenericAdapter;
    public RouterTest() {
        this.routerManagementGenericAdapter =
        new RouterManagementGenericAdapter();
        loadData();
    }
    /** Code omitted **/
}

RouterTest构造函数中,我们实例化了用于执行测试的RouterManagementGenericAdapter输入适配器类。loadData方法从FrameworkTestData父类加载一些测试数据。

一旦我们正确配置了测试的要求,我们就可以继续进行测试:

  1. 首先,我们测试路由器检索:

    @Test
    public void retrieveRouter() {
        var id = Id.withId(
        "b832ef4f-f894-4194-8feb-a99c2cd4be0c");
        var actualId = routerManagementGenericAdapter.
                       retrieveRouter(id).getId();
        assertEquals(id, actualId);
    }
    

    我们调用输入适配器,通知它我们想要检索的路由器id。使用assertEquals,我们比较预期的 ID 与实际 ID,以查看它们是否匹配。

  2. 为了测试路由器创建,我们必须实现createRouter测试方法:

    @Test
    public void createRouter() {
        var ipAddress = "40.0.0.1";
        var routerId  = this.
        routerManagementGenericAdapter.createRouter(
                Vendor.DLINK,
                Model.XYZ0001,
                IP.fromAddress(ipAddress),
                locationA,
                RouterType.EDGE).getId();
        var router = this.routerManagementGenericAdapter.
        retrieveRouter(routerId);
        assertEquals(routerId, router.getId());
        assertEquals(Vendor.DLINK, router.getVendor());
        assertEquals(Model.XYZ0001, router.getModel());
        assertEquals(ipAddress,
        router.getIp().getIpAddress());
        assertEquals(locationA, router.getLocation());
        assertEquals(RouterType.EDGE,
        router.getRouterType());
    }
    

    从路由器输入适配器中,我们调用createRouter方法来创建和持久化一个新的路由器。然后,我们使用刚刚创建的路由器之前生成的 ID 调用retrieveRouter方法。最后,我们运行assertEquals以确认从数据源检索的路由器确实是我们创建的路由器。

  3. 为了测试将路由器添加到核心路由器,我们实现了addRouterToCoreRouter测试方法:

    @Test
    public void addRouterToCoreRouter() {
        var routerId = Id.withId(
        "b832ef4f-f894-4194-8feb-a99c2cd4be0b");
        var coreRouterId = Id.withId(
        "b832ef4f-f894-4194-8feb-a99c2cd4be0c");
        var actualRouter =
        (CoreRouter) this.routerManagementGenericAdapter.
        addRouterToCoreRouter(routerId,coreRouterId);
        assertEquals(routerId,
        actualRouter.getRouters().get(routerId).getId());
    }
    

    我们将变量routerIdcoreRouterId作为参数传递给输入适配器的addRouterToCoreRouter方法,该方法返回一个核心路由器。assertEquals检查核心路由器是否包含我们添加的路由器。

  4. 为了测试从核心路由器中移除路由器,我们将使用以下代码:

    @Test
    public void removeRouterFromCoreRouter(){
        var routerId = Id.withId(
        "b832ef4f-f894-4194-8feb-a99c2cd4be0a");
        var coreRouterId = Id.withId(
        "b832ef4f-f894-4194-8feb-a99c2cd4be0c");
        var removedRouter =
        this.routerManagementGenericAdapter.
        removeRouterFromCoreRouter(routerId,
        coreRouterId);
        var coreRouter =
        (CoreRouter)this.routerManagementGenericAdapter.
        retrieveRouter(coreRouterId);
        assertEquals(routerId, removedRouter.getId());
        assertFalse(
        coreRouter.getRouters().containsKey(routerId));
    }
    

    此测试与上一个测试非常相似。我们再次使用routerIdcoreRouterId变量,但现在我们还使用了removeRouterFromCoreRouter方法,该方法返回被移除的路由器。assertEquals检查移除的路由器的 ID 是否与routerId变量的 ID 匹配。

要运行这些测试,请在 Maven 项目根目录中执行以下命令:

mvn test

输出应类似于以下内容:

[INFO]  T E S T S
[INFO] -------------------------------------------------------
[INFO] Running dev.davivieira.topologyinventory.framework.NetworkTest
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.654 s - in dev.davivieira.topologyinventory.framework.NetworkTest
[INFO] Running dev.davivieira.topologyinventory.framework.RouterTest
[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.014 s - in dev.davivieira.topologyinventory.framework.RouterTest
[INFO] Running dev.davivieira.topologyinventory.framework.SwitchTest
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.006 s - in dev.davivieira.topologyinventory.framework.SwitchTest

除了RouterTest之外,我们还有来自SwitchTestNetworkTest的测试,这些测试可以在之前提到的书的 GitHub 仓库中找到。

通过实现框架六边形测试,我们完成了框架六边形的开发以及整个拓扑和库存系统后端。结合本章和前几章所学,我们可以将涵盖的所有技术应用于创建遵循六边形架构原则的系统。

摘要

我们通过首先实现输出适配器来启动框架六边形构建,以使拓扑和库存系统可以使用 H2 内存数据库作为其主要数据源。

然后,我们创建了三个输入适配器:一个用于路由器操作,另一个用于交换操作,最后一个用于与网络相关的操作。最后,我们实现了测试以确保适配器和整个六边形系统按预期工作。通过完成框架六边形的开发,我们已经完成了我们整体六边形系统的开发。

我们可以通过探索Java 模块平台系统JPMS)提供的可能性来改进我们创建的六边形系统。例如,我们可以利用六边形模块结构来应用依赖倒置原则DIP)。通过这样做,我们可以使六边形系统更加松散耦合。我们将在下一章中探讨 DIP 和其他令人兴奋的功能。

问题

  1. Framework hexagon Java 模块依赖于哪些其他 Java 模块?

  2. 为什么我们需要创建输出适配器?

  3. 为了与输入端口通信,输入适配器实例化输入端口对象并将它们分配给一个接口引用。那么这个接口是什么?

  4. 当我们测试Framework hexagon的输入适配器时,我们也在测试其他六边形。为什么会发生这种情况?

答案

  1. Framework hexagon模块依赖于领域和应用程序六边形的 Java 模块。

  2. 我们创建输出适配器以使六边形系统能够连接到外部数据源。

  3. 那是使用案例接口。

  4. 因为输入适配器依赖于领域和应用程序六边形提供的组件。

第九章:使用 Java 模块应用依赖反转

在前面的章节中,我们学习了如何将每个六边形作为 Java 模块来开发。通过这样做,我们开始强制实施架构中每个六边形的范围和职责。然而,我们没有深入挖掘 Java 模块的功能,例如封装和依赖反转,以及这些功能如何通过使系统更加健壮和松散耦合来增强六角系统的整体结构。

要理解 Java 平台模块系统JPMS)在开发六角系统中所扮演的角色,我们需要了解 JPMS 旨在解决哪些问题。一旦我们知道在封装和依赖反转方面可以使用 JPMS 做些什么,我们就可以将这些技术与六角架构结合使用。

因此,在本章中,我们将学习如何将 JPMS 与六角架构结合使用,以创建一个封装良好、边界清晰、由系统的模块结构和依赖反转技术加强的系统。我们将涵盖以下主题:

  • 介绍 JPMS

  • 在六角应用程序中反转依赖

  • 使用 Java 平台的 ServiceLoader 类来检索 JPMS 提供者实现

到本章结束时,您将学会如何使用 JPMS 中的服务、消费者和提供者来应用依赖反转和封装原则,以实现六角系统。

技术要求

要编译和运行本章中展示的代码示例,您需要在您的计算机上安装最新的 Java SE 开发工具包Maven 3.8。它们都适用于 Linux、macOS 和 Windows 操作系统。

您可以在 GitHub 上找到本章的代码文件,链接为 github.com/PacktPublishing/-Designing-Hexagonal-Architecture-with-Java---Second-Edition/tree/main/Chapter09

介绍 JPMS

classpath 参数之前。classpath 参数是我们放置以 JAR 文件 形式存在的依赖项的地方。然而,问题是无法确定特定依赖项来自哪个 JAR 文件。如果您有两个具有相同名称的类,它们位于同一个包中,并且存在于两个不同的 JAR 文件中,那么其中一个 JAR 文件会首先被加载,导致一个 JAR 文件被另一个 JAR 文件覆盖。

classpath 参数,但只加载了一个 JAR 文件,覆盖了其余的文件。这种 JAR 依赖项纠缠问题也被称为 classpath 参数,当我们看到系统运行时意外的 ClassNotFoundException 异常时。

JPMS 无法完全防止与依赖版本不匹配和阴影相关的 JAR 地狱问题。尽管如此,模块化方法帮助我们更好地了解系统所需的依赖。这种更广泛的依赖视角有助于防止和诊断此类依赖问题。

在 JPMS 之前,没有方法可以控制从不同的 JAR 文件中访问公共类型。Java 虚拟机JVM)的默认行为是始终在其他 JAR 文件之间使这些公共类型可用,这通常会导致涉及具有相同名称和包的类的冲突。

JPMS 引入了module路径和严格的封装策略,默认情况下,该策略限制了不同模块之间对所有公共类型的访问。不再可以从其他依赖中访问所有公共类型。使用 JPMS 时,module必须声明包含公共类型的哪些包可供其他模块使用。我们通过在domain六边形模块上使用exports指令来实现这一点:

module domain {
    exports dev.davivieira.topologyinventory.domain.entity;
    exports dev.davivieira.topologyinventory.domain.entity
      .factory;
    exports dev.davivieira.topologyinventory.domain
      .service;
    exports dev.davivieira.topologyinventory.domain
      .specification;
    exports dev.davivieira.topologyinventory.domain.vo;
    requires static lombok;
}

然后,为了访问domain六边形模块,我们在application六边形模块中使用了requires指令:

module application {
    requires domain;
}

这种模块化机制通过一个新的 Java 结构module来组装代码。正如我们之前所看到的,module可能需要确定它打算导出哪个包以及它需要哪些其他模块。在这种安排中,我们对自己的应用程序暴露和消费的内容有更多的控制。

如果你针对基于云的环境进行开发,并关心性能和成本,module系统的本质允许你构建一个定制的 Java 运行时(过去被称为JRE),其中只包含运行应用程序所需的模块。使用较小的 Java 运行时,应用程序的启动时间和内存使用量都会降低。假设我们谈论的是在云中运行的数百甚至数千个 Java Kubernetes pod。使用较小的 Java 运行时,我们可以在计算资源消耗方面实现相当可观的经济效益。

既然我们对 JPMS 的动机和好处有了更多的了解,让我们回到开发我们的拓扑和库存系统。我们将学习如何使用更高级的 JPMS 功能来增强封装和遵循依赖反转原则。

在六边形应用程序中反转依赖

由罗伯特·C·马丁提出的依赖反转原则DIP)表明,高级组件不应依赖于低级组件。相反,它们都应该依赖于抽象。乍一看,对于一些人来说,理解这样的概念可能并不明显。毕竟,高级和低级组件是什么意思? 我们所说的抽象是什么类型的?

高级组件有一组操作,这些操作被编排起来以实现主要系统行为。高级组件可能依赖于低级组件来提供主要系统行为。反过来,低级组件利用一种专门的行为来支持高级组件的目标。我们称一段充当高级组件的客户端代码,因为它依赖于并消费低级组件提供的功能。

高级组件可以是具体或抽象的元素,而低级组件应该是具体的,因为它总是提供实现细节。

让我们考虑一些客户端代码作为一个高级组件,它调用服务端代码上的方法。反过来,服务端代码可以被视为一个低级组件。这个低级组件包含了实现细节。在过程式编程设计中,常见高级组件直接依赖于低级组件提供的实现细节。马丁说,这种对实现细节的直接依赖是糟糕的,因为它使得系统变得僵化。例如,如果我们更改低级组件的实现细节,这些更改可能会立即对直接依赖于它们的低级组件造成问题。这就是这种僵化的来源:我们不能改变代码的一部分而不在其他部分引起副作用。

要反转依赖关系,我们需要让高级组件依赖于低级组件所派生的相同抽象。在面向对象设计中,我们可以通过使用抽象类或接口来实现这一功能。低级组件实现了一个抽象,而高级组件则引用这个抽象而不是低级实现。因此,这就是我们正确反转依赖关系所必须做的。

JPMS 引入了一种机制来帮助我们避免对实现细节的依赖。这个机制基于消费者、服务和提供者。除了这三个 JPMS 元素之外,还有一个在之前的 Java 版本中就已经知道的元素,称为ServiceLoader,它使系统能够找到并检索给定抽象的实现。

我们将一个通过uses指令声明需要使用提供者模块提供服务的消费者模块称为消费者。这个uses指令声明了一个接口或抽象类,它代表我们打算使用的服务。反过来,服务是实现了接口或扩展了uses指令中提到的抽象类的对象。提供者是一个模块,它通过提供者和指令分别声明服务接口及其实现。

让我们看看我们如何使用 JPMS 将这种 DIP 应用到我们的六边形系统、拓扑和库存中。我们还将看到使用输入适配器、用例和输入端口来反转依赖关系的表示。

使用用例和输入端口提供服务

在开发拓扑和库存系统时,我们将用例设计为接口,将输入端口设计为这些接口的实现。我们可以将用例和输入端口视为与 JPMS 对服务定义相匹配的六边形架构组件。应用程序六边形模块可以被视为提供服务的模块。那么消费者呢? 框架六边形模块是应用程序六边形模块的直接消费者。

基于这个推理,我们将重新实现应用程序和框架六边形模块,以便框架六边形的输入适配器不再需要依赖于应用程序六边形的输入端口实现。相反,输入适配器将只依赖于用例接口类型,而不是输入端口的具体类型。在这种情况下,我们可以将输入适配器视为高级组件,将输入端口视为低级组件。输入适配器指的是用例接口。输入端口实现这些用例。以下图示说明了这一点:

图 9.1 – 使用输入适配器、用例和输入端口的依赖反转

图 9.1 – 使用输入适配器、用例和输入端口的依赖反转

上述图示说明了我们如何在六边形架构中实现依赖反转。此示例考虑了框架和应用六边形的依赖反转,但我们可以用领域六边形做同样的事情。

让我们考虑一下 RouterManagementGenericAdapter 当前是如何访问实现细节而不是抽象的:

private RouterManagementUseCase;
public RouterManagementGenericAdapter(){
    setPorts();
}
private void setPorts(){
    this.routerManagementUseCase = new
      RouterManagementInputPort(
            RouterManagementH2Adapter.getInstance()
    );
}

通过调用 new RouterManagementInputPort(RouterManagementH2Adapter.getInstance()),我们使输入适配器依赖于 RouterManagementInputPort 输入端口和由 RouterManagementH2Adapter 表达的输出适配器的实现细节。

要使输入端口类有资格作为 JPMS 中的提供者类使用,我们需要做以下事情:

  1. 首先,我们必须添加一个无参数构造函数:

    @NoArgsConstructor
    public class RouterManagementInputPort implements
      RouterManagementUseCase {
    /** Code omitted **/
    }
    
  2. 然后,我们必须在用例接口中声明 setOutputPort 方法:

    public interface RouterManagementUseCase {
        void setOutputPort(
        RouterManagementOutputPort
          routerManagementOutputPort);
    }
    
  3. 最后,我们必须在输入端口中实现 setOutputPort 方法:

    @Override
    public void setOutputPort(RouterManagementOutputPort
      routerManagementOutputPort) {
        this.routerManagementOutputPort =
        routerManagementOutputPort;
    }
    

现在,我们可以更新应用程序六边形的 module 描述符来定义我们将通过使用用例接口及其输入端口的实现来提供的服务:

module application {
    requires domain;
    requires static lombok;
    exports dev.davivieira.topologyinventory.application
      .ports.
    input;
    exports dev.davivieira.topologyinventory.application
      .ports.
    output;
    exports dev.davivieira.topologyinventory.application
      .usecases;
/** Code omitted **/
}

我们首先声明 application 模块对领域六边形和 lombok 模块的依赖。然后,我们使用 exports 来启用对输入端口、输出端口和用例的访问。

接下来,我们必须声明我们想要提供的服务。我们可以通过提供用例接口及其实现它的输入端口来完成此服务声明。让我们声明路由管理的服务提供者:

    provides dev.davivieira.topologyinventory.application
      .usecases.
    RouterManagementUseCase
    with dev.davivieira.topologyinventory.application.ports
      .input.
    RouterManagementInputPort;

在前面的代码中,RouterManagementUseCase 是由 RouterManagementInputPort 提供的。

接下来,我们必须定义用于交换管理的服务提供商:

    provides dev.davivieira.topologyinventory.application
      .usecases.
    SwitchManagementUseCase
    with dev.davivieira.topologyinventory.application.ports
      .input.
    SwitchManagementInputPort;

在前面的代码中,SwitchManagementUseCase是由SwitchManagementInputPort提供的。

最后,我们必须声明网络管理的服务提供商:

    provides dev.davivieira.topologyinventory.application
      .usecases.
    NetworkManagementUseCase
    with dev.davivieira.topologyinventory.application.ports
      .input.
    NetworkManagementInputPort;

在这里,NetworkManagementUseCase是由NetworkManagementInputPort提供的。

在我们学习如何通过 JPMS 服务在输入适配器中访问这些输入端口之前,让我们学习如何在处理输出端口和输出适配器时反转依赖关系。

使用输出端口和输出适配器提供服务

在框架六边形中,我们将输出端口作为接口,将输出适配器作为它们的实现。输入端口依赖于输出端口。从这个意义上讲,输入端口可以被视为高级组件,因为它们依赖于输出端口提供的抽象。输出适配器作为低级组件,为输出端口抽象提供实现。以下图表展示了这种依赖反转安排的示意图:

图 9.2 – 使用输入端口、输出端口和输出适配器的依赖反转

图 9.2 – 使用输入端口、输出端口和输出适配器的依赖反转

注意,输入端口和输出适配器都指向相同的输出端口抽象。这意味着我们可以使用 JPMS 将这些架构组件应用于依赖反转原则。

然而,为了使用输出适配器作为实现提供商,我们必须满足一个要求。这个要求需要每个提供者类都有一个不带参数的公共构造函数,而我们在前几章中实现的输出适配器并不符合这一要求:

private RouterManagementH2Adapter(){
    setUpH2Database();
}

我们将RouterManagementH2Adapter构造函数实现为private,以强制使用单例模式。为了展示如何将此输出适配器作为 JPMS 服务提供商使用,我们需要通过将构造函数的访问修饰符从private更改为public来禁用单例模式:

public RouterManagementH2Adapter(){
    setUpH2Database();
}

现在,我们可以更新框架六边形的module(即info.java文件),以定义服务:

module framework {
    requires domain;
    requires application;
    /** Code omitted **/
    exports dev.davivieira.topologyinventory.framework
      .adapters.
    output.h2.data;
    opens dev.davivieira.topologyinventory.framework
      .adapters.
    output.h2.data;
    provides dev.davivieira.topologyinventory.application
      .ports.
    output.RouterManagementOutputPort
    with dev.davivieira.topologyinventory.framework
      .adapters.output.
    h2.RouterManagementH2Adapter;
    provides dev.davivieira.topologyinventory.application
      .ports.
    output.SwitchManagementOutputPort
    with dev.davivieira.topologyinventory.framework
      .adapters.output.
    h2.SwitchManagementH2Adapter;
}

我们首先使用requires指令声明对域和应用六边形模块的模块依赖。然后,我们使用exports指令启用对dev.davivieira.topologyinventory.framework.adapters.output.h2.data包中所有公共类型的访问。我们使用opens指令允许对输出适配器进行运行时反射访问。我们需要这种反射访问,因为这些输出适配器有数据库库依赖项。

最后,我们使用provideswith指令来告知输出端口接口,包括RouterManagementOutputPortSwitchManagementOutputPort,以及它们各自的输出适配器实现,即RouterManagementH2AdapterSwitchManagementH2Adapter

现在我们已经完成了配置,以启用输出端口和适配器之间的依赖反转,让我们学习如何配置输入适配器通过它们的抽象访问依赖项。

使输入适配器依赖于抽象

消费我们使用 provideswith 指令公开的服务的第一步是更新消费者 framework 六边形模块的 module 描述符,利用 uses 指令。我们将执行以下步骤来完成此操作:

  1. 让我们首先更新模块描述符:

    module framework {
        /** Code omitted **/
        uses dev.davivieira.topologyinventory.application
          .usecases
        .RouterManagementUseCase;
        uses dev.davivieira.topologyinventory.application
          .usecases
        .SwitchManagementUseCase;
        uses dev.davivieira.topologyinventory.application
          .usecases
        .NetworkManagementUseCase;
        uses dev.davivieira.topologyinventory.application
          .ports.output
        .RouterManagementOutputPort;
        uses dev.davivieira.topologyinventory.application
          .ports.output
        .SwitchManagementOutputPort;
    }
    

    前三个 uses 指令指向应用程序六边形模块提供的服务。最后两个 uses 指令指的是我们在框架六边形模块中公开的服务。

    现在我们已经适当地配置了 module 描述符,允许系统依赖于接口而不是实现,我们需要重构输入适配器,以便它们只依赖于应用程序六边形模块的使用情况接口,并输出框架六边形模块的端口接口。

  2. 首先,我们必须配置 RouterManagementGenericAdapter 适配器:

    public class RouterManagementGenericAdapter {
        private RouterManagementUseCase
          routerManagementUseCase;
        public RouterManagementGenericAdapter(
        RouterManagementUseCase routerManagementUseCase) {
            this.routerManagementUseCase =
              routerManagementUseCase;
        }
        /** Code omitted **/
    }
    

    注意,RouterManagementGenericAdapter 现在不再依赖于 RouterManagementInputPortRouterManagementH2Adapter,就像之前那样。只有一个依赖项在 RouterManagementUseCase 接口上。

  3. 对于 SwitchManagementGenericAdapter 输入适配器,这是我们应该配置依赖项的方式:

    public class SwitchManagementGenericAdapter {
        private SwitchManagementUseCase
          switchManagementUseCase;
        private RouterManagementUseCase
          routerManagementUseCase;
        public SwitchManagementGenericAdapter (
        RouterManagementUseCase,
        SwitchManagementUseCase switchManagementUseCase){
            this.routerManagementUseCase =
              routerManagementUseCase;
            this.switchManagementUseCase =
              switchManagementUseCase;
        }
        /** Code omitted **/
    }
    

    SwitchManagementGenericAdapter 输入适配器依赖于 RouterManagementUseCaseSwitchManagementUseCase 使用情况接口以执行其活动。

  4. 最后,我们必须调整 NetworkManagementGenericAdapter 适配器类:

    public class NetworkManagementGenericAdapter {
        private SwitchManagementUseCase
          switchManagementUseCase;
        private NetworkManagementUseCase
          networkManagementUseCase;
        public NetworkManagementGenericAdapter(
        SwitchManagementUseCase,
        NetworkManagementUseCase networkManagementUseCase) {
            this.switchManagementUseCase =
              switchManagementUseCase;
            this.networkManagementUseCase =
              networkManagementUseCase;
        }
        /** Code omitted **/
    }
    

    NetworkManagementGenericAdapter 输入适配器遵循我们在之前输入适配器中使用的相同模式,并在输入适配器的构造函数中需要使用情况引用。在这里,我们正在使用 SwitchManagementUseCaseNetworkManagementUseCase 使用情况接口。

在本节中,我们提到了一个关键的 JPMS 功能:服务提供者。通过使用它们,我们可以将输入端口实现绑定到使用情况接口。这就是我们组织代码的方式。因此,输入适配器可以依赖于使用情况抽象来触发应用程序六边形上的操作。

现在,让我们学习如何使用 ServiceLoader 根据我们定义的 JPMS 提供者检索服务实现。

使用 Java 平台的 ServiceLoader 类检索 JPMS 提供者实现

到目前为止,我们已经配置了应用程序和框架六边形模块的 module 描述符。我们已经重构了输入适配器,以便它们只依赖于使用情况接口提供的抽象。但是,我们如何检索实现这些使用情况接口的具体实例呢? 这正是 ServiceLoader 类所做的事情。

ServiceLoader不是一个仅为了支持 JPMS 特性而创建的新类。相反,ServiceLoader自 Java 版本module描述符以来就存在于 Java 中,用于查找给定服务提供者接口的实现。

为了说明如何使用ServiceLoader,让我们通过创建一个名为loadPortsAndUseCases的方法来更新FrameworkTestData测试类。此方法使用ServiceLoader检索我们需要实例化输入适配器的对象。我们需要创建loadPortsAndUseCases方法,因为我们将在初始化输入适配器时通过ServiceLoader调用它。在创建loadPortsAndUseCases方法之前,我们需要声明我们将使用以分配由ServiceLoader辅助实例化的对象的输入适配器变量:

public class FrameworkTestData {
  protected RouterManagementGenericAdapter
  routerManagementGenericAdapter;
  protected SwitchManagementGenericAdapter
  switchManagementGenericAdapter;
  protected NetworkManagementGenericAdapter
  networkManagementGenericAdapter;
  /** Code omitted **/
}

我们在这里声明的变量用于存储我们将使用从ServiceLoader类获得的输入端口和输出适配器对象创建的输入适配器的引用。

让我们先初始化RouterManagementGenericAdapter

初始化RouterManagementGenericAdapter

我们将使用ServiceLoader实例检索创建RouterManagementGenericAdapter所需的必要对象来开始loadPortsAndUseCases方法的实现。我们将执行以下步骤来完成此操作:

  1. 以下代码展示了loadPortsAndUseCases方法的初始实现:

    protected void loadPortsAndUseCases() {
      // Load router implementations
      ServiceLoader<RouterManagementUseCase>
        loaderUseCaseRouter =
      ServiceLoader.load(RouterManagementUseCase.class);
      RouterManagementUseCase =
      loaderUseCaseRouter.findFirst().get();
      // Code omitted //
    }
    

    ServiceLoaderload方法接收一个RouterManagementUseCase.class文件作为参数。此方法可以找到RouterManagementUseCase接口的所有实现。由于RouterManagementInputPort是唯一可用于用例接口的实现,我们可以调用loaderUseCaseRouter.findFirst().get()来获取该实现。

    除了为RouterManagementUseCase接口提供适当的实现外,我们还需要为RouterManagementOutputPort接口提供实现。

  2. 以下代码展示了如何检索RouterManagementOutputPort对象:

    ServiceLoader<RouterManagementOutputPort> loaderOutpu
      tRouter =
    ServiceLoader.load(RouterManagementOutputPort.class);
    RouterManagementOutputPort = loaderOutputRouter.find
      First().get();
    

    loaderOutputRouter.findFirst().get()的调用检索了一个RouterManagementH2Adapter对象,这是唯一可用于RouterManagementOutputPort接口的实现。

    通过从ServiceLoader加载RouterManagementInputPortRouterManagementH2Adapter对象,我们拥有了创建输入适配器所需的必要对象。但在创建输入适配器之前,我们需要设置用例的输出端口。

  3. 这就是我们在RouterManagementUseCase中设置RouterManagementOutputPort对象的方法:

    routerManagementUseCase.setOutputPort(routerManagemen
      tOutputPort);
    

    通过调用routerManagementUseCase.setOutputPort(routerManagementOutputPort),我们在RouterManagementUseCase中设置了RouterManagementOutputPort

  4. 现在,我们可以通过将我们刚刚创建的RouterManagementUseCase传递给其构造函数来创建一个新的RouterManagementGenericAdapter适配器:

    this.routerManagementGenericAdapter =
    new RouterManagementGenericAdapter(routerManagemen
      tUseCase);
    

现在,让我们继续学习如何初始化SwitchManagementGenericAdapter

初始化SwitchManagementGenericAdapter

仍然在 loadPortsAndUseCases 方法内部,我们需要使用 ServiceLoader 来找到 SwitchManagementUseCase 的可用实现。我们将执行以下步骤出于相同的原因:

  1. 在以下代码中,我们正在检索一个 SwitchManagementUseCase 实现:

    ServiceLoader<SwitchManagementUseCase> loaderUseCas
      eSwitch = ServiceLoader.load(SwitchManagementUse
        Case.class);
    SwitchManagementUseCase switchManagementUseCase =
      loaderUseCaseSwitch.findFirst().get();
    

    通过调用 ServiceLoader.load(SwitchManagementUseCase.class),我们正在检索一个包含所有可用 SwitchManagementUseCase 实现的 ServiceLoader 对象。在我们的情况下,唯一的可用实现是 SwitchManagementInputPort 输入端口。要加载此类实现,我们必须调用 loaderUseCaseSwitch.findFirst().get()

    我们还需要 SwitchManagementOutputPort 输出端口的实现。

  2. 以下代码展示了我们如何获取 SwitchManagementOutputPort 实现:

    ServiceLoader<SwitchManagementOutputPort> loaderOut
      putSwitch = ServiceLoader.load(SwitchManagementOut
        putPort.class);
    SwitchManagementOutputPort = loaderOutputSwitch.find
      First().get();
    

    输出适配器实现了输出端口。因此,要获取输出端口实现,我们应该调用 ServiceLoader.load(SwitchManagementOutputPort.class) 来加载 SwitchManagementH2Adapter 实现,然后调用 loaderOutputSwitch.findFirst().get() 来检索该实现对象。

  3. 现在,我们可以使用输出端口对象将其设置在用例中:

    switchManagementUseCase.setOutputPort(switchManagemen
      tOutputPort);
    
  4. 最后,我们可以启动输入适配器:

    this.switchManagementGenericAdapter =
    new SwitchManagementGenericAdapter(
    routerManagementUseCase, switchManagementUseCase);
    

    要实例化 SwitchManagementGenericAdapter,我们需要传递 RouterManagementUseCaseSwitchManagementUseCase 用例的引用。

现在,让我们继续学习如何初始化 NetworkManagementGenericAdapter

初始化 NetworkManagementGenericAdapter

对于 NetworkManagementGenericAdapter,我们只需要加载 NetworkManagementUseCase 的实现。按照以下步骤操作:

  1. 以下代码展示了我们应该如何使用 ServiceLoader 来获取 NetworkManagementUseCase 对象:

    ServiceLoader<NetworkManagementUseCase> load
      erUseCaseNetwork = ServiceLoader.load(NetworkManage
        mentUseCase.class);
    NetworkManagementUseCase networkManagementUseCase =
      loaderUseCaseNetwork.findFirst().get()
    
  2. 然后,我们必须重用之前加载的 RouterManagementOutputPort,以设置 NetworkManagementUseCase

    networkManagementUseCase.setOutputPort(routerManage
      mentOutputPort);
    
  3. 最后,我们可以启动 NetworkManagementGenericAdapter

    this.networkManagementGenericAdapter = new NetworkMan
      agementGenericAdapter(switchManagementUseCase, net
        workManagementUseCase);
    

    要启动一个新的 NetworkManagementGenericAdapter 适配器,我们必须传递 SwitchManagementUseCaseNetworkManagementUseCase 用例的引用。

本节教我们如何使用 ServiceLoader 与 JPMS 服务提供者结合来检索接口实现。使用这种技术,我们可以构建只依赖于抽象而不是实现的代码。

摘要

在本章中,我们首先探讨了 JPMS 的动机和好处。我们发现 JPMS 解决的一个问题是 JAR 地狱,在那里很难控制应用程序应该暴露和使用的依赖项。JPMS 通过关闭对模块中每个公共类型的访问来解决这个问题,要求开发者明确声明哪些包含公共类型的包应该对其他模块可见。此外,开发者应在 module 描述符中声明给定模块所依赖的模块。

接下来,我们讨论了 DIP,并认识到使用案例、输入端口、输入适配器和输出适配器作为我们可以应用于 DIP 的组件。然后,我们使用 JPMS 功能,如消费者、服务和提供者,重构拓扑和库存系统,以实现与六边形架构组件结合的依赖倒置。

通过采用 DIP,我们创建了一个更灵活的设计,这在构建容错系统时是一个重要特性。我们了解到 JPMS 是一种 Java 技术,我们可以用它来实现 DIP。这种技术还使我们能够通过将相关代码隔离到模块中来提供强大的封装。如果我们希望建立和执行领域、应用和框架六边形的边界,这种能力至关重要。

在下一章中,我们将开始我们的云原生之旅,通过学习 Quarkus 框架以及如何使用它来准备和优化六边形系统以在云原生环境中运行。

问题

回答以下问题以测试你对本章知识的掌握:

  1. JPMS 旨在解决哪种 JAR 依赖问题?

  2. 我们应该使用哪个 JPMS 指令来启用对包含公共类型的包的访问?

  3. 要声明对模块的依赖,我们应该使用哪个 JPMS 指令?

  4. 在应用依赖倒置原则于六边形架构时,哪些组件可以被视为高级、抽象和低级?

答案

这里是本章问题的答案:

  1. JAR 地狱问题。

  2. exports 指令。

  3. requires 指令。

  4. 输入适配器、用例和输入端口,分别。

进一步阅读

  • 依赖倒置原则,由 Robert C. Martin 撰写,C++ Report,1996 年。

第三部分:成为云原生

在本部分中,你将集成 Quarkus 框架到六边形应用程序中,使其真正成为现代云原生软件,准备好在云环境中部署。

我们将学习如何将 Quarkus 添加到我们现有的拓扑和库存系统中。然后,我们将探索一些令人兴奋的 Quarkus 特性,如 CDI Bean、RESTEasy Reactive 和 Hibernate Reactive。结合 Quarkus 和六边形架构后,我们将学习如何 docker 化并创建 Kubernetes 对象,以便将我们的六边形应用程序部署到 Kubernetes 集群。

本部分包含以下章节:

  • 第十章将 Quarkus 添加到模块化的六边形应用程序中

  • 第十一章利用 CDI Bean 管理端口和用例

  • 第十二章使用 RESTEasy Reactive 实现输入适配器

  • 第十三章使用输出适配器和 Hibernate Reactive 持久化数据

  • 第十四章设置 Dockerfile 和 Kubernetes 对象以进行云部署

第十章:将 Quarkus 添加到模块化六边形应用中

本章将通过探索将我们的六边形应用转变为云原生应用的概念和技术来拓宽我们的视野。为了支持我们走向云端的旅程,我们有 Quarkus 作为关键技术,它是一个突出的 Java 云原生框架。为了理解 Quarkus 以及如何利用其特性来增强六边形系统,我们需要重新审视与 Java 虚拟机(JVM)内部工作原理相关的某些基本知识。通过理解 JVM 的主要特性和它们的工作方式,我们可以更好地理解 Quarkus 旨在解决的问题。

在本章中,我们还将简要浏览 Quarkus 的主要特性,以了解我们可以用这样一款优秀的软件做什么。一旦我们熟悉了 Quarkus,我们就会迈出将我们的六边形系统转变为云原生系统的第一步。为了实现这一点,我们将创建一个新的 Java 模块并配置 Quarkus 依赖项。

这些是我们将在本章中讨论的主题:

  • 重新审视 JVM

  • 介绍 Quarkus

  • 将 Quarkus 添加到模块化六边形应用中

到本章结束时,你将了解如何配置 Quarkus 与六边形应用一起工作。这是准备一个系统以接收 Quarkus 提供的所有云原生特性的第一步。

技术要求

要编译和运行本章中展示的代码示例,你需要在你的计算机上安装最新的Java 标准版(SE)开发工具包Maven 3.8。它们都适用于 Linux、Mac 和 Windows 操作系统。你可以在 GitHub 上找到本章的代码文件,网址为github.com/PacktPublishing/-Designing-Hexagonal-Architecture-with-Java---Second-Edition/tree/main/Chapter10

重新审视 JVM

当 Java 在 1995 年回归时,虚拟机VM)的概念并不是什么新鲜事物。在此之前,许多其他语言都使用了虚拟机,尽管它们在开发者中并不那么受欢迎。Java 架构师决定使用虚拟机,因为他们想要一个机制来创建平台独立性,以提高开发者的生产力。

在详细阐述虚拟机概念之前,让我们首先检查 Java 可以在虚拟机中运行什么。在 C 或 C++等语言中,我们将源代码编译成针对特定操作系统和 CPU 架构定制的本地代码。当用 Java 编程时,我们将源代码编译成字节码。JVM 理解字节码中的指令。

虚拟机的想法来源于在真实机器之上运行的程序在中间或虚拟环境中的概念。在这种安排中,程序不需要直接与底层操作系统通信——程序只与虚拟机打交道。然后虚拟机将字节码指令转换为本地代码指令。

我们可以用一个著名的 Java 格言来表达 JVM 的一个优点——“一次编写,到处运行”。在过去,我想现在也是如此,使用一种允许你开发无需重新编译就能在不同的操作系统和 CPU 架构上运行的软件的语言是非常吸引人的。对于其他语言,如 C++,你需要为每个目标操作系统和 CPU 架构调整你的代码,这促使你付出更多努力以使你的程序与不同的平台兼容。

在今天的云计算世界中,我们有像 Docker 和 Kubernetes 这样的服务,使软件单元比以往任何时候都更加可移植。为了在 Java 中实现可移植性,我们有执行相同编译字节码到不同操作系统和 CPU 架构上运行的 JVM 的特权。可移植性是可能的,因为每个 JVM 实现都必须遵守 JVM 规范,无论它在何处或如何实现。

相反,我们可以通过将编译好的软件及其运行时环境和依赖项打包到容器镜像中来使用容器虚拟化以实现可移植性。在不同的操作系统和 CPU 架构上运行的容器引擎可以根据容器镜像创建容器。

当你有更快、更便宜的替代方案时,JVM 在将字节码转换为本地代码以制作可移植软件方面的吸引力不再那么吸引人。今天,你可以将应用程序打包——无需 JVM 和重新编译——到一个 Docker 镜像中,并在不同的操作系统和 CPU 架构上分发。然而,我们不应忘记像 JVM 这样的软件是多么的健壮和经得起时间的考验。我们很快将回到关于 Docker 和 Kubernetes 的讨论,但就目前而言,让我们来探讨一些更有趣的 JVM 特性。

另一个重要的 JVM 方面与内存管理相关。在使用 Java 时,开发者无需担心程序如何处理内存释放和分配。这种责任转移给了 JVM,因此开发者可以更多地关注程序的功能细节,而不是技术细节。问问任何 C++开发者,在大型系统上调试内存泄漏有多有趣。

负责在 JVM 内部管理内存的功能被称为垃圾回收器。其目的是自动检查对象是否不再被使用或引用,以便程序可以释放未使用的内存。JVM 可以使用跟踪对象引用并标记不再引用任何对象的算法。存在不同的垃圾回收器算法,例如并发标记清除CMS)和垃圾优先垃圾回收器G1 GC)。自 JDK7 更新 4 以来,G1 GC 已经取代了 CMS,因为它强调首先识别和释放大部分为空的 Java 对象堆区域,从而释放更多内存,并且比 CMS 方法更快。

垃圾收集器并非在每一个 JVM 实现中都是必需的,但只要内存资源在计算中仍然是限制因素,我们经常会看到带有垃圾收集器的 JVM 实现。

JVM 还负责应用程序的整个生命周期。一切始于将 Java 类文件加载到虚拟机中。当我们编译 Java 源文件时,编译器会生成一个包含字节码的 Java 类文件。字节码是 JVM 能识别的格式。虚拟机的主要目标是加载并处理这种字节码,通过实现和遵守 JVM 规范的算法和数据结构。

以下图表说明了执行 Java 程序所需的步骤:

图 10.1 – JVM 上的 Java 编译和类加载

图 10.1 – JVM 上的 Java 编译和类加载

所有这一切都始于 Java 源代码文件,该文件由 Java 编译器编译成 Java 类文件(字节码)。这个字节码由 JVM 读取,并转换为本地操作系统所能理解的指令。

这种字节码问题一直是那些试图找到更快处理方法的人不懈工作的对象。

随着时间的推移,JVM 得到了良好的改进和增强技术,这些技术大大提高了字节码加载性能。在这些技术中,我们可以引用即时编译JIT)和提前编译AOT)。让我们来考察这两个。

通过即时编译加速运行时性能

JIT 编译器起源于这样的想法:某些程序指令可以在程序运行时进行优化以获得更好的性能。因此,为了完成这种优化,JIT 编译器寻找具有优化潜力的程序指令。一般来说,这些指令是程序执行最频繁的指令。

由于这些指令执行频率很高,它们消耗了大量的计算机时间和资源。记住这些指令是以字节码格式存在的。传统的编译器会在运行程序之前将所有字节码编译成本地代码。与 JIT 编译器不同,如下图表所示:

图 10.2 – JIT 工作原理

图 10.2 – JIT 工作原理

JIT 编译器通过使用其动态优化算法选择字节码的一部分。然后,它编译并应用优化到这些字节码部分。结果是经过优化的本地代码,经过调整以提供更好的系统性能。使用JIT这个术语是因为优化是在代码执行之前进行的。

然而,在使用即时编译器(JIT compilers)时,并没有免费的午餐。即时编译器最著名的缺点之一是应用程序启动时间的增加,这是因为即时编译器在运行程序之前进行的初始优化。为了克服这个启动问题,存在另一种称为 AOT 编译的技术。包括 Quarkus 在内的各种云原生框架都使用了这种技术。让我们看看 AOT 编译是如何工作的。

使用 AOT 编译提高启动时间

AOT在 Java 领域如此吸引人,因为传统的 Java 系统——主要是基于企业应用服务器,如JBossWebLogic——启动时间太长。除了较慢的启动时间外,我们还需要考虑这些应用服务器消耗的计算机资源。这些特性对于想要将 Java 工作负载迁移到云中的人来说是一个致命的缺点,在云中,实例和 Kubernetes Pods 疯狂地被创建和销毁。因此,通过在 Java 中使用 AOT,我们放弃了 JVM 及其字节码提供的跨平台能力,以换取 AOT 及其本地代码提供的更好性能。通过使用 Docker 和 Kubernetes 等容器技术,跨平台问题在一定程度上得到了缓解。

这里有一个表示如何将 Java 字节码转换为机器代码的 AOT 编译过程直观表示:

图 10.3 – AOT 的工作原理

图 10.3 – AOT 的工作原理

在 Java 中使用 AOT 并不总是有利。AOT 编译器生成本地二进制文件所需的时间比 Java 编译器创建字节码类所需的时间要多。因此,AOT 编译可能会对持续集成(CI)管道产生相当大的影响。此外,开发者需要做一些额外的工作来确保使用反射时一切正常。GraalVM是用于为 Java 和其他基于 JVM 的语言提供本地二进制文件的 AOT 编译器。

使用 Quarkus,我们有权利选择使用 JIT 或 AOT 编译方法来创建应用程序。选择哪种技术更适合我们的需求取决于我们。

在本节中,我们了解了一些关于 JVM 内部工作原理以及它是如何通过 JIT 和 AOT 编译来尝试改进字节码加载的背景知识。这种知识对于理解 Quarkus 在底层是如何工作以及如何实现显著的性能提升非常重要。

既然我们已经熟悉了一些 JVM 基础和基本的编译技术,让我们深入探讨并了解 Quarkus 的主要特性。

介绍 Quarkus

如果你开发企业级 Java 应用程序,你已经使用过 Spring Boot 了。经过时间考验且在业界广泛使用,Spring Boot 是一款功能强大且社区活跃的软件。它的库通过提供现成的解决方案,如安全性、持久性、API 等,大大提高了开发效率,这些都是典型企业级应用程序所必需的。你可能想知道为什么这本书没有讨论 Spring Boot 而是 Quarkus。有两个原因。首先,关于 Spring Boot 的资料比 Quarkus 多,这是可以理解的,因为 Spring Boot 存在的时间更长,拥有更大的社区。第二个原因是 Quarkus 的核心是云原生开发,而 Spring Boot 则是适应了这一趋势。由于这本书专注于云原生开发和六边形架构,因此选择了 Quarkus,因为它是一个以云为先的框架。

专注于性能,Quarkus 内置了对基于 GraalVM 的本地可执行文件的支持,这使得快速启动成为可能。

为了吸引开发者,它提供了诸如实时开发等有价值的特性,该特性通过避免在代码中发生更改时需要重新启动应用程序来提高生产力。

面向云原生环境,Quarkus 配备了适当的工具,让你能够处理约束并利用在基于容器环境(如 Kubernetes)上开发软件时带来的好处。

从企业级开发中借鉴了优秀思想,Quarkus 建立在诸如上下文和依赖注入CDI)框架、Hibernate ORM 实现的Jakarta Persistence APIJPA)规范以及 RESTEasy 实现的Jakarta RESTful Web ServicesJAX-RS)规范等成熟标准之上。对于那些沉浸在 Java 企业版EE)世界的人来说,这意味着掌握 Quarkus 的学习曲线很浅,因为他们已经获得的大部分企业级开发知识可以重新用于开发 Quarkus 应用程序。

由红帽公司创建的 Quarkus,通过作为一个从头开始设计的软件开发框架来处理云技术,将自己与竞争对手区分开来。与那些带来过时代码和功能的更老框架不同,Quarkus 展示了自己作为一个新鲜现代的软件产品。

建立在其他成熟的开源项目之上,Quarkus 是我们用来为云原生系统准备六边形系统的云原生框架。在此之前,我们将探索这个框架提供的一些主要功能。让我们首先看看如何使用 Quarkus 创建 REST 端点。

使用 JAX-RS 创建 REST 端点

使用 Quarkus 创建 REST 端点非常简单。为了做到这一点,该框架依赖于一个名为 RESTEasy 的 JAX-RS 实现。此实现可在以下 Maven 依赖项中找到:

<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-resteasy</artifactId>
</dependency>

看看以下示例,它展示了如何使用 RESTEasy 创建 REST 服务:

package dev.davivieira.bootstrap.samples;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;
@Path("/app")
public class RestExample {
    @GET
    @Path("/simple-rest")
    @Produces(MediaType.TEXT_PLAIN)
    public String simpleRest() {
        return "This REST endpoint is provided by Quarkus";
    }
}

我们使用 @Path 注解设置端点地址。使用 @GET,我们设置该端点支持的 HTTP 方法。使用 @Produces,我们定义请求的返回类型。

在同一个 RestExample 类中,我们可以注入与 REST 端点一起使用的依赖项。让我们看看如何完成这个任务。

使用 Quarkus DI 进行依赖注入

Quarkus 有自己的基于 Quarkus ArC 的依赖注入机制,该机制反过来又来自 CDI 规范,其根源可以追溯到 Java EE 6。使用 CDI,我们不再需要控制提供给系统的依赖对象创建和生命周期。如果没有依赖注入框架,你必须这样创建对象:

BeanExample beanExample = new BeanExample();

在使用 CDI 时,你只需将 class 属性注解为 @Inject 注解即可,如下所示:

@Inject
BeanExample beanExample

为了使 @Inject 注解生效,我们首先需要将依赖项声明为托管豆。请看以下示例:

package dev.davivieira.bootstrap.samples;
import javax.enterprise.context.ApplicationScoped;
import javax.validation.Valid;
@ApplicationScoped
public class BeanExample {
    public String simpleBean() {
        return "This is a simple bean";
    }
}

@ApplicationScoped 注解表示只要应用程序没有终止,这个豆就会可用。此外,这个豆在整个系统中可以从不同的请求和调用中访问。让我们更新我们的 RestExample 以注入这个豆,如下所示:

package dev.davivieira.bootstrap.samples;
import javax.inject.Inject;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;
@Path("/app")
public class RestExample {
    @Inject
    BeanExample beanExample;
    /** Code omitted **/
    @GET
    @Path("/simple-bean")
    @Produces(MediaType.TEXT_PLAIN)
    public String simpleBean() {
        return beanExample.simpleBean();
    }
}

在顶部,我们使用 @Inject 注解注入 BeanExample 依赖项。然后,我们调用注入的 BeanExample 依赖项中的 simpleBean 方法。

接下来,让我们看看当系统接收到 HTTP 请求时如何验证创建的对象。

验证对象

我们学习了如何创建 REST 端点以及如何在应用程序中注入依赖项。但是关于对象验证呢? 我们如何确保给定请求提供的数据是有效的呢? Quarkus 可以帮助我们解决这个问题。Quarkus 验证机制在以下 Maven 依赖项中可用:

<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-hibernate-validator</artifactId>
</dependency>

Quarkus 验证机制基于 Hibernate Validator

为了了解它是如何工作的,让我们首先创建一个包含我们期望在请求中出现的字段的示例对象,如下所示:

package dev.davivieira.bootstrap.samples;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotBlank;
public class SampleObject {
    @NotBlank(message = "The field cannot be empty")
    public String field;
    @Min(message = "The minimum value is 10", value = 10)
    public int value;
}

使用 @NotBlank 注解,我们声明 field 变量永远不应该为空。然后,通过使用 @Min 注解,我们确保 value 变量应该始终包含一个等于或高于 10 的数字。让我们回到 RestExample 类并创建一个新的 REST 端点来验证请求,如下所示:

@POST
@Path("/request-validation")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public Result validation(@Valid SampleObject sampleObject) {
    try {
        return new Result("The request data is valid!");
    } catch (ConstraintViolationException e) {
        return new Result(e.getConstraintViolations());
    }
}

当捕获到 ConstraintViolationException 时,系统返回一个 HTTP 400 Bad Request 失败响应。

注意在 SampleObject 之前使用的 @Valid 注解。通过使用该注解,每当请求击中 /app/request-validation 端点时,都会触发验证检查。查看以下结果:

$ curl -H "Content-Type: application/json"  -d '{"field": "", "value": 10}' localhost:8080/app/request-validation | jq
{
  "exception": null,
  "propertyViolations": [],
  "classViolations": [],
  "parameterViolations": [
    {
      "constraintType": "PARAMETER",
      "path": "validation.arg0.field",
      "message": "The field cannot be empty",
      "value": ""
    }
  ],
  "returnValueViolations": []
}

在之前的 POST 请求中,字段为空,导致返回了一个带有 HTTP 400 Bad Request 代码的失败响应。

在下一个请求中,我们将 value 设置为一个小于 10 的数字,如下所示:

$ curl -s -H "Content-Type: application/json"  -d '{"field": "test", "value": 9}' localhost:8080/app/request-validation | jq
{
  "exception": null,
  "propertyViolations": [],
  "classViolations": [],
  "parameterViolations": [
    {
      "constraintType": "PARAMETER",
      "path": "validation.arg0.value",
      "message": "The minimum value is 10",
      "value": "9"
    }
  ],
  "returnValueViolations": []
}

再次,违反了约束,结果显示验证失败。失败的原因是我们发送了数字 9,而接受的最低值是 10

这里是一个带有有效数据的正确请求:

$ curl -s -H "Content-Type: application/json"  -d '{"field": "test", "value": 10}' localhost:8080/app/request-validation | jq
{
  "message": "The request data is valid!",
  "success": true
}

field 参数不是 nullvalue 也不是小于 10。因此,请求返回了一个有效的响应。

配置数据源和使用 Hibernate ORM

Quarkus 允许您以两种方式连接到数据源。第一种是传统的基于 JDBC 连接的方式。要使用此方法连接,您需要 agroal 库以及您想要连接的特定数据库类型的 JDBC 驱动程序。第二种是反应式的方式,允许您将数据库连接视为数据流。为此模式,您需要 Vert.x 反应式驱动程序。

在以下步骤中,我们将使用传统的 JDBC 方法设置数据源连接:

  1. 要开始,我们需要以下依赖项:

    <dependency>
      <groupId>io.quarkus</groupId>
      <artifactId>quarkus-agroal</artifactId>
    </dependency>
    <dependency>
      <groupId>io.quarkus</groupId>
      <artifactId>quarkus-jdbc-h2</artifactId>
    </dependency>
    <dependency>
      <groupId>io.quarkus</groupId>
      <artifactId>quarkus-hibernate-orm</artifactId>
    </dependency>
    

    quarkus-hibernate-orm 指的是 JPA 的 Hibernate ORM 实现。正是这个依赖项提供了将 Java 对象映射到数据库实体的能力。

  2. 接下来,我们需要在 application.properties 文件中配置数据源设置,如下所示:

    quarkus.datasource.db-kind=h2
    quarkus.datasource.jdbc.url=jdbc:h2:mem:de
      fault;DB_CLOSE_DELAY=-1
    quarkus.hibernate-orm.dialect=org.hibernate.dia
      lect.H2Dialect
    quarkus.hibernate-orm.database.generation=drop-and-
      create
    

    quarkus.datasource.db-kind 是可选的,但我们使用它来强调应用程序使用 H2 内存数据库。我们使用 quarkus.datasource.jdbc.url 来提供连接字符串。quarkus.hibernate-orm.dialect 选项设置用于数据源通信的方言,而 quarkus.hibernate-orm.database.generation=drop-and-create 强制在启动时创建数据库结构。

    如果 classpath 中存在 import.sql 文件,这个 drop-and-create 选项允许使用该文件将数据加载到数据库中。关于这个 drop-and-create 选项的一个非常有趣的特点是,应用程序实体或 import.sql 文件上的每次更改都会自动检测并应用到数据库中,而无需重新启动系统。为了实现这一点,系统需要以实时开发模式运行。

让我们创建一个 SampleEntity 类以持久化到数据库中,如下所示:

@Entity
@NamedQuery(name = "SampleEntity.findAll",
        query = "SELECT f FROM SampleEntity f ORDER BY
          f.field",
        hints = @QueryHint(name =
          "org.hibernate.cacheable",
        value = "true") )
public class SampleEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;
    @Getter
    @Setter
    private String field;
    @Getter
    @Setter
    private int value;
}

SampleEntity 类对应于我们之前创建的 SampleObject 类。使用 SampleEntity 类作为数据库实体的要求是使用 @Entity 注解对其进行标注。在该注解之后,我们有 @NamedQuery,我们稍后会使用它从数据库中检索所有实体。为了自动生成 ID 值,我们将使用 GenerationType.AUTOSampleEntity 中的 fieldvalue 变量映射到 SampleObject 类中存在的相同变量。

现在让我们创建一个新的名为 PersistenceExample 的 Bean,以帮助我们创建和检索数据库实体。以下是这样做的方法:

package dev.davivieira.bootstrap.samples;
import javax.enterprise.context.ApplicationScoped;
import javax.inject.Inject;
import javax.persistence.EntityManager;
import javax.transaction.Transactional;
import java.util.List;
@ApplicationScoped
public class PersistenceExample {
    @Inject
    EntityManager em;
    /** Code omitted **/
}

要与数据库交互,我们首先要做的是注入EntityManager。Quarkus 将负责检索一个EntityManager对象,该对象包含我们在application.properties文件中提供的所有数据库连接设置。继续PersistenceExample实现,让我们创建一个持久化实体的方法,如下所示:

@Transactional
public String createEntity(SampleObject sampleObject) {
    SampleEntity sampleEntity = new SampleEntity();
    sampleEntity.setField(sampleObject.field);
    sampleEntity.setValue(sampleObject.value);
    em.persist(sampleEntity);
    return "Entity with field "+sampleObject.field+"
      created!";
}

createEntity方法将实体持久化到数据库中。

方法声明上方的@Transactional注解将使EntityManager对象在数据库操作提交时刷新事务。这如下面的代码片段所示:

@Transactional
public List<SampleEntity> getAllEntities(){
    return em.createNamedQuery(
    "SampleEntity.findAll", SampleEntity.class)
            .getResultList();
}

getAllEntities方法从数据库中检索所有实体。

现在,让我们回到RestExample来创建 REST 端点以触发数据库实体的创建和检索。我们将首先注入PersistenceExample,这样我们就可以使用这个豆来开始对数据库的操作。代码如下所示:

@Inject
PersistenceExample persistenceExample;

然后,我们创建一个/create-entity端点,如下所示:

@POST
@Path("/create-entity")
@Produces(MediaType.TEXT_PLAIN)
@Consumes(MediaType.APPLICATION_JSON)
public String persistData(@Valid SampleObject sampleObject) {
    return persistenceExample.createEntity(sampleObject);
}

我们传递SampleObject作为参数。这个对象代表了POST请求的主体。

最后,我们创建一个/get-all-entities端点来从数据库中检索所有实体,如下所示:

@GET
@Path("/get-all-entities")
public List<SampleEntity> retrieveAllEntities() {
    return persistenceExample.getAllEntities();
}

retrieveAllEntities方法在PersistenceExample豆中调用getAllEntities。结果是SampleEntity对象的一个列表。

让我们看看当我们点击/create-entity来创建一个新实体时我们会得到什么。你可以在这里看到输出:

$ curl -s -H "Content-Type: application/json"  -d '{"field": "item-a", "value": 10}' localhost:8080/app/create-entity
Entity with field item-a created!
$ curl -s -H "Content-Type: application/json"  -d '{"field": "item-b", "value": 20}' localhost:8080/app/create-entity
Entity with field item-b created!

要查看我们创建的实体,我们向/get-all-entities发送请求,如下所示:

$ curl -s localhost:8080/app/get-all-entities | jq
[
  {
    "field": "item-a",
    "value": 10
  },
  {
    "field": "item-b",
    "value": 20
  }
]

如预期的那样,我们以 JSON 格式收到了之前在数据库中持久化的所有实体。

Quarkus 是一个庞大且持续增长的框架,它吸收了越来越多的功能。我们看到的特性涵盖了开发现代应用程序所需的一些基本功能。

我们将在重新实现输入适配器以支持我们六边形应用程序的 REST 时使用 RESTEasy。Quarkus DI 将使我们能够更好地管理框架和应用六边形对象的生命周期。Quarkus 验证机制将有助于验证进入六边形系统的数据。数据源配置和 Hibernate ORM 将支持输出适配器的重构。

在本节中,我们学习了如何调整application.properties文件以在 Quarkus 上配置数据库连接,并简要探讨了 Hibernate 的 ORM 功能,这些功能有助于将 Java 类映射到数据库实体。我们将在第十三章中进一步探讨这个主题,使用输出适配器和 Hibernate Reactive 持久化数据

现在我们来看看如何将 Quarkus 集成到六边形系统中。

将 Quarkus 添加到模块化的六边形应用程序中

为了总结,我们将拓扑和库存系统结构化为三个模块化的六角:领域应用框架。可能出现的疑问是,哪个模块应该负责启动 Quarkus 引擎? 好吧,为了避免模糊拓扑和库存系统中每个模块的责任,我们将创建一个专门的模块,其唯一目的是聚合其他六角系统模块并启动 Quarkus 引擎。我们将把这个新模块命名为Bootstrap,如下面的图所示:

图 10.4 – Bootstrap 聚合器模块

图 10.4 – Bootstrap 聚合器模块

bootstrap模块是一个聚合器模块,它从一方面提供初始化 Quarkus 所需的依赖项,从另一方面提供与 Quarkus 一起使用的hexagonal模块依赖项。

让我们在拓扑和库存系统中创建这个新的bootstrap模块,如下所示:

  1. 在拓扑和库存系统的 Maven 根项目中,你可以执行以下 Maven 命令来创建这个bootstrap模块:

    mvn archetype:generate \
    -DarchetypeGroupId=de.rieckpil.archetypes  \
    -DarchetypeArtifactId=testing-toolkit \
    -DarchetypeVersion=1.0.0 \
    -DgroupId=dev.davivieira \
    -DartifactId=bootstrap \
    -Dversion=1.0-SNAPSHOT \
    -Dpackage=dev.davivieira.topologyinventory.bootstrap \
    bootstrap module. We set artifactId to bootstrap and groupId to dev.davivieira, as this module is part of the same Maven project that holds the modules for other topology and inventory system hexagons. The final high-level structure should be similar to the one shown here:
    

图 10.5 – 拓扑和库存系统高级目录结构

图 10.5 – 拓扑和库存系统高级目录结构

  1. 接下来,我们需要在项目的根pom.xml文件中设置 Quarkus 依赖项,如下所示:

    <dependencyManagement>
      <dependencies>
        <dependency>
          <groupId>io.quarkus</groupId>
          <artifactId>quarkus-universe-bom</artifactId>
          <version>${quarkus.platform.version}</version>
          <type>pom</type>
          <scope>import</scope>
        </dependency>
    </dependencyManagement>
    

    quarkus-universe-bom依赖项使所有 Quarkus 扩展可用。

    由于我们正在处理一个多模块应用程序,我们需要配置 Quarkus 以在不同的模块中查找 CDI 豆。

  2. 因此,我们需要在 Maven 项目的根pom.xml文件中配置jandex-maven-plugin,如下所示:

    <plugin>
      <groupId>org.jboss.jandex</groupId>
      <artifactId>jandex-maven-plugin</artifactId>
      <version>${jandex.version}</version>
      <executions>
        <execution>
          <id>make-index</id>
          <goals>
            <goal>jandex</goal>
          </goals>
        </execution>
      </executions>
    </plugin>
    

    如果没有前面的插件,我们将在框架和应用六角上设置和使用 CDI 豆时遇到问题。

  3. 现在是至关重要的部分——配置quarkus-maven-plugin。为了使bootstrap模块成为启动 Quarkus 引擎的模块,我们需要在该模块中正确配置quarkus-maven-plugin

    这是我们在bootstrap/pom.xml上应该如何配置quarkus-maven-plugin

    <build>
      <plugins>
        <plugin>
          <groupId>io.quarkus</groupId>
          <artifactId>quarkus-maven-plugin</artifactId>
          <version>${quarkus-plugin.version}</version>
          <extensions>true</extensions>
          <executions>
            <execution>
              <goals>
                <goal>build</goal>
                <goal>generate-code</goal>
                <goal>generate-code-tests</goal>
              </goals>
            </execution>
          </executions>
        </plugin>
      </plugins>
    </build>
    

    这里重要的是包含<goal>build</goal>的行。通过为bootstrap模块设置此构建目标,我们使该模块负责启动 Quarkus 引擎。

  4. 接下来,我们需要添加拓扑和库存系统六角中的 Maven 依赖项。我们在bootstrap/pom.xml文件中这样做,如下所示:

    <dependency>
      <groupId>dev.davivieira</groupId>
      <artifactId>domain</artifactId>
    </dependency>
    <dependency>
      <groupId>dev.davivieira</groupId>
      <artifactId>application</artifactId>
    </dependency>
    <dependency>
      <groupId>dev.davivieira</groupId>
      <artifactId>framework</artifactId>
    </dependency>
    
  5. 最后,我们创建一个带有 Quarkus 和拓扑及库存六角模块的requires指令的module-info.java Java 模块描述符,如下所示:

    module dev.davivieira.bootstrap {
        requires quarkus.core;
        requires domain;
        requires application;
        requires framework;
    }
    

为了将三个六角模块聚合为一个部署单元,我们将配置 Quarkus 生成一个 uber .jar文件。这种 JAR 将所有运行应用程序所需的依赖项组合在一个单一的 JAR 中。为了实现这一点,我们需要在项目的根pom.xml文件中设置以下配置:

<quarkus.package.type>uber-jar</quarkus.package.type>

然后,我们可以通过运行以下 Maven 命令来编译应用程序:

mvn clean package

此 Maven 命令将编译整个应用程序并创建一个可以执行以下命令以启动应用程序的 uber .jar文件:

java -jar bootstrap/target/bootstrap-1.0-SNAPSHOT-runner.jar

注意,我们使用的工件是由bootstrap模块生成的,该模块聚合了所有其他模块。以下截图展示了运行中的 Quarkus 应用程序应该看起来是什么样子:

图 10.6 – 运行中的 Quarkus 应用程序

图 10.6 – 运行中的 Quarkus 应用程序

在前面的屏幕截图中看到的程序正在prod配置文件下运行。在该配置文件中,出于安全考虑,一些功能被禁用。我们还可以看到应用程序中运行的功能。当我们向pom.xml添加 Quarkus 扩展依赖项时,这些功能被激活。

bootstrap模块充当桥梁,使我们能够将外部开发框架连接到构成六边形系统的六边形模块。对于拓扑和库存应用程序,我们使用了 Quarkus,但也可以使用其他开发框架。我们无法说我们已经完全解耦了系统逻辑和开发框架;毕竟,仍有一些系统逻辑可以从框架功能中受益。然而,本章中提出的方法表明,该系统的一部分可以先开发,然后再引入开发框架。

摘要

在本章中,我们回顾了 JVM 的基本原理,评估了一些与其 JIT 编译和 AOT 编译相关的功能。我们了解到 JIT 可以提高运行时性能,而 AOT 有助于提高应用程序的启动时间,这证明了对目标云环境框架(如本例中的 Quarkus)来说是一个基本功能。

在熟悉了一些 JVM 概念之后,我们继续学习 Quarkus 及其提供的一些重要功能。最后,我们将 Quarkus 集成到我们已开发的六边形系统拓扑和库存中。为了完成这样的集成,我们创建了一个新的bootstrap模块,作为六边形系统模块和开发框架之间的桥梁。我们现在知道将 Quarkus 集成到模块化六边形应用程序需要什么。

在下一章中,我们将深入了解 Quarkus 和六边形架构之间的集成。我们将学习如何重构用例和端口从应用程序六边形到利用 Quarkus DI 功能。

问题

  1. 使用 JIT 编译的优势是什么?

  2. 使用 AOT 编译,我们能获得哪些好处?

  3. Quarkus 是为哪种环境专门设计的开发框架?

  4. bootstrap模块在六边形架构中扮演什么角色?

答案

  1. JIT 提高了应用程序的运行时性能。

  2. AOT 提高了应用程序的启动时间。

  3. Quarkus 是为开发云环境中的应用程序而设计的。

  4. 其作用是将 Quarkus 框架与六边形系统集成。

第十一章:利用 CDI Bean 管理端口和用例

Quarkus 提供自己的依赖注入解决方案,称为Quarkus DI。它源自Java 2.0规范的上下文和依赖注入CDI)。我们使用 CDI 将提供对象实例的责任委托给外部依赖,并在整个应用程序中管理其生命周期。市场上存在几个依赖注入解决方案承担这样的责任。Quarkus DI 就是其中之一。

使用依赖注入机制的价值在于,我们不再需要担心如何以及何时提供对象实例。依赖注入解决方案使我们能够自动创建并提供对象作为依赖项,通常使用注解属性。

在六边形架构的上下文中,框架和应用六边形是利用 CDI 解决方案提供的好处的好候选者。我们不必使用使用具体类注入依赖项的构造函数,而是可以使用 CDI 发现机制自动查找接口实现并将它们提供给应用程序。

在本章中,我们将学习如何通过将端口和用例转换为 Bean 来增强端口和用例的供应。我们将探索 Bean 作用域及其生命周期,并了解何时以及如何使用可用的 Bean 作用域。一旦我们了解了 CDI 基础知识,我们将学习如何将其应用于六边形系统。

本章将涵盖以下主题:

  • 了解 Quarkus DI

  • 将端口、用例和适配器转换为 CDI Bean

  • 使用 Quarkus 和 Cucumber 测试用例

到本章结束时,你将了解如何通过将用例和端口转换为可以在六边形系统中注入的托管 Bean,将 Quarkus DI 集成到六边形应用中。你还将知道如何结合 Cucumber 使用 Quarkus 来测试用例。

技术要求

要编译和运行本章中展示的代码示例,你需要在你的计算机上安装最新的Java SE 开发工具包Maven 3.8。它们适用于 Linux、Mac 和 Windows 操作系统。

你可以在 GitHub 上找到本章的代码文件,链接为github.com/PacktPublishing/-Designing-Hexagonal-Architecture-with-Java---Second-Edition/tree/main/Chapter11

了解 Quarkus DI

Quarkus DI是 Quarkus 框架提供的依赖注入解决方案。这个解决方案也称为ArC,基于Java 2.0 规范的 CDI。Quarkus DI 并没有完全实现这样的规范。相反,它提供了一些定制和修改后的实现,这些实现更倾向于 Quarkus 项目的目标。然而,这些更改在你深入了解 Quarkus DI 提供的内容时更为明显。对于那些只使用 Java 2.0 规范中 CDI 描述的基本和最常见功能的人来说,Quarkus DI 的体验与其他 CDI 实现相似。

使用 Quarkus DI 或任何依赖注入解决方案的优势是我们可以更多地关注我们正在开发的软件的业务方面,而不是与提供应用程序功能所需的对象的供应和生命周期控制相关的管道活动。为了实现这种优势,Quarkus DI 处理所谓的豆。

与豆类一起工作

豆类是我们可以用作注入依赖项或作为依赖项本身被注入到其他豆类中的特殊对象。这种注入活动发生在容器管理的环境中。这个环境不过是应用程序运行的运行时环境。

豆类有一个上下文,它影响其实例对象何时以及如何创建。以下是由 Quarkus DI 支持的上下文的主要类型:

  • ApplicationScoped:带有此类上下文的豆在整个应用程序中可用。仅创建一个豆实例,并在所有注入此豆的系统区域中共享。另一个重要方面是ApplicationScoped豆是延迟加载的。这意味着只有在第一次调用豆的方法时才会创建豆实例。看看这个例子:

    @ApplicationScoped
    class MyBean {
        public String name = "Test Bean";
        public String getName(){
            return name;
        }
    }
    class Consumer {
        @Inject
        MyBean myBean;
        public String getName() {
            return myBean.getName();
        }
    }
    

    MyBean类不仅对Consumer类可用,也对其他注入豆的类可用。只有在第一次调用myBean.getName()时,才会创建豆实例。

  • Singleton:与ApplicationScoped豆类似,对于Singleton豆,也只有一个豆对象被创建并在整个系统中共享。唯一的区别是Singleton豆是预先加载的。这意味着一旦系统启动,Singleton豆实例也会启动。以下是一个示例代码:

    @Singleton
    class EagerBean { ... }
    class Consumer {
        @Inject
        EagerBean eagerBean;
    }
    

    EagerBean对象将在系统初始化期间创建。

  • RequestScoped:当我们只想让豆在与其关联的请求存在的时间内可用时,我们通常将豆标记为RequestScope。以下是我们如何使用RequestScope的示例:

    @RequestScoped
    class RequestData {
        public String getResponse(){
            return "string response";
        }
    }
    @Path("/")
    class Consumer {
        @Inject
        RequestData requestData;
        @GET
        @Path("/request")
        public String loadRequest(){
            return requestData.getResponse();
        }
    }
    

    每当请求到达/request时,都会创建一个新的RequestData豆对象,一旦请求完成,该对象就会被销毁。

  • 依赖性: 标记为 依赖性 的豆豆(Beans)其作用域被限制在它们被使用的地方。因此,依赖性 豆豆不会在系统中的其他豆豆之间共享。此外,它们的生命周期与注入它们的豆豆中定义的生命周期相同。例如,如果你将一个 依赖性 注解的豆豆注入到一个 RequestScoped 豆豆中,前一个豆豆将使用后者的作用域:

    @Dependent
    class DependentBean { ... }
    @ApplicationScoped
    class ConsumerApplication {
        @Inject
        DependentBean dependentBean;
    }
    @RequestScoped
    class ConsumerRequest {
        @Inject
        DependentBean dependentBean;
    }
    

    当将 DependentBean 类注入到 ConsumerApplication 时,它将成为 ApplicationScoped,注入到 ConsumerRequest 时将变为 RequestScoped

  • SessionScoped: 我们使用这个作用域在同一个 HTTP 会话的所有请求之间共享豆豆上下文。我们需要 quarkus-undertow 扩展来在 Quarkus 上启用 SessionScoped

    @SessionScoped
    class SessionBean implements Serializable {
        public String getSessionData(){
            return "sessionData";
        }
    }
    @Path("/")
    class Consumer {
        @Inject
        SessionBean sessionBean;
        @GET
        @Path("/sessionData")
        public String test(){
            return sessionBean.getSessionData();
        }
    }
    

    在前面的示例中,在向 /sessionData 发送第一个请求之后,将创建一个 SessionBean 实例。这个相同的实例将可用于来自同一会话的其他请求。

总结来说,Quarkus 提供以下豆豆作用域:ApplicationScopedRequestScopedSingletonDependentSessionScoped。对于无状态应用程序,大多数情况下,你可能只需要 ApplicationScopedRequestScoped。通过了解这些作用域的工作方式,我们可以根据系统需求进行选择。

现在我们已经了解了 Quarkus DI 的优势以及其基本工作原理,让我们学习如何使用六角架构中的端口和用例来应用依赖注入技术。

将端口、用例和适配器转换为 CDI 豆豆

在为拓扑和库存系统设计应用程序六边形时,我们将用例定义为接口,并将输入端口定义为它们的实现。我们还在框架六边形中定义了输出端口,并将输出适配器定义为它们的实现。在本节中,我们将重构来自应用程序和框架六边形的组件,以启用与 Quarkus DI 一起使用依赖注入。

与 Quarkus DI 一起工作的第一步是在项目的根 pom.xml 中添加以下 Maven 依赖项:

<dependency>
  <groupId>io.quarkus</groupId>
  <artifactId>quarkus-resteasy</artifactId>
</dependency>

除了 RESTEasy 库之外,这个 quarkus-resteasy 库还提供了与 Quarkus DI 一起工作的所需库。

让我们从与路由管理相关的类和接口开始我们的重构工作。

实现路由管理对象中的 CDI

在开发拓扑和库存系统时,我们定义了一系列端口、用例和适配器来管理路由相关的操作。我们将逐步介绍所需的更改以启用此类操作中的依赖注入:

  1. 我们首先将 RouterManagementH2Adapter 输出适配器转换为托管豆豆:

    import jakarta.enterprise.context.ApplicationScoped;
    @ApplicationScoped
    public class RouterManagementH2Adapter implements
      RouterManagementOutputPort {
        @PersistenceContext
        private EntityManager em;
       /** Code omitted **/
            private void setUpH2Database() {
            EntityManagerFactory entityManagerFactory =
            Persistence.createEntityManagerFactory(
              "inventory");
            EntityManager em =
            entityManagerFactory.createEntityManager();
            this.em = em;
        }
    }
    

    我们通过在RouterManagementH2Adapter类上放置@ApplicationScoped注解,将这个类转换为一个托管 Bean。注意EntityManager属性——我们也可以在这个属性上使用依赖注入。我们将在第十三章中这样做,使用输出适配器和 Hibernate Reactive 持久化数据,但现在我们不会涉及这一点。

  2. 在更改RouterManagementUseCase接口及其实现RouterManagementInputPort之前,让我们分析一下当前实现的一些方面:

    public interface RouterManagementUseCase {
        void setOutputPort(
        RouterManagementOutputPort
          routerManagementOutputPort);
        /** Code omitted **/
    }
    

    我们定义了setOutputPort方法来接收和设置一个RouterManagementOutputPort类型的实例,这由一个RouterManagementH2Adapter输出适配器来满足。由于我们不再需要显式提供这个输出适配器对象(因为 Quarkus DI 将注入它),我们可以从RouterManagementUseCase接口中删除setOutputPort方法。

    以下代码演示了在没有 Quarkus DI 的情况下如何实现RouterManagementInputPort

    @NoArgsConstructor
    public class RouterManagementInputPort implements
      RouterManagementUseCase {
        private RouterManagementOutputPort
        routerManagementOutputPort;
        @Override
        public void setOutputPort(
        RouterManagementOutputPort
          routerManagementOutputPort) {
            this.routerManagementOutputPort =
            routerManagementOutputPort;
        }
        /** Code omitted **/
    }
    

    要提供一个RouterManagementOutputPort类型的对象,我们需要使用之前提到的setOutputPort方法。在实现 Quarkus DI 之后,这将不再必要,正如我们将在下一步看到的那样。

  3. 这就是实现 Quarkus DI 后RouterManagementOutputPort应该看起来像什么:

    import jakarta.enterprise.context.ApplicationScoped;
    import jakarta.inject.Inject;
    @ApplicationScoped
    public class RouterManagementInputPort implements
      RouterManagementUseCase {
        @Inject
        RouterManagementOutputPort
          routerManagementOutputPort;
        /** Code omitted **/
    }
    

    首先,我们在RouterManagementInputPort上添加ApplicationScoped以使其能够注入到其他系统部分。然后,通过使用@Inject注解,我们注入RouterManagementOutputPort。我们不需要引用输出适配器的实现。Quarkus DI 将找到适当的实现来满足这个输出端口接口,这恰好是我们之前转换成托管 Bean 的RouterManagementH2Adapter输出适配器。

  4. 最后,我们必须更新RouterManagementGenericAdapter输入适配器:

    @ApplicationScoped
    public class RouterManagementGenericAdapter {
        @Inject
        private RouterManagementUseCase
          routerManagementUseCase;
        /** Code omitted **/
    }
    

    我们不能使用构造函数来初始化RouterManagementUseCase,而必须通过@Inject注解提供依赖。在运行时,Quarkus DI 将为该用例引用创建并分配一个RouterManagementInputPort对象。

对于与路由管理相关的类和接口,我们必须做的更改就这些了。现在,让我们学习关于开关管理类和接口我们需要更改的内容。

实现开关管理对象的 CDI

在本节中,我们将遵循与我们在重构与路由管理相关的端口、用例和适配器时相同的路径:

  1. 我们首先将SwitchManagementH2Adapter输出适配器转换为一个托管 Bean:

    import jakarta.enterprise.context.ApplicationScoped;
    @ApplicationScoped
    public class SwitchManagementH2Adapter implements
      SwitchManagementOutputPort {
        @PersistenceContext
        private EntityManager em;
        /** Code omitted **/
    }
    

    SwitchManagementH2Adapter适配器也使用了EntityManager。我们不会修改EntityManager对象提供的方式,但在第十三章中,使用输出适配器和 Hibernate Reactive 持久化数据,我们将将其改为使用依赖注入。

  2. 我们在第九章中更改了SwitchManagementUseCase接口的定义,使用 Java 模块应用依赖倒置,并定义了setOutputPort方法:

    public interface SwitchManagementUseCase {
        void setOutputPort(
        SwitchManagementOutputPort
          switchManagementOutputPort)
    /** Code omitted **/
    }
    

    由于 Quarkus DI 将提供适当的SwitchManagementOutputPort实例,我们不再需要这个setOutputPort方法,因此我们可以将其删除。

  3. 以下代码显示了在没有依赖注入的情况下如何实现SwitchManagementInputPort

    @NoArgsConstructor
    public class SwitchManagementInputPort implements
      SwitchManagementUseCase {
        private SwitchManagementOutputPort
        switchManagementOutputPort;
        @Override
        public void setOutputPort(
        SwitchManagementOutputPort
          switchManagementOutputPort) {
            this.switchManagementOutputPort =
            switchManagementOutputPort;
        }
        /** Code omitted **/
    }
    

    我们调用setOutputPort方法来初始化一个SwitchManagementOutputPort对象。在使用依赖注入技术时,不需要显式实例化或初始化对象。

  4. 实现依赖注入后,SwitchManagementInputPort应该看起来是这样的:

    import jakarta.enterprise.context.ApplicationScoped;
    import jakarta.inject.Inject;
    @ApplicationScoped
    public class SwitchManagementInputPort implements
      SwitchManagementUseCase {
        @Inject
        private SwitchManagementOutputPort
        switchManagementOutputPort;
        /** Code omitted **/
    }
    

    我们使用@ApplicationScoped注解将SwitchManagementInputPort转换为托管 Bean,并使用@Inject注解让 Quarkus DI 发现实现SwitchManagementOutputPort接口的托管 Bean 对象,恰好是SwitchManagementH2Adapter输出适配器。

  5. 我们仍然需要调整SwitchManagementGenericAdapter输入适配器:

    public class SwitchManagementGenericAdapter {
        @Inject
        private SwitchManagementUseCase
          switchManagementUseCase;
        @Inject
        private RouterManagementUseCase
          routerManagementUseCase;
        /** Code omitted **/
    }
    

    在这里,我们正在为SwitchManagementUseCaseRouterManagementUseCase对象注入依赖项。在使用注解之前,这些依赖项以这种方式提供:

    public SwitchManagementGenericAdapter (
    RouterManagementUseCase routerManagementUseCase,
      SwitchManagementUseCase switchManagementUseCase){
        this.routerManagementUseCase =
          routerManagementUseCase;
        this.switchManagementUseCase =
          switchManagementUseCase;
    }
    

    我们获得的好处是,我们不再需要依赖于构造函数来初始化SwitchManagementGenericAdapter依赖项。Quarkus DI 将自动为我们提供所需的实例。

下一节是关于网络管理操作的内容。让我们学习我们应该如何更改它们。

实现网络管理类和接口的 CDI

对于网络部分,我们不需要做太多更改,因为我们没有为网络相关操作创建特定的输出端口和适配器。因此,实现更改将仅发生在用例、输入端口和输入适配器上:

  1. 让我们先看看NetworkManagementUseCase用例接口:

    public interface NetworkManagementUseCase {
        void setOutputPort(
        RouterManagementOutputPort
          routerNetworkOutputPort);
        /** Code omitted **/
    }
    

    正如我们在其他用例中所做的那样,我们也定义了setOutputPort方法以允许初始化RouterManagementOutputPort。在实现 Quarkus DI 之后,此方法将不再需要。

  2. 这是实现没有 Quarkus DI 的NetworkManagementInputPort的方式:

    import jakarta.enterprise.context.ApplicationScoped;
    import jakarta.inject.Inject;
    public class NetworkManagementInputPort implements
      NetworkManagementUseCase {
        private RouterManagementOutputPort
        routerManagementOutputPort;
        @Override
        public void setOutputPort(
        RouterManagementOutputPort
          routerManagementOutputPort) {
            this.routerManagementOutputPort =
           routerManagementOutputPort;
        }
        /** Code omitted **/
    }
    

    NetworkManagementInputPort输入端口仅依赖于RouterManagementOutputPort,在没有依赖注入的情况下,它通过setOutputPort方法进行初始化。

  3. 这是实现 Quarkus DI 后NetworkManagementInputPort的样子:

    @ApplicationScoped
    public class NetworkManagementInputPort implements
      NetworkManagementUseCase {
        @Inject
        private RouterManagementOutputPort
        routerManagementOutputPort;
        /** Code omitted **/
    }
    

    如您所见,setOutputPort方法已被删除。现在,Quarkus DI 通过@Inject注解提供RouterManagementOutputPort的实现。@ApplicationScoped注解将NetworkManagementInputPort转换为托管 Bean。

  4. 最后,我们必须更改NetworkManagementGenericAdapter输入适配器:

    import jakarta.enterprise.context.ApplicationScoped;
    import jakarta.inject.Inject;
    @ApplicationScoped
    public class NetworkManagementGenericAdapter {
        @Inject
        private SwitchManagementUseCase
          switchManagementUseCase;
        @Inject
        private NetworkManagementUseCase
          networkManagementUseCase;
       /** Code omitted **/
    }
    

    NetworkManagementGenericAdapter输入适配器依赖于SwitchManagementUseCaseNetworkManagementUseCase用例,在系统中触发与网络相关的操作。正如我们在之前的实现中所做的那样,这里我们使用@Inject在运行时提供依赖项。

    以下代码展示了在 Quarkus DI 之前这些依赖是如何提供的:

    public NetworkManagementGenericAdapter(
    SwitchManagementUseCase switchManagementUseCase, Net
      workManagementUseCase networkManagementUseCase) {
        this.switchManagementUseCase =
          switchManagementUseCase;
        this.networkManagementUseCase =
          networkManagementUseCase;
    }
    

    在实现注入机制之后,我们可以安全地移除这个NetworkManagementGenericAdapter构造函数。

我们已经完成了所有必要的更改,将输入端口、用例和适配器转换为可用于依赖注入的组件。这些更改展示了如何将 Quarkus CDI 机制集成到我们的六边形应用程序中。

现在,让我们学习如何将六边形系统适应以在测试期间模拟和使用托管 Bean。

使用 Quarkus 和 Cucumber 测试用例

在实现应用程序六边形的过程中第七章构建应用程序六边形,我们使用了 Cucumber 来帮助我们塑造和测试我们的用例。通过利用 Cucumber 提供的行为驱动设计技术,我们能够以声明性方式表达用例。现在,我们需要集成 Cucumber,使其与 Quarkus 协同工作:

  1. 第一步是将 Quarkus 测试依赖项添加到应用程序六边形的pom.xml文件中:

    <dependency>
      <groupId>io.quarkiverse.cucumber</groupId>
      <artifactId>quarkus-cucumber</artifactId>
      <version>1.0    .0</version>
      <scope>test</scope>
    </dependency>
    <dependency>
      <groupId>io.quarkus</groupId>
      <artifactId>quarkus-junit5</artifactId>
      <scope>test</scope>
    </dependency>
    

    quarkus-cucumber依赖提供了我们运行 Quarkus 测试所需的集成。我们还需要quarkus-junit5依赖,它使我们能够使用@QuarkusTest注解。

  2. 接下来,我们必须添加必要的 Cucumber 依赖项:

    <dependency>
      <groupId>io.cucumber</groupId>
      <artifactId>cucumber-java</artifactId>
      <version>${cucumber.version}</version>
      <scope>test</scope>
    </dependency>
    <dependency>
      <groupId>io.cucumber</groupId>
      <artifactId>cucumber-junit</artifactId>
      <version>${cucumber.version}</version>
      <scope>test</scope>
    </dependency>
    <dependency>
      <groupId>io.cucumber</groupId>
      <artifactId>cucumber-picocontainer</artifactId>
      <version>${cucumber.version}</version>
      <scope>test</scope>
    </dependency>
    

    通过添加cucumber-javacucumber-junitcucumber-picocontainer依赖项,我们可以在系统中启用 Cucumber 引擎。

让我们看看在没有 Quarkus 的情况下如何配置 Cucumber:

package dev.davivieira.topologyinventory.application;
import io.cucumber.junit.Cucumber;
import io.cucumber.junit.CucumberOptions;
import org.junit.runner.RunWith;
@RunWith(Cucumber.class)
@CucumberOptions(
        plugin = {"pretty", "html:target/cucumber-result"}
)
public class ApplicationTest {
}

使用@RunWith(Cucumber.class)注解来激活 Cucumber 引擎。当使用 Quarkus 时,这是实现ApplicationTest的方式:

package dev.davivieira.topologyinventory.application;
import io.quarkiverse.cucumber.CucumberQuarkusTest;
import io.quarkus.test.junit.QuarkusTest;
@QuarkusTest
public class ApplicationTest extends CucumberQuarkusTest {
}

@QuarkusTest注解激活了 Quarkus 测试引擎。通过扩展CucumberQuarkusTest类,我们也启用了 Cucumber 测试引擎。

ApplicationTest类上没有测试,因为这个只是一个启动类。记住,Cucumber 测试是在单独的类中实现的。在更改这些类之前,我们需要模拟所需的托管 Bean,以提供RouterManagementOutputPortSwitchManagementOutputPort的实例。

让我们为RouterManagementOutputPort创建一个模拟 Bean 对象:

package dev.davivieira.topologyinventory.application.mocks;
import dev.davivieira.topologyinventory.applica
  tion.ports.output.RouterManagementOutputPort;
import dev.davivieira.topologyinventory.domain.en
  tity.Router;
import dev.davivieira.topologyinventory.domain.vo.Id;
import io.quarkus.test.Mock;
@Mock
public class RouterManagementOutputPortMock implements
  RouterManagementOutputPort {
    @Override
    public Router retrieveRouter(Id id) {
        return null;
    }
    @Override
    public Router removeRouter(Id id) {
        return null;
    }
    @Override
    public Router persistRouter(Router router) {
        return null;
    }
}

这是一个我们创建的虚拟模拟 Bean,用于防止 Quarkus 抛出UnsatisfiedResolutionException。通过使用@Mock注解,Quarkus 将实例化RouterManagementOutputPortMock类,并在测试期间将其作为 Bean 注入。

同样地,我们将模拟SwitchManagementOutputPort

package dev.davivieira.topologyinventory.application.mocks;
import dev.davivieira.topologyinventory.applica
  tion.ports.output.SwitchManagementOutputPort;
import dev.davivieira.topologyinventory.domain.en
  tity.Switch;
import dev.davivieira.topologyinventory.domain.vo.Id;
import io.quarkus.test.Mock;
@Mock
public class SwitchManagementOutputPortMock implements
  SwitchManagementOutputPort {
    @Override
    public Switch retrieveSwitch(Id id) {
        return null;
    }
}

对于 SwitchManagementOutputPort,我们创建了 SwitchManagementOutputPortMock 以提供一个虚拟的托管 Bean,这样 Quarkus 就可以在测试期间使用它进行注入。如果没有模拟,我们需要从 RouterManagementH2AdapterSwitchManagementH2Adapter 输出适配器获取真实实例。

尽管我们在测试期间没有直接引用输出接口和输出端口适配器,但 Quarkus 仍然试图在它们上执行 Bean 发现。这就是为什么我们需要提供模拟的原因。

现在,我们可以重构测试以使用 Quarkus DI 提供的依赖注入。让我们在 RouterAdd 测试中学习如何做到这一点:

public class RouterAdd extends ApplicationTestData {
    @Inject
    RouterManagementUseCase routerManagementUseCase;
   /** Code omitted **/
}

在使用 Quarkus DI 之前,这是我们对 RouterManagementUseCase 的实现方式:

this.routerManagementUseCase = new RouterManagementInput
  Port();

一旦实现了 @Inject 注解,就可以删除前面的代码。

我们可以在重构其他测试类时遵循相同的做法,添加 @Inject 注解并删除构造函数调用以实例化输入端口对象。

运行与 Cucumber 集成的 Quarkus 测试后,你将得到以下类似的结果:

[INFO] -------------------------------------------------------
[INFO]  T E S T S
[INFO] -------------------------------------------------------
[INFO] Running dev.davivieira.topologyinventory.application.ApplicationTest
2021-09-08 22:44:15,596 INFO  [io.quarkus] (main) Quarkus 2.2.1.Final on JVM started in 1.976s. Listening on: http://localhost:8081
2021-09-08 22:44:15,618 INFO  [io.quarkus] (main) Profile test activated.
2021-09-08 22:44:15,618 INFO  [io.quarkus] (main) Installed features: [cdi, cucumber, smallrye-context-propagation]
@RouterCreate
Scenario: Creating a new core router
#dev/davivieira/topologyinventory/application/routers/RouterCreate.feature:4
.  Given I provide all required data to create a core router
#dev.davivieira.topologyinventory.application.RouterCreate.create_core_router()
.  Then A new core router is created
#dev.davivieira.topologyinventory.application.RouterCreate.a_new_core_router_is_created()

注意,在已安装功能的输出条目中,Quarkus 提到了 CDICucumber 作为正在使用的扩展。

在本节中,我们学习了如何配置 Quarkus 正确地与 Cucumber 一起工作。此配置是为了配置 Quarkus 模拟并重构测试类,以注入输入端口对象而不是通过构造函数调用创建它们。

摘要

在本章中,我们有机会学习 Quarkus 如何通过 Quarkus DI 提供依赖注入。我们首先回顾了 CDI 为 Java 2.0 规范定义的一些概念,该规范是 Quarkus DI 所依据的。然后,我们继续在我们的六边形应用程序中实现这些概念。我们在重构用例、端口和适配器时定义了托管 Bean 并注入了它们。最后,我们学习了如何将 Quarkus 与 Cucumber 集成,以便在测试我们的六边形应用程序时获得两者的最佳效果。

通过将 Quarkus 依赖注入机制集成到六边形系统中,我们也将它转变为一个更健壮和现代的系统。

在下一章中,我们将关注适配器。Quarkus 提供了创建反应式 REST 端点的强大功能,我们将学习如何将它们与六边形系统适配器集成。

问题

  1. Quarkus DI 基于哪个 Java 规范?

  2. ApplicationScopedSingleton 范围之间的区别是什么?

  3. 我们应该使用哪种注解来通过 Quarkus DI 提供依赖关系,而不是使用调用构造函数?

  4. 要启用 Quarkus 测试功能,我们应该使用哪个注解?

答案

  1. 它基于 Java 2.0 规范的 CDI。

  2. 当使用 ApplicationScope 时,对象是延迟加载的。使用 Singleton 时,对象是预先加载的。

  3. @Inject 注解。

  4. @QuarkusTest 注解。

第十二章:使用 RESTEasy Reactive 实现输入适配器

输入适配器就像一扇前门,它展示了六角系统提供的所有功能。每当用户或其他应用程序想要与六角系统通信时,它们都会接触到可用的输入适配器之一。通过这样的适配器,我们可以在六角系统中提供不同的方式来访问相同的功能。如果客户端不支持 HTTP 通信,我们可以使用不同的协议实现适配器。这里的显著优势是,移除或添加新的适配器不会影响域逻辑。

由于六角架构的解耦和良好封装特性,我们可以在不改变系统域逻辑的情况下更改技术。

在本章中,我们将继续探索 Quarkus 的激动人心特性。与实现输入适配器非常契合的一个特性是RESTEasy Reactive JAX-RS 实现,它是 Quarkus 框架的一部分。RESTEasy Reactive 提出了一种异步和事件驱动的 HTTP 端点暴露方式。因此,我们将学习如何将这种反应能力与六角系统的输入适配器集成。

本章我们将涵盖以下主题:

  • 探索处理服务器请求的方法

  • 使用 RESTEasy Reactive 实现输入适配器

  • 添加 OpenAPI 和 Swagger UI

  • 测试反应式输入适配器

到本章结束时,你将了解如何实现和测试具有反应行为的输入适配器。你还将了解如何使用 OpenAPI 和 Swagger UI 发布这些输入适配器的 API。

技术要求

要编译和运行本章中展示的代码示例,你需要在计算机上安装最新的Java SE 开发工具包Maven 3.8。它们适用于 Linux、Mac 和 Windows 操作系统。

你可以在 GitHub 上找到本章的代码文件,地址为github.com/PacktPublishing/-Designing-Hexagonal-Architecture-with-Java---Second-Edition/tree/main/Chapter12

探索处理服务器请求的方法

在客户端-服务器通信中,我们有一个流程,其中客户端发送请求,服务器接收它并开始工作。一旦服务器完成工作,它就会向客户端发送一个结果。从客户端的角度来看,这个流程不会改变。它始终是发送请求和接收响应。不过,可以改变的是服务器如何内部处理请求。

处理服务器请求处理有两种方法:反应式命令式。那么,让我们看看服务器如何以命令式处理请求。

命令式

在运行于Tomcat的传统 Web 应用程序中,服务器接收到的每个请求都会在所谓的线程池上触发创建一个工作线程。在 Tomcat 中,线程池是一种控制服务应用程序请求的工作线程的生命周期和可用性的机制。因此,当你发起一个服务器请求时,Tomcat 会从线程池中拉取一个专用线程来服务你的请求。这个工作线程依赖于阻塞 I/O 来访问数据库和其他系统。以下图表说明了命令式方法的工作原理:

图 12.1 – 命令式方法

图 12.1 – 命令式方法

如前图所示,服务器需要为每个请求创建一个新的 I/O 阻塞工作线程。

一旦创建并分配一个工作线程来服务一个请求,它就会在请求得到满足之前被阻塞。服务器有有限数量的线程。如果你有很多长时间运行的请求,并且在服务器完成它们之前继续发送这样的请求,服务器将耗尽线程,这会导致系统故障。

线程创建和管理也是昂贵的。服务器在创建和切换线程以服务客户端请求时消耗了宝贵的资源。

因此,命令式方法的底线是,工作线程一次只被阻塞来服务一个——并且只有一个——请求。为了并发服务更多的请求,你需要提供更多的工人线程。此外,命令式方法影响了代码的编写方式。命令式代码相对更容易理解,因为事物是按顺序处理的。

现在,让我们看看反应式方法与命令式方法是如何对比的。

反应式

如你所想,反应式方法背后的理念是,你不需要阻塞一个线程来满足请求。相反,系统可以使用相同的线程同时处理不同的请求。在命令式方法中,我们有只处理一个请求的工作线程,而在反应式方法中,我们有 I/O 非阻塞线程可以并发处理多个请求。在这里,我们可以看到反应式方法是如何工作的:

图 12.2 – 反应式方法

图 12.2 – 反应式方法

如前图所示,一个非阻塞线程可以处理多个请求。

在响应式方法中,我们有持续性的感觉。与命令式方法的顺序性不同,在响应式方法中,我们可以看到事物具有连续性。通过持续性,我们指的是每当一个响应式服务器接收到一个请求时,这样的请求会被作为一个带有持续性的 I/O 操作分发。这个持续性就像一个回调,一旦服务器返回响应,就会被触发并继续执行请求。如果这个请求需要获取数据库或任何远程系统,服务器在等待响应时不会阻塞 I/O 线程。相反,I/O 线程将触发一个带有持续性的 I/O 操作,并释放 I/O 线程以接受其他请求。

以下图示说明了 I/O 线程如何触发 I/O 操作:

图 12.3 – I/O 线程流程

图 12.3 – I/O 线程流程

如我们所见,I/O 线程调用一个非阻塞任务,该任务触发一个 I/O 操作并立即返回。这是因为 I/O 线程不需要等待第一个 I/O 操作完成就可以调用第二个操作。当第一个 I/O 操作仍在执行时,同一个 I/O 线程会调用另一个非阻塞任务。一旦 I/O 操作完成,I/O 线程通过完成非阻塞任务来继续执行。

通过避免在命令式方法中浪费任何时间和资源,响应式方法使得线程在等待 I/O 操作完成时得到优化利用。

接下来,我们将学习如何使用 Quarkus 提供的 RESTEasy Reactive JAX-RS 实现来实施响应式输入适配器。

使用 RESTEasy Reactive 实现输入适配器

RESTEasy Reactive是一个支持命令式和响应式 HTTP 端点的 JAX-RS 实现。这种实现与Vert.x集成,Vert.x 是一个我们可以用来构建分布式响应式系统的工具包。RESTEasy Reactive 和 Vert.x 在 Quarkus 中协同工作,以提供响应式功能。

要理解一个响应式端点是什么样的,我们将把 RESTEasy Reactive 与拓扑和库存系统的输入适配器集成。

让我们先配置所需的 Maven 依赖项:

<dependencies>
  <dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-resteasy-reactive</artifactId>
  </dependency>
  <dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-resteasy-reactive-
      jackson</artifactId>
  </dependency>
</dependencies>

使用quarkus-resteasy-reactive,我们引入了响应式库,包括响应式 RESTEasy 和Mutiny库,我们将使用这些库以响应式的方式编写代码。我们将使用quarkus-resteasy-reactive-jackson来处理涉及响应式响应的反序列化任务。

一旦我们配置了依赖项,我们就可以开始实现拓扑和库存系统中的路由管理响应式输入适配器。

实现用于路由管理的响应式输入适配器

我们将在我们创建的现有输入适配器上工作,这些适配器在 第八章构建框架六边形 中创建。我们将更改这些输入适配器以启用 JAX-RS 和响应式功能。我们将执行以下步骤来完成此操作:

  1. 让我们从在 RouterManagementAdapter 类上定义与路由管理相关的请求的最高级路径开始:

    @ApplicationScoped
    @Path("/router")
    public class RouterManagementAdapter {
        @Inject
        RouterManagementUseCase routerManagementUseCase;
        /** Code omitted **/
    }
    

    我们使用 @Path 注解将 URL 路径映射到系统中的资源。我们可以在类或方法上使用此注解。

    这个类的唯一字段是 RouterManagementUseCase,它使用 @Inject 注解注入。通过利用这个用例引用,我们可以访问与路由管理相关的系统功能。

  2. 接下来,让我们定义一个响应式端点来检索一个路由:

    @GET
    @Path("/{id}")
    public Uni<Response> retrieveRouter(Id id) {
        return Uni.createFrom()
                .item(
                   routerManagementUseCase.
                   retrieveRouter(id))
                .onItem()
                .transform(
                 router -> router != null ?
                 Response.ok(f) :
                 Response.ok(null))
                .onItem()
                .transform(Response.Response
                    Builder::build);
    

    @GET 注解表示只允许 HTTP GET 请求。方法级别的 @Path("/{id}") 注解与类级别的 @Path("/router") 注解连接。因此,要到达这个 retrieveRouter 方法,我们必须向 /router/{id} 发送请求。

    此外,请注意 @PathParam("id") 注解,我们使用它来从 URL 中捕获一个参数。

    使这个端点成为响应式的是它的 Uni<Response> 响应类型。UniMutiny 库提供的两种类型之一。除了 Uni,还有一个 Multi 类型。

    我们使用 UniMulti 类型来表示我们正在处理的数据类型。例如,如果你的响应只返回一个项目,你应该使用 Uni。否则,如果你的响应类似于来自消息服务器的数据流,那么 Multi 可能更适合你的目的。

    通过调用 Uni.createFrom().item(routerManagementUseCase.retrieveRouter(id)),我们创建了一个执行 routerManagementUseCase.retrieveRouter(id) 的管道。结果被捕获在 transform(f -> f != null ? Response.ok(f) : Response.ok(null)) 上。如果请求成功,我们得到 Response.ok(f);否则,我们得到 Response.ok(null)。最后,我们调用 transform(Response.ResponseBuilder::build) 将结果转换为 Uni<Response> 对象。

    Response.ResponseBuilder::build 是一个方法引用,可以写成以下 lambda 表达式:(Response.ResponseBuilder responseBuilder) -> responseBuilder.build())。responseBuilder 代表我们接收的对象参数,然后调用 build 方法来创建一个新的 Response 对象。我们倾向于使用方法引用方法,因为我们用更少的代码完成同样的事情。

    我们即将实现的其余端点都遵循之前描述的类似方法。

  3. 在实现检索路由的端点之后,我们可以实现从系统中删除路由的端点:

    @DELETE
    @Path("/{id}")
    public Uni<Response> removeRouter(@PathParam("id") Id
      id) {
        return Uni.createFrom()
                .item(
                 routerManagementUseCase.removeRouter(id))
                .onItem()
                .transform(
                 router -> router != null ?
                 Response.ok(router) :
                 Response.ok(null))
                .onItem()
                .transform(Response.Response
                  Builder::build);
    }
    

    @DELETE注解对应于HTTP DELETE方法。同样,我们正在在@Path("/{id}")注解上定义一个Path参数。方法体中有一个Uni管道,它执行routerManagementUseCase.removeRouter(id)并返回Uni<Response>

  4. 让我们实现创建新路由器的端点:

    @POST
    @Path("/")
    public Uni<Response> createRouter(CreateRouter cre
      ateRouter) {
        /** Code omitted **/
        return Uni.createFrom()
                .item(
                   routerManagementUseCase.
                   persistRouter(router))
                .onItem()
                .transform(
                 router -> router != null ?
                 Response.ok(f) :
                 Response.ok(null))
                .onItem()
                .transform(Response.Response
                   Builder::build);
    }
    

    我们使用@POST注解,因为我们正在创建一个新的资源。在方法级别上,@Path("/")注解与类级别上的@Path("/router")注解连接,生成/router/路径。我们在方法体中有响应式代码来处理请求并返回Uni<Response>

  5. 接下来,我们将实现一个端点,以便可以将路由器添加到核心路由器:

    @POST
    @Path("/add")
    public Uni<Response> addRouterToCoreRouter(AddRouter
      addRouter) {
        /** Code omitted **/
        return Uni.createFrom()
                .item(routerManagementUseCase.
                        addRouterToCoreRouter(router,
                          coreRouter))
                .onItem()
                .transform(
                 router -> router != null ?
                 Response.ok(router) :
                 Response.ok(null))
                .onItem()
                .transform(Response.Response
                   Builder::build);
    }
    

    同样,我们在这里使用@POST注解。在方法级别上,@Path("/add")注解与类级别上的@Path("/router")注解连接,生成/router/add路径。响应式代码创建了一个管道来执行routerManagementUseCase.addRouterToCoreRouter(router, coreRouter)并返回Uni<Response>

  6. 最后,我们必须实现一个端点来从核心路由器中移除一个路由器:

    @DELETE
    @Path("/{routerId}/from/{coreRouterId}")
    public Uni<Response> removeRouterFromCoreRouter(
        /** Code omitted **/
        return Uni.createFrom()
                .item(routerManagementUseCase.
                        removeRouterFromCoreRouter(
                        router, coreRouter))
                .onItem()
                .transform(
                 router -> router != null ?
                      Response.ok(f) :
                      Response.ok(null))
                .onItem()
                .transform(Response.Response
                   Builder::build);
    }
    

    在这里,我们使用@DELETE注解来处理HTTP DELETE请求。在@Path注解中,我们有两个路径参数 - routerIdcoreRouterId。当我们通过Uni提供的管道调用routerManagementUseCase.removeRouterFromCoreRouter(router, coreRouter)时,我们使用这两个参数来获取RouterCoreRouter对象。

如我们所见,当使用 Quarkus 时,从命令式转换为响应式实现 REST 端点并不需要太多工作。大部分工作都是在框架及其库的幕后完成的。

现在,让我们继续前进并实现用于开关管理的响应式输入适配器。

实现用于开关管理的响应式输入适配器

按照与上一节中类似的方法,我们可以通过执行以下步骤来实现用于开关管理的响应式输入适配器:

  1. 我们将首先在SwitchManagementAdapter类上启用 JAX-RS:

    @ApplicationScoped
    @Path("/switch")
    public class SwitchManagementAdapter {
        @Inject
        SwitchManagementUseCase switchManagementUseCase;
        @Inject
        RouterManagementUseCase routerManagementUseCase;
        /** Code omitted **/
    }
    

    这个类被注解为@Path("/switch"),因此所有与开关管理相关的请求都将被导向它。随后,我们注入了SwitchManagementUseCaseRouterManagementUseCase以在应用程序六边形上执行操作。

  2. 为了在拓扑和库存系统中启用开关检索,我们需要在retrieveSwitch方法上实现响应式行为:

    @GET
    @Path("/{id}")
    public Uni<Response> retrieveSwitch(@PathParam("id")
      Id switchId) {
        return Uni.createFrom()
                .item(
                 switchManagementUseCase.
                 retrieveSwitch(switchId))
                .onItem()
                .transform(
                 aSwitch -> aSwitch != null ?
                 Response.ok(aSwitch) :
                 Response.ok(null))
                .onItem()
                .transform(Response.Response
                   Builder::build);
    }
    

    通过添加@GET@Path注解,我们激活了retrieveSwitch方法的 JAX-RS。我们将switchManagementUseCase.retrieveSwitch(switchId)放置在返回Uni<Response>Mutiny管道中执行。

    item的调用立即返回。它触发了由retrieveSwitch方法执行的操作,并允许线程继续服务其他请求。结果是在我们调用onItem时获得的,它代表了当我们调用item时触发的操作继续。

  3. 接下来,我们必须向createAndAddSwitchToEdgeRouter方法添加响应式行为:

    @POST
    @Path("/create/{edgeRouterId}")
    public Uni<Response> createAndAddSwitchToEdgeRouter(
                CreateSwitch createSwitch,
                @PathParam("edgeRouterId") Id
                  edgeRouterId){
        /** Code omitted **/
        return Uni.createFrom()
                .item((EdgeRouter)
                  routerManagementUseCase.
                  persistRouter(router))
                .onItem()
                .transform(
                 router -> router != null ?
                 Response.ok(f) :
                 Response.ok(null))
                .onItem()
                .transform(Response.Response
                  Builder::build);
    }
    

    前面的方法处理了创建开关对象并将其添加到边缘路由器的HTTP POST请求。在这里,我们调用routerManagementUseCase.persistRouter(router)方法,该方法被封装在一个Mutiny管道中,以返回Uni<Response>

  4. 最后,我们必须定义一个响应式端点来从一个边缘路由器中移除一个开关:

    @DELETE
    @Path("/{switchId}/from/{edgeRouterId}")
    public Uni<Response> removeSwitchFromEdgeRouter(
            @PathParam("switchId") Id switchId,
            @PathParam("edgeRouterId") Id
              edgeRouterId) {
        /** Code omitted **/
        return Uni.createFrom()
                .item(
                 (EdgeRouter)routerManagementUseCase.
                  persistRouter(router))
                .onItem()
                .transform(
                 router -> router != null ?
                 Response.ok(f) :
                 Response.ok(null))
                .onItem()
                .transform(Response.Response
                  Builder::build);
    }
    

正如我们在之前的移除操作中所做的那样,我们从核心路由器中移除了一个路由器,我们使用@DELETE注解来使removeSwitchFromEdgeRouter方法只接受HTTP DELETE请求。我们传递Path参数switchIdedgeRouterId,以获取操作所需的开关和边缘路由器对象。

在定义了retrieveSwitchcreateAndAddSwitchToEdgeRouterremoveSwitchFromEdgeRouter的响应式端点之后,我们可以开始实现网络管理的响应式输入适配器。

实现网络管理的响应式输入适配器

如你所想,network响应式输入适配器遵循与路由器和开关响应式适配器相同的标准。在以下步骤中,我们将为与网络管理相关的端点启用响应式行为:

  1. 让我们从启用NetworkManagementAdapter输入适配器的 JAX-RS 开始:

    @ApplicationScoped
    @Path("/network")
    public class NetworkManagementAdapter {
        @Inject
        SwitchManagementUseCase switchManagementUseCase;
        @Inject
        NetworkManagementUseCase networkManagementUseCase;
        /** Code omitted **/
    }
    

    在这个阶段,你可能已经熟悉了类级别的@Path注解。我们注入SwitchManagementUseCaseNetworkManagementUseCase用例,以协助执行此输入适配器所执行的操作。

  2. 接下来,我们必须定义一个响应式端点,以便可以将网络添加到开关中:

    @POST
    @Path("/add/{switchId}")
    public Uni<Response> addNetworkToSwitch(AddNetwork
      addNetwork, @PathParam("switchId") Id switchId) {
        /** Code omitted **/
        return Uni.createFrom()
                .item(
                  networkManagementUseCase.
                   addNetworkToSwitch(
                   network, networkSwitch))
                .onItem()
                .transform(
                  f -> f != null ?
                  Response.ok(f) :
                  Response.ok(null))
                .onItem()
                .transform(Response.Response
                   Builder::build);
    }
    

    我们在这里应用的想法与之前的应用相同。在addNetworkToSwitch方法内部,我们添加了一些将使用Mutiny管道调用networkManagementUseCase.addNetworkToSwitch(network, networkSwitch)并返回Uni<Response>的响应式代码。

  3. 最后,我们必须定义一个响应式端点来从一个开关中移除一个网络:

    @DELETE
    @Path("/{networkName}/from/{switchId}")
    public Uni<Response> removeNetworkFromSwitch(@Path
      Param("networkName") String networkName, @Path
        Param("switchId") Id         switchId) {
        /** Code omitted **/
        return Uni.createFrom()
                .item(
                 networkManagementUseCase.
                 removeNetworkFromSwitch(
                 networkName, networkSwitch))
                .onItem()
                .transform(
                  f -> f != null ?
                  Response.ok(f) :
                  Response.ok(null))
                .onItem()
                .transform(Response.Response
                  Builder::build);
    }
    

    在这里,我们使用@DELETE注解和两个路径参数networkNameswitchId来从一个开关中移除一个网络。在Mutiny管道内部,我们调用networkManagementUseCase.removeNetworkFromSwitch(networkName, networkSwitch)。管道结果是Uni<Response>

通过这样,我们已经完成了网络管理响应式输入适配器的实现。现在,RouterManagementAdapterSwitchManagementAdapterNetworkManagementAdapter输入适配器已准备好以响应式方式处理 HTTP 请求。

这三个输入适配器和它们的端点构成了六边形系统 API。

在本节中,我们不仅学习了如何创建普通的 REST 端点,而且还通过使用 RESTEasy Reactive 在输入适配器的端点上启用响应式行为而更进一步。这是利用响应式方法优势的基本步骤。采用响应式方法后,我们不再需要依赖于 I/O 阻塞线程,这些线程可能比 I/O 非阻塞线程消耗更多的计算资源。I/O 阻塞线程需要等待 I/O 操作完成。I/O 非阻塞线程更高效,因为同一个线程可以同时处理多个 I/O 操作。

下一节将介绍如何使用 OpenAPI 和 Swagger UI 发布系统 API。

添加 OpenAPI 和 Swagger UI

理解和与第三方系统交互有时是一项非同寻常的任务。在最佳情况下,我们可能拥有系统文档、一个有组织的代码库和一组 API,这些 API 共同帮助我们了解系统的作用。在最坏的情况下,我们可能没有这些。这种具有挑战性的情况需要勇气、耐心和毅力,去尝试理解一个错综复杂的代码库。

OpenAPI 代表了提高我们表达和理解系统作用能力的一项值得尊敬的努力。最初基于 Swagger 规范,OpenAPI 规范标准化了 API 的文档和描述方式,以便任何人都可以不费吹灰之力地掌握系统提供的功能。

我们在上一节中实现了构成我们六边形系统 API 的响应式输入适配器。为了使这个系统对其他人或系统更易于理解,我们将使用 OpenAPI 来描述输入适配器及其端点提供的功能。此外,我们还将启用Swagger UI,这是一个展示系统 API 清晰和组织视图的 Web 应用程序。

Quarkus 自带对OpenAPI v3规范的支持。要启用它,我们需要以下 Maven 依赖项:

<dependencies>
  <dependency>
      <groupId>io.quarkus</groupId>
      <artifactId>quarkus-smallrye-openapi</artifactId>
  </dependency>
</dependencies>

quarkus-smallrye-openapi依赖项提供了包含我们可以用来描述输入适配器类上响应式端点方法的 OpenAPI 注解的库。此依赖项还允许我们配置 Swagger UI。

记住,我们配置了四个 Java 模块:domainapplicationframeworkbootstrap。为了激活和配置 Swagger UI,我们需要在bootstrap模块内部创建resource/application.properties文件。以下是配置此文件的方法:

quarkus.swagger-ui.always-include=true
quarkus.swagger-ui.urls-primary-name=Topology & Inventory
quarkus.swagger-ui.theme=material
quarkus.swagger-ui.title=Topology & Inventory - Network
  Management System
quarkus.swagger-ui.footer=&#169; 2021 | Davi Vieira
quarkus.swagger-ui.display-operation-id=true
mp.openapi.extensions.smallrye.info.title=Topology & Inven
  tory API
mp.openapi.extensions.smallrye.info.version=1.0
mp.openapi.extensions.smallrye.info.description=Manage net
  works assets

我们将quarkus.swagger-ui.always-include设置为true,以确保当应用程序使用prod(生产)配置文件启动时,Swagger UI 也将可用——这是 Quarkus 内置配置文件之一。通过quarkus.swagger-ui.theme,我们可以配置界面主题。我们将使用剩余的属性来提供 API 的高级描述。

让我们学习如何使用 OpenAPI 注解来暴露和描述六边形系统的端点。看看以下来自RouterManagementAdapter类的示例:

@ApplicationScoped
@Path("/router")
@Tag(name = "Router Operations", description = "Router man
  agement operations")
public class RouterManagementAdapter {
    @GET
    @Path("/retrieve/{id}")
    @Operation(operationId = "retrieveRouter",
    description = "Retrieve a router from the network
      inventory")
    public Uni<Response> retrieveRouter(@PathParam("id")
      Id id) {
     /** Code omitted **/
}

在类级别使用的@Tag注解使我们能够定义应用于RouterManagementAdapter类中定义的所有端点的元数据信息。这意味着方法端点,如RouterManagementAdapter类中的retrieveRouter方法,将继承该类级别的@Tag注解。

我们使用@Operation注解来提供操作的详细信息。在上面的代码中,我们描述了在/retrieve/{id}路径上执行的操作。这里我们有operationId参数,它用于唯一标识端点,以及description参数,它用于提供有意义的操作描述。

为了使 Quarkus 和 Swagger UI 显示我们六边形系统 API 的华丽 UI,我们只需将这些 OpenAPI 注解添加到我们想要在 Swagger UI 上公开的类和方法(正确配置了 JAX-RS)中。

您可以使用本书 GitHub 仓库中的代码编译和运行应用程序。确保在chapter12目录中执行以下命令:

$ mvn clean package
$ java -jar bootstrap/target/bootstrap-1.0-SNAPSHOT-runner.jar

这将在您的浏览器上打开以下 URL:

http://localhost:8080/q/swagger-ui/

此外,您将看到类似于以下截图的内容:

图 12.4 – 来自拓扑和库存系统的 Swagger UI

图 12.4 – 来自拓扑和库存系统的 Swagger UI

在前面的截图中,操作被分组到我们为每个输入适配器类插入的@Tag注解中。每个端点都继承了自己的@Tag元数据信息。

到目前为止,我们已经正确配置了我们的六边形系统,其中包含用 OpenAPI 和 Swagger UI 良好记录的反应式端点。现在,让我们学习如何测试这些端点以确保它们按预期工作。

测试反应式输入适配器

我们的测试工作从领域六边形开始,通过单元测试核心系统组件。然后,我们转向应用六边形,在那里我们可以使用行为驱动设计技术测试用例。现在,我们在框架六边形上实现了反应式 REST 端点,我们需要找到一种方法来测试它们。

幸运的是,Quarkus 在端点测试方面装备齐全。要开始,我们需要以下依赖项:

<dependencies>
  <dependency>
    <groupId>io.rest-assured</groupId>
    <artifactId>rest-assured</artifactId>
    <scope>test</scope>
  </dependency>
</dependencies>

rest-assured依赖项使我们能够测试 HTTP 端点。它提供了一个直观的库,对于进行请求和从 HTTP 调用中提取响应非常有用。

为了了解它是如何工作的,让我们为/router/retrieve/{routerId}端点实现一个测试:

@Test
@Order(1)
public void retrieveRouter() throws IOException {
    var expectedRouterId =
      "b832ef4f-f894-4194-8feb-a99c2cd4be0c";
    var routerStr = given()
            .contentType("application/json")
            .pathParam("routerId", expectedRouterId)
            .when()
            .get("/router/retrieve/{routerId}")
            .then()
            .statusCode(200)
            .extract()
            .asString();
    var actualRouterId =
    getRouterDeserialized(routerStr).getId().getUuid()
      .toString();
    assertEquals(expectedRouterId, actualRouterId);
}

为了创建一个请求,我们可以使用静态的io.restassured.RestAssured.given方法。我们可以使用given方法指定请求的内容类型、参数、HTTP 方法和正文。发送请求后,我们可以使用statusCode检查其状态。为了获取响应,我们调用extract。在下面的示例中,我们以字符串的形式获取响应。这是因为反应式端点的返回类型是Uni<Response>。因此,结果是 JSON 字符串。

在运行断言之前,我们需要将 JSON 字符串反序列化为Router对象。反序列化工作由getRouterDeserialized方法完成:

public static Router getRouterDeserialized(String jsonStr)
  throws IOException {
    var mapper = new ObjectMapper();
    var module = new SimpleModule();
    module.addDeserializer(Router.class, new
      RouterDeserializer());
    mapper.registerModule(module);
    var router = mapper.readValue(jsonStr, Router.class);
    return router;
}

此方法接收一个 JSON 字符串作为参数。当我们调用mapper.readValue(jsonStr, Router.class)时,这个 JSON 字符串会被传递给一个ObjectMapper映射器。除了提供映射器外,我们还需要扩展并实现com.fasterxml.jackson.databind.deser.std.StdDeserializer类中的deserialize方法。在先前的示例中,这个实现由RouterDeserializer提供。这个反序列化器将 JSON 字符串转换为Router对象,如下面的代码所示:

public class RouterDeserializer extends StdDeserial
  izer<Router> {
    /** Code omitted **/
    @Override
    public Router deserialize(JsonParser jsonParser,
    DeserializationContext ctxt)
            throws IOException {
        JsonNode node =
        jsonParser.getCodec().readTree(jsonParser);
        var id = node.get("id").get("uuid").asText();
        var vendor = node.get("vendor").asText();
        var model = node.get("model").asText();
        var ip = node.get("ip").get("ipAddress").asText();
        var location = node.get("location");
        var routerType = RouterType.valueOf(
          node.get("routerType").asText());
        var routersNode = node.get("routers");
        var switchesNode = node.get("switches");
        /** Code omitted **/
}

deserialize方法的目的是将每个相关的 JSON 属性映射到领域类型。我们通过从JsonNode对象中检索我们想要的值来完成这个映射。在映射我们想要的值之后,我们可以创建一个router对象,如下面的代码所示:

var router = RouterFactory.getRouter(
        Id.withId(id),
        Vendor.valueOf(vendor),
        Model.valueOf(model),
        IP.fromAddress(ip),
        getLocation(location),
        routerType);

一旦所有值都被检索,我们调用RouterFactory.getRouter来生成一个Router对象。因为一个路由器可能有子路由器和开关,所以我们调用fetchChildRoutersfetchChildSwitches,以便它们也有StdDeserializer实现:

fetchChildRouters(routerType, routersNode, router);
fetchChildSwitches(routerType, switchesNode, router);

我们调用fetchChildRoutersfetchChildSwitches方法,因为一个路由器可能有子路由器和开关需要反序列化。这些方法将执行所需的反序列化。

在反序列化 JSON 字符串响应之后,我们可以在Router对象上运行断言:

var actualRouterId = getRouterDeserialized(router
  Str).getId().getUuid().toString();
assertEquals(expectedRouterId, actualRouterId);

为了测试/router/retrieve/{routerId}端点,我们正在检查通过反应式端点检索的路由器的 ID 是否与我们请求中传递的 ID 相等。

您可以通过在Chapter12目录内执行以下命令来运行此测试和其他本书 GitHub 仓库中可用的测试:

$ mvn test

上述代码的输出将类似于以下内容:

[INFO] -------------------------------------------------------
[INFO]  T E S T S
[INFO] -------------------------------------------------------
[INFO] Running dev.davivieira.topologyinventory.framework.adapters.input.rest.NetworkManagementAdapterTest
2021-09-29 00:47:36,825 INFO  [io.quarkus] (main) Quarkus 2.2.1.Final on JVM started in 2.550s. Listening on: http://localhost:8081
2021-09-29 00:47:36,827 INFO  [io.quarkus] (main) Profile test activated.
2021-09-29 00:47:36,827 INFO  [io.quarkus] (main) Installed features: [cdi, resteasy-reactive, resteasy-reactive-jackson, smallrye-context-propagation, smallrye-openapi, swagger-ui]
[EL Info]: 2021-09-29 00:47:38.812--ServerSession(751658062)--EclipseLink, version: Eclipse Persistence Services - 3.0.1.v202104070723
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 5.418 s - in dev.davivieira.topologyinventory.framework.adapters.input.rest.NetworkManagementAdapterTest
[INFO] Running dev.davivieira.topologyinventory.framework.adapters.input.rest.RouterManagementAdapterTest
[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.226 s - in dev.davivieira.topologyinventory.framework.adapters.input.rest.RouterManagementAdapterTest
[INFO] Running dev.davivieira.topologyinventory.framework.adapters.input.rest.SwitchManagementAdapterTest
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.085 s - in dev.davivieira.topologyinventory.framework.adapters.input.rest.SwitchManagementAdapterTest
2021-09-29 00:47:39,675 INFO  [io.quarkus] (main) Quarkus stopped in 0.032s

上述输出描述了针对RouterManagementAdapterSwitchManagementAdapterNetworkManagementAdapter输入适配器的反应式端点测试的执行。

执行这些端点测试的一个好处是,我们不仅测试了框架六边形上的端点功能,而且还进行了全面的测试,检查了系统所有六边形的行怍。

摘要

在本章中,我们有幸深入了解更多 Quarkus 功能,特别是 RESTEasy Reactive。我们首先回顾了在客户端-服务器通信的上下文中,命令式和响应式分别意味着什么。

然后,我们了解到 Quarkus 提供了 RESTEasy Reactive 作为其 JAX-RS 实现,使我们能够在输入适配器上实现响应式端点。之后,我们使用 OpenAPI 和 Swagger UI 暴露了六边形系统的 API。为了确保我们正确实现了响应式端点,我们使用 rest-assured 库编写了端点测试。

在下一章中,我们将继续探索 Quarkus 提供的响应式功能,并强调使用 Hibernate Reactive 的数据持久性方面。

问题

  1. 命令式请求和响应式请求之间的区别是什么?

  2. Quarkus 提供的 JAX-RS 实现的名称是什么?

  3. OpenAPI 的目的是什么?

  4. 在 Quarkus 中,我们应该使用哪个库来测试 HTTP 端点?

答案

  1. 命令式只能通过一个 I/O 阻塞工作线程一次处理一个请求。响应式可以通过 I/O 非阻塞线程处理多个请求。

  2. RESTEasy Reactive。

  3. 它用于标准化描述和记录 API 的方式。

  4. 我们应该使用 rest-assured 库。

第十三章:使用输出适配器和 Hibernate Reactive 持久化数据

在上一章中,我们学习了使用 Quarkus 反应式能力可以为系统带来的某些优势。我们在反应式道路上的第一步是使用 RESTEasy Reactive 实现反应式输入适配器。尽管输入适配器的端点正在以反应式方式提供服务,但我们仍然有输出适配器以同步和阻塞的方式工作。

为了将六边形系统转变为更反应式的一个,在本章中,我们首先将学习如何配置 Panache。一旦系统实体得到适当配置,我们将学习如何使用这些实体以反应式方式连接到 MySQL 数据库。

本章我们将涵盖以下主题:

  • 介绍 Hibernate Reactive 和 Panache

  • 在输出适配器上启用反应式行为

  • 测试反应式输出适配器

由于我们在上一章已经实现了反应式输入适配器,我们的目标是在本章中通过实现反应式输出适配器来扩展六边形系统的反应式行为。这种实现发生在框架六边形上,这是我们专注于适配器的架构元素。

到本章结束时,您将学习如何将 Quarkus 与六边形系统集成以以反应式方式访问数据库。通过理解所需的配置步骤和基本实现细节,您将能够实现反应式输出适配器。这些知识将帮助您应对非阻塞 I/O 请求比 I/O 阻塞请求提供更多优势的情况。

技术要求

要编译和运行本章中提供的代码示例,您需要在您的计算机上安装最新的 Java SE 开发工具包Maven 3.8。它们都适用于 Linux、Mac 和 Windows 操作系统。

此外,您需要在您的机器上安装 Docker

您可以在 GitHub 上找到本章的代码文件,链接为 github.com/PacktPublishing/-Designing-Hexagonal-Architecture-with-Java---Second-Edition/tree/main/Chapter13

介绍 Hibernate Reactive 和 Panache

在过去几年中,处理 Java 中数据库操作的技术和技巧已经发生了很大的变化。基于 Java 持久化 APIJPA)规范,我们被介绍了几种 ORM 实现,如 Spring Data JPA、EclipseLink,当然还有 Hibernate。这些技术通过抽象出处理数据库所需的大部分管道工作,使我们的生活变得更加容易。

Quarkus 集成了 Hibernate ORM 和其反应式对应物 Hibernate Reactive。此外,Quarkus 还附带了一个名为 Panache 的库,该库简化了我们与数据库的交互。

接下来,我们将简要介绍 Hibernate Reactive 和 Panache 的主要功能。

Hibernate Reactive 功能

找到一个能够解决与数据库访问相关所有问题的银弹方案,如果不是不可能的话,是非常罕见的。当我们谈论数据库处理的反应式和命令式方法时,理解这两种方法的优势和劣势是至关重要的。

命令式方法访问数据库之所以吸引人,在于其开发代码的简单性。当你需要使用命令式方法读取或持久化数据时,需要调整和思考的事情较少。然而,当其阻塞特性开始影响系统的用例时,这种方法可能会导致问题。为了避免这种问题,我们有了反应式方法,它使我们能够以非阻塞的方式处理数据库,但这并不是没有在开发和处理数据库时带来额外的复杂性以及新的问题和挑战。

原始的 Hibernate 实现是为了解决开发者在将 Java 对象映射到数据库实体时遇到的问题而设计的。原始实现依赖于 I/O 阻塞同步通信与数据库交互。这曾经是,现在仍然是 Java 中访问数据库最传统的方式。另一方面,Hibernate Reactive 源于对反应式编程运动的渴望以及对异步通信访问数据库的需求。Hibernate Reactive 不是依赖于 I/O 阻塞,而是依赖于 I/O 非阻塞通信与数据库交互。

在反应式实现中,实体映射属性保持不变。然而,变化的是我们打开数据库反应式连接的方式以及我们应如何构建软件代码以反应式地处理数据库实体。

当使用 Quarkus 时,无需基于persistence.xml文件提供反应式持久化配置,因为 Quarkus 已经为我们配置好了。尽管如此,我们仍将简要探讨它,以便了解 Hibernate Reactive 单独的工作方式。

要设置 Hibernate Reactive,你可以遵循配置META-INF/persistence.xml文件的常规方法,如下面的示例所示:

<persistence-unit name="mysql">
    <provider>
       org.hibernate.reactive.provider
       .ReactivePersistenceProvider
    </provider>
    <class>dev.davivieria.SomeObject</class>
    <properties>
    <property name=»javax.persistence.jdbc.url»
       value=»jdbc:mysql://localhost/hreact"/>
    </properties>
</persistence-unit>

注意,我们正在使用ReactivePersistenceProvider来打开数据库的反应式连接。一旦persistence.xml文件配置得当,我们就可以在我们的代码中开始使用 Hibernate Reactive:

import static javax.persistence.Persistence.createEnti
  tyManagerFactory;
SessionFactory factory = createEntityManagerFactory (
persistenceUnitName ( args ) ).unwrap(SessionFac
  tory.class);
/** Code omitted **/
public static String persistenceUnitName(String[] args) {
    return args.length > 0 ?
    args[0] : "postgresql-example";
}

我们首先导入 Hibernate Reactive 提供的静态javax.persistence.Persistence.createEntityManagerFactory方法。这个静态方法简化了SessionFactory对象的创建。

为了创建SessionFactory对象,系统使用persistence.xml文件中定义的属性。有了SessionFactory,我们可以开始与数据库进行反应式通信:

SomeObject someObject = new SomeObject();
factory.withTransaction(
     (
org.hibernate.reactive.mutiny.Mutiny.
  Transaction session,
org.hibernate.reactive.mutiny.Mutiny.Transaction tx) ->
session.persistAll(someObject)).subscribe();

要持久化数据,首先,我们需要通过调用 withTransaction 方法创建一个事务。在事务内部,我们从 SessionFactory 调用 persistAll 方法来持久化一个对象。我们调用 subscribe 方法以非阻塞方式触发持久化操作。

通过在应用程序和数据库之间建立一层,Hibernate 提供了我们处理 Java 数据库所需的所有基本功能。

现在,让我们看看 Panache 如何使事情变得更加简单。

Panache 特性

Panache 位于 Hibernate 之上,并通过提供处理数据库实体的简单接口来进一步增强它。Panache 主要是为了与 Quarkus 框架一起使用而开发的,它是一个旨在抽象处理数据库实体所需的大量样板代码的库。使用 Panache,你可以轻松地应用如 Active RecordRepository 这样的数据库模式。让我们简要地看看如何做到这一点。

应用活动记录模式

PanacheEntity 中,看看以下示例:

@Entity
@Table(name="locations")
public class Location extends PanacheEntity {
    @Id @GeneratedValue
    private Integer id;
    @NotNull @Size(max=100)
    public String country;
    @NotNull @Size(max=100)
    public String state;
    @NotNull @Size(max=100)
    public String city;
}

前面的 Location 类是一个基于 Hibernate 的常规实体,它扩展了 PanacheEntity。除了扩展 PanacheEntity 之外,这个 Location 类没有其他新内容。我们使用了 @NotNull@Size 等注解来验证数据。

以下是一些我们可以使用活动记录实体执行的操作:

  • 要列出实体,我们可以调用 listAll 方法。此方法在 Location 上可用,因为我们正在扩展 PanacheEntity 类:

    List<Location> locations = Location.listAll();
    
  • 要删除所有 Location 实体,我们可以调用 deleteAll 方法:

    Location.deleteAll();
    
  • 要通过其 ID 查找特定的 Location 实体,我们可以使用 findByIdOptional 方法:

    Optional<Location> optional = Location.findByIdOp
      tional(locationId);
    
  • 要持久化 Location 实体,我们必须在打算持久化的 Location 实例上调用 persist 方法:

    Location location = new Location();
    location.country = "Brazil";
    location.state = "Sao Paulo";
    location.city = "Santo Andre";
    location.persist();
    

每次我们执行前面描述的任何操作时,它们都会立即提交到数据库。

现在,让我们看看如何使用 Panache 来应用仓储模式。

应用仓储模式

我们不是使用实体类在数据库上执行操作,而是使用一个单独的类,这个类通常专门用于在仓储模式中提供数据库操作。这种类就像数据库的仓储接口一样工作。

要应用仓储模式,我们应该使用常规的 Hibernate 实体:

@Entity
@Table(name="locations")
public class Location {
/** Code omitted **/
}

注意,此时我们并没有扩展 PanacheEntity 类。在仓储模式中,我们不是通过实体类直接调用数据库操作。相反,我们通过仓储类调用它们。以下是如何实现仓储类的示例:

@ApplicationScoped
public class LocationRepository implements PanacheReposi
  tory<Location> {
   public Location findByCity(String city){
       return find ("city", city).firstResult();
   }
   public Location findByState(String state){
       return find("state", state).firstResult();
   }
   public void deleteSomeCountry(){
       delete ("country", "SomeCountry");
  }
}

通过在 LocationRepository 类上实现 PanacheRepository,我们启用了 PanacheEntity 类中存在的所有标准操作,如 findByIddeletepersist 等。此外,我们可以通过使用 PanacheEntity 类提供的 finddelete 方法来定义我们自己的自定义查询,就像前面的示例中所做的那样。

注意,我们将仓库类标注为@ApplicationScoped类型的 bean。这意味着我们可以在其他类中注入并使用它:

@Inject
LocationRepository locationRepository;
public Location findLocationByCity(City city){
    return locationRepository.findByCity(city);
}

在这里,我们有在仓库类上可用的最常见操作:

  • 要列出所有Location实体,我们需要从LocationRepository调用listAll方法:

    List<Location> locations = locationReposi
      tory.listAll();
    
  • 通过在LocationRepository上调用deleteAll,我们移除所有的Location实体:

    locationRepository.deleteAll();
    
  • 要通过其 ID 查找Location实体,我们在LocationRepository上调用findByIdOptional方法:

    Optional<Location> optional = locationReposi
      tory.findByIdOptional(locationId);
    
  • 要持久化Location实体,我们需要将Location实例传递给LocationRepository中的persist方法:

    Location location = new Location();
    location.country = "Brazil";
    location.state = "Sao Paulo";
    location.city = "Santo Andre";
    locationRepository.persist(location);
    

在前面的示例中,我们使用仓库类执行所有数据库操作。我们在这里调用的方法与 Active Record 方法中存在的那些方法相同。这里唯一的区别是仓库类的使用。

通过学习如何使用Panache来应用 Active Record 和 Repository 模式,我们提高了提供处理数据库实体良好方法的能力。没有更好的或更差的模式。项目的具体情况最终将决定哪种模式更适合。

Panache是一个专门为 Quarkus 制作的库。因此,将数据库配置委托给 Quarkus 以连接 Hibernate Reactive 对象(如SessionFactoryTransaction)到Panache的最佳方式是,Quarkus 将自动为您提供这些对象。

现在我们已经熟悉了 Hibernate Reactive 和Panache,让我们看看如何在六边形系统中实现输出适配器。

启用输出适配器的反应式行为

使用六边形架构最重要的好处之一是提高了在不进行重大重构的情况下更改技术的灵活性。六边形系统设计得如此之好,以至于其领域逻辑和业务规则对执行它们所使用的技术一无所知。

没有免费的午餐——当我们决定使用六边形架构时,我们必须为这种架构能提供的利益付出代价。(这里的代价是指按照六边形原则结构化系统代码所需的工作量和复杂性的显著增加。)

如果您担心代码重用,您可能会发现一些实践难以将代码从特定技术中解耦。例如,考虑一个场景,其中我们有一个领域实体类和一个数据库实体类。我们可能会争论,为什么不只有一个类同时服务于这两个目的呢? 好吧,最终,这完全是一个优先级的问题。如果您认为领域和特定技术类之间的耦合不是问题,那么请继续。在这种情况下,您将不会承担维护领域模型及其所有支持基础设施代码的负担。然而,相同的代码会服务于不同的目的,从而违反了单一职责原则SRP)。否则,如果您认为使用相同代码服务于不同目的存在风险,那么输出适配器可以帮助您。

第二章《在领域六边形内封装业务规则》中,我们介绍了一个输出适配器,该适配器将应用程序与文件系统集成。在第四章《创建与外部世界交互的适配器》中,我们创建了一个更详细的输出适配器,用于与 H2 内存数据库通信。现在,我们有了 Quarkus 工具箱,我们可以创建响应式输出适配器。

配置响应式数据源

为了继续在前一章中通过实现响应式输入适配器开始的响应式努力,我们将通过执行以下步骤创建和连接响应式输出适配器到这些响应式输入适配器:

  1. 让我们从在框架六边形的pom.xml文件中配置所需的依赖项开始:

    <dependencies>
      <dependency>
        <groupId>io.quarkus</groupId>
        artifactId>quarkus-reactive-mysql-client
          </artifactId>
      </dependency>
      <dependency>
        <groupId>io.quarkus</groupId>
       <artifactId>quarkus-hibernate-reactive-panache</ar
          tifactId>
      </dependency>
    </dependencies>
    

    quarkus-reactive-mysql-client依赖包含我们需要的库,用于与 MySQL 数据库建立响应式连接,而quarkus-hibernate-reactive-panache依赖包含 Hibernate Reactive 和Panache。需要注意的是,这个库特别适合响应式活动。对于非响应式活动,Quarkus 提供不同的库。

  2. 现在,我们需要在启动六边形的application.properties文件上配置数据库连接。让我们从数据源属性开始:

    quarkus.datasource.db-kind = mysql
    quarkus.datasource.reactive = true
    quarkus.datasource.reactive.url = mysql://lo
      calhost:3306/inventory
    quarkus.datasource.username = root
    quarkus.datasource.password = password
    

    quarkus.datasource.db-kind属性不是必需的,因为 Quarkus 可以通过查看从 Maven 依赖中加载的特定数据库客户端来推断数据库类型。将quarkus.datasource.reactive设置为true,我们正在强制执行响应式连接。我们需要在quarkus.datasource.reactive.url上指定响应式数据库连接 URL。

  3. 最后,我们必须定义 Hibernate 配置:

    quarkus.hibernate-orm.sql-load-script=inventory.sql
    quarkus.hibernate-orm.database.generation = drop-and-
      create
    quarkus.hibernate-orm.log.sql = true
    

    在 Quarkus 创建数据库及其表之后,您可以加载一个.sql文件来在数据库上执行更多指令。默认情况下,它会搜索并加载一个名为import.sql的文件。我们可以通过使用quarkus.hibernate-orm.sql-load-script属性来改变这种行为。

    注意在生产环境中不要使用quarkus.hibernate-orm.database.generation = drop-and-create。否则,它将删除您所有的数据库表。如果您没有设置任何值,默认值none将被使用。默认行为不会对数据库进行任何更改。

    最后,我们启用quarkus.hibernate-orm.log.sql来查看 Hibernate 在幕后执行的 SQL 查询。我建议您只为开发目的启用log功能。在生产环境中运行应用程序时,别忘了禁用此选项。

让我们现在看看如何配置应用程序实体以与 MySQL 数据库一起工作。

配置实体

拓扑和库存系统需要四个数据库表来存储其数据:路由器、交换机、网络和位置。每个这些表都将被映射到一个正确配置以与 MySQL 数据源一起工作的 Hibernate 实体类。

我们将应用 Repository 模式,因此我们不需要实体来执行数据库操作。相反,我们将创建单独的仓库类来在数据库上触发操作,但在创建仓库类之前,让我们先实现拓扑和库存系统的 Hibernate 实体。我们将配置这些实体以与 MySQL 数据库一起工作。

路由器实体

对于这个实体以及随后将要实现的其它实体,我们应该在框架六角形的dev.davivieira.topologyinventory.framework.adapters.output.mysql.data包中创建类。

Router实体类应该看起来像这样:

@Entity(name = "RouterData")
@Table(name = "routers")
@EqualsAndHashCode(exclude = "routers")
public class RouterData implements Serializable {
    @Id
    @Column(name="router_id", columnDefinition =
      «BINARY(16)")
    private UUID routerId;
    @Column(name="router_parent_core_id",
    columnDefinition = "BINARY(16)")
    private UUID routerParentCoreId;
   /** Code omitted **/
}

对于routerIdrouterParentCoreId字段,我们必须将columnDefinition,即@Column注解参数,设置为BINARY(16)。这是在 MySQL 数据库上使UUID属性工作所必需的。

然后,我们创建路由器与其他表之间的关系映射:

{
    /**Code omitted**/
   @ManyToOne(cascade = CascadeType.ALL)
   @JoinColumn(name="location_id")
   private LocationData routerLocation;
   @OneToMany(cascade = {CascadeType.MERGE},
   fetch = FetchType.EAGER)
   @JoinColumn(name="router_id")
   private List<SwitchData> switches;
   @OneToMany(cascade = CascadeType.ALL, fetch =
     FetchType.EAGER)
   @JoinColumn(name="router_parent_core_id")
   private Set<RouterData> routers;
   /**Code omitted**/
}

在这里,我们定义了路由器和位置之间的多对一关系。之后,我们有两个与交换机和路由器分别的一对多关系。使用fetch = FetchType.EAGER属性来避免在反应式连接期间可能发生的任何映射错误。

让我们继续配置Switch实体类。

Switch 实体

以下代码展示了我们应该如何实现Switch实体类:

@Entity
@Table(name = "switches")
public class SwitchData {
    @ManyToOne
    private RouterData router;
    @Id
    @Column(name="switch_id", columnDefinition =
      «BINARY(16)")
    private UUID switchId;
    @Column(name="router_id", columnDefinition =
      «BINARY(16)")
    private UUID routerId;
    @OneToMany(cascade = CascadeType.ALL, fetch =
      FetchType.EAGER)
    @JoinColumn(name="switch_id")
    private Set<NetworkData> networks;
    @ManyToOne
    @JoinColumn(name="location_id")
    private LocationData switchLocation;
    /**Code omitted**/
}

我们省略了其他列属性,只关注 ID 和关系。我们首先定义了交换机和路由器之间的多对一关系。主键是switchId字段,它恰好是一个UUID属性。我们还有一个UUID属性来映射routerId字段。

此外,交换机和网络之间存在一对一关系,交换机和位置之间存在多对一关系。

现在,让我们配置Network实体类。

网络实体

虽然我们不将网络视为领域模型中的实体,但在数据库中有一个单独的表。因此,在框架六边形级别,我们将它们视为数据库实体,但当它们达到领域六边形时,我们将它们视为值对象。这个例子表明,六边形系统决定了数据在领域六边形级别如何被处理。通过这样做,六边形系统保护了领域模型免受技术细节的影响。

我们如下实现Network实体类:

@Entity
@Table(name = "networks")
public class NetworkData {
    @ManyToOne
    @JoinColumn(name="switch_id")
    private SwitchData switchData;
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name="network_id")
    private int id;
   /**Code omitted**/
}

这是一个简单的实体类,它在网络和交换之间有一个多对一的关系。对于网络,我们依赖数据库生成网络 ID。此外,网络在领域模型中不被视为实体。相反,我们将网络视为由聚合控制的值对象。对于聚合,我们需要处理UUID,但对于值对象,我们不需要。这就是为什么我们不处理网络数据库实体的 UUID。

我们还需要实现一个用于位置的最后一个实体。让我们来做这件事。

位置实体

在网络中,位置在领域六边形级别不被视为一个实体,但由于我们有一个单独的位置表,因此我们需要在框架六边形级别将其视为数据库实体。

以下代码用于实现Location实体类:

Entity
@Table(name = "location")
public class LocationData {
    @Id
    @Column(name="location_id")
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private int locationId;
    @Column(name="address")
    private String address;
    @Column(name="city")
    private String city;
    /**Code omitted**/
}

我们再次依赖数据库内置的 ID 生成机制来处理位置数据的 ID。之后,我们有了addresscity等属性,它们是位置的一部分。

现在我们已经适当地配置了所有必需的实体,我们可以继续前进并使用Panache创建响应式存储库类,我们将使用这些类来触发我们配置的实体所进行的数据库操作。

实现响应式存储库类

通过实现PanacheRepositoryBase接口,您创建了一个响应式存储库类。我们需要一个存储库类用于路由操作,另一个用于交换操作。

定义一个聚合根的单一存储库至关重要。在我们的案例中,Router实体是路由管理操作的聚合根,而Switch是交换管理操作的聚合根。聚合的目的在于确保所有受该聚合控制的对象的一致性。任何聚合的入口点始终是聚合根。为了确保数据库事务中的聚合一致性,我们定义了一个专门的存储库类,该类仅用于根据聚合根控制数据库操作。

我们即将实现的类位于dev.davivieira.topologyinventory.framework.adapters.output.mysql.repository包中:

  • 以下代码实现了RouterManagementRepository类:

    @ApplicationScoped
    public class RouterManagementRepository implements Pa
      nacheRepositoryBase<RouterData, UUID> {
    }
    

    注意,我们正在传递RouterData作为我们正在处理的实体,以及UUID作为映射到 ID 的属性类型。如果我们不需要任何自定义查询,我们可以留这个类为空,因为Panache已经提供了大量的标准数据库操作。

    注意,我们还在该类上标注了@ApplicationScoped,这样我们就可以在其他地方注入该组件,例如我们即将实现的输出适配器。

  • 以下代码实现了SwitchManagementRepository类:

    @ApplicationScoped
    public class SwitchManagementRepository implements Pa
      nacheRepositoryBase<SwitchData, UUID> {
    }
    

    这里,我们遵循与RouterManagementRepository类相同的方法。

在正确实现了响应式存储库类之后,我们准备好创建响应式输出适配器。让我们这么做吧!

实现响应式输出适配器

只是为了回顾一下,我们需要为RouterManagementOutputPort输出端口接口提供一个适配器实现:

public interface RouterManagementOutputPort {
    Router retrieveRouter(Id id);
    boolean removeRouter(Id id);
    Router persistRouter(Router router);
}

当实现 MySQL 输出适配器时,我们将为前面每个方法声明提供一个响应式实现。

我们还需要实现SwitchManagementOutputPort输出适配器接口:

public interface SwitchManagementOutputPort {
    Switch retrieveSwitch(Id id);
}

这比较简单,因为我们只需要提供一个响应式实现的方法。

让我们先来实现路由管理响应式输出适配器。

MySQL 输出适配器的响应式路由管理

为了使六边形系统能够与 MySQL 数据库通信,我们需要创建一个新的输出适配器以允许这种集成(因为我们使用 Quarkus,这样的输出适配器实现相当简单)。我们将按照以下步骤进行:

  1. 我们首先注入RouterManagementRepository存储库类:

    @ApplicationScoped
    public class RouterManagementMySQLAdapter implements
      RouterManagementOutputPort {
        @Inject
        RouterManagementRepository
          routerManagementRepository;
        /** Code omitted **/
    }
    

    我们将使用RouterManagementRepository存储库来执行数据库操作。

  2. 然后,我们实现retrieveRouter方法:

    @Override
    public Router retrieveRouter(Id id) {
        var routerData =
        routerManagementRepository.findById(id.getUuid())
          .subscribe()
          .asCompletionStage()
          .join();
        return RouterMapper.routerDataToDomain(router
          Data);
    }
    

    当我们调用routerManagementRepository.findById(id.getUuid())时,系统启动一个 I/O 非阻塞操作。这个subscribe调用试图解析由findById操作产生的项目。然后,我们调用asCompletionStage来接收项目。最后,我们调用join,它在操作完成时返回结果值。

  3. 现在,我们需要实现removeRouter方法:

    @Override
    public Router removeRouter(Id id) {
     return routerManagementRepository
            .deleteById(
            id.getUuid())
            .subscribe().asCompletionStage().join();
    }
    

    在这里,我们调用routerManagementRepository.deleteById(id.getUuid()) Panache操作从数据库中删除一个路由器。之后,我们调用subscribeasCompletionStagejoin来执行这些操作以实现响应式。

  4. 最后,我们实现persistRouter方法:

    @Override
    public Router persistRouter(Router router) {
        var routerData =
        RouterH2Mapper.routerDomainToData(router);
        Panache.withTransaction(
        ()->routerManagementRepository.persist
        (routerData));
        return router;
    }
    

    这里的结构不同。为了确保在请求过程中客户端和服务器之间事务不会丢失,我们将持久化操作包裹在Panache.withTransaction中。这是需要持久化数据操作的要求。

现在,让我们实现开关管理的响应式输出适配器。

MySQL 输出适配器的响应式开关管理

这里使用的方法与我们实现路由管理响应式输出适配器时使用的方法相同。我们将执行以下步骤来实现响应式输出适配器:

  1. 让我们先注入SwitchManagementRepository仓库类:

    @ApplicationScoped
    public class SwitchManagementMySQLAdapter implements
      SwitchManagementOutputPort {
        @Inject
        SwitchManagementRepository
          switchManagementRepository;
        /** Code omitted **/
    }
    

    正如我们已经看到的,注入一个仓库类是必要的,这样我们就可以用它来触发数据库操作。

  2. 在此之后,我们实现retrieveSwitch方法:

    @Override
    public Switch retrieveSwitch(Id id) {
        var switchData =
        switchManagementRepository.findById(id.getUuid())
           .subscribe()
           .asCompletionStage()
           .join();
        return RouterMapper.switchDataToDo
          main(switchData);
    }
    

    我们使用这个方法来响应式地检索一个Switch对象。因为没有持久化方法,因为所有的写操作都应该始终通过路由管理输出适配器来执行。

通过在六边形系统中实现响应式输出适配器,我们可以利用响应式编程技术的优势。在六边形架构中,在同一系统中同时拥有响应式和命令式输出适配器来满足不同的需求并不是什么大问题。

Quarkus 数据库的响应式特性对于任何尝试开发响应式系统的开发者来说至关重要。通过理解如何使用这些特性,我们可以为我们的应用程序处理数据库的方式提供一个响应式的替代方案。但这并不意味着响应式方法总是比传统的命令式方法更好;这取决于你和你项目的需求,来决定哪种方法更适合。

现在我们已经实现了RouterManagementMySQLAdapterSwitchManagementMySQLAdapter输出适配器,让我们测试它们。

测试响应式输出适配器

我们需要实现单元测试来确保输出适配器的方法按预期工作。以下是如何为RouterManagementMySQLAdapter创建单元测试的示例:

@QuarkusTest
public class RouterManagementMySQLAdapterTest {
    @InjectMock
    RouterManagementMySQLAdapter
    routerManagementMySQLAdapter;
    @Test
    public void testRetrieveRouter() {
        Router router = getRouter();
        Mockito.when(
        routerManagementMySQLAdapter.
        retrieveRouter(router.getId())).thenReturn(router);
        Router retrievedRouter =
        routerManagementMySQLAdapter.
        retrieveRouter(router.getId());
        Assertions.assertSame(router, retrievedRouter);
    }
   /** Code omitted **/
}

可以使用@InjectMock注解来模拟RouterManagementMySQLAdapter输出适配器。在执行testRetrieveRouter测试方法时,我们可以通过使用Mockito.when来模拟对routerManagementMySQLAdapter.retrieveRouter(router.getId)的调用。thenReturn方法返回我们的模拟测试应该返回的对象。在这种情况下,它是一个Router对象。通过Assertions.assertSame(router, retrievedRouter),我们可以断言retrieveRouter(router.getId)的执行结果。

我们不需要实现新的测试类来执行响应式输出适配器的集成测试。我们可以依赖之前章节中使用的相同测试来测试响应式输入适配器。这些测试调用输入适配器,反过来,通过使用用例操作调用输出适配器。

然而,变化的是,我们将需要一个 MySQL 数据库来测试响应式输出适配器。

Quarkus 提供了基于 Docker 的容器,我们可以用于开发或测试目的。为了启用这样的数据库容器,在application.properties文件中不需要提供详细的数据源连接配置。以下是我们在测试目的下应该如何配置该文件的方法:

quarkus.datasource.db-kind=mysql
quarkus.datasource.reactive=true
quarkus.hibernate-orm.database.generation=drop-and-create
quarkus.hibernate-orm.sql-load-script=inventory.sql
quarkus.vertx.max-event-loop-execute-time=100

注意,我们没有指定数据库连接 URL。通过这样做,Quarkus 理解它需要提供一个数据库。之前描述的 application.properties 文件应放置在 tests/resource/ 目录中。在这个目录内,我们还应放置 inventory.sql 文件,该文件将数据加载到数据库中。这个 .sql 文件在本章的 GitHub 仓库中可用。

您可以覆盖 application.properties 中的条目以使用环境变量。这可能对配置如 quarkus.hibernate-orm.database.generation 有用,其中您可以根据应用程序的环境变量设置属性值。例如,对于本地或开发目的,您可以使用 ${DB_GENERATION},这是一个解析为 drop-and-create 的环境变量。在生产中,这个环境变量可以解析为 none

在正确设置 application.propertiesinventory.sql 文件后,我们可以在项目的根目录中运行以下命令来测试应用程序:

$ mvn test

以下输出显示了在测试期间启动的 MySQL Docker 容器:

2021-10-10 01:33:40,242 INFO  [  .0.24]] (build-10) Creating container for image: mysql:8.0.24
2021-10-10 01:33:40,876 INFO  [  .0.24]] (build-10) Starting container with ID: 67e788aab66f2f2c6bd91c0be1a164117294ac29cc574941ad41ff5760de918c
2021-10-10 01:33:41,513 INFO  [  .0.24]] (build-10) Container mysql:8.0.24 is starting: 67e788aab66f2f2c6bd91c0be1a164117294ac29cc574941ad41ff5760de918c
2021-10-10 01:33:41,520 INFO  [  .0.24]] (build-10) Waiting for database connection to become available at jdbc:mysql://localhost:49264/default using query 'SELECT 1'
2021-10-10 01:34:01,078 INFO  [  .0.24]] (build-10) Container is started (JDBC URL: jdbc:mysql://localhost:49264/default)
2021-10-10 01:34:01,079 INFO  [  .0.24]] (build-10) Container mysql:8.0.24 started in PT20.883579S
2021-10-10 01:34:01,079 INFO  [io.qua.dev.mys.dep.MySQLDevServicesProcessor] (build-10) Dev Services for MySQL started.

Quarkus 创建了一个名为 default 的数据库,其中创建了表。inventory.sql 文件在此 default 数据库上运行。

数据库准备就绪后,Quarkus 开始测试系统,结果类似于以下内容:

[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 32.672 s - in dev.davivieira.topologyinventory.framework.adapters.input.rest.NetworkManagementAdapterTest
[INFO] Running dev.davivieira.topologyinventory.framework.adapters.input.rest.RouterManagementAdapterTest
[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.232 s - in dev.davivieira.topologyinventory.framework.adapters.input.rest.RouterManagementAdapterTest
[INFO] Running dev.davivieira.topologyinventory.framework.adapters.input.rest.SwitchManagementAdapterTest
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.088 s - in dev.davivieira.topologyinventory.framework.adapters.input.rest.SwitchManagementAdapterTest
[INFO] Running dev.davivieira.topologyinventory.framework.adapters.input.rest.outputAdapters.RouterManagementMySQLAdapterTest
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.116 s - in dev.davivieira.topologyinventory.framework.adapters.input.rest.outputAdapters.RouterManagementMySQLAdapterTest
[INFO] Running dev.davivieira.topologyinventory.framework.adapters.input.rest.outputAdapters.SwitchManagementMySQLAdapterTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.013 s - in dev.davivieira.topologyinventory.framework.adapters.input.rest.outputAdapters.SwitchManagementMySQLAdapterTest

为了测试输出适配器,我们需要调用输入适配器。如果我们能够成功测试输入适配器,那么这也意味着我们已成功测试了输出适配器。

摘要

当我们需要使用 Quarkus 处理数据库的响应式操作时,Hibernate Reactive 和 Panache 使我们的生活变得更加简单。我们了解到 Hibernate Reactive 是建立在传统 Hibernate 实现之上,但增加了响应式功能。

在研究 Panache 的过程中,我们了解到它可以帮助我们实现 Active Record 和 Repository 模式以执行数据库操作。对于实践部分,我们实现了数据库实体、仓库和响应式输出适配器,并将它们一起使用以与 MySQL 数据库进行交互。最后,我们配置了六边形系统测试以使用 Quarkus 提供的 MySQL Docker 容器。

在下一章中,我们将学习一些将六边形系统打包到 Docker 镜像中的技术。我们还将学习如何在 Kubernetes 集群中运行六边形系统。这些知识将使我们能够使我们的六边形应用程序准备好在基于云的环境中部署。

问题

  1. Hibernate Reactive 实现了哪个 Java 规范?

  2. Active Record 和 Repository 模式之间的区别是什么?

  3. 我们应该实现哪个接口来应用 Repository 模式?

  4. 为什么我们应该在 withTransaction 方法内运行写操作?

答案

  1. Hibernate Reactive 实现了 JPA 规范。

  2. Active Record 模式允许我们使用实体类对数据库进行操作,而 Repository 模式则有一个专门的类来执行此类操作。

  3. 我们应该实现 PanacheRepositoryBase 接口。

  4. 为了确保在反应式操作过程中数据库事务不会丢失。

第十四章:设置 Dockerfile 和 Kubernetes 对象以进行云部署

我们在之前的章节中探讨了 Quarkus 提供的一些令人惊叹的功能,以帮助我们创建云原生应用程序。更进一步,我们还学习了如何将 Quarkus 集成到六边形系统中。

现在,我们需要准备六边形系统,以便它可以在云环境中部署。Docker 和 Kubernetes 是目前主导云场景的领先技术。如果您的应用程序已准备好在这些技术上运行,那么您可以在大多数云提供商上安全地运行它。

因此,在本章中,我们将学习如何将六边形系统封装在 Docker 镜像中,并在 Kubernetes 集群上运行它。对于 Docker 镜像,我们将探讨创建此类镜像的两种技术:一种依赖于可执行的 .jar 文件,另一种使用原生可执行文件。我们还将学习如何在基于本地minikube的 Kubernetes 集群中部署六边形系统。

本章将涵盖以下主题:

  • 准备 Docker 镜像

  • 创建 Kubernetes 对象

  • 在 minikube 上部署

到本章结束时,您将了解如何使六边形系统在基于 Docker 和 Kubernetes 的云原生环境中运行。如今,大多数现代应用程序都在云端运行。通过将六边形系统转变为云原生系统,您将能够利用在云端存在的优势。

技术要求

要编译和运行本章中展示的代码示例,您需要在您的计算机上安装最新的Java SE 开发工具包Maven 3.8。它们适用于 Linux、macOS 和 Windows 操作系统。

您还需要在您的机器上安装Dockerminikube

您可以在 GitHub 上找到本章的代码文件,链接为 github.com/PacktPublishing/-Designing-Hexagonal-Architecture-with-Java---Second-Edition/tree/main/Chapter14

准备 Docker 镜像

基于容器的虚拟化技术并非新事物。在 Docker 之前,就有像 OpenVZ 这样的技术,它们应用了 Docker 所应用的基本概念。即使今天,我们也有像Linux 容器LXC)这样的替代方案,它提供了一个强大的基于容器的解决方案。Docker 的独特之处在于它使处理容器化应用程序变得非常简单直观。Docker 将可移植性提升到了另一个层次,简化了容器技术,使其对更广泛的受众成为可行的技术。

在过去,其他容器平台不像今天的 Docker 那样易于使用。容器是一个更多与系统管理员相关的话题,而不是与软件开发者相关。今天,由于我们拥有的简单而强大的基于容器的解决方案,情况已经不同。由于其简单性,Docker 迅速在开发者中流行起来,他们开始将其纳入他们的项目中。

如我之前所述,Docker 的优势在于其使用和学习简单。以 Docker 如何抽象化将应用程序包裹在容器内所需的复杂性为例。你只需要定义一个 Dockerfile,描述应用程序应在容器内如何配置和执行。你可以通过使用一组简单的指令来完成此操作。因此,Docker 保护用户免受先前容器技术中存在的底层复杂性。

使 Quarkus 如此特别的事情之一是它是一个以容器为先的框架。它旨在构建基于容器的应用程序。因此,如果你针对基于容器的环境,Quarkus 是一个极佳的选择。

使用 Quarkus,我们可以使用 .jar 艺术品或原生可执行艺术品生成 Docker 镜像。接下来,我们将探讨这两种方法。

使用 uber .jar 艺术品创建 Docker 镜像

我们的方法是将 uber .jar 艺术品包裹在 Docker 镜像中,以便容器可以通过执行该 .jar 文件来启动和运行应用程序。要构建 Docker 镜像,我们需要创建一个包含构建此类镜像指令的 Dockerfile。

以下代码显示了如何为使用 uber .jar 文件的拓扑和库存系统创建 Dockerfile:

FROM eclipse-temurin:17.0.8_7-jdk-alpine
ENV APP_FILE_RUNNER bootstrap-1.0-SNAPSHOT-runner.jar
ENV APP_HOME /usr/apps
EXPOSE 8080
COPY bootstrap/target/$APP_FILE_RUNNER $APP_HOME/
WORKDIR $APP_HOME
ENTRYPOINT ["sh", "-c"]
CMD ["exec java -jar $APP_FILE_RUNNER"]

此 Dockerfile 应放置在项目的根目录中。

第一行是定义艺术品的名称和路径的 APP_FILE_RUNNERAPP_HOME 环境变量。由于 Quarkus 配置为在端口 8080 上运行,我们必须使用 EXPOSE 属性来外部暴露此端口。COPY 命令将复制 Maven 生成的艺术品。WORKDIR 定义了命令将在容器内执行的路径。通过 ENTRYPOINTCMD,我们可以定义容器将如何执行应用程序的 uber .jar 文件。

按照以下步骤生成 Docker 镜像并启动容器:

  1. 首先,我们需要编译并生成一个 uber .jar 文件:

    $ mvn clean package
    
  2. 然后,我们可以生成 Docker 镜像:

    $ docker build . -t topology-inventory
    Sending build context to Docker daemon  38.68MB
    Step 1/8 : FROM eclipse-temurin:17.0.8_7-jdk-alpine
     ---> 9b2a4d2e14f6
    Step 2/8 : ENV APP_FILE_RUNNER bootstrap-1.0-SNAPSHOT-runner.jar
     ---> Using cache
     ---> 753b39c99e78
    Step 3/8 : ENV APP_HOME /usr/apps
     ---> Using cache
     ---> 652c7ce2bd47
    Step 4/8 : EXPOSE 8080
     ---> Using cache
     ---> 37c6928bcae4
    Step 5/8 : COPY bootstrap/target/$APP_FILE_RUNNER $APP_HOME/
     ---> Using cache
     ---> 389c28dc9fa7
    Step 6/8 : WORKDIR $APP_HOME
     ---> Using cache
     ---> 4ac09c0fe8cc
    Step 7/8 : ENTRYPOINT ["sh", "-c"]
     ---> Using cache
     ---> 737bbcf2402b
    Step 8/8 : CMD ["exec java -jar $APP_FILE_RUNNER"]
     ---> Using cache
     ---> 3b17c3fa0662
    Successfully built 3b17c3fa0662
    eclipse-temurin:17.0.8_7-jdk-alpine image. Then, it proceeds by defining the environment variables and handling the application artifact by preparing it to be executed every time a new container from that image is created.
    
  3. 现在,我们可以使用以下命令启动容器:

    -p parameter, we’re mapping the 5555 host port to the 8080 container port. So, we’ll need to use the 5555 port to access the system.
    
  4. 要确认应用程序正在 Docker 容器上运行,我们可以访问 http://localhost:5555/q/swagger-ui 的 Swagger UI URL。

现在,让我们学习如何使用原生可执行文件生成 Docker 镜像。

使用原生可执行文件创建 Docker 镜像

第十章,“将 Quarkus 添加到模块化六边形应用程序”,我们了解到 Quarkus 使用 Ahead-Of-Time (AOT) 编译技术来优化字节码并生成提供改进性能的原生代码,主要在应用程序启动期间。

这个原生可执行文件是 Quarkus 执行的 AOT 编译的结果。与可以在不同操作系统和 CPU 架构上运行的 uber .jar 文件不同,原生可执行文件是平台相关的。但我们可以通过将原生可执行文件包装在一个可以分发到不同操作系统和 CPU 架构的 Docker 镜像中来克服这个限制。

生成原生可执行文件有不同的方法。其中一些需要我们安装 GraalVM 分发版和其他软件。然而,为了保持简单,我们将遵循一个简单且方便的方法,其中 Quarkus 在包含 GraalVM 的 Docker 容器内为我们生成原生可执行文件。

按照以下步骤生成包含原生可执行文件的 Docker 镜像:

  1. 在项目根目录的 pom.xml 文件中,我们需要在 </project> 标签之前包含以下代码:

    <profiles>
      <profile>
        <id>native</id>
        <properties>
          <quarkus.package.type>native
            </quarkus.package.type>
        </properties>
      </profile>
    </profiles>
    

    上述配置创建了一个配置文件,将 quarkus.package.type 属性设置为 native,导致 Quarkus 构建原生可执行文件。

  2. 然后,我们必须在 bootstrap 六边形上创建 ReflectionConfiguration 类:

    @RegisterForReflection(targets = {
            CoreRouter.class,
            EdgeRouter.class,
            Switch.class,
            Id.class,
            IP.class,
            Location.class,
            Model.class,
            Network.class,
            Protocol.class,
            RouterType.class,
            SwitchType.class,
            Vendor.class,
    })
    public class ReflectionConfiguration {}
    

    原生可执行文件的一个限制是它只提供部分反射支持。我们可以创建一个包含我们想要注册的类的 .json 配置文件,或者我们可以创建一个带有 @RegisterForReflection 注解的类,包含我们想要注册的类。在上面的代码中,我们使用的是后者,它依赖于注解的类。

  3. 要生成原生可执行文件,我们必须运行以下命令:

    6g is not enough for you, feel free to increase it to prevent errors.
    
  4. 接下来,我们必须创建一个名为 Dockerfile-native 的文件,其中包含构建包含原生可执行文件的 Docker 镜像的指令:

    FROM registry.access.redhat.com/ubi8/ubi-minimal
    ENV APP_FILE_RUNNER bootstrap-1.0-SNAPSHOT-runner
    ENV APP_HOME /work
    EXPOSE 8080
    COPY bootstrap/target/$APP_FILE_RUNNER $APP_HOME/
    WORKDIR $APP_HOME
    RUN echo $APP_FILE_RUNNER
    CMD ["./bootstrap-1.0-SNAPSHOT-runner", "-
      Dquarkus.http.host=0.0.0.0"]
    

    我们不是使用 JDK 17 基础镜像,而是使用来自官方 Red Hat 仓库的 ubi-minimal 镜像。这个镜像适合运行原生可执行文件。

  5. 然后,我们必须使用以下命令生成 Docker 镜像:

    -t topology-inventory-native:latest and -f Dockerfile-native to create a different Docker image based on the native executable rather than the uber .jar file. The output of this docker build command will be similar to the one we generated when we created the Docker image for the uber .jar file. The only difference will be the entries related to the native executable artifact.
    
  6. 标记并上传您的镜像到您的个人 Docker 仓库:

    $ docker tag topology-inventory-native:latest s4intlaurent/topology-inventory-native:latest
    $ docker push s4intlaurent/topology-inventory-native:latest
    The push refers to repository [docker.io/s4intlaurent/topology-inventory-native]
    f3216c6ba268: Pushed
    0b911edbb97f: Layer already exists
    54e42005468d: Layer already exists
    latest: digest: sha256:4037e5d9c2cef01bda9c4bb5722bccbe0d003336534c28f8245076223ce77273 size: 949
    

    当在 minikube 集群上部署应用程序时,我们将使用系统的原生镜像。

  7. 现在,我们可以启动容器:

    http://localhost:5555/q/swagger-ui.
    

通过这样,我们已经为 uber .jar 和原生可执行文件配置了 Docker 镜像。这些 Docker 镜像可以在 Kubernetes 集群上部署。但是,为了做到这一点,我们需要创建所需的 Kubernetes 对象以允许部署。因此,在下一节中,我们将学习如何为容器化的六边形系统创建 Kubernetes 对象。

创建 Kubernetes 对象

Docker 引擎不提供任何容错或高可用性机制。它只提供基于容器的虚拟化技术。因此,如果你计划使用 Docker 运行关键任务应用程序,你可能需要制定解决方案以确保容器在运行时是可靠的,或者将这项责任委托给容器编排器。

容器编排器作为对 IT 行业容器使用增加的回应而出现。在这些编排器中,我们可以引用 Docker Swarm、Rancher 以及在行业中占据主导地位的:Kubernetes

最初在 Google 内部作为名为 Borg 的闭源软件构思,后来以 Kubernetes 的名字开源。这是一种强大的技术,可以在你的计算机上用于开发目的,或者控制成百上千的服务器节点,为运行中的应用程序提供 Pod。

你可能想知道,什么是 Pod? 我们很快就会找到答案。

我们在这里的目的不是深入研究 Kubernetes 的内部结构,但我们将回顾一些基本概念以确保我们处于同一页面上。

检查 Kubernetes 的主要对象

如我们之前所见,Kubernetes 是一个容器编排器,帮助我们管理容器。为了实现这一点,大多数——如果不是所有——Kubernetes 配置都可以通过 .yaml 文件完成。在 Kubernetes 中,我们有当前状态和期望状态的概念。当前者与后者相匹配时,我们就没问题。否则,我们就有问题。

这种目前期望状态方法的基础是基于 YAML 文件的 Kubernetes 配置机制。通过这些文件,我们可以表达集群内事物的期望状态。Kubernetes 将施展其魔法以确保当前状态始终与期望状态相匹配。但是,你可能想知道,是什么状态? 答案是 Kubernetes 对象的状态。让我们看看其中的一些:

  • Pod: Pod 是 Kubernetes 中的一个对象,它控制 Kubernetes 集群中容器的生活周期。可以将多个容器附加到同一个 Pod 上,尽管这不是一个常见的做法。

  • Deployment 对象控制 Pod 的生命周期。使用 Deployment,你可以指定为你的应用程序提供多少个 Pod。Kubernetes 将负责在集群中找到可用资源来启动这些 Pod。如果由于某种原因,其中一个 Pod 崩溃,Kubernetes 将尝试启动一个新的 Pod 来确保达到期望的状态。

  • 附加到该 Pod 的 Service 对象。这个 Service 对象充当 DNS 入口点,为 Pod 提供基本的负载均衡访问。例如,如果你有三个 Pod 上运行着应用程序,Service 对象将处理位于 Service 对象后面的三个 Pod 之一的应用程序请求。通过使用如 Istio 这样的服务网格技术,可以实现更复杂的负载均衡功能。

  • ConfigMap 是可以帮助你的对象。

  • ConfigMap但可以用来存储敏感信息,如凭证或私钥。Secret对象中的数据应该使用base64编码。

既然我们已经熟悉了一些最重要的 Kubernetes 对象,让我们看看如何使用它们来准备我们的六边形系统以便在 Kubernetes 集群上部署。

配置六边形系统的 Kubernetes 对象

在创建 Kubernetes 对象之前,首先,让我们配置 Quarkus 以启用 YAML 配置以及健康检查机制。当我们将在 Kubernetes 上部署应用程序时,我们需要这两个配置:

<dependencies>
  <dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-config-yaml</artifactId>
  </dependency>
  <dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-smallrye-health</artifactId>
  </dependency>
</dependencies>

使用quarkus-config-yaml,我们可以为大多数 Quarkus 配置使用application.yaml文件。并且为了启用健康检查端点,我们可以使用quarkus-smallrye-health

在创建 Kubernetes 对象之前,让我们在bootstrap六边形上配置application.yaml文件:

quarkus:
  datasource:
    username: ${QUARKUS_DATASOURCE_USERNAME:root}
    password: ${QUARKUS_DATASOURCE_PASSWORD:password}
    reactive:
      url: ${QUARKUS_DATASOURCE_REACTIVE_URL:
        mysql://localhost:3306/inventory}

这个.yaml文件允许我们使用大多数,但不是所有的 Quarkus 上的配置。因此,同时使用application.yamlapplication.properties是正常的。我们使用 YAML 配置是因为我们可以使用一种称为变量插值的技术。以以下配置条目为例:

${QUARKUS_DATASOURCE_USERNAME:root}

当应用程序启动时,它将尝试解析一个名为QUARKUS_DATASOURCE_USERNAME的环境变量。如果应用程序无法解析变量名,它将回退到默认值root。这种技术在定义本地开发中的默认配置时非常有用,因为环境变量可能没有设置。

你可能已经注意到了QUARKUS_DATASOURCE_USERNAMEQUARKUS_DATASOURCE_PASSWORDQUARKUS_DATASOURCE_REACTIVE_URL环境变量的存在。Kubernetes 将通过SecretConfigMap对象提供这些环境变量。因此,让我们学习如何配置这些以及其他需要部署拓扑和库存系统(我们将在下面描述的文件被放置在项目根目录下的k8s目录中)所需的 Kubernetes 对象:

  1. 我们将首先配置configmap.yaml文件:

    apiVersion: v1
    kind: ConfigMap
    metadata:
      name: topology-inventory
    data:
      QUARKUS_DATASOURCE_REACTIVE_URL:
        «mysql://topology-inventory-mysql:3306/inventory»
    

    这个ConfigMap提供了一个名为QUARKUS_DATASOURCE_REACTIVE_URL的环境变量,其中包含应用程序连接到 MySQL 数据库所需的反应式数据库 URL。

  2. 然后,我们必须配置secret.yaml文件:

    apiVersion: v1
    kind: Secret
    metadata:
      name: topology-inventory
    type: Opaque
    data:
      QUARKUS_DATASOURCE_USERNAME: cm9vdAo=
      QUARKUS_DATASOUCE_PASSWORD: cGFzc3dvcmQK
    

    在前面的Secret中,我们定义了QUARKUS_DATASOURCE_USERNAMEQUARKUS_DATASOUCE_PASSWORD环境变量作为连接到系统 MySQL 数据库的凭证。

  3. 要生成base64,你可以在基于 Unix 的系统上执行以下命令:

    $ echo root | base64 && echo password | base64
    cm9vdAo=
    root and password values as the credentials to authenticate on the MySQL database.
    
  4. 让我们配置deployment.yaml文件:

    apiVersion: apps/v1
    kind: Deployment
    metadata:
      name: topology-inventory
      labels:
        app: topology-inventory
    spec:
      replicas: 1
      selector:
        matchLabels:
          app: topology-inventory
      template:
        metadata:
          labels:
            app: topology-inventory
    /** Code omitted **/
    

    在这里,我们描述了deployment.yaml文件中的一些元数据条目:

    • metadata.labels.app 字段:Kubernetes Service 对象可以通过使用 labels 属性来识别属于同一 Deployment 的 Pods 来应用负载均衡。我们将很快看到 Service 对象如何引用该标签。

    • replicas 字段:这定义了此 Deployment 将仅提供一个 Pod。

  5. 仍然在 deployment.yaml 文件中,我们可以开始定义容器配置的条目:

        spec:
          initContainers:
            - name: topology-inventory-mysql-init
              image: busybox
              command: [ ‹sh›, ‹-c›, ‹until nc -zv
                topology-inventory-mysql.default.svc.clus
                  ter.local 3306; do echo waiting
                for topology-inventory-mysql.de
                  fault.svc.cluster.local; sleep 5;
                done;› ]
          containers:
            - name: topology-inventory
              image: s4intlaurent/topology-
                inventory:latest
              envFrom:
              - configMapRef:
                  name: topology-inventory
              livenessProbe:
                httpGet:
                  path: /q/health/ready
                  port: 8080
                initialDelaySeconds: 30
                timeoutSeconds: 5
                periodSeconds: 3
              ports:
    - containerPort: 8080
    

    让我们看看用于容器配置的条目:

    • initContainers 字段:当我们需要在主容器启动之前执行一些任务或等待某些事情时使用。在这里,我们使用一个 init 容器来等待 MySQL 数据库可用。加载数据库的 .yaml 文件可在本书的 GitHub 仓库中找到,针对本章内容。

    • Containers 字段:这是设置 Pod 运行的容器配置的地方。

    • image 字段:这是我们告知应用程序镜像位置的地方。它可以是公共或私有仓库。

    • configMapRef 字段:此字段用于将 ConfigMap 数据注入到容器中。

    • livenessProbe 字段:Kubernetes 可以发送探测数据包来检查应用程序是否存活。这是我们之前配置的健康检查机制将在这里使用的地方。

    • containerPort 字段:这是我们告知暴露的 Docker 容器端口的地点。

  6. 最后,我们将配置 service.yaml 文件:

    apiVersion: v1
    kind: Service
    metadata:
      name: topology-inventory
      labels:
        app: topology-inventory
    spec:
      type: NodePort
      ports:
        - port: 8080
          targetPort: 8080
          nodePort: 30080
          protocol: TCP
      selector:
        app: topology-inventory
    

    Kubernetes 提供了三种不同的服务类型:ClusterIP 用于内部通信,以及 NodePortLoadBalance 用于外部通信。我们使用 NodePort 从 Kubernetes 集群外部访问应用程序。让我们看看最重要的字段:

    • port 字段:此字段声明 Kubernetes 集群内部其他 Pods 可用的服务端口

    • targetPort 字段:此字段指定容器暴露的端口

    • nodePort 字段:此字段指定外部端口,允许外部客户端访问应用程序

准备一个要在 Kubernetes 集群上部署的应用程序并非易事。在本节中,我们了解了 Kubernetes 的主要对象。理解这些对象是至关重要的,因为它们是任何在 Kubernetes 集群上运行的应用程序的基本构建块。

在所有必需的 Kubernetes 对象都得到充分配置后,我们可以在 Kubernetes 集群中部署菱形系统。

在 minikube 上部署

minikube 是一个专为开发目的而创建的 Kubernetes 集群。它允许我们轻松地创建和销毁集群。由于其简单性,我们将使用 minikube 通过以下步骤来部署我们的菱形系统(我建议遵循minikube.sigs.k8s.io/docs/start/上的说明来在您的机器上安装 minikube):

  1. 一旦您安装了 minikube,您可以通过以下命令启动您的集群:

    $ minikube start
    :) minikube v1.4.0 on Fedora 30
       Creating virtualbox VM (CPUs=2, Memory=2000MB, Disk=20000MB) ...
       Preparing Kubernetes v1.16.0 on Docker 18.09.9 ...
       Pulling images ...
       Launching Kubernetes ...
       Waiting for: apiserver proxy etcd scheduler controller dns
       Done! kubectl is now configured to use "minikube"
    

    默认集群配置消耗 2 个 CPU、2GB 的 RAM 和 20GB 的磁盘空间。

  2. 为了确认您的集群是活跃的,请运行以下命令:

    $ kubectl get nodes
    NAME       STATUS   ROLES    AGE   VERSION
    minikube   Ready    master   5m    v1.16.0
    

    太棒了! 现在,我们可以将拓扑和库存系统部署到我们的本地 Kubernetes 集群。

  3. 部署过程相当简单。我们只需应用上一节中创建的 Kubernetes YAML 文件:

    $ kubectl apply -f k8s/
    configmap/topology-inventory created
    deployment.apps/topology-inventory-mysql created
    service/topology-inventory-mysql created
    deployment.apps/topology-inventory created
    secret/topology-inventory created
    service/topology-inventory created
    
  4. 然后,我们可以运行以下命令来查看拓扑和库存系统是否正在运行:

    $ kubectl get pods
    NAME                                       READY   STATUS    RESTARTS   AGE
    topology-inventory-76f4986846-zq5t8        1/1     Running   0          73s
    topology-inventory-mysql-dc9dbfc4b-7sct6   1/1     Running   0          73s
    
  5. 要访问应用程序,我们需要使用 minikube 集群 IP。您可以使用以下代码在基于 Unix 的操作系统上检索该 IP:

    $ minikube ssh "ip addr show eth0" | grep "inet\b" | awk '{print $2}' | cut -d/ -f1
    192.168.49.2
    
  6. 使用该 IP,我们可以查询健康检查端点以查看拓扑和库存系统是否存活:

    $ curl -s http://192.168.49.2:30080/q/health/ready | jq
    {
      "status": "UP",
      "checks": [
        {
          "name": "Reactive MySQL connections health
             check",
          "status": "UP",
          "data": {
            "<default>": "UP"
          }
        }
      ]
    http://192.168.49.2:30080/q/swagger-ui, as shown in the following screenshot:
    

图 14.1 – 在 minikube 上运行的拓扑和库存的 Swagger UI

图 14.1 – 在 minikube 上运行的拓扑和库存的 Swagger UI

注意,我们正在使用端口30080来访问 minikube 上的 Swagger UI URL。30080是我们配置的 Kubernetes 节点端口,用于启用对应用程序的外部访问。

有了这些,我们已经完成了将六边形系统转变为云原生系统的基本步骤。我们的应用程序已准备好部署到本地 minikube 集群和任何提供 Kubernetes 集群的云服务提供商。

摘要

我们以学习可用于创建六边形系统 Docker 镜像的构建块开始本章。然后,我们创建了两种 Docker 镜像类型。第一种是基于 uber .jar 文件,用于打包和运行应用程序,而第二种是基于原生态可执行文件,我们可以利用 Quarkus 提供的功能来创建原生态可执行文件。

然后,我们创建了在 Kubernetes 集群中部署六边形系统所需的 Kubernetes 对象。最后,我们在本地 minikube 集群中部署了六边形系统。现在,我们不仅有一个六边形系统,而且还有一个准备好利用云环境提供的优势的云原生六边形系统。

在下一章中,我们将学习六边形架构如何与分层架构相关联,这是一种许多应用程序使用的架构风格。了解这两种架构之间的差异有助于我们评估在启动或重构软件项目时,哪种架构可能更适合采用。

问题

  1. 与 uber .jar 文件原生态可执行文件相比,其优势是什么?

  2. 我们可以使用哪个 Kubernetes 对象来存储环境变量和挂载配置文件?

  3. 我们使用什么服务类型来使 Kubernetes Pod 对外可用?

答案

  1. 启动时间比传统的 uber .jar 文件原生态可执行文件快得多。

  2. 我们可以使用ConfigMap对象。

  3. NodePort服务类型。

第四部分:六边形架构及其超越

在这部分,您将了解六边形架构与广泛使用的分层架构的区别。我们将突出两者的差异,并让您在开始下一个软件项目时做出更明智的决策,选择使用哪种架构。

然后,我们将探讨如何将 SOLID 原则与六边形架构理念相结合,以构建更好、更易于变更的应用程序。

最后,为了完成本书,我们将讨论一些您可以遵循的良好设计实践,以创建健壮的六边形系统。

本部分包含以下章节:

  • 第十五章, 比较六边形架构与分层架构

  • 第十六章, 使用 SOLID 原则与六边形架构

  • 第十七章, 您的六边形应用程序的良好设计实践

第十五章:比较六边形架构与分层架构

六边形架构只是几种软件架构方法之一。在这些方法中,一个突出的是所谓的分层架构,它在企业软件开发中已经广泛使用多年。其广泛采用的原因在于分层架构原则相对简单易用,并且这是在未做出关于新项目应使用哪种架构方法的有意识决策时可能自然出现的模式之一。

理解分层架构并意识到其在与六边形架构比较时的差异,这对我们做出更明智的决定很有帮助,即在选择开始或重构软件项目时使用哪种软件架构方法。这就是为什么在本章中,我们将首先回顾分层架构的概念。然后,基于这些概念,我们将实现一个简单的应用程序来学习如何应用分层架构的概念。接下来,我们将使用六边形架构的概念重构这个简单的应用程序,以便我们更好地掌握两种架构之间的对比。最后,我们将评估六边形和分层架构的优缺点。

本章将涵盖以下主题:

  • 检查分层架构

  • 使用分层架构创建应用程序

  • 将分层架构应用程序重写为六边形架构

  • 评估六边形和分层架构的利弊

到本章结束时,您将了解分层架构和六边形架构之间的区别,这将使您在下一个软件项目中做出更好的、有信息量的决策。

技术要求

要编译和运行本章中提供的代码示例,您需要在您的计算机上安装最新的Java SE 开发工具包Maven 3.8。它们适用于 Linux、Mac 和 Windows 操作系统。

您可以在 GitHub 上找到本章的代码文件,网址为 github.com/PacktPublishing/-Designing-Hexagonal-Architecture-with-Java---Second-Edition/tree/main/Chapter15

检查分层架构

在我看来,当负责一个项目的开发者群体没有停下来思考哪种架构更适合他们想要开发的软件时,分层架构可能会出现。我观察到在项目中,如果没有有意识的团队规划,代码结构会发展到一定程度的关注点分离,其中表示层/API 代码将与业务和基础设施代码相对隔离。例如,你不会在负责提供 REST 端点的类中看到核心业务逻辑。你可能会注意到,在这样的项目中,名为modelrepositoryservicecontroller的包作为基于分层架构思想的提示。它们是提示,因为每个包通常代表将特定软件责任分配的意图。model包中存在的代码用于表示数据库实体。repository包包含显示系统可以基于模型实体执行哪种数据库操作的类。service是一个包含一些业务逻辑的包,这些业务逻辑是在使用repository包中的类从数据库检索数据时执行的。最后,controller包包含暴露 API 端点的类,允许触发支持的应用程序行为之一。

作为分层架构的一种形式,我们可以看到基于modelrepositoryservicecontroller包的代码结构。每个包代表一个层,其责任直接依赖于来自下一个包/层或其下层的代码。控制器依赖于服务,服务依赖于仓库,仓库依赖于模型。看到这种模式略有变化并不罕见,即使引入了更多层,但向下依赖的一般思想始终存在。甚至可能存在某些情况下,一个层绕过下一层并依赖于另一层的类。以下图中我们可以看到基于分层架构的后端应用程序通常是如何结构的:

图 15.1 – 分层架构示例

图 15.1 – 分层架构示例

与开发网络应用时可能需要的表示层不同,我们拥有包含具有 REST 端点逻辑的类的 API 层。这些类负责接收客户端请求并在服务层触发一些应用行为。这一层通常包含依赖于外部数据的业务逻辑。为了处理外部数据,我们拥有包含负责获取、持久化和映射外部数据的类的数据层。在这里我不使用“持久化”这个词,以避免暗示数据源将是数据库。数据可以来自任何地方,包括数据库。

我在大型企业和初创企业的几个软件开发项目中看到了这种模式的运用。有趣的是,如果你问参与项目的开发者他们使用了哪种架构,他们可能会说没有应用特定的架构,尽管他们的代码表明软件是基于分层架构开发的。

由于分层架构已经存在多年,它已经逐渐成为企业级软件项目的标准架构。基于高级系统组件划分责任的想法似乎能够满足我们在企业软件中看到的大部分需求,通常是因为这类软件大多数时候遵循相同的模式:接收输入,从某处获取数据,执行数据处理,然后将数据持久化或发送到另一个系统。鉴于许多企业应用程序都是按照这种模式开发的,这些应用程序之间显著的不同在于包含特定应用业务规则的数据处理部分。其他部分也可能发生变化,但变化并不大,因为 API 的暴露方式和数据检索/持久化的方式可能在不同组织的同一应用程序中标准化,尤其是在同一个团队维护这些应用程序时。

尽管分层架构有助于提供一定程度的解耦,但它并不能完全避免在某一层的变化也可能需要另一层做出改变的情况。当你有业务/服务层依赖于持久化层时,后者的变化可能会影响到前者。接下来,我将分享一个使用分层架构的经验。

我记得有一次,我所在的团队在项目进行到中途时决定更改数据库技术。在实施过程中,我们发现新数据库技术中可用的 ORM 功能在新数据库中不可用。问题是系统中有一些业务规则直接依赖于新数据库中缺失的这个功能。最终,我们不得不通过显著改变这些业务规则的处理方式来调整我们的方法。这个应用程序,特别是,在没有团队讨论应该遵循哪些架构原则的情况下演变而来,最终演变成了具有分层架构特征的项目。

为了更好地理解分层架构,让我们基于这个架构理念开发一个应用程序。

使用分层架构创建应用程序

在上一节中,我们看到了基于分层架构的后端应用程序是如何结构的。我们的示例有三个层:API、服务和数据。遵循这个结构,我们将开发一个简单的用户应用程序,允许用户注册和登录。我们将实现数据层,然后进行服务层,最后是 API 层。该应用程序将基于 Quarkus,因此我们可以依赖框架提供 REST 端点和连接到数据库。

实现数据层

数据层负责允许获取、持久化和映射外部数据。我们依赖数据库来存储用户应用程序的用户信息:

  1. 因此,让我们首先准备 Quarkus,以便我们能够使用 H2 内存数据库:

    quarkus.datasource.db-kind=h2
    quarkus.datasource.jdbc.url=jdbc:h2:mem:default;DB_CLO
      SE_DELAY=-1.;NON_KEYWORDS=user
    quarkus.hibernate-orm.database.generation=drop-and-
      create
    

    quarkus.datasource.db-kind属性告诉 Quarkus 使用 H2 驱动程序。quarkus.datasource.jdbc.url配置了一个在应用程序运行期间存在的内存数据库。最后,我们将quarkus.hibernate-orm.database.generation设置为允许在应用程序启动时自动创建数据库。

  2. dev.davivieira.layered.data.entity包中,我们创建User ORM 实体类:

    package dev.davivieira.layered.data.entity;
    import jakarta.persistence.Entity;
    import jakarta.persistence.GeneratedValue;
    import jakarta.persistence.GenerationType;
    import jakarta.persistence.Id;
    import lombok.*;
    @Entity
    @Getter
    @Setter
    @RequiredArgsConstructor
    @NoArgsConstructor
    public class User {
        @Id
        @GeneratedValue(strategy =
        GenerationType.IDENTITY)
        private Long id;
        @NonNull
        private String email;
        @NonNull
        private String password;
    }
    

    User是一个 ORM 实体,因为 Jakarta 的@Entity注解放置在类顶部。id属性被注解为@GeneratedValue,因此底层数据库生成id值。我们通过emailpassword属性完成了实现,这些属性对于新用户注册和登录是必需的。

  3. dev.davivieira.layered.data.repository包中,我们创建UserRepository类:

    package dev.davivieira.layered.data.repository;
    import dev.davivieira.layered.data.entity.User;
    import io.quarkus.hibernate.orm.
      panache.PanacheRepository;
    import jakarta.enterprise.context.ApplicationScoped;
    import java.util.Optional;
    @ApplicationScoped
    public class UserRepository implements PanacheReposi
      tory<User> {
        public Optional<User> findByEmail(String email) {
            return find("email",
            email).firstResultOptional();
        }
    }
    

    通过实现PanacheRepository,我们获得预定义的标准数据库操作,以允许获取、保存和删除数据。除了这些预定义的操作之外,我们还创建了findByEmail来使用电子邮件地址搜索User实体。如果没有找到数据,它将返回一个空的Optional

User实体和仓库构成了数据层,使我们能够从数据库中持久化和检索用户数据。现在让我们实现服务层。

实现服务层

我们需要一个地方来放置逻辑,以检查在注册新用户或登录过程中验证用户凭据时电子邮件地址是否已存在。服务层是我们放置该逻辑的地方:

  1. dev.davivieira.layered.service包中,我们开始实现UserService类:

    @ApplicationScoped
    public class UserService {
        @Inject
        UserRepository userRepository;
        /** Code omitted **/
    }
    

    我们注入UserRepository以使服务类能够通过数据层处理外部数据。

  2. 当接收到客户端请求时,我们不会直接将请求映射到 ORM 实体。相反,我们将这些请求映射到一个UserDto类:

    public record UserDto (String email, String password)
      {}
    

    record类将自动生成电子邮件和密码字段的类构造函数、获取器和设置器。

  3. 继续实现UserService类,我们实现了createAccountisEmailAlreadyUsed方法:

    @Transactional
    public String createAccount(UserDto userDto) throws
      Exception {
        isEmailAlreadyUsed(userDto.email());
        var user = new User(userDto.email(),
        userDto.password());
        userRepository.persist(user);
        return "User successfully created";
    }
    private void isEmailAlreadyUsed(String email) throws
      Exception {
        if(userRepository.findByEmail(email).isPresent()){
            throw new Exception("Email address already
                                 exist");
        }
    }
    

    createAccount方法接收UserDto参数。我们从该参数中获取电子邮件并将其传递给isEmailAlreadyUsed方法,该方法使用UserRepositoryfindByEmail来检查该电子邮件是否已存在。

  4. 为了完成UserService的实现,我们创建了loginisThePasswordValid方法:

    public String login(UserDto userDto) {
        var optionalUser =
        userRepository.findByEmail(userDto.email());
        if (optionalUser.isPresent()) {
            var user = optionalUser.get();
            var isThePasswordValid =
            isThePasswordValid(user, userDto);
            if (isThePasswordValid) {
                return "Authenticated with success";
            } else {
                return "Invalid credentials";
            }
        } else {
            return "Invalid credentials";
        }
    }
    private boolean isThePasswordValid(User user, UserDto
      userDto) {
        return
        user.getPassword().equals(userDto.password());
    }
    

    login方法中,我们从UserDto获取电子邮件并使用它来检查是否存在该电子邮件的用户账户。如果没有,我们返回无效凭证信息。否则,我们检查UserDto中的密码是否与从数据库中通过UserRepository检索到的User实体中的密码匹配。

创建用户账户和验证登录凭证是服务层的责任。它通过依赖数据层从数据库获取用户数据来完成。现在我们需要公开一个 API,以便客户端可以向我们的应用程序发送请求。

实现 API 层

最后一个层,API 层,是我们实现用户创建和用户登录请求的 REST 端点:

  1. dev.davivieira.layered.api包中,我们开始实现UserEndpoint类:

    @Path("/user")
    public class UserEndpoint {
        @Inject
        UserService userService;
        /** Code omitted **/
    }
    

    我们注入UserService以访问服务层的createAccountlogin方法。

  2. 我们首先定义注册端点:

    @POST
    @Produces(MediaType.TEXT_PLAIN)
    @Consumes(MediaType.APPLICATION_JSON)
    @Path("/register")
    public String register(UserDto userDto) throws Excep
      tion {
        return userService.createAccount(userDto);
    }
    

    这是一个简单的 REST 端点实现,接收映射到UserDto的 JSON 有效负载并返回纯文本。UserDto直接从服务层的UserService类传递到createAccount方法。

  3. 最后,我们定义登录端点:

    @POST
    @Produces(MediaType.TEXT_PLAIN)
    @Consumes(MediaType.APPLICATION_JSON)
    @Path("/login")
    public String login(UserDto userDto) {
        return userService.login(userDto);
    }
    

    就像我们在之前的注册端点中所做的那样,这里我们只是公开 REST 端点并将 DTO 直接传递到服务层。

    API 层只负责公开 REST 端点,不再做其他事情。我们避免在这个层上放置任何业务逻辑,以确保我们在这层和其他层之间分离关注点。

    现在我们来看看如何测试这个分层应用。

测试分层应用

我们将通过仅关注检查电子邮件是否已存在和凭证是否有效的逻辑来测试服务层。以下是一个测试示例:

@QuarkusTest
public class UserServiceTest {
    @Inject
    UserService userService;
    @Test
    public void
     givenTheUserEmailAlreadyExistsAnExceptionIsThrown()
     throws Exception {
        var userDto = new UserDto("test@davivieira.dev",
        "password");
        userService.createAccount(userDto);
        Assertions.assertThrows(
                Exception.class,
                ()-> userService.createAccount(userDto)
        );
    }
    /** Code omitted **/
}

前面的测试检查当电子邮件地址已存在时是否会抛出异常。请注意,为了使此测试正常工作,服务层依赖于数据层,这需要数据库来持久化数据。因此,存在于服务层中的核心系统逻辑直接依赖于由 ORM 实体和仓库类组成的数据层。我们如何处理外部数据决定了我们在服务层中能做什么。

假设我们想要避免创建这种依赖,即核心系统逻辑依赖于并且紧邻数据处理代码。在这种情况下,六边形架构可以帮助我们以不同的安排来实现,其中核心系统逻辑不依赖于任何东西,并且提供了在无需担心外部数据处理方式的情况下演进核心逻辑的灵活性。让我们看看如何通过将我们的分层架构应用程序重构为六边形架构来实现这一点。

将分层架构应用程序重构为六边形架构

到目前为止,我们已经对如何实现分层架构应用程序有了概念。让我们将我们刚刚开发的应用程序重构为六边形架构。这个练习将突出两种架构之间的显著差异。

实现领域六边形

领域六边形包含具有核心系统逻辑的数据和行为。在以下步骤中,我们将看到如何使用六边形方法从分层应用程序中重构一些数据和行为的示例:

  1. 在使用分层架构时,我们通过实现数据层开始开发系统。我们将将其重构为一个仅包含User领域实体类的领域六边形:

    @Getter
    @Setter
    @RequiredArgsConstructor
    @NoArgsConstructor
    public class User {
        private Long id;
        @NonNull
        private String email;
        @NonNull
        private String password;
        public User(Long id, String email, String
        password) {
            this.id = id;
            this.email = email;
            this.password = password;
        }
        /** Code omitted **/
    }
    

    主要区别在于这个实体不是一个用于映射数据库实体的 ORM。这个实体是一个 POJO,它不仅包含数据,还包含行为。让我们实现这些行为。

  2. User实体类中,我们实现了isEmailAlreadyUsed方法:

    public void isEmailAlreadyUsed(Optional<User> op
      tionalUser) throws Exception {
        if(optionalUser.isPresent()) {
            throw new Exception(
            "Email address already exist");
        }
    }
    

    isEmailAlreadyUsed方法接收一个Optional<User>参数。如果值存在,则抛出异常。

  3. 为了完成User实体类的实现,我们创建了loginisPasswordValid方法:

    public String login(Optional<User> optionalUser) {
        if (optionalUser.isPresent()) {
            var user = optionalUser.get();
            var isThePasswordValid =
            isThePasswordValid(user);
            if (isThePasswordValid) {
                return "Authenticated with success";
            } else {
                return "Invalid credentials";
            }
        } else {
            return "Invalid credentials";
        }
    }
    private boolean isThePasswordValid(User user) {
        return user.getPassword().equals(this.password);
    }
    

    逻辑与我们在分层应用程序中实现的方法类似,但不同的是,我们不是使用UserDto类,而是直接在User领域实体类上操作。

    在六边形应用程序中,我们遵循领域驱动设计(DDD)方法,将逻辑从服务层推入领域六边形。原来在服务层上的包含核心系统逻辑的方法现在成为了领域六边形中User领域实体类的一部分。

    这里的重要区别在于领域六边形不依赖于任何东西。相比之下,在分层架构方法中,包含核心系统逻辑的服务层依赖于数据层。

实现应用程序六边形

我们在包含核心逻辑以处理用户注册和登录的领域六边形上实现了User领域实体类。我们需要定义以无差别的方式触发行为以及如何检索外部数据。这里的无差别是指表达对外部数据的需求,而不涉及提供此类数据的技术细节。我们在应用程序六边形中使用了用例和输入输出端口:

  1. 让我们先定义UserAccessUserCase接口:

    public interface UserAccessUseCase {
        String createAccount(User user) throws Exception;
        String login(User user);
    }
    

    创建账户和能够登录是我们应用程序支持的两种用例。

  2. 为了允许处理外部数据,我们定义了UserAccessOutputPort接口:

    public interface UserAccessOutputPort {
        Optional<User> findByEmail(String email);
        void persist(User user);
    }
    

    此接口仅包含findByEmailpersist方法定义的 POJO。在分层架构方法中,我们把这些方法作为数据层中仓库类的一部分。在仓库类中,数据来自数据库是隐含的。在六边形方法中,我们通过输出端口接口表达数据可以来自任何地方。

  3. 我们以实现UserAccessInputPort类结束:

    @ApplicationScoped
    public class UserAccessInputPort implements UserAcces
      sUseCase {
    @Inject
    UserAccessOutputPort userAccessOutputPort;
    @Override
    public String createAccount(User user) throws
    Exception {
            user.isEmailAlreadyUsed
              (userAccessOutputPort.findByEmail
                (user.getEmail()));
        userAccessOutputPort.persist(user);
        return "User successfully created";
    }
    @Override
    public String login(User user) {
        return
        user.login(
        userAccessOutputPort
        .findByEmail(user.getEmail()));
       }
    }
    

    UserAccessInputPort实现了UserAccessUseCase接口。注意我们正在注入UserAccessOutputPort。正是通过这个输出端口,输入端口将处理外部数据。createAccount方法通过依赖User领域实体类中提供的isEmailAlreadyUsed方法的逻辑来检查电子邮件是否已存在。login方法也依赖于领域六边形,通过调用存在于User领域实体类中的login方法。

应用六边形允许我们表达系统应该如何处理外部数据,同时结合领域六边形的核系统逻辑。与我们在分层架构方法中所做的方法相反,核心系统逻辑和外部数据处理已经被定义,而没有指定数据是来自数据库还是其他地方。

通过输出端口表达系统需要哪些数据,而不暴露系统将如何获取这些数据的方法,这是分层架构和六边形架构之间的重要区别。

实现框架六边形

应用六边形协调来自领域六边形的核系统逻辑和外部数据。然而,我们需要提供一种获取这些外部数据的方法。在分层架构方法中,数据层允许我们从数据库获取数据,API 层暴露了 REST 端点。在框架六边形中,我们使用输入适配器提供 REST 端点,使用输出适配器从数据库获取数据。让我们来实现它:

  1. 我们从UserAccessInputAdapter类开始:

    @Path("/user")
    public class UserAccessInputAdapter {
        @Inject
        UserAccessUseCase userAccessUseCase;
        /** Code omitted **/
    }
    

    我们注入UserAccessUseCase以访问应用六边形中可用的操作。

  2. UserAccessInputAdapter类中,我们实现register方法:

    @POST
    @Produces(MediaType.TEXT_PLAIN)
    @Consumes(MediaType.APPLICATION_JSON)
    @Path("/register")
    public String register(UserDto userDto) throws Excep
      tion {
        return userAccessUseCase.createAccount(new
        User(userDto.email(), userDto.password()));
    }
    

    我们直接将UserDto映射到User领域实体类。然后,我们将它传递给UserAccessUseCase中的createAccount方法。

  3. 为了完成UserAccessInputAdapter的实现,我们创建login方法:

    @POST
    @Produces(MediaType.TEXT_PLAIN)
    @Consumes(MediaType.APPLICATION_JSON)
    @Path("/login")
    public String login(UserDto userDto) {
        return userAccessUseCase.login(new
        User(userDto.email(), userDto.password()));
    }
    

    正如我们在register方法中所做的那样,我们将UserDto映射到User领域实体类,然后将其传递给login方法。

    我们仍然需要实现输出适配器。让我们来做这件事。

  4. UserAccessOutputAdapter实现了UserAccessOutputPort

    @ApplicationScoped
    public class UserAccessOutputAdapter implements
      UserAccessOutputPort {
        @Inject
        UserRepository userRepository;
        /** Code omitted **/
    }
    

    通过注入UserRepository,我们实际上将这个输出适配器转变为处理数据库的适配器。

  5. 我们需要实现findByEmail方法:

    @Override
    public Optional<User> findByEmail(String email) {
        return UserMapper
               .userDataToDomain(
               userRepository.findByEmail(email));
    }
    

    当从UserAccessOutputPort接口实现findByEmail时,我们使用UserRepositoryUserMapper是一个辅助类,用于将 ORM 实体类UserData映射到User领域实体类。

  6. 最后,我们实现persist方法:

    @Transactional
    @Override
    public void persist(User user) {
        var userData = UserMapper.userDomainToData(user);
        userRepository.persist(userData);
    }
    

    我们再次使用UserMapper辅助类将User领域实体类映射到UserDataORM 实体类。这是必需的,因为我们不能持久化领域实体。因此,我们将UserDataORM 实体类传递给UserRepository中的persist方法。

    引入框架六边形允许客户端访问由输入适配器提供的系统 API,并将六边形应用程序连接到外部数据源,在我们的例子中是一个数据库。与 API 层提供的 REST 端点相比,框架六边形的输入适配器没有太大区别。两种方法都公开了类似的方法,依赖于 DTO 类来映射客户端请求,并将它们发送到服务层或应用程序六边形。

    显著变化的是外部数据是如何处理的。在六边形方法中,输出适配器实现了一个输出端口,提供了输出端口抽象的灵活性。可以不干扰核心系统逻辑实现一个新的输出适配器。另一方面,在分层架构方法中没有这样的抽象。服务层直接依赖于数据层的仓库类。

    现在我们来看看如何测试六边形应用程序。

测试六边形应用程序

因为核心系统逻辑是领域六边形的一部分,我们可以创建单元测试来验证User领域实体行为。以下是一个这样的单元测试的例子:

@QuarkusTest
public class UserServiceTest {
@Test
public void givenTheUserEmailAlreadyExistsAnException
  IsThrown() {
    var user = new User("test@davivieira.dev", "password");
    var optionalUser = Optional.of(user);
    Assertions.assertThrows(
            Exception.class,
            ()-> user.isEmailAlreadyUsed(optionalUser)
    );
}
/** Code omitted **/
}

在分层方法中,我们必须注入一个服务类并提供一个数据库来测试电子邮件是否已经被使用。在六边形方法中,我们直接从User领域实体类测试逻辑。将核心系统逻辑从分层架构的服务层移动到六边形架构的领域六边形,提供了在不依赖外部资源的情况下运行更受限制的测试的灵活性。

基于我们使用分层和六边形架构实现相同应用程序的实践,让我们评估每种架构的优缺点。

评估六边形和分层架构的优缺点

分层应用程序的结构比六边形应用程序更简单。在分层方法中,我们有服务层直接依赖于数据层。这种依赖意味着核心系统逻辑依赖于数据层的 ORM 实体和仓库类。与六边形方法相反,没有关于外部数据访问的抽象,核心系统逻辑与处理外部数据的代码嵌入在一起。这是好是坏?就像软件开发中的大多数事情一样,这取决于你的上下文。

在本章开头分享的经验,即我的团队在项目中途不得不更改数据库技术,是一个例子,说明采用六边形方法将是有益的。如果你预计你的项目会有相当大的需求变化,那么六边形方法可能是一个好主意,以便使你的应用程序更容易适应这些变化。否则,分层架构是一个不错的选择,因为使用这种架构快速启动新应用程序是很快的。

分层架构提供了一种简单快捷的方法来开发新应用程序。大多数后端开发者都熟悉有一个 API 层来暴露端点,一个包含核心系统逻辑的服务层,以及通常提供数据库访问的数据层。因此,让新团队成员加入维护基于这种架构的应用程序是一项小任务。权衡的是,当需要更改基础设施组件时,这种架构提供的灵活性较少。

另一方面,六边形架构使我们能够将核心系统逻辑代码与基础设施/外部数据处理代码解耦。然而,这种解耦并非免费获得。由于增加了诸如端口、用例和适配器等额外组件,六边形架构略微增加了代码复杂性,这些组件我们使用以确保解耦。主要好处是具有变化容忍度的应用程序,可以免受意外系统需求不可预测性的影响。由于六边形架构不如其分层对应物广泛使用,因此吸纳新团队成员可能需要额外的努力。因此,人们需要更多的时间来掌握六边形方法的思想,以便开始为项目做出贡献。

摘要

本章探讨了分层架构及其与六边形架构的不同之处。我们首先回顾了分层架构的目的,即通过包含具有特定职责的代码的逻辑层,提供一定程度的关注点分离。在回顾了分层方法的概念之后,我们亲自动手从头开始实现了一个简单的用户访问应用程序,其中包含 API、服务和数据层。为了突出分层架构和六边形架构之间的差异,我们将用户访问应用程序重构为使用六边形方法。通过这样做,我们发现分层架构并不能完全保护应用程序免受重大变化的影响,例如那些触及基础设施组件,如外部数据访问处理的变化。最后,我们评估了分层架构和六边形架构的优缺点,得出结论:当预期项目需求没有重大变化时,分层架构是一个不错的选择;而当需要更易于适应变化的应用程序,能够容纳相当大的系统变化,尤其是在基础设施层面时,建议使用六边形架构。在下一章中,我们将探讨如何将 SOLID 原则与六边形架构结合使用。

问题

  1. 为什么你会选择在新的项目中使用分层架构而不是六边形架构?

  2. 虽然分层架构提供了一定程度的关注点分离,但它并没有完全解耦核心系统逻辑与基础设施代码。为什么?

  3. 在哪种场景下使用六边形架构而不是分层架构是有意义的?

答案

  1. 它提供了一种简单快捷的方式来启动新应用程序。

  2. 由于核心系统逻辑直接依赖于基础设施代码,通常当存在依赖于数据层的服务层时。

  3. 当预期项目需求会发生变化时,使用六边形架构可以创建能够适应那些需求的易于适应变化的应用程序。

第十六章:使用 SOLID 原则与六边形架构

拥有一套原则来帮助我们开发更好的软件的想法让我感到有趣。多年来,程序员们面临了许多问题;有些问题发生得如此频繁,以至于出现了解决这类问题的模式,从而产生了所谓的设计模式。这些模式被用来解决特定的软件开发问题。为了补充那些主要针对重复和特定编码问题的设计模式,人们还提出了解决软件项目中可维护性问题的新思路。其中一套引人注目且具有影响力的思想被综合为众所周知的SOLID 原则

本章将探讨 SOLID 原则以及我们如何在使用六边形架构时利用它们。我们将首先回顾每个原则,然后我们将继续探讨它们如何在六边形系统的背景下应用。最后,我们将讨论如何将建造者和抽象工厂等设计模式与六边形架构结合使用。

本章将涵盖以下主题:

  • 理解 SOLID 原则

  • 在六边形架构系统中应用 SOLID

  • 探索其他设计模式

完成本章学习后,您将能够结合六边形架构技术使用 SOLID 原则。同时,您还将了解如何在开发六边形系统时使用设计模式,例如责任链模式、装饰者模式、建造者模式和单例模式。

技术要求

要编译和运行本章中展示的代码示例,您需要在您的计算机上安装最新的Java SE 开发工具包Maven 3.8。它们适用于 Linux、MacOS 和 Windows 操作系统。

您可以在 GitHub 上找到本章的代码文件,链接为 github.com/PacktPublishing/-Designing-Hexagonal-Architecture-with-Java---Second-Edition/tree/main/Chapter16

理解 SOLID 原则

自从编程诞生以来,开发者们一直在讨论想法并捕捉原则,以帮助开发更好的软件。这些原则的出现是为了应对处理高度复杂代码的需求。在多次遭受相同重复问题后,开发者开始认识到这些问题的模式,并设计了防止此类问题的技术。一个显著的例子是关于设计模式的四人帮GoF)书籍,它在面向对象的世界中产生了巨大影响,并继续影响着一代又一代的开发者。另一个引人注目且具有影响力的例子是罗伯特·马丁提出的思想,这些思想导致了 SOLID 原则的形成。

SOLID 代表以下原则:

  • 单一职责原则SRP

  • 开闭原则OCP

  • 里氏替换原则LSP

  • 接口隔离原则ISP

  • 依赖倒置 原则DIP

这些原则旨在通过代码帮助开发者创建健壮且易于更改的软件,基于这些原则定义的一系列规则。我相信使用这些原则并不能完全保证软件没有可维护性问题。然而,这些原则可以显著提高整体代码质量。本质上,这全部关于采用允许以可持续的方式向代码库引入更改的技术。我的意思是,软件会增长,但它的复杂性将得到控制。

SOLID 原则与六边形架构以类似的方式工作,因为两者都旨在提供技术来开发更易于维护、更易于更改的软件。因此,探索这些原则如何在六边形应用程序的背景下应用是有意义的。让我们从回顾 SOLID 原则的每一个开始我们的探索。

单一职责原则(SRP)

我记得一个情况,我会目睹或成为导致副作用代码更改的作者,这种副作用只有在应用程序部署到预发布环境或更糟糕的生产环境后才会被发现。一个利益相关者会报告应用程序在导致副作用的更改部署后开始出现的问题。所以,尽管这个更改解决了某个利益相关者的问题,但它为另一个利益相关者创造了问题。为什么?因为导致问题的更改违反了 SRP。违反发生是因为相同的系统逻辑为两个不同的利益相关者服务。这个更改解决了某个利益相关者的问题,但为另一个利益相关者创造了副作用,导致了问题。

当我们过早地定义抽象时,也可能违反 SRP。假设我们定义了一个抽象类,其中包含我们认为将适用于该抽象类所有未来实现的一些数据和行为。然后,后来通过一个不幸的事故报告,我们发现该抽象类中的一些数据或行为在另一位开发者的最近实现中导致了意外的结果,这位开发者假设由该抽象提供的行为和数据将在导致问题的实现中工作。

SRP 确保一个方法或函数的更改仅基于一种利益相关者或行为者的请求,通常是一个组织的部门或一条业务线。确保部门 A 的逻辑,例如,不会干扰部门 B 的逻辑是很重要的,这可以通过以某种方式安排代码来实现,即服务于不同利益相关者的逻辑得到适当的分离。

开放-封闭原则(OCP)

这个原则背后的想法在于在不改变现有事物的情况下增加软件的功能。为了做到这一点,一个软件组件或模块应该是可扩展的,但不可修改的。我可以回忆起一个经历,当时我在实现报告功能。我没有使用一个类来处理所有类型的报告,而是创建了一个具有报告的基本属性的基础抽象类。每次需要实现新的报告类型时,就会通过实现基础抽象类创建一个新的具体类。额外的属性和功能会被附加到基础抽象类的基本属性上。

我们使用 OCP 来避免那些我们想要添加新功能,并且为了实现这个功能,我们也需要更改已经支持现有功能的一些逻辑的情况。通过这样做,我们违反了 OCP。相反,我们需要安排代码,以便我们可以在不修改现有代码的情况下添加新功能。

李斯克夫替换原则(LSP)

基于我在 OCP 描述中给出的报告示例,让我们假设我们有一个包含 print 方法声明的 Report 类。根据给定的问题域,print 方法是任何报告支持的行为。除了 Report 类之外,假设我们还有扩展它的 WorkdayReportWeekendReport 类。LSP 规定,如果我们向期望 Report 类型的方法传递 WorkdayReportWeekendReport 类型的对象,该方法将能够触发所有报告类型固有的行为——在这种情况下,就是 print 方法。总之,Report 类型应该被设计得使其声明的、在子类型中重写的方法与子类型的目的保持一致。

接口隔离原则(ISP)

当我们想要为客户提供只包含他们需要的声明的方法的接口时,ISP 是有帮助的。这个原则通常在我们有一个包含许多方法声明的单一接口,并且一个特定的客户端只实现一些方法并为不需要的方法提供占位实现时被使用。通过应用 ISP,我们打破了那个包含多个接口的单个接口,这些接口针对特定的客户端需求进行了定制。

依赖倒置原则(DIP)

稳定和不稳定的软件组件有截然不同的概念。稳定意味着那些不太经常变化的组件,而不稳定则相反。一个客户端组件直接依赖于一个不稳定的组件可能是危险的,因为不稳定代码的变化可能会触发客户端的变化。大多数情况下,不稳定组件是一个具有不需要暴露给其客户端的实现细节的具体类。

为了避免暴露此类实现细节并保护客户端免受依赖变更的影响,DIP 规定客户端应始终依赖于抽象而非具体实现。不稳定的组件——一个具有实现细节的具体类——应通过实现一个接口(例如)从抽象中派生出来。然后,客户端应依赖于一个稳定的组件,即不稳定组件(具体类)实现的接口。我们称接口为稳定组件,因为它充当合同,而合同更不易发生变化。

让我们在下一节中看看如何将 SOLID 原则应用于使用六边形架构开发的应用程序。

在六边形架构系统中应用 SOLID 原则

为了了解每个 SOLID 原则是如何应用的,我们将回到本书中开发的全局拓扑和库存系统。让我们首先看看 SRP 在拓扑和库存系统中是如何应用的。

应用 SRP

只是为了回顾,拓扑和库存系统管理网络资源,如路由器和交换机。这样的系统适合电信或互联网服务提供商ISP)公司,他们希望对其为服务客户所使用的网络资源进行库存管理。

在拓扑和库存系统中,我们有核心路由器和边缘路由器。核心路由器处理来自一个或多个边缘路由器的高负载网络流量。边缘路由器用于处理来自最终用户的流量。边缘路由器连接到网络交换机。

考虑一个场景,核心路由器和边缘路由器更改位置。例如,现在位于法国的核心路由器由于某些原因需要重新配置到意大利,而位于法兰克福的边缘路由器需要重新配置到柏林。还要考虑网络跨国家变化由演员 A 处理,而网络跨城市变化由演员 B 处理。

让我们将拓扑和库存应用程序更改为满足描述的要求。以下描述的更改是在域六边形中进行的:

  1. 创建 AllowedCountrySpec 规范类:

    public final class AllowedCountrySpec extends Ab
      stractSpecification<Location> {
        private List<String> allowedCountries =
        List.of(
        "Germany", "France", "Italy", "United States");
        @Override
        public boolean isSatisfiedBy(Location location) {
            return allowedCountries
                    .stream()
                    .anyMatch(
                     allowedCountry -> allowedCountry
                     .equals(location.country()));
        }
        /** Code omitted **/
    }
    

    此规范限制了可以通过 allowedCountries 属性选择的哪些国家。这并不是在真实应用程序中应该如何表示它,但它足以说明 SRP 的概念。

  2. 现在,创建 AllowedCitySpec 规范类:

    public final class AllowedCitySpec extends Ab
      stractSpecification<Location> {
        private List<String> allowedCities =
        List.of(
        "Berlin", "Paris", "Rome", "New York");
        @Override
        public oolean isSatisfiedBy(Location location) {
            return allowedCities
                    .stream()
                    .anyMatch(
                     allowedCountry -> allowedCountry
                    .equals(location.city()));
        }
        /** Code omitted **/
    }
    

    沿用之前规范中的相同思路,我们在这里通过 allowedCities 属性限制允许的城市。

  3. Router 抽象类中声明 changeLocation 方法:

    public abstract sealed class Router extends Equipment
      permits CoreRouter, EdgeRouter {
       /** Code omitted **/
       public abstract void changeLocation(
       Location location);
       /** Code omitted **/
    }
    

    注意,Router 是一个抽象密封类,只允许 CoreRouterEdgeRouter 类实现它。

  4. CoreRouter 提供实现:

    @Override
    public void changeLocation(Location location) {
        var allowedCountrySpec = new AllowedCountrySpec();
        allowedCountrySpec.check(location);
        this.location = location;
    }
    

    我们使用 AllowedCountrySpec 来检查新路由器 Location 是否被允许。如果提供了不允许的国家,将抛出异常。否则,将新位置分配给 Router 对象的 location 变量。

  5. EdgeRouter 提供实现:

    @Override
    public void changeLocation(Location location) {
        var allowedCountrySpec = new AllowedCountrySpec();
        var allowedCitySpec = new AllowedCitySpec();
        allowedCountrySpec.check(location);
        allowedCitySpec.check(location);
        this.location = location;
    }
    

    EdgeRouter 的实现略有不同。除了 AllowedCountrySpec,我们还有 AllowedCitySpec。只有在这两个规范满足之后,才会为 Router 对象分配一个新的 Location

    让我们回顾一下我们在这里做了什么。我们首先创建了 AllowedCountrySpecAllowedCitySpec 规范;然后,我们在 Router 抽象类上声明了 changeLocation 方法。由于 CoreRouterEdgeRouter 都实现了这个类,我们必须重写 changeLocation 方法来满足角色 A 和角色 B 的需求。角色 A 负责处理跨国家的位置更改——在这种情况下,是 CoreRouter。角色 B 负责处理跨城市的位置更改,这是 EdgeRouter 的责任。

    假设我们不是将 changeLocation 声明为抽象的,而是提供了一个由 CoreRouterEdgeRouter 类共享的具体实现。这将违反 SRP,因为 changeLocation 逻辑将服务于不同的角色。

应用 OCP

我们尚未声明,但 RouterCoreRouterEdgeRouter 类之间的安排代表了 OCP 的应用。观察以下统一建模语言(UML)图:

图 16.1 – 应用 OCP

图 16.1 – 应用 OCP

OCP 确保模块或组件对更改是封闭的,但对扩展是开放的。我们不是提供一个包含处理核心和边缘路由器逻辑的单个类的类设计,而是利用 Java 的继承能力来扩展 Router 抽象类的可能性,而不改变其属性和行为。这种扩展通过 CoreRouterEdgeRouter 具体类实现是可能的。

应用 LSP

为了演示 LSP 的应用,我们需要在拓扑和库存系统中进行更多更改。在应用 SRP 和 OCP 的同时,我们改变了域六边形。现在,我们将对应用程序六边形进行更改:

  1. RouterManagementUseCase 接口中声明 changeLocation 方法:

    public interface RouterManagementUseCase {
        /** Code omitted **/
        Router changeLocation(
        Router router, Location location);
        /** Code omitted **/
    }
    

    更改路由器的位置是我们添加到拓扑和库存系统中的新用例,因此我们添加了 changeLocation 方法声明来表达该用例。

  2. RouterManagementInputPort 中实现 changeLocation 方法:

    public class RouterManagementInputPort implements
      RouterManagementUseCase {
        /** Code omitted **/
        @Override
        public Router changeLocation(Router router,
        Location location) {
            router.changeLocation(location);
            return persistRouter(router);
        }
        /** Code omitted **/
    }
    

    RouterManagementInputPort 中的 changeLocation 方法通过传递一个 Location 对象来调用 Router 中的 changeLocationRouter 中的 changeLocation 具有检查提供的 Location 是否被允许的逻辑。如果一切正常,我们调用 persitRouter 来持久化带有其新 LocationRouter

    当我们在 RouterManagementInputPort 中实现 changeLocation 方法时,我们可以观察到 LSP 的应用。注意 changeLocation 期望一个 Router 类型:

    public Router changeLocation(Router router,
      Location location) {
        router.changeLocation(location);
        return persistRouter(router);
    }
    

这意味着我们可以传递一个CoreRouter或一个EdgeRouter对象,因为它们都扩展了Router,并且它们都提供了changeLocation的实现,这是所有路由器固有的行为。

应用 ISP(接口隔离原则)

在应用 LSP(Liskov 替换原则)时,我们在应用程序六边形中创建了RouterManagementUseCaseRouterManagementInputPort。让我们通过在框架六边形中提供一个输入适配器来完成我们的实现,以将输入适配器连接到输入端口:

  1. RouterManagementAdapter类中实现changeLocation方法:

    @Transactional
    @POST
    @Path("/changeLocation/{routerId}")
    @Operation(operationId = "changeLocation", description
      = "Change a router location")
    public Uni<Response> changeLocation(@PathParam
      ("routerId") String routerId, LocationChange loca
        tionChange) {
        Router router = routerManagementUseCase
           .retrieveRouter(Id.withId(routerId));
        Location location =
            locationChange.mapToDomain();
            return Uni.createFrom()
           .item(routerManagementUseCase.changeLocation(ro
            uter, location))
           .onItem()
           .transform(f -> f != null ? Response.ok(f) :
                  Response.ok(null))
                 .onItem()
                 .transform(
                    Response.ResponseBuilder::build);
    }
    

    通过使用POSTPATH注解,我们将此方法转换为REST端点,以接收发送到/router/changeLocation/{routerId} URI 的请求。URI 中的路由部分来自RouterManagementAdapterPATH注解的最高级定义。

    此输入适配器使用RouterManagementUseCase中的retrieveRouter方法获取Router。然后,它将LocationRequest对象转换为Location域对象。最后,它将RouterLocation传递给RouterManagementUseCase中的changeLocation方法。

    为了确认我们的实现是有效的,让我们实现一个测试来检查整个流程。

  2. RouterManagementAdapterTest类中实现以下测试:

    @Test
    public void changeLocation() throws IOException {
        var routerId =
            "b832ef4f-f894-4194-8feb-a99c2cd4be0c";
        var expectedCountry = "Germany";
        var location = createLocation("Germany",
            "Berlin");
        var updatedRouterStr = given()
              .contentType("application/json")
              .pathParam("routerId", routerId)
              .body(location)
              .when()
              .post("/router/changeLocation/{routerId}")
              .then()
              .statusCode(200)
              .extract()
              .asString();
        var changedCountry =
        getRouterDeserialized(
        updatedRouterStr).getLocation().country();
        assertEquals(expectedCountry, changedCountry);
    }
    

    此测试更改了路由器的位置,这是一个位于美国的核心路由器。在发送包含国家为“德国”和城市为“柏林”的Location对象的POST请求后,我们运行一个断言以确保返回的Router对象具有更改后的位置——德国而不是美国。

ISP 可以通过使用例操作对输入适配器可用来观察到。我们有RouterManagementInputPort类实现了RouterManagementUseCase接口。ISP 被采用,因为RouterManagementUseCase接口的所有方法声明都由RouterManagementInputPort实现,并且都是相关的。

应用 DIP(依赖倒置原则)

我们在第九章,“使用 Java 模块应用依赖倒置”中讨论了依赖倒置,其中我们使用了Java 平台模块系统JPMS)来应用依赖倒置。为了回顾,让我们查看以下图表:

图 16.2 – 依赖倒置的回顾

图 16.2 – 依赖倒置的回顾

DIP 指出,客户端应该始终依赖于抽象而不是具体实现。这正是我们通过使RouterManagementAdapter依赖于RouterManagementUseCase接口,而不是RouterManagementInputPort具体类所做的事情:

public class RouterManagementAdapter {
    @Inject
    RouterManagementUseCase routerManagementUseCase;
    /** Code omitted **/
}

第九章,“使用 Java 模块应用依赖倒置”中,RouterManagementUseCase接口的实现——一个RouterManagementInputPort对象——由 JPMS 提供。在当前实现中,我们使用 Quarkus 和@Inject注解来提供RouterManagementInputPort

探索其他设计模式

在前面的章节中,我们在开发拓扑和库存系统时应用了一些设计模式。这些模式帮助我们更好地组织代码以支持应用需求。因此,在本节中,我们将回顾我们在实现六边形架构时应用的设计模式。

单例模式

在介绍 Quarkus 到我们的拓扑和库存系统之前,我们必须提供自己的机制来创建一个单独的数据库连接对象。在处理基于数据库的连接时,通常只有一个实例连接到数据库,并将该连接与其他对象共享。

单例模式是我们用来创建单个数据库连接实例的模式,如下面的示例所示:

public class RouterNetworkH2Adapter implements RouterNet
  workOutputPort {
    private static RouterNetworkH2Adapter instance;
    @PersistenceContext
    private EntityManager em;
    private RouterNetworkH2Adapter(){
        setUpH2Database();
    }
    private void setUpH2Database() {
        EntityManagerFactory entityManagerFactory =
        Persistence.createEntityManagerFactory
          ("inventory");
        EntityManager em =
        entityManagerFactory.createEntityManager();
        this.em = em;
    }
    public static RouterNetworkH2Adapter getInstance() {
        if (instance == null) {
            instance = new RouterNetworkH2Adapter();
        }
        return instance;
    }
}

为了确保只创建一个对象,我们创建一个私有的构造函数以防止客户端创建额外的实例。对象创建由getInstance方法处理,该方法检查实例属性是否为null。如果是null,则创建一个新的RouterNetworkH2Adapter并将其分配给instance变量。然后私有的构造函数使用EntityManagerFactory创建数据库连接。

当第二次执行getInstance方法时,我们不是创建一个新的RouterNetworkH2Adapter实例,而是返回之前创建的现有实例。

构建器

构建器是一种设计模式,它帮助我们以表达性的方式创建复杂的对象。它适用于具有许多参数和不同方式创建相同对象的场景。我们已使用该设计模式创建了CoreRouterEdgeRouter对象。

考虑以下示例,其中我们使用其构造函数创建CoreRouter的一个实例:

var router = new CoreRouter(
                  id,
                  parentRouterId,
                  vendor,
                  model,
                  ip,
                  location,
                  routerType,
                  routers);

直接使用构造函数的一个缺点是我们需要知道如何以正确的顺序传递参数。在先前的示例中,我们必须首先传递id,然后是parentRouterId,依此类推。

现在,让我们看看如何使用构建器进行对象创建:

var router = CoreRouter.builder()
              .id(id == null ? Id.withoutId() : id)
              .vendor(vendor)
              .model(model)
              .ip(ip)
              .location(location)
              .routerType(routerType)
              .build();

除了跳过一些参数,如parentRouterId之外,我们通过vendormodel等构建器方法以任何顺序传递参数。一旦完成,我们调用build方法来返回CoreRouter实例。

在整本书中,我们没有提供自定义构建器的实现。相反,我们依靠有用的 Lombok 库,通过简单地在类的构造函数上添加Builder注解来创建构建器:

@Builder
public CoreRouter(Id id, Id parentRouterId, Vendor vendor,
  Model model, IP ip, Location location, RouterType router
    Type, Map<Id, Router> routers) {
/** Code omitted **/
}

如果你没有对你对象创建的特殊要求,Lombok 可能就足够了。否则,你可以实现自己的构建器机制。这通常发生在你想要定义对象创建的强制或可选参数以及其他规则时。

抽象工厂

在上一节中,我们讨论了如何应用 LSP 使我们能够传递一个CoreRouterEdgeRouter对象给期望Router类型的函数,然后我们可以使用该对象而没有任何问题。当我们实现RouterFactory类时,抽象工厂模式就派上用场了:

public class RouterFactory {
    public static Router getRouter(Id id,
                                   Vendor vendor,
                                   Model model,
                                   IP ip,
                                   Location location,
                                   RouterType routerType){
        switch (routerType) {
            case CORE -> {
                 return CoreRouter.builder().
                     Id(id == null ? Id.withoutId() : id).
                     Vendor(vendor).
                     Model(model).
                     Ip(ip).
                     Location(location).
                     routerType(routerType).
                     Build();
            }
            case EDGE -> {
                 return EdgeRouter.builder().
                     Id(id==null ? Id.withoutId():id).
                     Vendor(vendor).
                     Model(model).
                     Ip(ip).
                     Location(location).
                     routerType(routerType).
                     Build();
            }
            default -> throw new
            UnsupportedOperationException(
            "No valid router type informed");
        }
    }
}

RouterFactory类只包含getRouter方法,该方法接收创建代码和边缘路由器所需的参数,并返回一个类型为Router的对象。请注意,我们传递了一个用于switch语句的RouterType参数,用于识别需要创建哪种类型的路由器,即CoreRouterEdgeRouter。无论具体的路由器子类型如何,我们总是将其作为Router超类型返回以供使用,例如,在可以应用 LSP 的场景中。

摘要

本章让我们探索了如何将 SOLID 原则与六边形架构一起使用。我们在实现拓扑和库存系统时也回顾了我们的设计模式。我们首先简要地讨论了 SOLID 原则。

在对原则有了基本了解之后,我们继续探讨它们如何在六边形应用程序的上下文中应用。然后,我们将更改路由位置功能实现到拓扑和库存系统中。最后,我们回顾了在设计六边形系统时如何使用如建造者、单例和抽象工厂等设计模式。

下一章和最后一章将进一步探讨设计实践,以帮助我们构建更好的软件。

问题

  1. OCP 代表什么,它的目的是什么?

  2. DIP 的目标是什么?

  3. 哪种设计模式可以支持 LSP?

答案

  1. 它代表的是开放封闭原则。其目的是确保软件组件或模块对修改是封闭的,但对扩展是开放的。

  2. DIP 规定客户端应始终依赖于抽象而不是具体实现。通过这样做,我们保护客户端免受具体实现变化的影响,这些变化可能需要修改客户端代码。

  3. 抽象工厂模式根据其超类型提供对象,这可以在 LSP 中使用,其中超类型被子类型替换,同时保持对象行为的一致性。

第十七章:为您的六边形应用程序制定良好的设计实践

在本书中探索六边形架构时,我们了解了一些表征六边形应用程序的原则和技术。通过可视化具有明确定义边界的系统,我们建立了三个六边形:领域、应用程序和框架。

以这些六边形为指导,我们探讨了如何将业务代码与技术代码分离。这种分离使我们能够探索创建易于变更的系统的方法。但我们并没有止步于此。更进一步,我们学习了如何使用 Quarkus 框架将六边形应用程序转变为云原生应用程序。

我们已经掌握了创建六边形系统所需的基本思想,结束了这本书的阅读。在本章中,我们将探讨一些在创建健壮的六边形应用程序时可以应用的有用的设计实践。

在本章中,我们将涵盖以下主要主题:

  • 使用领域驱动设计DDD)来塑造领域六边形

  • 创建端口和用例的需求

  • 处理多个适配器类别

  • 结论——六边形之旅

到本章结束时,您将了解可以使您的六边形架构项目更健壮的设计实践。这些实践还将帮助您决定何时以及如何应用六边形架构原则。

技术要求

要编译和运行本章中展示的代码示例,您需要在您的计算机上安装最新的Java SE 开发工具包Maven 3.8。它们都适用于LinuxMacWindows操作系统。

您可以在 GitHub 上找到本章的代码文件:

github.com/PacktPublishing/-Designing-Hexagonal-Architecture-with-Java---Second-Edition/tree/main/Chapter17

使用领域驱动设计来塑造领域六边形

当使用六边形架构来设计系统的代码结构时,我们无法强调首先实现领域六边形的重要性。正是领域六边形为整个应用程序的开发定下了基调。

只要您将代码保持在仅表达问题域的领域六边形中——不将业务关注点与技术关注点合并的代码——您就走在确保有利于更易于变更的设计的封装级别的正确道路上。您在开发领域六边形时使用的技巧不应是您当前的主要关注点——相反,您的目标应该是创建一个专注于系统目的的领域六边形,而不是您可能用于实现它的技术。因此,您可以使用自己的原则集来开发领域六边形,或者您可以借鉴那些以前解决过类似问题的他人的想法。

使用领域驱动设计(DDD)的优势在于,这意味着你不需要重新发明轮子。大多数——如果不是所有——你需要来建模你的问题域的概念和原则,在 DDD 技术丰富的知识体系中都已确立。然而,这并不意味着你必须字面遵循所有 DDD 原则。推荐的方法是采用和适应对你项目有帮助的东西。

接下来,我们将探讨一些在使用 DDD 设计领域六边形时你可以遵循的方法。

理解我们所从事的业务

一个好的应用程序设计反映了对其所服务业务的良好理解。设计之旅不是从代码开始的,而是通过寻求业务知识。我并不是要你成为你打算为它编写软件的领域的业务专家。然而,我认为理解基础很重要,因为如果你不这样做,设计阶段初期犯下的错误可能会造成不可逆转的损害,这种损害会延伸到整个软件项目。

在最佳情况下,项目可以幸存这些早期的错误,但不是没有付出混乱和难以维护的软件的高昂代价。在最坏的情况下,结果是不可用的软件,从头开始一个新的项目是最好的选择。

理解业务基础是我们应该做的第一件事。业务细节也很重要,如果我们想制作出顶级的软件,我们应该密切关注它们。但与细节相关的错误并不像与基础相关的错误那样严重。前者通常更容易且更便宜地修复。

让我们暂时回顾一下拓扑和库存系统。我们有一个业务规则,即只有来自同一国家的边缘路由器才能相互连接。我们使用边缘路由器来处理区域流量,因为它们的流量容量比核心路由器小。核心路由器可以位于不同的国家,因为它们的流量容量更大。

整个领域模型都是基于这些业务前提构建的。如果我们无法理解和将这些业务前提转化为一个连贯的领域模型,我们将妥协整个系统开发。在我们之上构建的一切都将基于薄弱或错误的假设。这就是为什么我们需要投入必要的时间来掌握业务基础。

现在,让我们看看我们可以使用的一些技术来构建业务知识。

商业模式画布

使用商业模式画布技术可以做一个很好的练习,以了解业务是如何运作的。商业模式画布是一个创建商业模式的工具。它提供了分析和理解业务主要要素的工具。通过提供一种结构化和简化的方式来识别业务的主要方面,商业模式画布可以成为绘制您和您的团队需要理解业务基础的大图的起点。

工具的主要好处是它关注对业务盈利至关重要的关键要素。另一个有益的方面是它如何表示整体商业景观中的客户和合作伙伴。这有助于我们了解商业模式在多大程度上满足了客户和合作伙伴的期望。

一个缺点是它并没有提供一个深入和全面的视角,说明业务应该如何运作才能产生良好的结果。此外,它也没有涉及业务战略。它的大部分重点在于最终结果,而不是长期目标。

商业模式画布有一个变体和替代方案,称为精益画布,它更倾向于初创企业。这种方法的 主要区别在于它关注初创企业在尝试开发新想法和产品时面临的高度不确定性水平。

这里是商业模式画布的示意图:

图 17.1 – 商业模式画布

图 17.1 – 商业模式画布

如前图所示,商业模式画布让我们能够将每个业务方面结构化为不同的部分。这种分离有助于我们可视化构成业务的主要要素。以下是商业模式画布的要素:

  • 关键合作伙伴要素代表我们的关键合作伙伴和供应商,并包含有关在该关系中涉及的关键资源或活动的信息

  • 关键活动中,我们陈述了实现关键活动所需的价值主张

  • 对于关键资源,我们需要确定使关键资源得以实现的必要价值主张

  • 价值主张中,我们描述了我们打算向客户提供的价值要素

  • 客户关系要素是关于每个客户细分在建立和维护与我们关系时的期望

  • 渠道中,我们确定客户细分将通过哪些沟通渠道联系我们

  • 客户细分要素代表了我们希望为其提供价值的群体

  • 成本结构要素描述了使商业模式得以实现的最高成本

  • 收入来源要素显示了客户真正愿意为其支付的价值

除了商业模式画布,我们还有事件风暴技术作为替代方案,它更适合 DDD 项目。现在让我们来考察它。

事件风暴

如果你认为商业模型画布不是一个合适的方法,另一种称为事件风暴的技术可以帮助你理解你的业务需求。由 Alberto Brandolini 创建的事件风暴使用彩色便利贴将业务元素映射到领域事件、命令、行为者和聚合体。每个便利贴元素都有自己的颜色,如下面的流程图所示:

图 17.2 – 事件风暴技术

图 17.2 – 事件风暴技术

如前图所示,事件风暴使用的术语与我们处理领域驱动设计(DDD)时遇到的术语相同。这是因为事件风暴是专门为那些使用 DDD 并需要理解其项目业务需求的人而创建的。

事件风暴会议应由开发者、领域专家和协调会议的协调员共同进行,以确保映射工作朝正确的方向进行。

事件风暴会议的起点通常是一个具有挑战性的业务流程模型。在这些会议中,通常讨论的是行为者和他们的行为如何影响业务流程。另一个重点是外部系统如何支持并与业务流程交互。风险和痛点也是必须映射的主题,以确定业务关键区域。要了解更多关于事件风暴的信息,请访问其网站www.eventstorming.com

一旦我们理解了业务运作的方式,我们需要将这种知识转化为领域模型。在下一节中,我们将看到协作如何帮助我们增加对业务的了解。

促进协作以增加知识

领域模型是人们试图理解业务并将这种理解转化为代码的结果。为了最大限度地利用这一过程,在复杂度高且事情难以完成的情况下,协作发挥着至关重要的作用。为了克服这种复杂性,我们需要建立一个协作氛围,让所有参与项目的人都能提供相关信息,帮助构建整体图景。协作方法有助于确保每个人都对问题领域有相同的理解,从而产生更好地反映业务关注的领域模型。

除了使用代码本身来捕捉和传达问题领域知识外,书面文档也是协作的另一个有用工具。我并不是在谈论编写长篇和全面的文档——我的意思是相反的。让我来解释一下。

简洁的文档,专注于解释系统的构建块,可以帮助那些不熟悉代码的人迈出理解系统、进而理解问题域的第一步。有时,对系统主要元素的介绍可以迅速导致对问题域的全面理解。

我所说的可能看起来很显然,但很多时候,我遇到了一个复杂且缺乏或完全没有文档的代码库。当问题域复杂时,代码变得复杂也是自然的。没有文档来解释基本系统,原本复杂的东西变得更加难以理解。

我建议在项目结束时留出一些时间来编写系统文档。特别是新加入的人员,将受益于一份友好的文档,它提供了对系统整体概览的概述。

现在我们已经了解到,基于对业务需求的理解建立坚实的基础是多么重要,并且我们已经讨论了协作在增加我们对问题域知识了解的价值,那么让我们来探讨一些在构建领域六边形时可以采用的 DDD 技术。

将 DDD 技术应用于构建领域六边形

在本节中,我们将探讨一些设计实践,以帮助我们确立六边形系统中的明确边界。补充我们在第二章中看到的,“将业务规则封装在领域六边形内”,我们将看到创建子域、寻找通用语言和定义边界上下文以区分问题域不同方面的重要性。

子域

子域的目的是将支持核心域但又不属于表达核心域的元素分组。这些支持元素对于核心域的活动至关重要。没有支持元素,核心域无法工作。也存在一些通用子域,其目的是为核心域和支持子域提供额外的功能。通用子域作为一个独立组件工作,不依赖于其他领域提供的东西。

我们可以说,核心域中有主要活动。而在子域中,我们有次要活动,这些活动使得主要活动得以实现。如果我们混合主要和次要活动,我们最终会得到一个具有混合关注点的领域模型。对于较小的系统来说,这可能不是什么大问题,但在较大的系统中,它可能会增加相当大的复杂性,这可能会损害试图理解系统的人的生产力。这就是为什么将领域分解为子域是一个好的方法。我们总会有一个专注于代码最重要部分的中心域。

让我们以银行系统为例,进一步探讨子域的概念。在这样的系统中,可以识别以下领域:

  • 作为核心域,我们有事务,允许用户接收和发送金钱

  • 作为支持子域,我们可能有贷款保险,它们为系统增加了更多功能,但依赖于事务核心域来启用这些功能

  • 最后,我们有认证作为一个通用子域,为需要每个交易都进行认证的核心域和支持子域提供服务

下图显示了子域与核心域之间的关系:

图 17.3 – 银行系统子域

图 17.3 – 银行系统子域

事务核心域包含系统的构建块元素。这些元素也存在于贷款保险子域中,但用途不同。通用的认证子域对其他域一无所知。它只提供一种认证机制,该机制在核心域和支持子域之间共享。

通用语言

DDD 的一个试金石是它强调我们如何使用语言来描述领域模型。这种强调旨在避免我们的一般沟通中的歧义渗透到我们想要创建的系统代码中。

作为人类,我们比计算机有更大的处理语言歧义的能力,因为我们可以在我们的词语中添加上下文。另一方面,除非我们为它们提供,否则计算机没有这种能力。为了降低系统的歧义水平,通用语言寻求精确的术语来描述构成领域模型的元素。

然而,定义精确的术语并不足以确保我们始终在领域模型中传达正确的含义,因为相似的词语在不同的上下文中可能有不同的含义。这就是为什么在 DDD 中还有另一种称为边界上下文的技术,我们可以用它来处理领域模型中的意义差异。

边界上下文

边界上下文的概念是对这样一个事实的回应:词语的含义取决于它们被使用的上下文。当我们把这个想法带到领域驱动设计(DDD)中时,我们可能会发现,一个领域模型元素在不同的应用上下文中可能具有不同的含义或表现不同的行为。如果我们不主动采取行动来明确定义上下文以阐明这样的领域模型元素的含义,我们就在系统中增加了歧义。

例如,考虑拓扑和库存系统。假设除了库存功能外,我们希望系统能够从路由器和其他网络设备获取实时状态和基本信息。这个新功能可能导致两种上下文:一种用于库存,另一种用于状态。

从库存的角度来看,路由器意味着数据库中的一个静态记录。另一方面,从状态的角度来看,路由器是一个活生生的东西,它发布实时数据。通过将这种区别以边界上下文的形式表达出来,我们确保我们对一个上下文的理解不会与另一个上下文混淆。不仅如此,通过在边界上下文可以提供的清晰边界内组织代码,我们正在创建一个可以以更组织化的方式演化和接收变更的系统。此外,我们在模块级别强制执行单一职责原则。这意味着一个模块只应该因为一个原因而改变,而不是多个原因。

本次会议讨论的 DDD 技术,如果我们不首先掌握我们的业务需求,那么它们的价值就不大。这就是为什么我们首先探索了一些我们可以用来增强我们对业务模型理解的技术。一旦我们了解了我们所从事的业务,我们就可以安全地使用 DDD 技术(如子域和边界上下文)来建立不同系统组件之间的边界,并消除领域模型内的歧义。

因此,让我们看看我们如何在六边形系统中实现边界上下文和子域。

在六边形系统中实现边界上下文和子域

我们实现边界上下文的方法依赖于子域的创建。在这里,我们讨论了边界上下文和子域。

边界上下文可以存在于子域内或不存在子域。我们已经看到,拓扑和库存系统可以检查网络设备的状况。假设我们确定状态元素是问题域的一个基本且关键的特性。在这种情况下,我们可以将状态元素作为核心域的一部分,而不是将其放入一个支持子域。但我们需要处理域元素服务于不同目的的歧义。为了解决这个问题,我们必须在核心域内建立两个边界上下文:一个用于库存,另一个用于状态。

如果我们决定状态元素不是核心域的一部分,我们可以将其建模为一个子域,正如我们接下来将要看到的。

在开发拓扑和库存系统时,我们将一个单一域模型放置在领域六边形内。这个域模型满足了与网络资产库存管理相关的业务需求。考虑拓扑和库存系统可以访问网络设备以检查其状态的情况。为了避免库存管理和状态信息之间的关注点混合,我们将领域六边形拆分为两个域模型。第一个是一个核心域,用于满足库存管理需求。第二个域模型是一个子域,用于满足状态信息需求。以下图表显示了新的领域六边形的表示:

图 15.4 – 领域六边形

图 15.4 – 领域六边形

域六边形 内,我们现在有 库存核心域状态子域。在以下步骤中,我们将配置域六边形模块以反映新的结构:

  1. 在项目的根 pom.xml 文件中,我们添加了新的 Maven modules 元素,它代表核心域和子域:

    <modules>
      <module>domain</module>
      <module>domain/inventory-core-domain</module>
      <module>domain/status-sub-domain</module>
      <module>application</module>
      <module>framework</module>
      <module>bootstrap</module>
    </modules>
    

    注意,我们在 pom.xml 文件中添加了 domain/inventory-core-domaindomain/status-sub-domain Maven 模块。

    在继续之前,请确保将所有文件从 domain/src/main/java 移动到 domain/inventory-core-domain/src/main/javadomain Maven 模块将用作父项目,以聚合核心域和子域项目。

  2. 接下来,我们将配置 domain Maven 模块的 pom.xml 文件:

    <?xml version="1.0" encoding="UTF-8"?>
      <!-- Code omitted -->
      <artifactId>domain</artifactId>
      <dependencies>
        <dependency>
          <groupId>dev.davivieira</groupId>
          <artifactId>inventory-core-domain</artifactId>
        </dependency>
        <dependency>
          <groupId>dev.davivieira</groupId>
          <artifactId>status-sub-domain</artifactId>
        </dependency>
      </dependencies>
    </project>
    

    domain Maven 模块依赖于 inventory-core-domainstatus-sub-domain。我们保留了 domain 模块,但将其拆分为两部分。采用这种方法,将不需要在应用程序和框架六边形中更改任何内容。

  3. 我们还需要重新配置 module-info.java 模块描述符:

    module domain {
        requires transitive inventory_core_domain;
        requires transitive status_sub_domain;
    }
    

    transitive 关键字是必要的,以确保从 inventory_core_domainstatus_sub_domain 导出的内容对依赖于 domain 模块的其它模块可见。

  4. 接下来,我们配置 inventory-core-domain Maven 模块的 pom.xml 文件:

    <?xml version="1.0" encoding="UTF-8"?>
      <!-- Code omitted -->
      <parent>
        <groupId>dev.davivieira</groupId>
        <artifactId>topology-inventory</artifactId>
        <version>1.0-SNAPSHOT</version>
      </parent>
      <artifactId>inventory-core-domain</artifactId>
    </project>
    

    上述示例是一个简单的 pom.xml 文件,只包含 artifactIdparent 坐标。除了 pom.xml 之外,我们还需要提供一个 module-info.java 文件,如下所示:

    module inventory_core_domain {
        exports
          dev.davivieira.topologyinventory.domain.entity;
        exports
          dev.davivieira.topologyinventory.domain.service;
        exports
          dev.davivieira.topologyinventory.domain
          .specification;
        exports
          dev.davivieira.topologyinventory.domain.vo;
        exports
          dev.davivieira.topologyinventory.domain.entity
          .factory;
        requires static lombok;
    }
    

    此 Java 模块为库存核心域提供了更好的封装。请注意,我们还在导出 entityservicespecificationvo 包。它们都是核心域的一部分。

  5. 接下来,我们配置 status-sub-domain Maven 模块的 pom.xml 文件:

    <?xml version="1.0" encoding="UTF-8"?>
      <!-- Code omitted -->
      <artifactId>status-sub-domain</artifactId>
      <dependencies>
        <dependency>
          <groupId>dev.davivieira</groupId>
          <artifactId>inventory-core-domain</artifactId>
          <version>1.0-SNAPSHOT</version>
        </dependency>
      </dependencies>
    </project>
    

    我们声明了对 inventory-core-domain Maven 模块的依赖,因为我们使用核心域中存在的相同实体,在 status-sub-domain 子域 Maven 模块中提供状态信息功能。然而,当我们在状态信息上下文中时,相同的实体(例如 Router),可以有不同的含义(以及数据模型)。

  6. 最后,我们需要为 status_sub_domain 配置 module-info.java 文件:

    module status_sub_domain {
       exports dev.davivieira.topologyinventory.status;
       requires inventory_core_domain;
    }
    

    我们只导出一个包,并声明此模块依赖于 inventory_core_domain

    现在我们已经正确配置了 Maven 和 Java 模块,以帮助我们强制核心域和子域之间的边界,让我们来探索使用边界上下文。

让我们考虑拓扑和库存系统现在可以检查路由器的状态。为了隔离这种行为并建立此类活动的上下文,我们将在子域中创建一个名为 RouterInfo 的类:

package dev.davivieira.topologyinventory.status;
import dev.davivieira.topologyinventory.domain.entity.factory.RouterFactory;
import dev.davivieira.topologyinventory.domain.vo.IP;
import dev.davivieira.topologyinventory.domain.vo.Id;
import dev.davivieira.topologyinventory.domain.vo.Model;
import dev.davivieira.topologyinventory.domain.vo.RouterType;
import dev.davivieira.topologyinventory.domain.vo.Vendor;
public class RouterInfo {
    public String getRouterStatus () {
        var router = RouterFactory.getRouter(
                Id.withoutId(),
                Vendor.CISCO,
                Model.XYZ0004,
                IP.fromAddress("55.0.0.1"),
                null,
                RouterType.CORE);
        return "Router with "+router.getIp()+" is alive!";
    }
}

RouterInfo类中,我们有一个名为getRouterStatus的占位符方法,它只是用来说明Router实体可以在状态信息的上下文中具有不同的行为和数据模型。这使得将子域功能提供给应用程序和框架六边形变得非常简单。

让我们执行以下步骤,看看子域如何融入整体六边形系统:

  1. 我们首先在RouterManagementUseCase中添加一个新的方法定义:

    public interface RouterManagementUseCase {
        /** Code omitted **/
        String getRouterStatus();
    }
    

    getRouterStatus方法与子域集成以检索路由状态。

  2. 接下来,我们在RouterManagementInputPort中实现getRouterStatus

    @Override
    public String getRouterStatus() {
        var routerInfo = new RouterInfo();
        return routerInfo.getRouterStatus();
    }
    

    在这里,我们从子域获取RouterInfo对象的实例并调用getRouterStatus方法。

  3. 最后,我们在RouterManagementAdapter中实现端点:

    @Transactional
    @GET
    @Path("/get-router-status")
    @Operation(operationId = "getRouterStatus", description = "Get router status")
    @Produces(MediaType.TEXT_PLAIN)
    public Uni<Response> getRouterStatus() {
        return Uni.createFrom()
                .item(routerManagementUseCase
                .getRouterStatus())
                .onItem()
                .transform(
                  router -> router != null ?
                  Response.ok(router) :
                  Response.ok(null))
                .onItem()
                .transform(Response.ResponseBuilder::build);
    }
    

在这里,我们使用 RESTEasy Reactive 来实现/get-router-status端点,该端点将从子域获取路由状态信息:

$ curl -X GET http://localhost:8080/router/get-router-status

执行前面的curl命令会给我们以下输出:

Router with IP(ipAddress=55.0.0.1, protocol=IPV4) is alive!

这种实施领域驱动设计(DDD)元素,如子域和边界上下文,帮助我们理解如何将这些元素与六边形架构集成。使用 Maven 和 Java 模块,我们可以更强调核心领域和子域之间的边界。

现在,让我们将注意力转向应用程序六边形,这是端口和用例的领域。

创建端口和用例的需求

在领域六边形中对问题域进行建模投入了一些努力之后,下一步就是转向应用程序六边形,并定义系统如何启用满足来自领域六边形的业务相关操作的特性。参与者——可以是用户和其他系统——驱动这些行为。它们决定了系统的能力。

当我们开始实施应用程序六边形时,这个时刻至关重要,因为我们将开始从与领域模型不直接相关的方面进行思考。相反,这些方面可能与与其他系统通信的集成相关。但我们不应走得太远,以至于决定使用哪些技术。在实施应用程序六边形时,我们不做出与技术相关的决策。相反,技术问题是我们深入探讨框架六边形的一个主题。

我们使用用例来定义系统可以做什么以满足参与者的需求。不考虑具体的技术细节,我们可以声明,创建用例的好时机是我们需要表达参与者对系统的意图时。参与者的意图在塑造系统行为中起着基本的作用。通过使用用例,我们可以描述这样的行为。接下来,通过定义系统将如何实际实现参与者的目标,我们定义输入端口。输入端口可以立即实现,也可以稍后实现。然而,它们必须在您决定继续到框架六边形之前实现。如果您选择在实现输入端口之前实现框架六边形,将无法使框架六边形与应用程序六边形通信。换句话说,用例和端口是连接两个六边形的桥梁。

当涉及到输出端口时,我们无需过多担心,因为它们是框架六边形中输出适配器实现的接口。然而,如果我们有多个适配器类别,输出适配器可能会带来一些问题。接下来,我们将评估拥有多个适配器类别的一些后果。

处理多个适配器类别

在六边形架构的背景下,适配器帮助我们增加六边形系统对不同协议和技术的兼容性。在框架六边形中,我们最终决定系统将通过输入适配器如何暴露其功能,以及它将通过输出适配器如何与外部系统通信。

与应用和领域六边形中发生的情况类似,框架六边形被封装在其自己的 Java 模块中。这种模块方法帮助我们强制实施每个系统六边形之间的边界。从框架六边形的视角来看,将所有输入和输出适配器分组在同一模块内是很好的。尽管模块化可以帮助我们设定边界,但这不足以防止我们在处理多个适配器类别时可能面临的维护挑战。

我所说的适配器类别是指一种分类,用于将能够与特定技术集成的适配器分组。例如,在拓扑和库存系统中,我们有RouterManagementAdapterSwitchManagementAdapter输入适配器。这些适配器暴露 HTTP RESTful 端点。因此,这些输入适配器构成了提供 HTTP 支持的适配器类别,为六边形系统提供支持。如果我们想与其他技术集成,例如 gRPC,我们需要在支持暴露 gRPC 端点的适配器类别中创建一组新的适配器。

当处理输入适配器时,我们不会因为多个适配器类别为六边形系统中的不同技术提供支持而面临重大的维护负担。然而,如果我们有多个输出适配器类别,可能会出现一些问题。

使用输出适配器,我们可以将六边形应用与外部系统集成。但重要的是要注意,在每次新的集成中,我们需要提供翻译机制的地方。这些翻译帮助我们映射通过输出适配器传入和传出的数据。如果输出适配器的适配器类别变得过大,可能会潜在地造成维护问题。在这种情况下,我们需要为每个适配器类别保持多个翻译机制。

考虑以下场景。想象一个系统,最初所有的数据都由数据库提供服务。随着主系统的演变,开发者将其部分迁移到更小的子系统,以防止主系统变得过大。但在迁移过程中,某些用例无法完全迁移到新的子系统,导致主系统仍然需要从数据库和子系统获取数据,以履行某些业务规则。在这种情况下,主系统需要两个输出适配器:一个用于数据库,另一个用于子系统。由于迁移未完成,允许两个输出适配器服务于同一目的可能会潜在地增加维护成本。这种方法的主要问题之一是需要翻译来自数据库和子系统的领域模型数据。

因此,对于输入适配器,当我们采用多个适配器类别时,风险较低。然而,对于输出适配器来说,情况并非如此。这里的建议是要意识到维护多个输出适配器的翻译机制所必须做出的权衡。

结论——六边形之旅

软件开发中令人着迷的一点是,我们可以采用许多方法来实现相同的结果。这种自由增加了软件开发的乐趣,并促进了创造力的发展。创造力是解决复杂问题的巧妙方案背后的主要力量。这就是为什么我们应当在任何软件项目中都为创造力留出空间。但是,当与紧张的时间表和资源相结合时,自由和创造力应该得到管理,以便在不增加不必要复杂性的情况下产生有价值的软件。

我认为六边形架构是一种可以帮助我们管理这些不同需求的方法。它提供了一套明确的原理,以灵活且一致的方式组织系统代码。六边形方法提供了一个模型,以有组织且在一定程度上标准化的方式指导我们的创造力。

六角形架构并非适合所有人,也不一定适合每个项目。然而,那些寻求标准化软件开发实践方法的人会发现,六角形架构是一个有用的蓝图,可以帮助他们构建下一个软件项目。尽管如此,理解使用六角原则构建系统的相当复杂性是很重要的。如果项目是一个中等或大型、长期且高度可变性的系统,我相信六角形架构是一个确保系统长期可维护性的优秀选择。另一方面,如果我们谈论的是负责一两个小功能的小型应用程序,那么使用六角形架构就像是拿着枪杀蚂蚁。因此,你需要仔细评估情况,以确定六角形架构是否会给你的项目带来比问题更多的解决方案。

六角形架构并非是能神奇地解决你的技术债务和维护问题的银弹。这些问题更多地与你的保持事物简单的心态有关,而不是你选择的结构应用程序的软件架构。但是,如果你已经承诺保持简单和易于理解的态度,无论你处理的问题域多么复杂,六角形架构都可以帮助你解决这些问题。我鼓励你保持简单的心态,探索和扩展六角形架构的思想。对我来说,设计六角形系统一直是一个不断学习和有益的经历。我希望这同样对你也是如此。

让我通过真诚地感谢你陪伴我走过这段六角形之旅来结束这本书。

摘要

我们从探讨与领域驱动设计(DDD)相关的一些想法开始本章,并讨论了在直接进入开发之前理解我们的业务需求的重要性。我们还学习了商业模式画布和事件风暴。

在讨论领域驱动设计(DDD)时,我们了解到子域和边界上下文对于在领域六角形内建立清晰的边界是至关重要的。之后,我们讨论了用例和端口。我们了解到在开始构建框架六角形之前实现输入端口是至关重要的。

接下来,我们学习了拥有多个适配器类别对可维护性的影响,主要是在处理需要翻译机制的输出适配器时。最后,我们通过反思我们的六角形之旅和保持软件开发简单的重要性来结束这本书。

当使用 Quarkus 时,尤其是使用原生镜像功能,我们需要考虑构建原生可执行文件所需的大量内存和时间。如果你的 CI 环境受限,你可能会遇到由计算资源不足引起的问题。此外,请注意,编译原生镜像时编译时间会显著增加。如果你的优先级是更快的编译而不是更快的系统启动,你可能需要重新考虑使用原生镜像。我总是建议通过官方邮件列表和其他渠道检查 Quarkus 文档和 Quarkus 社区。这可以帮助你了解更多关于 Quarkus 的信息,并保持对常见问题和解决方法更新的了解。如果社区帮助不够,你可以寻求由 Red Hat 提供的 Quarkus 官方支持。

六边形架构为我们提供了开发健壮和可变系统原则。Quarkus 是一种前沿技术,我们可以用它将六边形原则应用于创建现代、云原生应用程序。通过将六边形架构与 Quarkus 相结合,我们可以产生出色的软件。我鼓励你们进行实验,进一步探索这种迷人组合的可能性。这本书的六边形之旅到此结束,但你们可以通过应用、调整和演变我向你们展示的想法开始新的旅程。

问题

  1. 我们可以使用哪些技术来理解我们的业务需求?

  2. 为什么我们应该采用子域和边界上下文?

  3. 在实现框架六边形之前,为什么定义用例和创建输入端口很重要?

  4. 对于输出适配器有多个适配器类别会有什么后果?

答案

  1. 商业模式画布和事件风暴。

  2. 子域和边界上下文帮助我们建立清晰的边界,以防止在领域模型中混淆实体的意义和关注点。

  3. 因为用例和输入端口是框架和应用六边形之间的桥梁。

  4. 如果我们有很多这样的机制,可能会导致难以维护的几种翻译机制。

posted @ 2025-09-11 09:43  绝不原创的飞龙  阅读(40)  评论(0)    收藏  举报