28.3 服务契约设计
一句话
契约不是 “接口文档”,而是双方都能自动验证的兼容性承诺:字段只加不删、语义变更必须换版本、错误码与超时也是契约的一部分;不能自动校验的契约,三个月后一定与实现脱节。
什么算破坏性变更
| 变更 | 破坏性 | 原因 |
|---|---|---|
| 新增可选字段 | ✅ 兼容 | 老调用方忽略未知字段 |
| 新增必填字段/参数 | ❌ 破坏 | 老调用方不传,服务端拒绝 |
| 删除字段 | ❌ 破坏 | 调用方仍读它 |
| 改字段类型(int→string) | ❌ 破坏 | 反序列化失败 |
| 放宽类型(int32→int64) | ✅ 兼容 | 取值范围扩大 |
| 改字段语义(同名字段含义变) | ❌ 最危险 | 类型没错,结果错 |
| 新增枚举值 | ⚠️ 看消费端 | 用 default 分支才安全 |
| 改错误码含义 | ❌ 破坏 | 调用方按错误码做重试/补偿 |
代码:让 CI 判断 “是否破坏”
# OpenAPI 契约片段(v1)
paths:
/orders/{id}:
get:
responses:
"200":
content:
application/json:
schema:
type: object
required: [id, status] # 必填字段一旦增加就是破坏性变更
properties:
id: {type: string}
status: {type: string}
note: {type: string} # 可选字段:新增安全
# 用工具在 CI 里做兼容性比对(本机未安装,命令为真实用法)
oasdiff breaking ./api/old.yaml ./api/new.yaml # OpenAPI
buf breaking --against '.git#branch=main' # Protobuf/gRPC
# 判据:退出码非 0 即存在破坏性变更,阻断合并
实测:一个 20 行的契约检查脚本
本机跑的规则检查(旧 schema vs 新 schema):
amount 改类型 → 破坏
status 删除字段 → 破坏
note 新增字段 → 兼容
结论:存在破坏性变更 = True
def diff(old, new):
out = []
for k, v in old.items():
if k not in new: out.append((k, "删除字段", "破坏"))
elif new[k] != v: out.append((k, "改类型", "破坏"))
for k, v in new.items():
if k not in old: out.append((k, "新增字段", "破坏" if v == "required" else "兼容"))
return out
这段 20 行代码就是 “契约门禁” 的最小实现:改类型与删字段必须拦,加可选字段放行。真实项目里换成 oasdiff/buf breaking 即可,判据完全一样(退出码非 0 阻断合并)。
反模式
| 反模式 | 后果 |
|---|---|
| 契约只写“成功响应” | 错误码/超时行为全靠猜,调用方各自实现 |
| 用 “加个字段应该没事” 推进变更 | 消费端严格模式(如 Protobuf/Jackson strict)直接炸 |
| 同名字段改语义 | 最隐蔽的事故:类型对、值错 |
| 契约与实现分开维护 | 文档腐化,验证时才发现不一致 |
| 没有超时/重试语义 | 调用方无限重试,放大故障 |
| 版本号只加在 URL 不加在语义 | v2 里仍做破坏性变更 |
| 契约检查只在发版前跑一次 | 分支合并期无人拦截,破坏性变更照样进主干 | | 检查通过就认为“兼容” | 语义变更(同名字段换含义)检查不出来,仍需人工评审 |
三条铁律
🔑 只加不改:加可选字段安全,删改必换版本。
🔑 语义变更等同于删字段,必须走版本迁移。
🔑 错误码、超时、重试、幂等都是契约的一部分,写清楚。
自检
Q1 为什么 “改字段语义” 比 “改类型” 更危险?
▸ 答案:改类型会在反序列化时立即失败(可见、可拦);改语义(如 amount 从 “元” 变 “分”)类型不变、调用方照常解析,但金额放大 100 倍——错误在业务结果里,监控与测试都不一定发现。
Q2 新增枚举值为什么是 “⚠️ 看消费端”?
▸ 答案:因为消费端若用 switch 穷举且没有 default 分支,遇到新值会抛异常或静默走错分支;有 default(或未知值兜底)才安全。所以 “加枚举值” 在契约里要标注 “消费端必须容忍未知值”。
Q3 契约测试与接口测试的区别?
▸ 答案:接口测试验证 “服务行为是否符合预期”;契约测试验证 “双方对同一份契约的理解是否一致”,且由消费方驱动(消费方定义期望,生产方验证)。前者防 bug,后者防 “联调时才发现字段对不上”(见 53.5 契约测试)。
Q4 为什么要把 “超时与重试” 写进契约?
▸ 答案:因为它们决定故障如何传播:调用方若不知道服务端的超时预算,就会设更长超时并重试,把下游的慢变成自己的雪崩。契约里写清 “SLO 200ms、超时 500ms、重试仅幂等接口”,才能让两侧的预算对齐(见 35.5 超时、重试与退避)。
Q5 什么时候可以“悄悄加字段”而不通知调用方?
▸ 答案:只有调用方明确声明容忍未知字段时(JSON 宽松解析、Protobuf 未知字段保留)。否则严格解析器会失败。稳妥做法是:加字段前确认消费端解析策略,并先在测试环境验证。
Q6 契约应该由谁维护?
▸ 答案:生产方拥有、消费方参与评审:生产方对兼容性负责,消费方提供 “实际依赖哪些字段/错误码” 的输入。只由生产方单方面定义,会写出 “没人用但必须维护” 的字段;只由消费方定义,会变成 “许愿清单”。
Q7 为什么 “检查通过” 仍不能替代人工评审?
▸ 答案:因为工具只能检查结构(字段、类型、必填),检查不出语义:amount 从 “元” 改成 “分”,类型仍是 int,脚本会判 “兼容”,但业务结果放大 100 倍。所以流程必须是 “工具挡结构 + 人审语义”,两者缺一不可。
故障复盘(典型模式,脱敏)
| 阶段 | 现象 | 判断 | 动作 |
|---|---|---|---|
| 发布 | 新增必填字段 channel 上线 | 契约检查未拦 | 立即回滚 |
| T+5m | 老客户端 100% 失败(400) | 破坏性变更 | 改为可选+默认值 |
| T+30m | 恢复,但部分客户端缓存了错误响应 | 客户端无重试 | 通知 + 缓存清理 |
| 复盘 | 缺少 “必填字段” 门禁 | 流程缺口 | 加 buf breaking/oasdiff |
根因:把 “加个字段” 当兼容变更。代价:5 分钟全量失败 + 半小时恢复。根治:CI 阻断破坏性变更;必填字段只能 “先可选、再强制”。