3077 字
约 10 分钟
21
S02-A06 · 接口契约设计:从命名到演进的约束

接口改了个字段,三个调用方同时挂。复盘时提供方说「我加了个字段而已」,调用方说「你没说」。两边都没说谎:契约的边界不取决于提供方声明了什么,取决于别人已经依赖了什么。

设计期的每个命名、每个默认值、每个错误码,都是给未来改动预先上的锁。锁上得早,演进是加字段;上得晚,演进就是停机协调。


1. 契约的四方,与各自的失效方式

契约不只存在于提供方代码里。它同时被四方持有,任一方失真都会在联调时暴露。

参与方 持有的契约 失效方式
提供方 实现与 DTO 实现改了没同步 schema
调用方 客户端代码与假设 依赖未写入文档的字段
网关 路由、鉴权、超时、限流 超时短于后端处理时间
文档 人读的描述 与实际行为漂移,无人校验

网关这一方最常被漏掉:提供方承诺「3 秒内返回」,网关默认 2 秒超时,契约在链路上被单方面改写。超时、重试次数、请求体大小上限都该写进契约,而不是留在运维配置里。


2. 资源建模与动词

REST 不是唯一答案。选型看主要交互形态:以实体读写为主、调用方多样,REST 合适;以动作为主、参数强类型,RPC 更顺手;调用方需要按自己的形状取数,GraphQL 才有意义。

风格 建模单位 适合 代价
REST 资源 + 少量动作端点 开放接口、多客户端 复杂查询会催生伪资源
RPC 方法 + 请求/响应对象 内部服务、强类型 缓存与幂等要自己设计
GraphQL 图 + 字段选择 多端差异化取数 限流与 N+1 治理复杂
POST /orders/{id}/cancel          动作挂在资源下,可接受
POST /orders/cancel               动作当资源,语义漂移
POST /rpc OrderService.Cancel     明确是 RPC,不要伪装成 REST

判断标准不是「哪个更 RESTful」,而是调用方能否从 URL 与状态码推断出行为。推断不出来,说明建模与真实语义脱节。


3. 命名的四条约束

约束 反例 代价
名词用复数、表示集合 /order/{id} 单复数混用,客户端拼错
布尔字段用肯定式 disabled: false 双重否定 取反时读错
单位与口径进字段名 amount、time 元/分、秒/毫秒靠猜
同一概念全局一个词 user 与 member 并存 调用方以为是两个实体
// 差的命名:口径全靠文档
{ "amount": 1299, "time": 1710000000, "flag": 1 }
// 好的命名:字段自带语义
{ "amountCents": 129900, "createdAt": "2024-03-09T12:00:00Z", "enabled": true }

命名是最便宜的兼容手段:改名是破坏性变更,一开始就叫对则零成本。时间字段一律带时区,用本地时间字符串是给自己埋一个跨时区才爆的雷。


4. 幂等性:方法与落点

GET、PUT、DELETE 天然幂等;POST 不幂等,而支付、下单、发消息恰恰都用 POST。补偿手段是调用方带一个请求标识,服务端按键去重。

落点 实现 代价
请求头 Idempotency-Key 网关或过滤器统一拦截 需要共享存储,跨实例一致
业务单号 唯一索引兜底 只覆盖有天然单号的场景
请求体指纹 对关键字段做哈希 字段顺序与可选值要规范化
POST /payments HTTP/1.1
Idempotency-Key: 7f3c2a91-...
Content-Type: application/json

{ "orderNo": "A1024", "amountCents": 129900 }

落点三要素:键的存储要有时效(24 小时量级即可,按业务核对)、要存响应而不只是标记(重复请求返回首次结果,而不是空 200)、要先占位再处理(并发同键时靠唯一索引决出胜者,而不是先查后写)。


5. 分页的三种风格与深翻页

风格 参数 优点 深翻页表现
偏移 offset/limit 简单、可跳页 越翻越慢,OFFSET 大时扫描量大
键集 afterId/limit 稳定、走索引 快,但不能跳页
游标 不透明 cursor 可封装复合排序 快,但游标要能解码校验
{ "items": [], "nextCursor": "eyJpZCI6MTAyNCwiY3JlYXRlZEF0IjoiLi4uIn0=" }

偏移分页的问题不在 SQL 语法,而在它要求数据库数过前面所有行。数据量到百万级、页面翻到几百页时,响应时间随页码线性上升。键集分页把「数过去」换成「从某一行之后取」,代价是不能跳页——产品要跳页,就得接受深翻页慢,这是取舍不是 bug。


6. 错误模型:状态码还是业务码

方案 调用方要做什么 代价
纯 HTTP 状态码 按码分支 业务失败种类多时码不够用
纯业务码 + 恒 200 先判 HTTP 再判 body 监控、网关、重试全部失效
HTTP 表达类别 + body 给细节 先看类别再看码 需要约定映射,写错就两头不靠
// 200 里塞错误:网关看到的是成功,重试策略无从下手
{ "code": 50012, "msg": "库存不足" }

// 分开表达:类别归 HTTP,细节归 body
HTTP/1.1 409 Conflict
{ "code": "STOCK_SHORTAGE", "message": "库存不足", "retryable": false }

