《后端架构设计·演进与治理》2.3 契约变更门禁:schema 校验与跨服务兼容测试

从 order-service 改金额字段导致三个消费者在预发同时失败的事故切入,说明 provider 自测为何证明不了消费者,给出把消费者期望、provider 样例与 CI 合并门禁串起来的单包 Go 实现、真实测试输出,以及契约版本、缺失样例和类型收紧的处置规则。

预发环境里同时倒下的三个消费者

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 服务注册、健康检查与实例生命周期 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查