预发环境里同时倒下的三个消费者
order-service 的 v3 构建把 order.amount 从整数分改成字符串小数,并新增了 currency。provider 自己的集成测试全部通过,因为它的断言只看自己发出的 JSON;预发联调时,对账、报表、风控三个消费者同时失败,分别表现为反序列化错误、金额精度错误和字段缺失告警。三个团队随后各自修了代码,但没有人回答一个更基本的问题:为什么 provider 的 CI 没有提前发现。答案不是测试覆盖率不够,而是测试的对象错了。provider 测的是“我发出去的符合我的 schema”,消费者关心的是“我依赖的字段还在、类型没变”。这两件事只有在同一份可执行期望上对齐,才可能被同一条门禁拦住。
Provider 的绿灯证明不了 Consumer
provider 的测试天然站在生产者视角:构造自己的消息、断言自己的 schema、验证自己的业务分支。它不知道有多少消费者、每个消费者读了哪些字段、哪些字段是可选依赖。一个字段在 provider 看来“从未被业务使用”,可能在某个报表任务里是唯一的数据来源。消费者驱动的契约把关系倒过来:由消费者记录“我从这条消息里读什么、什么类型、是否必需”,provider 在 CI 里用当前构建的真实样例去回放这些期望。这样,破坏性变更在 provider 合并前就会被标红,而不是等到预发联调才暴露。站内API 设计与契约 讨论过接口契约的静态部分,本节补的是跨服务、可执行的验证环节。它也有明确边界:契约只覆盖消费者声明过的路径。消费者没写的字段,provider 删掉不会被发现;所以契约版本必须与消费者代码一起评审,不能由 provider 单方面维护。
消费者期望是可执行的数据
一条期望只需要三个字段:路径、类型、是否必需。路径用点号表示对象层级,例如 order.id;类型沿用 2.2 的 JSON 线类型;必需表示缺失即失败。数组下标被刻意排除,因为依赖“第几个元素”通常意味着契约设计有问题。
| 字段 | 含义 | 写错的后果 |
|---|---|---|
Path | 点号分隔的对象路径 | 指向不存在的层级,永远通过 |
Type | 期望的 JSON 线类型 | 类型漂移被放过 |
Required | 缺失是否失败 | 把关键字段标成可选,门禁形同虚设 |
门禁的输入是“消费者契约集合 + provider 当前构建的样例集合”。它按 provider 名匹配,逐条回放;缺样例、重名契约、空期望都按错误处理,而不是默默跳过。一个会静默跳过的门禁,比没有门禁更危险。
门禁放在合并队列之前
| 阶段 | 动作 | 失败处理 |
|---|---|---|
| 消费者 PR | 新增或修改期望,带契约版本 | 缺版本或重复路径拒绝合并 |
| Provider PR | 用当前构建样例跑全部消费者契约 | 任一 violation 阻断合并 |
| 合并队列 | 记录通过的 provider 版本与样例摘要 | 摘要缺失视为未验证 |
顺序很重要:消费者先发布期望,provider 后改实现。反过来,provider 先合并、消费者后补契约,门禁就永远慢一拍。样例必须来自当前构建的真实序列化结果,不能手写;手写样例只能证明“我以为我会发什么”。这套顺序与CI 与流水线 里的合并门禁是同一层机制。
可运行的验证器
下面的 VerifyContract 负责单条契约,RunGate 负责批量匹配。两者都只依赖标准库,类型系统复用 2.2 的 ValueType,所以三篇文章的代码可以放在同一个 package 下编译。
// file: internal/platform/contract/gate.go
// tier: L1
// verify: go test ./internal/platform/contract/ -count=1
package contract
import (
"fmt"
"sort"
"strings"
)
// Expectation is one thing a consumer relies on. Path uses dotted object
// keys ("order.id"); array indexes are not supported on purpose.
type Expectation struct {
Path string
Type ValueType
Required bool
}
// ConsumerContract is the expectation recorded in the consumer repository
// and replayed against the provider in CI.
type ConsumerContract struct {
Consumer string
Provider string
Version string
Expectations []Expectation
}
// Validate rejects contracts that would make the gate silently weaker.
func (c ConsumerContract) Validate() error {
if strings.TrimSpace(c.Consumer) == "" {
return fmt.Errorf("contract: consumer name is empty")
}
if strings.TrimSpace(c.Provider) == "" {
return fmt.Errorf("contract: consumer %q has no provider", c.Consumer)
}
if strings.TrimSpace(c.Version) == "" {
return fmt.Errorf("contract: consumer %q has no contract version", c.Consumer)
}
if len(c.Expectations) == 0 {
return fmt.Errorf("contract: consumer %q has no expectations", c.Consumer)
}
seen := make(map[string]bool, len(c.Expectations))
for _, e := range c.Expectations {
if strings.TrimSpace(e.Path) == "" {
return fmt.Errorf("contract: consumer %q has an empty path", c.Consumer)
}
if seen[e.Path] {
return fmt.Errorf("contract: consumer %q repeats path %q", c.Consumer, e.Path)
}
seen[e.Path] = true
if !e.Type.valid() {
return fmt.Errorf("contract: consumer %q path %q has invalid type %q", c.Consumer, e.Path, e.Type)
}
}
return nil
}
// Sample is one message captured from the provider's current build.
type Sample struct {
Provider string
Version string
Payload map[string]any
}
// Violation is one concrete reason a consumer would break.
type Violation struct {
Consumer string
Path string
Reason string
}
// GateResult is what CI prints and what the merge queue blocks on.
type GateResult struct {
Passed bool
Checked int
Violations []Violation
}
// VerifyContract checks one consumer against one provider sample. Missing
// optional paths are ignored; missing required paths and type mismatches
// are violations.
func VerifyContract(c ConsumerContract, s Sample) []Violation {
expectations := append([]Expectation(nil), c.Expectations...)
sort.Slice(expectations, func(i, j int) bool { return expectations[i].Path < expectations[j].Path })
var out []Violation
for _, e := range expectations {
value, ok := lookupPath(s.Payload, e.Path)
if !ok {
if e.Required {
out = append(out, Violation{c.Consumer, e.Path,
fmt.Sprintf("required path missing in %s@%s", s.Provider, s.Version)})
}
continue
}
if got := typeOf(value); got != e.Type {
out = append(out, Violation{c.Consumer, e.Path,
fmt.Sprintf("type changed: want %s, got %s in %s@%s", e.Type, got, s.Provider, s.Version)})
}
}
return out
}
// lookupPath walks dotted object keys. A non-object intermediate counts as
// absent instead of panicking.
func lookupPath(payload map[string]any, path string) (any, bool) {
var current any = payload
for _, part := range strings.Split(path, ".") {
obj, ok := current.(map[string]any)
if !ok {
return nil, false
}
value, ok := obj[part]
if !ok {
return nil, false
}
current = value
}
return current, true
}
// typeOf maps a decoded JSON value to its wire type. Plain Go ints are
// accepted in tests; a real JSON decoder yields float64 for every number.
func typeOf(v any) ValueType {
switch v.(type) {
case string:
return TypeString
case int, int32, int64:
return TypeInt
case float32, float64:
return TypeFloat
case bool:
return TypeBool
case map[string]any:
return TypeObject
case []any:
return TypeArray
default:
return ValueType(fmt.Sprintf("%T", v))
}
}
// RunGate verifies every contract against the sample with the same provider
// name. Malformed input is an error (a broken gate must fail loudly); real
// contract violations set Passed=false.
func RunGate(contracts []ConsumerContract, samples []Sample) (GateResult, error) {
byProvider := make(map[string]Sample, len(samples))
for _, s := range samples {
if strings.TrimSpace(s.Provider) == "" {
return GateResult{}, fmt.Errorf("contract: sample has no provider name")
}
if _, dup := byProvider[s.Provider]; dup {
return GateResult{}, fmt.Errorf("contract: duplicate sample for provider %q", s.Provider)
}
byProvider[s.Provider] = s
}
seen := make(map[string]bool, len(contracts))
result := GateResult{Passed: true}
for _, c := range contracts {
if err := c.Validate(); err != nil {
return GateResult{}, err
}
key := c.Consumer + "\x00" + c.Provider
if seen[key] {
return GateResult{}, fmt.Errorf("contract: duplicate contract %s -> %s", c.Consumer, c.Provider)
}
seen[key] = true
s, ok := byProvider[c.Provider]
if !ok {
result.Violations = append(result.Violations, Violation{c.Consumer, "",
fmt.Sprintf("no provider sample for %q", c.Provider)})
continue
}
result.Checked++
result.Violations = append(result.Violations, VerifyContract(c, s)...)
}
if len(result.Violations) > 0 {
result.Passed = false
}
return result, nil
}
三个实现选择直接影响门禁的可信度。第一,期望按路径排序后回放,输出顺序稳定,CI 日志可以逐行对比。第二,Validate 把空消费者、空 provider、空版本、空期望、重复路径和非法类型都当错误,避免一条写错的契约把门禁降级成“永远通过”。第三,缺少 provider 样例记为 violation 而不是跳过,因为“没有样例”本身就是未验证。测试覆盖三种单契约判定和一个完整的门禁场景。门禁场景同时包含成功、类型漂移、必填缺失和 provider 缺样例,用来验证“阻断”不是因为某一条特判,而是汇总结果。
// file: internal/platform/contract/gate_test.go
// tier: L1
// verify: go test ./internal/platform/contract/ -count=1
package contract
import "testing"
func orderPaidSample() Sample {
return Sample{Provider: "order-service", Version: "v3", Payload: map[string]any{
"event_id": "evt-1",
"paid": true,
"order": map[string]any{
"id": "ord-1",
"amount": 12800,
"currency": "CNY",
},
"items": []any{"sku-1", "sku-2"},
}}
}
func cc(consumer string, exp ...Expectation) ConsumerContract {
return ConsumerContract{Consumer: consumer, Provider: "order-service", Version: "1", Expectations: exp}
}
func TestVerifyContractMissingAndType(t *testing.T) {
sample := orderPaidSample()
cases := []struct {
name string
c ConsumerContract
want int
}{
{"optional missing ignored", cc("crm",
Expectation{Path: "order.id", Type: TypeString, Required: true},
Expectation{Path: "order.note", Type: TypeString}), 0},
{"required nested missing", cc("audit",
Expectation{Path: "order.seller.id", Type: TypeString, Required: true}), 1},
{"type changed", cc("ledger",
Expectation{Path: "order.amount", Type: TypeFloat, Required: true}), 1},
}
for _, tc := range cases {
if err := tc.c.Validate(); err != nil {
t.Fatalf("%s: invalid fixture: %v", tc.name, err)
}
got := VerifyContract(tc.c, sample)
if len(got) != tc.want {
t.Errorf("%s: violations=%d want=%d (%+v)", tc.name, len(got), tc.want, got)
}
for _, v := range got {
t.Logf("%s: path=%s reason=%s", tc.name, v.Path, v.Reason)
}
}
}
func TestRunGatePassesWhenAllConsumersMatch(t *testing.T) {
contracts := []ConsumerContract{
cc("billing", Expectation{Path: "order.id", Type: TypeString, Required: true},
Expectation{Path: "order.amount", Type: TypeInt, Required: true}),
cc("shipping", Expectation{Path: "order.id", Type: TypeString, Required: true},
Expectation{Path: "items", Type: TypeArray, Required: true}),
}
res, err := RunGate(contracts, []Sample{orderPaidSample()})
if err != nil {
t.Fatal(err)
}
t.Logf("pass case: passed=%t checked=%d violations=%d", res.Passed, res.Checked, len(res.Violations))
if !res.Passed || res.Checked != 2 || len(res.Violations) != 0 {
t.Fatalf("expected gate pass, got %+v", res)
}
}
func TestRunGateBlocksBreakingChange(t *testing.T) {
missing := cc("notification", Expectation{Path: "payment.id", Type: TypeString, Required: true})
missing.Provider = "payment-service"
contracts := []ConsumerContract{
cc("billing", Expectation{Path: "order.id", Type: TypeString, Required: true}),
cc("ledger", Expectation{Path: "order.amount", Type: TypeFloat, Required: true}),
cc("audit", Expectation{Path: "order.seller.id", Type: TypeString, Required: true}),
missing,
}
res, err := RunGate(contracts, []Sample{orderPaidSample()})
if err != nil {
t.Fatal(err)
}
t.Logf("fail case: passed=%t checked=%d violations=%d", res.Passed, res.Checked, len(res.Violations))
if res.Passed || res.Checked != 3 || len(res.Violations) != 3 {
t.Fatalf("expected three violations and a block, got %+v", res)
}
for _, v := range res.Violations {
t.Logf("violation: consumer=%s path=%s reason=%s", v.Consumer, v.Path, v.Reason)
}
}
func TestRunGateRejectsBrokenInput(t *testing.T) {
valid := cc("billing", Expectation{Path: "order.id", Type: TypeString, Required: true})
cases := []struct {
name string
contracts []ConsumerContract
samples []Sample
}{
{"no expectations", []ConsumerContract{{Consumer: "x", Provider: "order-service", Version: "1"}}, []Sample{orderPaidSample()}},
{"duplicate contract", []ConsumerContract{valid, valid}, []Sample{orderPaidSample()}},
{"duplicate sample", []ConsumerContract{valid}, []Sample{orderPaidSample(), orderPaidSample()}},
}
for _, tc := range cases {
if _, err := RunGate(tc.contracts, tc.samples); err == nil {
t.Errorf("%s: expected error, got nil", tc.name)
} else {
t.Logf("%s: rejected: %v", tc.name, err)
}
}
}
本机实测:三种单契约判定与一次门禁阻断
在 .local/checks/be-arch-gov-ch02 的 module archlab(go 1.26.0)中执行 gofmt -l .、go vet ./...、go test ./... -count=1 -v,全部通过;环境为 macOS darwin/arm64、Go 1.26.0。完整输出在 .local/reports/be-arch-gov/ch02-be-arch-gov-02-3-contract-change-gate-schema.txt。
=== RUN TestVerifyContractMissingAndType
gate_test.go:49: required nested missing: path=order.seller.id reason=required path missing in order-service@v3
gate_test.go:49: type changed: path=order.amount reason=type changed: want float, got int in order-service@v3
--- PASS: TestVerifyContractMissingAndType (0.00s)
=== RUN TestRunGatePassesWhenAllConsumersMatch
gate_test.go:65: pass case: passed=true checked=2 violations=0
--- PASS: TestRunGatePassesWhenAllConsumersMatch (0.00s)
=== RUN TestRunGateBlocksBreakingChange
gate_test.go:84: fail case: passed=false checked=3 violations=3
gate_test.go:89: violation: consumer=ledger path=order.amount reason=type changed: want float, got int in order-service@v3
gate_test.go:89: violation: consumer=audit path=order.seller.id reason=required path missing in order-service@v3
gate_test.go:89: violation: consumer=notification path= reason=no provider sample for "payment-service"
--- PASS: TestRunGateBlocksBreakingChange (0.00s)
=== RUN TestRunGateRejectsBrokenInput
gate_test.go:108: no expectations: rejected: contract: consumer "x" has no expectations
gate_test.go:108: duplicate contract: rejected: contract: duplicate contract billing -> order-service
gate_test.go:108: duplicate sample: rejected: contract: duplicate sample for provider "order-service"
--- PASS: TestRunGateRejectsBrokenInput (0.00s)
PASS
ok archlab/internal/platform/contract 0.413s
成功场景 checked=2 violations=0,说明门禁不是“见到 provider 就报错”。阻断场景 checked=3 violations=3:类型漂移、必填路径缺失、provider 缺样例各一条,三类问题都在同一份报告里可见,CI 可以直接把这份报告贴到 PR 上。三个坏输入全部以 error 退出,而不是 Passed=false。这个区分很重要:契约违规是业务事实,应该阻断合并;契约写坏了是门禁自身的故障,应该让人先修门禁,而不是给出一个可能被误读的“验证失败”。
常见错误与边界
第一,让 provider 维护消费者契约。provider 不知道消费者读了什么,写出来的契约只能覆盖自己愿意承诺的字段,门禁会退化成自测。第二,样例手写而不是从构建产物抓取。手写样例会随着实现漂移,最终验证的是“旧样例”而不是“当前构建”。样例必须由 provider 的序列化代码生成,并带版本摘要。
第三,契约只增不减。废弃字段的期望要随着消费者迁移一起删除,否则 provider 永远不敢删字段,expand-contract 卡在 expand 阶段。第四,把门禁当成全量兼容性证明。它只覆盖声明过的路径;未声明的字段、数组元素内部结构、消息顺序都不在范围内。这些需要 schema 检查、消费者代码评审和真实流量演练共同补齐。
小结
provider 的集成测试证明“我按自己的 schema 发”,消费者契约证明“你依赖的字段还在”。两者不是替代关系,而是生产者和消费者各自的证据;门禁把后者变成合并前的硬条件,破坏性变更才会在预发之前被拦住。本机的验证器用 3 个单契约用例和 1 个混合门禁场景验证了行为:通过场景 2 个消费者零违规,阻断场景 3 个消费者 3 条违规,坏输入 3 类全部以错误退出。这些数字只说明实现符合预期,不代表真实 provider 的契约覆盖率;覆盖率要靠消费者清单和字段访问日志来补。落地时先做三件事:把现有消费者的读取字段补成契约,让 provider 的 CI 用真实样例回放,再把“契约通过摘要”接进合并队列。做完这三步,2.2 的 schema 检查才有消费者侧的证据,2.1 的同步/异步选择也才有可验证的契约基础。
阅读导航:上一节:2.2 契约版本演进:向后兼容、弃用与消费者驱动 · 下一节:3.1 服务注册、健康检查与实例生命周期 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。