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 阻断破坏性变更;必填字段只能 “先可选、再强制”。

关联