《后端架构设计·架构基础与边界》5.2 契约优先与 OpenAPI 工作流

把 OpenAPI 文档当作唯一契约源,用一个最小 spec 覆盖 arch-lab 的五个订单操作,在 Go 侧校验路由、路径参数与错误码,并用生成文件比对测试阻止 spec 与代码悄悄漂移,同时说明 -update 为何只能由开发者执行。

契约漂移的三种形态

站内 API 设计与契约治理 已经讲过契约先行的原则:先写接口描述,再写实现,用测试防止破坏。原则不难,难的是让文档真的成为约束。arch-lab 在卷一遇到过三种漂移,它们都不会让编译失败:

  1. spec 滞后:代码新增了 POST /orders/{id}/pay,文档里没有,客户端按文档生成的 SDK 永远调不到。
  2. 实现超前:spec 声明 Idempotency-Key 必填,handler 却接受空值,文档承诺的 400 从未发生。
  3. 错误码私增:某个分支返回 OUT_OF_STOCK,spec 的 Error.code 枚举里没有,客户端只能把它归入未知错误。

这三种问题的共同点是“两边都能跑”;契约测试要抓的正是这种静默分歧。

唯一契约源放在哪里

arch-lab 的约定是:api/openapi.json 是唯一源,代码、SDK、网关配置都从它派生;任何一方需要新字段,先改 spec,再改实现和测试。为了让本节代码在独立模块里不依赖外部文件,下面的 spec.go 把同一份文档内联成常量;生产仓库把它放在 api/openapi.json,用 go:embed 或构建脚本注入,测试结构完全一样。

spec 只需回答四件事:路径与方法、operationId、路径参数是否必填、错误响应字段是否稳定。

操作方法与路径关键输入成功主要失败
createOrderPOST /api/v1/ordersIdempotency-Key 必填201/200400、409
getOrderGET /api/v1/orders/{id}路径参数 id200404
cancelOrderPOST /api/v1/orders/{id}/cancelid、可选 If-Match200404、409、422
payOrderPOST /api/v1/orders/{id}/payid、可选 If-Match200404、409、422
listOrdersGET /api/v1/orderscustomer_id、status、limit200400

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: truepath 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 错误模型、版本演进与兼容性 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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