“端口—适配器”模式的概念(1)
Summary
最近看了下 DDD(领域驱动设计)和在 Go 中的应用。目前看来,整洁架构(Clean Architecture)和 “端口-适配器架构”(Ports and Adapters Pattern,又叫六边形架构 Hexagonal Architecture)是相对比较成熟的方案了。而后者的概念比较复杂一些,于是在概念上进行一些梳理和澄清。本文是第一部分。
1.- INTRODUCTION
“端口—适配器”是一个对象结构模型,由 Dr. Alistair Cockburn 在 2005 年所写的一篇文章中提出。
如果你在想……“这文章不会太老了点吧?在软件开发持续进化,新技术或新框架每天都层出不穷,干掉我们昨天还在用着的这个时代,它还有什么价值?”很好,答案就在问题之中。“端口—适配器”是一个促进从技术和框架中解耦的模式。所以答案是不,它并不过时。好东西是永恒的。就像酒一样,愈陈愈香。
“端口—适配器”的主要思想是定义了应用程序的架构,这样它就能通过不同类型的客户端(人、测试用例、其他应用,等等)来运行,而且它能被从应用程序所依赖的真实世界的外部设备(数据库、服务端、其他应用等)来隔离测试。
让我们来看看是如何做到的。
2.- THE ARCHITECTURE
这一节我们会看到“端口—适配器”模式的要素和它们之间的关系。
2.1 - THE HEXAGON
“端口—适配器”模式把应用程序描述为一个封闭区域。
该封闭区域在由 Alistair Cockburn 来描绘应用程序时选择的是一个六边形,这也是该模式被称为“六边形架构”的原因。
我个人更喜欢“端口—适配器”的名称,因为它指出了架构的关键要素,如同我们将要看到的。另一方面,用来描绘应用程序的图形并不是那么重要。然而,看起来“六边形架构”的名称更加广为人知。
六边形是应用程序自身。说“六边形”跟说“应用程序”是一个东西,从现在起二者会被任意使用。
在六边形内,我们只放置那些应用程序尝试解决的重要业务问题。
六边形包含业务逻辑,而不关心任何技术,框架或者真实世界中的设备。因此应用程序是与技术无关的。
“端口—适配器”模式并未说明六边形内部结构的任何东西。你可以使用分层,你可以使用特性组件,你可以使用意大利面条式的代码,你可以使用“大泥球”,你可以使用 DDD 战术模式,你可以使用简单的 CRUD。一切取决于你。
2.2 - ACTORS
在六边形外,是任何应用程序与之交互的真实世界的东西。这些东西包括人,其他应用程序,或者任何硬件/软件设备。它们是“参与者”。我们可以说这些参与者是应用程序的环境。
参与者被环绕分布在六边形外,根据“谁”触发了应用程序和参与者之间的交互:
- 位于左/上一侧的参与者是“驱动者”(Drivers),或者主角(Primary Actors)。交互由该参与者触发。驱动者就是与应用程序交互以达成一个目标的参与者。驱动者是应用程序的使用者(无论人或设备)。
- 位于右/下一侧的参与者是“从动者”(Driven Actors),或者配角(Secondary Actors)。交互由应用程序来触发。从动者提供应用程序所需的某些功能以实现业务逻辑。
有两种类型的从动者:
- Repository:应用程序除了向其发送信息外,也可以从其获取信息。例如,数据库或任何其他存储设备。
- Recipient:应用程序只向其发送信息,然后就忘掉它了。例如,SMTP 服务器发送邮件。
下图展示了在驱动和从动两侧的一些参与者的示例:
这些关于主(驱动者)和次(从动者)参与者的概念涉及到了使用场景。
所以,要知道在应用程序跟参与者交互中,该参与者是哪种类型,问问你自己,“谁”触发了对话。如果答案是参与者,它就是驱动者。如果答案是应用程序,那么该参与者就是从动者。
2.3 - PORTS
参与者与应用程序之间的交互根据它们与应用程序交互的原因而组织在六边形的边界处。 具有给定目的/意图的每组交互就是一个端口。
端口应根据其用途而不是任何技术来命名。 因此,为了命名端口,我们应使用以“ing”结尾的动词,并应说“此端口用于…ing某物”。例如:
- 这个驱动者端口是用于“添加(adding)商品到购物车”。
- 这个从动者端口(repository)是用于“获取(obtaining)订单信息”。
- 这个从动者端口(recipient)是用于“发送(sending)提醒”。
端口是应用程序的边界,上面的图例中端口就是六边形的边。从外部世界,参与者只能跟六边形的端口交互,它们不应该访问到六边形的内部。端口是应用程序提供给外部世界的接口,允许参与者与应用程序交互。所以,应用程序应该遵循Information Hiding Principle。一件值得注意的重要事情是,端口属于应用程序。
驱动者端口为外部世界的驱动者提供了应用程序的功能。因此,驱动者端口被认为是应用程序的用例边界。 它们是应用程序的 API。
依赖于功能分组时所采用的粒度,我们可以有一个端口作为很多用例的接口,或只作为少数用例的接口。如果我们期望遵循Single Responsibility Principle,那么将会有很多端口,每个针对一个用例。这种场景下一个更好的选择是为端口使用 command bus 设计模式,对每个用例使用一个 command handler。同样的思想可以被用在查询上,这样我们将也会喜欢 CQRS 模式。我们会有一个端口负责执行命令,另一个端口负责执行查询。
从动者端口是功能的接口,该功能是应用程序实现业务逻辑所需的。这些功能是由从动参与者提供的。所以从动者端口是应用程序所需的 SPI(Service Provider Interface)。从动者端口会类似于 Required Interface。
2.4 - ADAPTERS
参与者通过使用特定技术的适配器与六边形的端口进行交互。一个适配器就是一个软件组件,允许用一种技术与六边形的端口来交互。给定一个端口,对于我们想要使用的每种技术都可以有一个适配器。适配器是在应用程序外部的。
驱动者适配器使用驱动者端口的接口,把一种特定技术的请求转换为对驱动者端口技术无关的请求。
下图展示了某些驱动者适配器的示例:
- 一个自动化测试框架:把测试用例转换为对驱动者端口的请求。
- 一个 CLI:把输入文本转换到 console 中。
- 一个桌面应用程序 GUI:通过图形组件转换被触发的事件。
- 一个 MVC Web 应用程序:Controller 接收从 View 中由用户请求的行为,并把其转换为对驱动者端口的请求。
- 一个 REST API controller:转换 REST API 请求。
- 一个事件订阅者:把来自消息队列的消息(事件)转换到所订阅的应用程序。
对于每个驱动者端口,都应该至少有两个端口:一个用于真实驱动者的执行,另一个用于测试该端口的行为。
从动者适配器实现一个从动者端口的接口,把该端口与技术无关的方法转换为特定技术的方法。
下图展示了从动适配器的某些示例:
- 一个 mock 适配器:模仿真实配角的行为。例如,内存数据库。
- 一个 SQL 适配器:实现一个从动者端口以通过 SQL 数据库持久化数据。
- 一个 email 适配器:实现一个从动者端口以通过发送邮件提醒人们。
- 一个 App-To-App 适配器:实现一个从动者端口以通过向远程应用程序请求来获取某些数据。
- 一个事件发布者:实现一个从动者端口以通过发送事件到消息队列来发布它们,这样它们对订阅者就可用了。
对于每个从动者端口,我们都应该编写至少两个适配器:一个用于真实世界的设备,另一个是 mock,用于模拟真实行为。
适配器最终所做的就是把一个接口转换到另一个,所以我们可以使用 Adapter Design Pattern 来实现它。
每个端口要使用哪种适配器是在应用程序启动时要配置的。这赋予了该模式以灵活性,因此我们能够在每次运行应用程序时从一种技术切换到另一种。如果针对从动者端口我们选择了一个测试驱动者和 mocks 适配器,应用程序就可以进行隔离性测试。
2.5 - SUMMARY
正如我们看到的,该架构的要素有这些:
- 六边形 ==> 应用程序
- 驱动者端口 ==> 应用程序提供的 API
- 从动者端口 ==> 应用程序所需的 SPI
- 参与者 ==> 与应用程序交互的环境设备
- 驱动者 ==> 应用程序的用户(无论人还是硬件/软件 设备)
- 从动者 ==> 提供应用程序所需的服务
- 适配器 ==> 调整特定技术以适配应用程序
- 驱动者适配器 ==> 使用驱动者端口
- 从动者适配器 ==> 实现从动者端口
除了这些要素之外,还有一个 Composition Root(也被 Robert C. Martin 称为“Main Component”,在他的 Clean Architecture: A Craftsman’s Guide to Software Structure and Design 书中)。这个组件会在启动时运行并构建整个系统,会做如下事情:
- 它初始化并配置环境(数据库、服务,等等)
- 对于每个从动者端口,它选择一个实现了该端口的从动者适配器,并创建一个该适配器的实例。
- 它创建一个应用程序的实例,将从动者适配器的实例注入到应用程序构造函数中。
- 对于每个驱动者端口:
- 它选择一个使用该端口的驱动者适配器,并创建其实例,把应用程序的实例注入到该适配器构造函数中。
- 它运行该驱动者适配器的实例。
2.6 - EXAMPLE
一个 Web 接口的简单应用程序,由公司雇员使用来相互分配任务。当一个雇员被分配一个任务时,应用程序向他/她发送一封邮件。