大多数代码库一开始都把框架放在中心:控制器调用服务,服务调用 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 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 用例和领域模型,没有任何框架或供应商代码。
- 左侧,驱动端: REST 控制器和测试驱动程序都调用入站端口 PlaceOrder。从核心的角度看,它们可以互换。
- 右侧,被驱动端: 用例调用两个出站端口。带空心箭头的虚线表示 Postgres 适配器和支付 API 适配器实现了这些端口,这也是代码依赖的方向。
- 最右侧: 真实的 Postgres 数据库和支付服务商,只有适配器知道它们。
- Markdown 块 写明了依赖规则,这是评审者唯一需要检查的事情。
测试变得更省力
大部分收益体现在测试上:
- 核心测试 使用内存适配器,几毫秒就能跑完。定价规则、状态转换和错误处理都可以在没有 I/O 的情况下测试。
- 适配器测试 针对真实技术检验单个适配器:用真实的 Postgres 测试 Postgres 仓储,用服务商的沙箱测试支付适配器。
- 少量端到端测试 用来证明各部分连接正确。
这种划分就是测试金字塔的具体落地,也是团队即使在小服务中也采用这种模式的原因。
增加一个新的入口
当第二个渠道出现时,入站端口就第一次显出价值。假设业务方希望除了网页之外,还能从合作伙伴的消息队列接收订单。你只需写一个消费者:读取消息,构造同样的 PlaceOrderCommand,调用同一个端口。校验、定价和支付逻辑都不用改,它们的测试也不用改。没有端口时,常见的结果是在消费者里出现第二份订单逻辑,几个版本之后就和第一份对不上了。
什么时候不值得
- 没有规则的 CRUD。 如果服务主要是在 HTTP 和一张表之间搬运数据,端口只是形式主义。
- 短命的代码。 原型和脚本收不回额外接口的成本。
- 永远只有一个适配器。 如果你永远不会更换数据库,而且本来就在真实数据库上测试,那么出站端口只是增加了一层收益很小的间接。即便如此,也要让核心不含框架代码。
六边形架构与洋葱架构、整洁架构是近亲。它们在分层术语上不同,但共享同一条依赖规则。
架构图上应该画什么
- 核心的边界, 画成一个只清楚地包住领域和用例的形状。
- 边界上的端口, 用核心对它们的叫法来命名。
- 外部的适配器, 标明是驱动还是被驱动,以及它们封装的技术。
- 被驱动适配器的依赖方向, 它与调用方向不同。
如果要看整个系统而不是单个服务,C4 模型图示例 展示了如何从容器放大到组件,微服务架构图 展示了这类服务在系统中的位置。支付网关集成图 则介绍了支付适配器背后发生的事情。
在 Archboard 中构建
- 使用 Google 或 GitHub 登录,并新建一张图。
- 添加基础节点包中的一个 六边形 表示应用核心,并加上一个 文本 标签。
- 在其中添加 C4 模型节点包中的 组件 节点表示用例和领域模型,再在边界上放置三个 组件 节点表示端口。
- 在外部添加 容器 节点表示驱动和被驱动适配器,再添加数据节点包中的 Postgres,以及一个表示支付服务商的 外部系统。
- 用带空心箭头的虚线画出实现关系,并添加一个基础节点包中的 Markdown 块,写明依赖规则。
这个示例共用 13 个图表节点,在 Free 套餐的限制之内。
常见问题
端口和适配器有什么区别? 端口是由核心拥有的接口。适配器是把这个接口连接到特定技术(比如 REST 框架或数据库)的代码。
什么是驱动适配器和被驱动适配器? 驱动适配器发起与核心的交互,比如控制器、消费者和测试。被驱动适配器由核心调用,比如仓储和 API 客户端。
六边形架构和整洁架构是一回事吗? 它们共享依赖规则:依赖指向领域。整洁架构和洋葱架构在核心内部加入了有名字的分层;六边形架构只区分内部和外部。
每个服务都需要六边形架构吗? 不需要。在有真实业务规则、并且有不止一种方式驱动或存储它们的地方,它才物有所值。对于很薄的 CRUD 服务,它大多只是多出来的文件。
事务应该放在哪里? 通常放在用例里,或者放在一个工作单元(unit of work)端口里,这样由核心决定哪些内容必须一起提交,由适配器决定如何提交。