「200 里塞错误」的代价是把所有基础设施变成瞎子:负载均衡的 5xx 计数、APM 的错误率、客户端的自动重试,全部拿不到信号。业务码可以留,但必须与 HTTP 类别保持一致的映射,且映射表要进契约文档。


7. 限流与配额也是契约

调用方按契约写重试逻辑,服务端却用未公开的限流把请求打回,等于契约单方面失效。

要素 做法 不做的后果
限额公开 文档写明 QPS/日配额 调用方按自己节奏打,互相拖垮
拒绝信号 429 + Retry-After 调用方立即重试,放大拥塞
剩余额度 X-RateLimit-Remaining 调用方无法预判,只能撞墙
维度说明 按 key、按租户还是按 IP 同一租户多实例共享额度时误判
HTTP/1.1 429 Too Many Requests
Retry-After: 2
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0

限流值本身可以变,信号不能变。给出 Retry-After 的服务端,实际上是在替调用方决定退避节奏,比自己拍一个指数退避更准。


8. 版本策略与兼容窗口

策略 表达方式 优点 代价
URL 版本 /v2/orders 直观、易路由 复制整棵路由树
Header 版本 Accept: application/vnd.x.v2+json URL 稳定 难调试、缓存易错
不版本化 只做兼容变更 运维最轻 需要严格的兼容纪律
// 兼容窗口用「字段寿命」表达,而不是用版本号表达
@Deprecated(since = "2024-03", forRemoval = true)
private String userName;   // 保留期内双写,调用方迁移完再删
变更 类别 代价
新增可缺省字段 兼容 调用方须忽略未知字段
放宽约束(范围/长度) 兼容 反向收紧要提前公告
新端点 兼容 补文档与鉴权即可
改类型 / 改语义 / 删字段 / 收紧校验 破坏 需要版本或迁移窗口

判据只有一条:已上线调用方在不通知、不配合的情况下会不会失败。不会,就不该开新版本。开版本的代价不是多写一个 controller,而是此后每个修复要在 N 条分支上重复。


9. 弃用与文档一致性

弃用不是发公告,是给调用方一个可执行的迁移路径和一个可观测的截止日期。

动作 落点 作用
响应头提示 Deprecation、Sunset 调用方在日志里能看到
调用量统计 按版本/字段分组 判断能否真的关停
灰度关停 先对内部调用方关,再对外 把事故限制在可控范围
文档同步 生成而非手写 防止文档与实现漂移
  # 契约以代码为准:CI 里比对 schema,破坏性变更直接失败
$ openapi-diff old.yaml new.yaml --fail-on incompatible
$ oasdiff breaking old.yaml new.yaml

文档手写必然漂移,因为没人会因为改文档而被打回。让 schema 从代码生成、让 CI 校验破坏性变更,是把一致性从人的自觉变成流水线的一部分。


10. 契约测试能测到什么

能测 测不到
字段名、类型、必填性 字段语义是否被正确理解
提供方是否满足既有约定 调用方是否忽略未知字段
枚举新增是否破坏消费方 超时、重试、幂等是否真的生效

契约测试的价值在于把「我以为」变成「CI 说」,它替代不了联调,也替代不了对语义的评审。只有一方写契约、另一方验证的流程,才叫契约测试;两边各自 mock 自己,测的是想象。


11. 排错:五种「文档没错但就是不通」

现象 原因 处理
调用方解析失败 新增字段未标注可选 schema 里显式 required
重试导致重复扣款 幂等键未落库或未占位 先写键再处理,唯一索引兜底
列表越翻越慢 偏移分页遇到大表 改键集,或限制最大页深
429 后雪崩 无 Retry-After,客户端立即重试 补退避信号并公开配额
网关 504 但后端成功 网关超时短于业务耗时 超时进契约,与 SLA 对齐

12. 边界:什么情况不必上这套

场景 结论 理由
内部单调用方 不版本化,靠同步发布 协调成本低于版本维护成本
短期活动接口 只保证字段可加 生命周期短于弃用流程
原型阶段 允许破坏性变更 过早固化会锁死错误建模
纯内部 RPC 契约由 IDL 保证 再加一层文档是重复
单机脚本调用 无需契约 没有第二方

契约的强度应与调用方数量 × 变更频率 × 故障代价匹配。三个调用方、每季度改一次、挂了能手动补,就不需要版本树;三十个调用方、每周发版、挂了要赔钱,连命名都得开会定。


13. 自测

  1. 契约的四方里,哪一方最容易被漏掉,漏掉后以什么形式暴露?
  2. 什么信号说明「该上 RPC 而不是 REST」?
  3. Idempotency-Key 为什么必须「先占位再处理」?
  4. 偏移分页在深翻页时慢在哪一层,键集分页用什么换掉了这个成本?
  5. 「200 里塞业务错误」损害的是哪三类基础设施能力?
  6. 判断一次改动是否需要新版本,只用哪一问?
S02-A06 · 接口契约设计:从命名到演进的约束
https://www.clxhxhhr.top/posts/6177/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。