契约漂移的三种形态
站内 API 设计与契约治理 已经讲过契约先行的原则:先写接口描述,再写实现,用测试防止破坏。原则不难,难的是让文档真的成为约束。arch-lab 在卷一遇到过三种漂移,它们都不会让编译失败:
- spec 滞后:代码新增了
POST /orders/{id}/pay,文档里没有,客户端按文档生成的 SDK 永远调不到。 - 实现超前:spec 声明
Idempotency-Key必填,handler 却接受空值,文档承诺的 400 从未发生。 - 错误码私增:某个分支返回
OUT_OF_STOCK,spec 的Error.code枚举里没有,客户端只能把它归入未知错误。
这三种问题的共同点是“两边都能跑”;契约测试要抓的正是这种静默分歧。
唯一契约源放在哪里
arch-lab 的约定是:api/openapi.json 是唯一源,代码、SDK、网关配置都从它派生;任何一方需要新字段,先改 spec,再改实现和测试。为了让本节代码在独立模块里不依赖外部文件,下面的 spec.go 把同一份文档内联成常量;生产仓库把它放在 api/openapi.json,用 go:embed 或构建脚本注入,测试结构完全一样。
spec 只需回答四件事:路径与方法、operationId、路径参数是否必填、错误响应字段是否稳定。
| 操作 | 方法与路径 | 关键输入 | 成功 | 主要失败 |
|---|---|---|---|---|
createOrder | POST /api/v1/orders | Idempotency-Key 必填 | 201/200 | 400、409 |
getOrder | GET /api/v1/orders/{id} | 路径参数 id | 200 | 404 |
cancelOrder | POST /api/v1/orders/{id}/cancel | id、可选 If-Match | 200 | 404、409、422 |
payOrder | POST /api/v1/orders/{id}/pay | id、可选 If-Match | 200 | 404、409、422 |
listOrders | GET /api/v1/orders | customer_id、status、limit | 200 | 400 |
spec 的最小可运行形态
下面这个文件只保留契约测试真正读取的字段:openapi 版本、info.version、paths、parameters、responses 与 components.schemas.Error。其余展示性字段(summary、tags、示例)由文档工具消费,不参与边界断言。
// file: internal/contract/spec.go
// tier: L1
// verify: go test ./internal/contract/
package contract
import (
"encoding/json"
"fmt"
)
// SpecJSON 与仓库中的 api/openapi.json 保持一致;内联是为了让契约测试
// 不依赖外部文件,也能在独立模块里直接运行。
const SpecJSON = `{
"openapi": "3.1.0",
"info": {"title": "arch-lab orders", "version": "1.0.0"},
"paths": {
"/api/v1/orders": {
"get": {"operationId": "listOrders", "parameters": [
{"name": "customer_id", "in": "query", "required": false},
{"name": "status", "in": "query", "required": false},
{"name": "limit", "in": "query", "required": false}
], "responses": {"200": {"description": "ok"}, "400": {"description": "invalid query"}}},
"post": {"operationId": "createOrder", "parameters": [
{"name": "Idempotency-Key", "in": "header", "required": true}
], "responses": {"201": {"description": "created"}, "200": {"description": "replayed"},
"400": {"description": "invalid body"}, "409": {"description": "idempotency conflict"}}}
},
"/api/v1/orders/{id}": {
"get": {"operationId": "getOrder", "parameters": [
{"name": "id", "in": "path", "required": true}
], "responses": {"200": {"description": "ok"}, "404": {"description": "missing"}}}
},
"/api/v1/orders/{id}/cancel": {
"post": {"operationId": "cancelOrder", "parameters": [
{"name": "id", "in": "path", "required": true},
{"name": "If-Match", "in": "header", "required": false}
], "responses": {"200": {"description": "ok"}, "404": {"description": "missing"},
"409": {"description": "version conflict"}, "422": {"description": "invalid state"}}}
},
"/api/v1/orders/{id}/pay": {
"post": {"operationId": "payOrder", "parameters": [
{"name": "id", "in": "path", "required": true},
{"name": "If-Match", "in": "header", "required": false}
], "responses": {"200": {"description": "ok"}, "404": {"description": "missing"},
"409": {"description": "version conflict"}, "422": {"description": "invalid state"}}}
}
},
"components": {"schemas": {"Error": {
"type": "object",
"required": ["code", "message", "request_id", "retryable"],
"properties": {
"code": {"type": "string", "enum": [
"IDEMPOTENCY_CONFLICT", "INTERNAL", "INVALID_ARGUMENT", "INVALID_STATE",
"NOT_FOUND", "OUT_OF_STOCK", "PAYMENT_FAILED", "VERSION_CONFLICT"
]},
"message": {"type": "string"},
"request_id": {"type": "string"},
"retryable": {"type": "boolean"},
"details": {"type": "object"}
}
}}}
}`
type Spec struct {
OpenAPI string `json:"openapi"`
Info Info `json:"info"`
Paths map[string]PathItem `json:"paths"`
Components Components `json:"components"`
}
type Info struct {
Version string `json:"version"`
}
type PathItem struct {
Get *Operation `json:"get"`
Post *Operation `json:"post"`
}
type Operation struct {
OperationID string `json:"operationId"`
Parameters []Parameter `json:"parameters"`
Responses map[string]Response `json:"responses"`
}
type Parameter struct {
Name string `json:"name"`
In string `json:"in"`
Required bool `json:"required"`
}
type Response struct {
Description string `json:"description"`
}
type Components struct {
Schemas map[string]Schema `json:"schemas"`
}
type Schema struct {
Type string `json:"type"`
Required []string `json:"required"`
Properties map[string]Prop `json:"properties"`
}
type Prop struct {
Type string `json:"type"`
Enum []string `json:"enum"`
}
func Parse() (*Spec, error) {
var spec Spec
if err := json.Unmarshal([]byte(SpecJSON), &spec); err != nil {
return nil, fmt.Errorf("parse openapi: %w", err)
}
return &spec, nil
}
Parse 只做结构反序列化,不负责业务校验;$ref 是否可解析、Schema 是否闭合交给专门 linter。本节关心“实现是否遵守 spec”,不是“spec 是否漂亮”。
从 spec 生成错误码镜像
错误码是最容易私增的部分。手写常量与 spec 枚举各改各的,最终客户端会在生产环境遇到没见过的 code。解决办法不是“记得同步”,而是让生成物进入版本库,并让测试在生成物过期时失败。
// file: internal/contract/generate.go
// tier: L1
// verify: go test ./internal/contract/
package contract
import (
"fmt"
"sort"
"strings"
)
// GenerateCodes 从 Error.code 的 enum 生成 Go 常量镜像。
// 生成结果写入 codes_gen.go,测试负责发现漂移。
func GenerateCodes(spec *Spec) []byte {
codes := append([]string(nil), spec.Components.Schemas["Error"].Properties["code"].Enum...)
sort.Strings(codes)
var out strings.Builder
out.WriteString("// Code generated by internal/contract.GenerateCodes; DO NOT EDIT.\n\n")
out.WriteString("package contract\n\n")
out.WriteString("// ErrorCodes 是 OpenAPI Error.code enum 的镜像,按字典序排列。\n")
out.WriteString("var ErrorCodes = []string{\n")
for _, code := range codes {
fmt.Fprintf(&out, "\t%q,\n", code)
}
out.WriteString("}\n")
return []byte(out.String())
}
生成文件头部必须带 Code generated ... DO NOT EDIT.,让 review 和工具知道它不应手改:
// file: internal/contract/codes_gen.go
// tier: L1
// verify: go test ./internal/contract/
// Code generated by internal/contract.GenerateCodes; DO NOT EDIT.
package contract
// ErrorCodes 是 OpenAPI Error.code enum 的镜像,按字典序排列。
var ErrorCodes = []string{
"IDEMPOTENCY_CONFLICT",
"INTERNAL",
"INVALID_ARGUMENT",
"INVALID_STATE",
"NOT_FOUND",
"OUT_OF_STOCK",
"PAYMENT_FAILED",
"VERSION_CONFLICT",
}
生成的是镜像而非运行时唯一来源:真正的裁决权在 spec。若有人只改 codes_gen.go,TestGeneratedCodesUpToDate 会重新生成并发现字节不一致。
四类边界测试
| 断言 | 防住什么 | 失败时的信号 |
|---|---|---|
| 操作集合与 operationId 一致 | 代码新增端点但 spec 没写 | 数量或 ID 不匹配 |
| 路径参数必填且在模板中 | 文档漏写 required: true | path parameter must be required |
| Error 的 required 与枚举稳定 | 错误响应少字段或私增 code | 逗号连接后的字符串不等 |
| 生成文件与 spec 一致 | 手改生成物或忘记重新生成 | codes_gen.go is stale |
测试直接读 SpecJSON,不启动 HTTP 服务,也不依赖数据库;它回答“契约与代码是否同一份事实”,接口行为由 5.1 的 handler 测试覆盖,两者不能互相替代。
// file: internal/contract/contract_test.go
// tier: L1
// verify: go test ./internal/contract/ -v
package contract
import (
"flag"
"os"
"strings"
"testing"
)
var update = flag.Bool("update", false, "rewrite generated files")
func operations(spec *Spec) map[string]*Operation {
out := map[string]*Operation{}
for path, item := range spec.Paths {
if item.Get != nil {
out["GET "+path] = item.Get
}
if item.Post != nil {
out["POST "+path] = item.Post
}
}
return out
}
func hasStatus(op *Operation, class byte) bool {
for code := range op.Responses {
if len(code) == 3 && code[0] == class {
return true
}
}
return false
}
func TestSpecMatchesServerRoutes(t *testing.T) {
spec, err := Parse()
if err != nil {
t.Fatal(err)
}
if spec.OpenAPI != "3.1.0" || spec.Info.Version != "1.0.0" {
t.Fatalf("unexpected metadata: %+v", spec.Info)
}
want := map[string]string{
"GET /api/v1/orders": "listOrders",
"POST /api/v1/orders": "createOrder",
"GET /api/v1/orders/{id}": "getOrder",
"POST /api/v1/orders/{id}/cancel": "cancelOrder",
"POST /api/v1/orders/{id}/pay": "payOrder",
}
got := operations(spec)
if len(got) != len(want) {
t.Fatalf("operation count = %d, want %d", len(got), len(want))
}
for key, id := range want {
op := got[key]
if op == nil || op.OperationID != id {
t.Errorf("%s: operationID = %v, want %s", key, op, id)
continue
}
if !hasStatus(op, '2') || !hasStatus(op, '4') {
t.Errorf("%s: must declare 2xx and 4xx responses", key)
}
}
}
func TestPathParametersAreRequired(t *testing.T) {
spec, _ := Parse()
for key, op := range operations(spec) {
for _, param := range op.Parameters {
if param.In != "path" {
continue
}
if !param.Required {
t.Errorf("%s: path parameter %q must be required", key, param.Name)
}
if !strings.Contains(key, "{"+param.Name+"}") {
t.Errorf("%s: path parameter %q is not in the template", key, param.Name)
}
}
}
}
func TestErrorSchemaIsStable(t *testing.T) {
spec, _ := Parse()
schema := spec.Components.Schemas["Error"]
wantRequired := "code,message,request_id,retryable"
if strings.Join(schema.Required, ",") != wantRequired {
t.Fatalf("required = %v", schema.Required)
}
wantCodes := "IDEMPOTENCY_CONFLICT,INTERNAL,INVALID_ARGUMENT,INVALID_STATE,NOT_FOUND,OUT_OF_STOCK,PAYMENT_FAILED,VERSION_CONFLICT"
if got := strings.Join(schema.Properties["code"].Enum, ","); got != wantCodes {
t.Fatalf("codes = %s", got)
}
}
func TestGeneratedCodesUpToDate(t *testing.T) {
spec, _ := Parse()
want := GenerateCodes(spec)
if *update {
if err := os.WriteFile("codes_gen.go", want, 0o644); err != nil {
t.Fatal(err)
}
return
}
got, err := os.ReadFile("codes_gen.go")
if err != nil {
t.Fatal(err)
}
if string(got) != string(want) {
t.Fatal("codes_gen.go is stale; run go test ./internal/contract -update")
}
}
-update 是唯一写入口:改了 spec 枚举后先运行 go test ./internal/contract -run TestGeneratedCodesUpToDate -update 重写生成文件,再提交两份 diff;CI 禁止 -update,否则一致性检查会退化成自动改代码。
实测
本机 macOS(darwin/arm64,Go 1.26.0)独立模块的真实输出:
$ gofmt -l .
$ go vet ./...
$ go test ./... -count=1
ok archlab/internal/contract 0.245s
gofmt -l . 与 go vet 均无输出,四个测试全部通过;0.245s 只反映本机这次运行,不是性能承诺。
常见错误与边界
第一,把 spec 当成“接口文档”而不是“构建输入”。文档可以滞后,输入不能;只要生成物和测试都从 spec 读取,滞后就会在 CI 失败。
第二,只在 CI 里跑 lint,不比对实现;linter 能说 spec 规范,不能说 handler 少注册了路径。
第三,让生成文件参与手写逻辑;生成物只放枚举、常量和客户端桩,业务判断留在手写文件,否则下次生成会覆盖。
第四,忽略兼容性窗口。删掉一个枚举值在 spec 里只是少一行,对已发布的客户端却是未知错误;枚举只能追加,废弃要走 5.3 的版本窗口。
小结
契约优先不是“先写文档”,而是让文档成为代码的输入:路由、路径参数、错误码和生成物都由 spec 派生,测试负责发现私自变更。代价是每次改接口多改一处 spec,收益是客户端不会在发布后遇到“文档里没有的字段”。下一节处理错误模型与版本窗口。
阅读导航:上一节:5.1 订单 API 的资源边界与命令语义 · 下一节:5.3 错误模型、版本演进与兼容性 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。