六边形架构图:通过示例理解端口与适配器

用图讲清六边形架构:核心、入站与出站端口、驱动与被驱动适配器、依赖规则,以及一个完整的订单示例。

· 创始人兼开发者

发布于

六边形架构图:通过示例理解端口与适配器

大多数代码库一开始都把框架放在中心:控制器调用服务,服务调用 ORM,业务规则渗入这三者之中。于是,测试一条定价规则需要一个 Web 服务器和一个数据库,而更换支付服务商则意味着要改动决定订单价格的代码。

六边形架构(也叫端口与适配器)把这一切反了过来。本文讲解其思想和术语(端口、驱动与被驱动适配器、依赖规则),给出一个订单服务的架构图,并说明什么时候这种模式值得多出来的那些文件。

一句话概括

把业务逻辑放进一个对 HTTP、数据库和供应商一无所知的核心,其他一切都通过核心所拥有的接口与它连接。

Alistair Cockburn 于 2005 年在他的 六边形架构原始文章 中描述了这一模式。它明确的目标是:让应用可以同样地被用户、程序、自动化测试或批处理脚本驱动,并能脱离最终使用的数据库和设备,独立地进行开发和测试。六边形这个形状没有别的含义,只是为了在边上留出画多个端口的空间。

术语

术语 含义 订单服务中的例子
核心 用例和领域模型 PlaceOrder、Order、Money、定价规则
入站(驱动)端口 核心向外部提供的接口 PlaceOrder.execute(command)
出站(被驱动)端口 核心需要外部提供的接口 OrderRepository、PaymentGateway
驱动适配器 把外部输入转换成端口调用 REST 控制器、CLI、消息消费者、测试
被驱动适配器 用真实技术实现出站端口 Postgres 仓储、支付 API 客户端

把这一切串起来的是 依赖规则:源代码依赖指向内部。适配器依赖端口;端口和核心不依赖六边形之外的任何东西。运行时是核心调用支付网关,但在代码中,是支付适配器依赖核心的接口,而不是反过来。

一个完整的示例

下单需要保存订单并向客户收款。在核心中:

// ports, owned by the core
interface OrderRepository { save(order: Order): Promise<void> }
interface PaymentGateway { authorize(amount: Money, token: string): Promise<AuthResult> }

// use case
class PlaceOrder {
  constructor(private orders: OrderRepository, private payments: PaymentGateway) {}
  async execute(cmd: PlaceOrderCommand): Promise<OrderId> {
    const order = Order.create(cmd.items, cmd.customerId)   // domain rules
    const auth = await this.payments.authorize(order.total(), cmd.paymentToken)
    if (!auth.ok) throw new PaymentDeclined(auth.reason)
    await this.orders.save(order.markPaid(auth.id))
    return order.id
  }
}

在核心之外,PostgresOrderRepository 用 SQL 实现 OrderRepository,PaymentApiGateway 则通过 HTTP 调用、重试和错误映射实现 PaymentGateway。REST 控制器解析请求,构造一个 PlaceOrderCommand 并调用用例。测试用内存适配器做同样的事,不需要 Web 服务器,也不需要数据库。

映射到目录结构

当目录结构把架构明明白白地表达出来时,它就更容易保持:

src/
  core/
    domain/        Order, Money, pricing rules
    usecases/      PlaceOrder, CancelOrder
    ports/         OrderRepository, PaymentGateway
  adapters/
    http/          REST controllers (driving)
    postgres/      repository implementations (driven)
    payments/      payment API client (driven)
  main.ts          wiring: builds adapters and passes them to use cases

添加一条 lint 规则或一个架构测试:只要 core/ 下的任何代码从 adapters/ 或框架包中导入,就让构建失败。这条规则很容易被无意破坏,而自动化地执行它成本很低。

常见错误

  • 按技术形状设计的端口。 OrderRepository.executeQuery(sql) 只是换了个名字的数据库 API。应当按核心的需要来命名端口:save、findOpenOrdersFor(customer)。
  • 领域对象同时是 ORM 实体。 持久化框架的注解会把数据库拖进核心。在适配器中完成领域对象与持久化模型之间的映射。
  • 包含业务规则的适配器。 一个判断“超过 500 美元的退款需要审批”的支付适配器,已经把一条规则搬出了核心。让适配器只负责转换和传输。
  • 每个类一个端口。 端口是为边界而存在的,不是为核心内部的每个协作者准备的。

架构图

一个代表应用核心的六边形,其中有 PlaceOrder 用例、领域模型和三个端口。REST 控制器和测试驱动程序调用入站端口。用例调用 OrderRepository 和 PaymentGateway 端口,它们分别由 Postgres 适配器和支付 API 适配器实现。

14 个组件 · 10 个连接

在 Archboard 中打开
图表内容
  • PlaceOrder use case
  • Domain model
  • PlaceOrder port
  • OrderRepository port
  • PaymentGateway port
  • REST controller
  • Test driver
  • Postgres adapter
  • Payment API adapter
  • Postgres
  • Payment provider
  • calls
  • implements
  • SQL
  • HTTPS
