理解六边形建筑
由 Mux 主办的 DEV 全球展示挑战赛:展示你的项目!
Alistair Cockburn先生非常友好地对这篇文章提出了他的意见。根据他的意见,我做了一些修改,并在文中标出了修改的部分。
六边形架构(也称为端口和适配器,或本文中的 HA)模式的目标,正如其作者Alistair Cockburn 先生最初提出的那样:
允许应用程序同样由用户、程序、自动化测试或批处理脚本驱动,并允许其在与最终运行时设备和数据库隔离的情况下进行开发和测试。
我们在之前的文章中总结了它的工作原理:
六边形架构提倡将应用程序内部逻辑与其与外部世界的交互分离。
我们的应用程序用六边形表示。六边形之外是外部参与者:与图形界面交互的用户、消息系统、数据库、供应商 API 等。
应用程序定义了它与外部参与者交互的方式。每个这样的契约都是一个端口。端口用应用程序语言表达,就像用例的一部分。适配器是外部参与者通过端口与应用程序建立的连接。它们是使用不同技术与应用程序交互的可互换方式。
2024年8月2日编辑:上文错误地指出适配器是端口的实现,这种说法只对了一半。现已修正。
在这篇文章中,我们将深入探讨该模式的每个部分,并将它们与代码示例联系起来。
驾驶和乘车
在深入探讨更多定义之前,让我们先从与外部参与者的交互角度来考虑一下我们的应用程序是如何工作的。
有些参与者会使用我们的应用程序(例如,调用我们的 REST API)。而另一些参与者则由我们的应用程序使用(例如,数据库)。
我们将前者称为驱动型参与者,后者称为被驱动型参与者。它们与我们的应用程序的交互方式将决定我们如何定义应用程序为它们设定的契约。
类似地,这种区别也适用于端口、适配器和应用程序的各个方面。
港口
端口是应用程序与外部参与者交互时签订的合约。根据参与者的类型(驱动方或被驱动方),该合约的形式会有所不同。
驱动程序端口定义了驱动程序参与者如何与应用程序交互。因此,它们指定了要请求应用程序执行参与者感兴趣的操作,必须提供哪些输入。
驱动端口定义了应用程序如何与驱动 Actor 交互。因此,它们明确规定了应用程序希望从 Actor 获得什么。
由于端口是由应用程序定义的,因此它们使用纯粹的“应用程序语言”编写,从而消除了与任何具体技术细节的耦合。我们的应用程序无需关心任何具体技术,因为端口已将其与它们隔离。
举例!
- 创建用户需要他们的邮箱和密码。此用例的驱动程序端口将这些数据定义为参与者必须遵守的协议才能使用该应用程序。无论您是谁,如果您希望我创建用户,您都必须提供这些信息。
- 在我们的应用程序中,用户是通过
User实体进行建模的。用于存储用户的驱动端口定义了必须有一种存储User实体的方法。
适配器
参与者与应用程序交互。端口定义了这些交互如何发生。适配器则连接参与者和应用程序,使它们之间能够进行通信。
适配器(这倒是出乎意料)会将不同技术的细节适配到应用程序预先通过端口定义的环境中。在这个过程中,这些细节不会渗透到应用程序内部,从而保持代码的简洁性。如果参与者需要任何响应,则由适配器负责生成。
沿用之前的示例,我们创建用户的端口指定应用程序需要电子邮件地址和密码。我们可能拥有:
-
对于“与图形界面交互的用户”这一角色,需要一个HTTP REST API适配器,该适配器接收HTTP请求中的值,并向客户端返回202响应。
-
对于“公司CRM系统”参与者,需要一个消息代理适配器,该适配器从消息有效负载中获取值。该适配器还负责发送消息的确认信息(ACK)。
-
为了与“旧系统”参与者进行一些奇怪的集成,需要一个适配器来读取该系统每晚上传到我们 FTP 服务器的 CSV 文件中的值。
将所有内容连接起来:配置器
2024年8月2日编辑:帖子第一版没有提到配置器。
除了我们的应用程序、它定义的端口以及连接这些端口的不同适配器之外,我们还需要一些东西来确保在正确的时间使用正确的元素。这就是该模式中所说的配置器。
大多数情况下,我们在构建软件应用程序时都会使用框架,例如 Ruby on Rails、Spring、.NET、Django 等。框架通常会负责连接各个组件,例如提供服务容器。在基于端口和适配器的应用程序中,这个组件就是配置器。
我们可能会认为这是理所当然的,但我们必须意识到,当不使用提供此类功能的框架时,我们必须自己实现一种机制,为每个请求向六边形提供正确的适配器。
好处
就目前所涵盖的内容而言,我们已经取得了显著的成果。
首先,我们的应用程序可能与技术无关。它与外部的通信是通过端口定义的,适配器负责将每种技术的特性转换为端口定义的格式,也就是应用程序能够理解的格式。
实际上,应用程序并不了解数据库结构,这由数据库适配器负责处理。或者,我们可能从 HTTP 请求或 CLI 命令获取输入数据,数据格式可以是 JSON 或 protobuff,数据来源可以是 RabbitMQ 或 Kafka。
其次,由于每个端口都充当入口点(或出口点,取决于端口是驱动端口还是被驱动端口),我们可以使用任意数量的不同适配器。因此,只需插入一个新的适配器,即可扩展我们的应用程序。这与策略模式非常相似。
还有第三个巨大的好处:检测。
为了在无需部署任何实际技术的情况下测试应用程序的行为,我们可以(而且肯定必须)实现一个测试适配器来替代实际技术。
对于驱动程序端口,我们不需要实际的消息代理,也不需要模拟 HTTP 请求。测试适配器只需按照端口约定调用应用程序即可。
对于驱动型端口,我们不需要实际运行的数据库。我们可以使用更快、更简单的内存实现。
事实上,端口 + 测试适配器的组合可以让你继续进行应用程序的实现,而无需担心集成将实际使用哪种技术。
这些决定可以推迟到最后一个合理的时机,避免过早地依赖某种技术,以免因错误的原因影响后续设计。
例如,我们可以使用内存持久化适配器,而无需在早期阶段就选择具体的数据库。我们可以在设计应用程序时不受所选数据库的限制,待系统进一步开发后,再选择最合适的数据库。
端口和适配器的命名
高可用性命名没有固定模式。Alistair Cockburn 是用例的坚定拥护者,他对命名模式的解释也强调要以用例为导向来命名端口和适配器。然而,这样做与否并不会改变命名模式所解决的问题。命名模式的目的是解决反复出现的问题,而不是使用某种特定的措辞。
Alistair 建议按照以下命名模式来命名端口For[Doing][Something]。例如,驱动端端口: `driver_ports` ForPlacingOrders、ForConfiguringSettings`driver_ports` 等;被驱动端端口: `driver_ports` ForStoringUsers、ForNotifyingAlerts`driver_ports` 等。这很棒,但可惜的是,我还没在实际应用中见过这种做法。
更常见的情况是,驱动程序端口被识别为PlaceOrder或,ConfigureSettings并且被驱动为UserRepository和AlertNotifier。
在适配器方面,诀窍在于引用我们正在适配的技术。我们可以使用CliCommandForPlacingOrders`T` 或 `T` MysqlDatabaseForStoringUsers。如果不遵循 Cockburn 的命名模式,我们可以使用 ` PlaceOrderApiControllerT` 或 `T` RabbitMqAlertNotifier。还有一种模式是添加Using[Technology]后缀(感谢Frank de Jonge 的贡献),结果为 `T`PlaceOrderUsingRestApi或 `T` UserRepositoryUsingPostgreSql。
找到你用起来顺手的命名规则,并坚持使用。记住,命名越能体现架构特性越好。
什么是“应用程序”?
2024年8月2日编辑:进行了编辑,使其更加简洁。
关于 HA 的文献中通常不会涵盖的一个方面是应用或六边形究竟是什么,这可能会让新手感到沮丧。
高可用性 (HA) 应用是该模式的一个组成部分。端口位于其边界。因此,适配器位于应用外部。HA 的核心在于通过边界隔离应用。
外部世界所感知到的应用是整个系统,包括HA应用、适配器、配置器以及运行该系统所需的任何其他代码或文件。
要让系统运行,我们需要一些高可用性 (HA) 模式之外的工具。这被称为“行走骨架”:即系统中能够运行系统所需的最小代码量。使用框架时,即使它内置的功能远超运行所需的最小功能,我们也可以将其视为行走骨架。为了学习目的而简化的高可用性示例可能只需要一个小型脚本作为行走骨架。
值得一提的是,“行走骨架”的概念是由 Alistair Cockburn 提出的。软件行业确实应该感谢他和他的工作。如果想深入了解这个概念,我强烈推荐《面向对象软件开发:测试驱动》(Growing Object-Oriented Software, Guided by Tests)这本书,其中有一章专门讨论了这个概念。
六边形架构中的请求生命周期
现在我们了解了所有组成部分,就可以预见应用高可用性技术构建的系统是如何工作的。
场景:公司客户关系管理系统 (CRM) 创建了一个新客户。根据业务策略,当这种情况发生时,我们会为该客户创建一个用户,发送电子邮件通知她,并将用户创建信息同步到其他系统,以便其他系统能够响应此操作。我们的应用程序负责用户创建,并提供一个 REST API,供 CRM 系统调用。
这几乎是任何系统都会提出的标准请求,没有什么特别复杂的地方。
CRM 是驱动型外部参与者。我们的应用程序定义了CreateUser驱动端口,该端口包含一个 REST API 适配器。CreateUserUsingRestAPI此外,还有一个UserRepository用于用户存储的驱动端口,以及另一个端口UserCreatedNotifier。它们的适配器分别是UserRepositoryUsingPostgres:。UserCreatedNotifierUsingEmailUserCreatedNotifierUsingRabbitMQ
我们的应用程序使用我们选择的框架构建,它接收到请求后会检测到请求是通过 HTTP 协议到达的。它会将我们为处理此请求而定义的服务中的适配器连接起来并执行该请求。
这样一来,应用程序就不会受到 HTTP 请求负载、内容类型或标头的污染。适配器会将所有这些都转换成端口定义的内容。
在驱动程序端,应用程序无需了解用于持久化用户的数据库表,也无需了解 RabbitMQ 消息的有效负载应该是什么。端口定义了它们将如何持久化用户以及何时创建用户,而适配器则负责将应用程序定义的指令转换为每种技术所需的格式。
代码示例
在深入代码之前,有一点必须强调:HA 仅仅关乎端口和适配器。
它并未规定应用程序的内部组织结构。它没有规定应用程序应该有多少层,甚至没有规定是否应该使用多层结构。
命令总线和处理程序、领域驱动设计 (DDD) 模式、洋葱架构和大写字母表示的“整洁架构”、大块泥球等等,都是实现应用六边形架构的应用程序内部实现的有效方法。但该模式本身并没有规定必须如何实现这些方法。
因此,以下示例仅用于说明端口和适配器如何协同工作,而非六边形结构的正确做法。
现在,我们“创建用户”用例的端口可能如下所示:
自从我写完最初的帖子后,我意识到第一个代码示例并不准确。驱动程序适配器使用应用程序,而这个示例只是建议应用程序使用它们。虽然我们都能获得相同的好处,但第二个代码示例才是真正应用六边形架构的示例。
interface CreateUser {
buildRequest(): CreateUser;
}
interface UserRepository {
add(User user): void;
}
interface UserCreatedNotifier {
notify(UserCreated event): void;
}
这些适配器是使用这些接口,并根据其名称所暗示的不同技术进行实现的接口。
我们的应用程序(就高可用性而言)有一个用于创建用户的服务,该服务依赖于用例中涉及的端口:
class CreateUserHandler {
private CreateUser createUser;
private UserRepository userRepository;
private array<UserCreatedNotifier> notifiers;
fn handle(): void
{
var request createUser.buildRequest();
var user = new User(request.email(), request.password());
userRepository.add(user);
notifiers.each(x => x.notify(new UserCreated(user.email())));
}
}
我们的框架配置为针对当前请求注入正确的实现(适配器),并调用该handle方法。
我们可以注意到,使用测试适配器,我们可以在不依赖实际基础设施服务的情况下测试处理程序的业务逻辑:它TestCreateUser可以提供预先准备好的测试值,InMemoryUserRepository不需要运行任何底层数据库系统等等。
另一种方法是不将驱动程序端口定义为接口,而是定义为适配器必须提供给应用程序的数据结构。
框架能够很好地识别驱动参与者使用的技术,我们通过调用正确的适配器将任务委托给它们。适配器会将请求契约传递给应用程序。这种方法与“驱动程序端口即接口”的方法具有相同的优势,即将驱动程序适配器的实现委托给框架的内置工具:
# replaces the interface
class CreateUser {
public readonly string email;
public readonly string password;
}
# the adapter is a framework controller
class CreateUserController {
private CreateUserHandler handler;
fn createUser(HttpRequest request) {
handler.handle(
new CreateUser(
request.payload.get('email'),
request.payload.get('password')
)
);
}
}
# the application no longer expects the port as dependency, but as a parameter
class CreateUserHandler {
private UserRepository userRepository;
private array<UserCreatedNotifier> notifiers;
fn handle(CreateUser request): void
{
var user = new User(request.email(), request.password());
userRepository.add(user);
notifiers.each(x => x.notify(new UserCreated(user.email())));
}
}
何时以及何时不
模式定义了解决常见问题的成熟方法。HA 解决的问题是,当应用程序与具体技术耦合过强时,维护、测试和演进就会变得困难。但“耦合过强”是一个非常依赖上下文且主观的衡量标准。适用于某个团队或项目的方法,可能并不适用于其他团队或项目。
此外,环境会随着时间推移而变化。今天合理的决定,明天可能就不合理了。
我个人认为,HA 实际上非常简单,它带来的负载也很小,因此对于任何全新项目,我都会默认采用这种方法。使用端口抽象技术细节感觉很自然。记住:HA 只是端口和适配器,它并不一定意味着领域驱动设计 (DDD)、总线、Presenter 等。
然而,某些情境因素可能导致不采用这种模式。如果应用程序预计不会扩展且已具备良好的测试覆盖率,那么将其迁移到高可用性架构可能并不值得。从更社会化的角度来看,对于核心框架开发人员团队(这确实是一个合理的观点),切换到将框架从应用程序核心剥离的架构可能会造成诸多摩擦,而这些摩擦可能不足以弥补预期收益。
一如既往,没有万能的秘诀。建议是尝试,充分探索,了解它的优点和缺点,然后在合适的时候选择它。
总结
希望这篇文章能帮助你了解六边形架构模式。或许你会感到有些困惑,因为它实际上非常抽象。我们开发者(人!)倾向于寻找现成的解决方案,无需过多思考就能应用,而六边形架构在这方面却相当宽松。它更像是一套避免出现棘手问题的指导原则,而不是一套指导应用程序构建的方法。
对于不那么抽象的指导原则,还有其他一些模式可以帮助我们更好地“按部就班”。HA 并不在意你是否使用这些其他模式,它只关心保护应用程序的边界。
在接下来的文章中,我们将探讨在遵循高可用性原则的前提下,设计应用程序内部架构的几种可能方法。希望它们不会像这篇文章一样耗时。
鸣谢
我使用的图表软件是Excalidraw ,并借鉴了Youri Tjang、Drwn.io、David Luzar和Ana Clara Cavalcante 的库。
封面图片来自 Penny Richards,根据知识共享许可协议发布。您可以在这里找到它。
文章来源:https://dev.to/xoubaman/understanding-hexagonal-architecture-3gk




