26.5 六边形架构 (端口与适配器)
一句话
六边形架构把系统切成内部应用核心 + 端口(接口)+ 适配器(实现),并且对"谁调用你"和"你调用谁"一视同仁:同一个核心可以同时挂 HTTP、CLI、MQ、定时任务入口和 MySQL、Redis、第三方 API 出口,换掉任何一个都不动核心。
三个词说清全部
| 概念 | 是什么 | 谁定义 | 例子 |
|---|---|---|---|
| 应用核心 | 用例编排 + 领域规则 | — | CancelOrder、TransferUseCase |
| 端口 (port) | 核心与外界交互的能力契约(就是接口) | 核心 | OrderRepo、EventBus、Clock |
| 适配器 (adapter) | 把外部协议/技术翻译成端口调用 | 外层 | HTTPAdapter、MySQLRepo、KafkaBus |
| 驱动适配器(driving / primary / 左侧) | 外部主动调用核心 | 外层 | HTTP handler、CLI、MQ consumer、定时任务 |
| 被驱动适配器(driven / secondary / 右侧) | 核心主动调用外部 | 外层 | Repository 实现、消息生产者、第三方客户端 |
驱动适配器(左侧:调用核心) 被驱动适配器(右侧:被核心调用)
HTTP ──▶ ┌──────────────┐ ┌──────────────┐ ◀── OrderRepo ── MySQL
CLI ──▶ │ HTTPAdapter │ │ MySQLRepo │
MQ ──▶ │ CLIAdapter │ ├──────────────┤ ◀── OrderRepo ── 内存
定时 ──▶ │ MQConsumer │ │ MemoryRepo │
└──────┬───────┘ ├──────────────┤ ◀── EventBus ─── Kafka
│ │ KafkaBus │
▼ └──────▲───────┘
┌──────────────┐ │
│ 应用核心 │─────────────────────┘
│ CancelOrder │ 核心通过端口调用,实现由右侧注入
│ 领域规则 │
└──────────────┘
🔑 六边形最本质的一句话:传统分层里 UI 在上、DB 在下,地位不同;六边形把它们拉到左右两侧,地位相同——都是"外部",都通过适配器接入。
"六" 这个数字不重要
Alistair Cockburn 的原意是:画几个边都行,六边形只是方便画——上面接入口、下面接存储、左右接其他系统。真正重要的是两条:
- 内部与外部之间只有端口,没有直连;
- 两侧对称:入口和出口都是"适配器 + 端口",没有谁比谁更"底层"。
⚠️ 把"六边形"理解成"必须有 6 个适配器"或"必须画成六边形",是把比喻当规范。画成八边形、十条边,架构不变。
对称性带来的实用产出:入口/出口清单
落地六边形最有价值的一步不是画图,而是把适配器列全:
| 入口(驱动) | 出口(被驱动) |
|---|---|
| HTTP API(App / 小程序 / 开放平台) | 主库(MySQL) |
| 后台管理界面 | 缓存(Redis) |
| 定时任务(对账、超时关单) | 消息队列(Kafka / RocketMQ) |
| 消息消费者(支付回调、库存变更) | 第三方支付 / 短信 / 物流 API |
| CLI / 运维脚本 | 对象存储(OSS / S3) |
| gRPC(内部服务调用) | 搜索引擎(ES) |
列完之后你会发现两类问题:某些入口偷偷共享了同一个 handler 里的业务判断(该收进核心),某些出口直接写在用例里(该抽端口)。
端口粒度:按"角色"分,不按方法分
| 粒度 | 例子 | 问题 |
|---|---|---|
| 一方法一端口 | type FindOrderPort interface { Find(...) } | 端口爆炸,构造函数参数跟着爆炸 |
| 一角色一端口(推荐) | OrderRepo(Find / Save / Delete) | 内聚,替换成本低,fake 好写 |
| 上帝端口 | type Storage interface { 20 个方法 } | 假接口:没人能完整实现,测试要写 20 个空方法 |
判断粒度只问一句:这些方法是不是总是一起变? 一起变 → 同一个端口;分开变(比如"读"走缓存、"写"走主库)→ 拆成两个端口,各给各的适配器。
💡 输入端口(driving port)通常不需要额外抽接口:一个用例就是一个方法,直接
*CancelOrder注入给 handler 即可。需要接口的场景只有三种:要加装饰器(日志、指标、权限)、要多实现(灰度两版逻辑)、要给入口写契约测试。不要为对称而对称。
代码:一个核心,两个入口,两个出口
go run main.go 直接跑。注意 CancelOrder 的 import 里没有 net/http,也没有任何数据库驱动:
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"time"
)
// ========== 核心(应用):不认识 HTTP、CLI、数据库、消息队列 ==========
type Order struct {
ID string
Status string // CREATED / PAID / SHIPPED / CANCELLED
Amount int64
Version int
}
var (
ErrNotFound = errors.New("订单不存在")
ErrShipped = errors.New("已发货,不能取消")
ErrEmptyReason = errors.New("取消原因不能为空")
)
type CancelCommand struct{ OrderID, Reason string }
type CancelResult struct{ OrderID, Status string }
// 端口:由核心定义,形状按核心需要来长
type OrderRepo interface {
Find(ctx context.Context, id string) (*Order, error)
Save(ctx context.Context, o *Order) error
}
type EventBus interface {
Publish(ctx context.Context, topic string, payload map[string]any) error
}
type Clock interface{ Now() time.Time }
type CancelOrder struct {
repo OrderRepo
bus EventBus
clk Clock
}
func NewCancelOrder(repo OrderRepo, bus EventBus, clk Clock) *CancelOrder {
return &CancelOrder{repo: repo, bus: bus, clk: clk}
}
func (s *CancelOrder) Handle(ctx context.Context, cmd CancelCommand) (CancelResult, error) {
if strings.TrimSpace(cmd.Reason) == "" {
return CancelResult{}, ErrEmptyReason
}
o, err := s.repo.Find(ctx, cmd.OrderID)
if err != nil {
return CancelResult{}, err
}
if o.Status == "CANCELLED" { // 幂等:重复取消直接返回当前状态
return CancelResult{OrderID: o.ID, Status: o.Status}, nil
}
if o.Status == "SHIPPED" {
return CancelResult{}, ErrShipped
}
o.Status = "CANCELLED"
o.Version++
if err := s.repo.Save(ctx, o); err != nil {
return CancelResult{}, err
}
// 事件发布失败不回滚业务:真实项目用 outbox 表保证最终送达
_ = s.bus.Publish(ctx, "order.cancelled", map[string]any{
"order_id": o.ID,
"reason": cmd.Reason,
"at": s.clk.Now().Format(time.RFC3339),
})
return CancelResult{OrderID: o.ID, Status: o.Status}, nil
}
// ========== 驱动适配器(左侧):只做协议 → 命令的转换 ==========
type HTTPAdapter struct{ uc *CancelOrder }
func writeJSON(w http.ResponseWriter, code int, body any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(body)
}
func (a *HTTPAdapter) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "只支持 POST"})
return
}
id := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/orders/"), "/cancel")
var body struct {
Reason string `json:"reason"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "请求体不是合法 JSON"})
return
}
res, err := a.uc.Handle(r.Context(), CancelCommand{OrderID: id, Reason: body.Reason})
if err != nil {
// 错误 → 状态码的映射属于适配器:核心不认识 HTTP
code := http.StatusInternalServerError
switch {
case errors.Is(err, ErrNotFound):
code = http.StatusNotFound
case errors.Is(err, ErrShipped):
code = http.StatusConflict
case errors.Is(err, ErrEmptyReason):
code = http.StatusBadRequest
}
writeJSON(w, code, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]string{"order_id": res.OrderID, "status": res.Status})
}
type CLIAdapter struct{ uc *CancelOrder }
func (c *CLIAdapter) Run(ctx context.Context, args []string) (string, error) {
if len(args) < 2 {
return "", errors.New("用法: cancel <order_id> <reason>")
}
res, err := c.uc.Handle(ctx, CancelCommand{OrderID: args[0], Reason: strings.Join(args[1:], " ")})
if err != nil {
return "", err
}
return fmt.Sprintf("订单 %s 已置为 %s", res.OrderID, res.Status), nil
}
// ========== 被驱动适配器(右侧):核心主动调用它们 ==========
type memoryRepo struct{ orders map[string]*Order }
func (r *memoryRepo) Find(_ context.Context, id string) (*Order, error) {
o, ok := r.orders[id]
if !ok {
return nil, fmt.Errorf("%w: %s", ErrNotFound, id)
}
return o, nil
}
func (r *memoryRepo) Save(_ context.Context, o *Order) error {
r.orders[o.ID] = o
return nil
}
type logBus struct{ events []string }
func (b *logBus) Publish(_ context.Context, topic string, payload map[string]any) error {
raw, _ := json.Marshal(payload) // map 的 key 会被排序,输出稳定
b.events = append(b.events, topic+" "+string(raw))
return nil
}
type fixedClock struct{ t time.Time }
func (c fixedClock) Now() time.Time { return c.t }
func main() {
repo := &memoryRepo{orders: map[string]*Order{
"O1": {ID: "O1", Status: "PAID", Amount: 9900},
"O2": {ID: "O2", Status: "SHIPPED", Amount: 12900},
"O3": {ID: "O3", Status: "CREATED", Amount: 500},
}}
bus := &logBus{}
uc := NewCancelOrder(repo, bus, fixedClock{t: time.Date(2026, 10, 24, 16, 0, 0, 0, time.UTC)})
// 入口一:HTTP(用 httptest 直接驱动,不用起端口)
h := &HTTPAdapter{uc: uc}
for _, tc := range []struct{ path, body string }{
{"/orders/O1/cancel", `{"reason":"用户改主意"}`},
{"/orders/O2/cancel", `{"reason":"下错单"}`},
{"/orders/O9/cancel", `{"reason":"测试"}`},
{"/orders/O1/cancel", `{"reason":"重复取消"}`},
} {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, tc.path, strings.NewReader(tc.body))
h.ServeHTTP(rec, req)
fmt.Printf("HTTP %-22s -> %d %s", tc.path, rec.Code, rec.Body.String())
}
// 入口二:CLI,同一核心,零改动
cli := &CLIAdapter{uc: uc}
out, err := cli.Run(context.Background(), []string{"O3", "重复下单"})
fmt.Printf("CLI O3 -> %q err=%v\n", out, err)
// 出口换成另一个实现,核心同样零改动
fmt.Printf("最终状态:O1=%s O3=%s(O3 版本号 %d)\n",
repo.orders["O1"].Status, repo.orders["O3"].Status, repo.orders["O3"].Version)
fmt.Println("已发布事件:")
for _, e := range bus.events {
fmt.Println(" " + e)
}
}
输出:
HTTP /orders/O1/cancel -> 200 {"order_id":"O1","status":"CANCELLED"}
HTTP /orders/O2/cancel -> 409 {"error":"已发货,不能取消"}
HTTP /orders/O9/cancel -> 404 {"error":"订单不存在: O9"}
HTTP /orders/O1/cancel -> 200 {"order_id":"O1","status":"CANCELLED"}
CLI O3 -> "订单 O3 已置为 CANCELLED" err=<nil>
最终状态:O1=CANCELLED O3=CANCELLED(O3 版本号 1)
已发布事件:
order.cancelled {"at":"2026-10-24T16:00:00Z","order_id":"O1","reason":"用户改主意"}
order.cancelled {"at":"2026-10-24T16:00:00Z","order_id":"O3","reason":"重复下单"}
四个 HTTP 响应连起来看,能读出三件事:
- 错误语义只有一份(
ErrShipped),两次被不同入口翻译成不同表现:HTTP 是 409,CLI 是错误字符串; - 幂等写在核心里:重复取消返回 200 而不是报错,所以 HTTP 重试是安全的;
- 事件只在真正发生状态变更时发布:O1 的第二次请求没有产生第二条事件——这条规则如果写在 handler 里,CLI 入口就会漏掉。
适配器的职责边界:只做翻译,不做决策
| 应该放适配器 | 应该放核心 |
|---|---|
| JSON 解析 / 序列化、字段命名 | "取消原因不能为空"这类校验 |
| HTTP 状态码 ↔ 错误语义 | "已发货不能取消"这类规则 |
| 重试、超时、连接池、限流参数 | 幂等语义(重复取消是否幂等) |
| 认证(你是谁:session / JWT 解析) | 授权(你能不能取消这一单) |
| SQL、索引、事务实现 | 事务边界(一个用例 = 一个事务) |
| 第三方 API 的签名、加解密 | 业务上是否允许调用第三方 |
⚠️ 最常见的一种破功:端口签名里出现框架类型。
Find(ctx context.Context, r *http.Request)一出现,核心就被 HTTP 绑架了——CLI 入口再也复用不了,测试要造*http.Request。端口里只允许出现领域类型 + 标准库的context。
测试:一个端口,一个契约,所有适配器都跑
六边形的测试收益来自"适配器可替换":
// 契约测试:任何 OrderRepo 实现都必须通过同一套断言
// 目录:order/repo_contract_test.go
package order_test
import (
"context"
"errors"
"testing"
"shop/internal/adapter/memory"
"shop/internal/order"
)
func testOrderRepo(t *testing.T, newRepo func() order.OrderRepo) {
t.Helper()
ctx := context.Background()
repo := newRepo()
if _, err := repo.Find(ctx, "missing"); !errors.Is(err, order.ErrNotFound) {
t.Fatalf("不存在的订单应返回 ErrNotFound,得到 %v", err)
}
if err := repo.Save(ctx, &order.Order{ID: "X", Status: "PAID"}); err != nil {
t.Fatal(err)
}
got, err := repo.Find(ctx, "X")
if err != nil || got.ID != "X" {
t.Fatalf("存进去取不出来: %v %v", got, err)
}
}
func TestMemoryRepo(t *testing.T) {
testOrderRepo(t, func() order.OrderRepo { return memory.NewOrderRepo() })
}
// 同一个 testOrderRepo,接上真实数据库再跑一遍:
// func TestMySQLRepo(t *testing.T) {
// testOrderRepo(t, func() order.OrderRepo { return mysqltest.NewOrderRepo(t) })
// }
| 测试类型 | 替换什么 | 单次耗时 | 覆盖什么 |
|---|---|---|---|
| 核心用例单测 | 全部端口换 fake | 微秒级 | 业务规则、幂等、错误语义 |
| 端口契约测试 | 只换适配器 | 毫秒–秒级 | "这个实现是否满足契约" |
| 端到端测试 | 不换 | 秒级 | 协议、序列化、部署 |
🔑 六边形把"测什么"分清了:规则测核心,契约测适配器,链路测端到端。 三者混在一起时,业务规则会被塞进 E2E,跑一次几十秒,最后没人跑。
三种画法怎么选
26.3 说过三者同构,这里给"按场景选":
| 你的情况 | 选哪个说法 | 理由 |
|---|---|---|
| 团队用 DDD 术语、领域模型复杂 | 洋葱 | 领域服务中心化,接口归领域 |
| 用例多、入口多、需要讲清"一次操作经过哪几步" | 整洁 | 用例层是天然的表达单位 |
| 入口/出口特别多(HTTP + MQ + 定时 + 多外部系统) | 六边形 | 端口/适配器清单便于盘点 |
| 团队没有统一术语 | 任选一个,写进 ADR | 混用会让 Repo 接口位置漂移 |
| 系统很小、只有一个入口一个数据库 | 先别套 | 三层足够,等第二个适配器出现再抽 |
反模式
| 反模式 | 后果 |
|---|---|
端口签名里出现 *http.Request、*gin.Context、*gorm.DB | 核心被技术绑架,其他入口无法复用 |
| 适配器里写业务规则("已发货不能取消"写在 handler) | 换入口就换行为,MQ 消费绕过校验 |
按技术命名端口(MySQLPort、RedisPort) | 端口变成"某个数据库的接口",换存储要改核心 |
| 每个方法抽一个端口 | 端口与构造参数爆炸,收益为零 |
核心 import 适配器包 | 依赖方向反转失败,六边形退化成三层 |
| 只有一个入口也硬造 6 个适配器 | 为对称而对称,代码量翻倍 |
| 把六边形当成微服务架构 | 六边形是进程内结构,与部署拓扑无关 |
| 端口定义在适配器包,核心 import 适配器 | 假端口:方向没变,只是多了一层 |
自检
Q1 一个用六边形架构的服务,HTTP handler 里做了 JWT 解析、角色校验("只有客服能取消订单")、然后调 uc.Handle。这三件事分别该放哪?
▸ 答案:JWT 解析放适配器(它是协议与凭证格式,换个入口——比如内部 gRPC——凭证方式可能不同);角色校验要看语义:"只有客服能取消"是业务规则,应放核心(或在核心的授权端口后),否则 CLI 脚本、MQ 补偿任务都能绕过;调 uc.Handle 是适配器的本职。判据:这条规则换个入口还需要吗?需要 → 核心;不需要 → 适配器。
Q2 端口应该由谁定义?如果放在 adapter/mysql 包里,编译能过,为什么仍然是错的?
▸ 答案:由核心定义。放适配器包里,核心就必须 import adapter/mysql 才能拿到接口类型,依赖方向重新指向外层——六边形退化成"三层 + 一个多余接口"。而且接口形状会由实现方决定(它会加 BulkInsert、BeginTx),核心被动依赖自己不需要的能力。检验方法:删掉整个 adapter 目录,核心还能编译吗? 不能就说明端口放错了。
Q3 同事说:"我们有两个入口,所以要在核心前面加一个 InputPort 接口,再写两个 InputAdapter,这样才对称。"你怎么判断该不该加?
▸ 答案:除非有装饰/多实现/契约测试需求,否则不加。两个入口共享的是核心用例,HTTPAdapter 与 CLIAdapter 各自依赖 *CancelOrder 就够了;多一层 InputPort 接口只是把同一个方法声明了两次。判据是"这个接口有没有带来第二种实现或横切能力"——没有就是为对称而对称。对称是理解工具,不是代码指标。
Q4 你把 EventBus.Publish 放在核心用例里,发布失败只记日志不回滚。有人主张"事件必须和业务在同一个事务里",怎么回应?
▸ 答案:先分清两件事:业务状态与事件的一致性要求"要么都成功、要么都可重放",这正是 outbox 模式要解决的——把事件写进与业务同一个本地事务,再由独立投递器发送。所以"同一个事务"的说法在本地库层面是对的,但不该通过"在核心用例里开分布式事务"实现,那会让核心依赖事务 API 与消息中间件。正确做法:核心依赖 EventBus 端口,基础设施层提供 outbox 实现(写库 + 异步投递)。端口保持不变,一致性由适配器保证。
Q5 系统的定时任务(超时关单)直接调用 mysqlOrderRepo 改状态,没有走核心用例。这会带来什么问题?
▸ 答案:绕过了核心的业务规则与副作用:状态机校验、版本号递增、order.cancelled 事件、幂等语义全部缺失,于是"用户取消"和"超时取消"两条路径的行为开始漂移,账务/库存侧收到的事件也可能不一致。正确做法是把定时任务写成又一个驱动适配器(只负责"找出候选订单 + 逐条调用 CancelOrder.Handle"),这样它和 HTTP 入口共享同一套规则。入口可以多,规则只能有一份。