采用六边形架构的订单服务。核心包含 PlaceOrder 用例和领域模型;它调用由 Postgres 和支付服务商适配器实现的出站端口,并由 REST 控制器或测试驱动程序通过入站端口驱动。

这张图的读法如下:

  • 六边形 是应用核心:PlaceOrder 用例和领域模型,没有任何框架或供应商代码。
  • 左侧,驱动端: REST 控制器和测试驱动程序都调用入站端口 PlaceOrder。从核心的角度看,它们可以互换。
  • 右侧,被驱动端: 用例调用两个出站端口。带空心箭头的虚线表示 Postgres 适配器和支付 API 适配器实现了这些端口,这也是代码依赖的方向。
  • 最右侧: 真实的 Postgres 数据库和支付服务商,只有适配器知道它们。
  • Markdown 块 写明了依赖规则,这是评审者唯一需要检查的事情。

测试变得更省力

大部分收益体现在测试上:

  • 核心测试 使用内存适配器,几毫秒就能跑完。定价规则、状态转换和错误处理都可以在没有 I/O 的情况下测试。
  • 适配器测试 针对真实技术检验单个适配器:用真实的 Postgres 测试 Postgres 仓储,用服务商的沙箱测试支付适配器。
  • 少量端到端测试 用来证明各部分连接正确。

这种划分就是测试金字塔的具体落地,也是团队即使在小服务中也采用这种模式的原因。

增加一个新的入口

当第二个渠道出现时,入站端口就第一次显出价值。假设业务方希望除了网页之外,还能从合作伙伴的消息队列接收订单。你只需写一个消费者:读取消息,构造同样的 PlaceOrderCommand,调用同一个端口。校验、定价和支付逻辑都不用改,它们的测试也不用改。没有端口时,常见的结果是在消费者里出现第二份订单逻辑,几个版本之后就和第一份对不上了。

什么时候不值得

  • 没有规则的 CRUD。 如果服务主要是在 HTTP 和一张表之间搬运数据,端口只是形式主义。
  • 短命的代码。 原型和脚本收不回额外接口的成本。
  • 永远只有一个适配器。 如果你永远不会更换数据库,而且本来就在真实数据库上测试,那么出站端口只是增加了一层收益很小的间接。即便如此,也要让核心不含框架代码。

六边形架构与洋葱架构、整洁架构是近亲。它们在分层术语上不同,但共享同一条依赖规则。

架构图上应该画什么

  • 核心的边界, 画成一个只清楚地包住领域和用例的形状。
  • 边界上的端口, 用核心对它们的叫法来命名。
  • 外部的适配器, 标明是驱动还是被驱动,以及它们封装的技术。
  • 被驱动适配器的依赖方向, 它与调用方向不同。

如果要看整个系统而不是单个服务,C4 模型图示例 展示了如何从容器放大到组件,微服务架构图 展示了这类服务在系统中的位置。支付网关集成图 则介绍了支付适配器背后发生的事情。

在 Archboard 中构建

  1. 使用 Google 或 GitHub 登录,并新建一张图。
  2. 添加基础节点包中的一个 六边形 表示应用核心,并加上一个 文本 标签。
  3. 在其中添加 C4 模型节点包中的 组件 节点表示用例和领域模型,再在边界上放置三个 组件 节点表示端口。
  4. 在外部添加 容器 节点表示驱动和被驱动适配器,再添加数据节点包中的 Postgres,以及一个表示支付服务商的 外部系统。
  5. 用带空心箭头的虚线画出实现关系,并添加一个基础节点包中的 Markdown 块,写明依赖规则。

这个示例共用 13 个图表节点,在 Free 套餐的限制之内。

常见问题

端口和适配器有什么区别? 端口是由核心拥有的接口。适配器是把这个接口连接到特定技术(比如 REST 框架或数据库)的代码。

什么是驱动适配器和被驱动适配器? 驱动适配器发起与核心的交互,比如控制器、消费者和测试。被驱动适配器由核心调用,比如仓储和 API 客户端。

六边形架构和整洁架构是一回事吗? 它们共享依赖规则:依赖指向领域。整洁架构和洋葱架构在核心内部加入了有名字的分层;六边形架构只区分内部和外部。

每个服务都需要六边形架构吗? 不需要。在有真实业务规则、并且有不止一种方式驱动或存储它们的地方,它才物有所值。对于很薄的 CRUD 服务,它大多只是多出来的文件。

事务应该放在哪里? 通常放在用例里,或者放在一个工作单元(unit of work)端口里,这样由核心决定哪些内容必须一起提交,由适配器决定如何提交。

免费开始 — 1 个席位,无需信用卡。

相关文章

← 返回博客

一分钟打开画布

使用 Google 或 GitHub 登录。单席位免费,团队加入后再升级。