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 的原意是:画几个边都行,六边形只是方便画——上面接入口、下面接存储、左右接其他系统。真正重要的是两条:

  1. 内部与外部之间只有端口,没有直连;
  2. 两侧对称:入口和出口都是"适配器 + 端口",没有谁比谁更"底层"。

⚠️ 把"六边形"理解成"必须有 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 响应连起来看,能读出三件事:

  1. 错误语义只有一份(ErrShipped),两次被不同入口翻译成不同表现:HTTP 是 409,CLI 是错误字符串;
  2. 幂等写在核心里:重复取消返回 200 而不是报错,所以 HTTP 重试是安全的;
  3. 事件只在真正发生状态变更时发布: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 入口共享同一套规则。入口可以多,规则只能有一份。