3118 字
约 10 分钟
14
S18-B04 · 接口与契约:对外承诺怎么写

联调最常出现的场景:对方说"返回 200 就是成功",你返回 200 但 body 里 success=false;对方把 status 当成订单状态,其实是支付状态;超时后对方重试三次,你扣了三笔。

这些都不是沟通问题,是契约没写清。契约不是接口签名——签名只说明"怎么调用",契约要说明"调用之后能依赖什么"。这篇讲怎么把这份承诺写成可执行的东西。


1. 签名不等于契约

只写签名 写清契约
POST /orders 幂等吗?重复提交返回什么?
CreateOrderResult create(CreateOrderCmd cmd) cmd 里哪些字段必填?取值为空是什么意思?
Return 200 200 代表业务成功还是仅代表收到?
throws OrderException 调用方该重试、该提示用户,还是该报障?

契约 = 调用方能依赖的全部约定,五类都要写:输入、输出、错误、时序、性能。

一份"看起来完整"的文档常缺的是后两类:出错之后怎么办,慢的时候算不算正常,而这恰是事故高发区。


2. 契约必须写明的五件事

项 必须回答的问题 不写的后果
输入范围 哪些字段必填、长度/精度上限、空值的含义 调用方传 "",服务端当有效值处理
输出含义 每个字段的业务语义,空集合与 null 的区分 前端把 null 当"没有",其实是"未查询"
错误分类 哪些错可重试、哪些错要改参数、哪些错要报障 一律重试,把参数错误重试成雪崩
幂等性 重复调用安全吗?幂等键是什么、窗口多久 超时重试造成重复扣款
超时行为 服务端多久放弃、放弃后状态是什么 调用方超时重试,服务端仍在执行

第一行的"空值含义"最常缺失:没传、传了空、查不到是三种不同语义,返回必须能区分。

// 输入:显式区分三种空
{ "couponCode": null }        // 不指定优惠券
{ "couponCode": "" }          // 非法,报参数错误
{ "couponCode": "AB12" }      // 指定

3. 错误契约:可区分、可行动、带上下文

错误设计的目标不是"表达出错了",而是让调用方能决定下一步做什么:调用方的错该改参数,服务方的错可重试,不确定的错先查状态。

错误类别 谁的问题 调用方该做什么 典型 HTTP
参数错误 调用方 改参数,不要重试 400
认证/授权失败 调用方 换凭证,不要重试 401/403
资源不存在 调用方 检查 ID,不要重试 404
冲突/状态非法 调用方 重新读取状态再决定 409
限流 双方 退避后重试 429
服务端错误/超时 服务方或未知 有限次重试 + 报障;超时先查状态 5xx/504

三条硬要求:code 是稳定枚举(调用方可以 switch,不要复用同一个 code 表示两种错);message 面向人(可改文案,调用方不得解析它做逻辑);details 给程序(带定位问题需要的字段与 traceId)。

// 错误响应:code 稳定、message 给人看、details 给程序用
{
  "code": "ORDER_STATUS_INVALID",
  "message": "订单已支付,不能重复支付",
  "details": { "orderId": "O-1024", "currentStatus": "PAID", "requiredStatus": "CREATED" },
  "traceId": "b7c1e9f0"
}

最常见的坏做法是"HTTP 200 + success:false"表示全部失败:网关、监控、重试库都按状态码判断,这个约定把它们全骗了。要么统一用语义化状态码,要么在契约里声明"业务错误一律 200 看 code"。


4. 幂等:重复请求的返回值也得写清

幂等的定义:同一个请求执行一次与执行多次,对系统状态的影响相同——注意是"状态影响相同",不是"返回值相同"。实现前提有三个,缺一不可:

前提 说明 缺了会怎样
幂等键 由调用方生成,全局唯一(如 requestId、业务单号) 服务端无法识别"这是同一次请求"
去重窗口 保留多久的键记录(典型量级:分钟到天,按业务重试跨度定) 窗口太短,重试穿透;太长,存储膨胀
返回值缓存 重复请求返回首次结果,而不是再算一遍 调用方拿到矛盾的结果
// 幂等键 + 结果缓存的最小实现
public Result createOrder(String requestId, CreateOrderCmd cmd) {
    var cached = idemStore.get(requestId);          // 去重窗口内的历史结果
    if (cached != null) return cached;              // 直接返回首次结果,标记 replay
    return idemStore.computeIfAbsent(requestId, () -> doCreate(cmd));
}

契约里要写清三件事:幂等键是哪个字段、窗口多久、重复请求返回首次结果还是409 冲突——必须选定一种,否则调用方没法写重试逻辑。


5. 向后兼容的三条规则

变更 安全吗 说明
加字段(可选、有默认) 安全 老客户端忽略未知字段,解析要宽松
加枚举值 有风险 老客户端 switch 无 default 会崩
改字段语义(同名改含义) 不安全 老客户端静默算错,最难排查
改字段类型(int → string) 不安全 反序列化直接失败
收紧校验(必填化、长度变小) 不安全 原本能过的请求开始失败
1. 加:只加可选字段,解析必须宽松(忽略未知字段)。
2. 改语义:一律视为不兼容,走新字段或新版本。
3. 删:先废弃并观测,确认无流量后再删,至少跨一个发布周期。

"加枚举值"是新手最容易踩的隐雷:看起来只多一个选项,实际会打崩所有没写 default 的客户端。


6. 版本策略:两种做法与各自的代价

策略 形式 优点 代价
URL 版本 /v1/orders、/v2/orders 直观、易路由、易做灰度 URL 膨胀,资源身份被版本切碎
内容协商 Accept: application/vnd.x.v2+json URL 稳定,语义干净 调试不直观,网关/缓存常配错
字段级演进 只加不改 维护成本最低 需要极强纪律,包袱留在同一接口

入门阶段的务实建议:能靠"只加不改"解决的不加版本号;必须破坏兼容时用 URL 版本,排障成本最低。定了策略就写进契约,别一半路径一半 header。


7. 契约的落地形式与文档腐化

形式 谁维护 能防住什么 腐化风险
手写文档(Wiki) 人 意图与背景 高,改代码不改文档
OpenAPI / Proto 定义 代码同仓 结构与字段漂移 低,但语义仍靠注释
契约测试(消费者驱动) CI 真实调用方依赖的字段 低,需要接入成本
类型定义共享 代码 编译期不一致 中,跨语言失效

文档腐化的根因是文档与代码是两份真相。能用代码生成的别手写;不能生成的(语义、时序、超时)写在与接口同仓的 Markdown 里,改接口的 PR 必须同改。


8. 内部接口与服务间接口

维度 内部接口(同进程) 服务间接口(跨进程)
谁写谁读 同一批人,可随时重构 不同团队,改动需协商
失败模式 抛异常,同栈可查 超时、半成功、重复投递
契约载体 类型定义 + 注释 显式文档 + 契约测试
变更通知 改完说一声 必须提前公告 + 兼容期
必须额外写清 线程安全性 超时、重试、幂等、鉴权、限流

跨进程之后要多承诺五件事。这是"能不能拆服务"的真正判据:团队承诺不了这五件事,拆出去就是把内部调用变成事故源。


9. 最小契约模板

## 接口:创建订单
- 方法与路径:POST /v1/orders
- 幂等:是。幂等键为 header `X-Request-Id`,窗口 24h,重复请求返回首次结果(含原 orderId)
- 超时:服务端 3s 放弃;放弃后状态可能已落库,调用方须以查询接口为准
- 输入:skuId 必填;qty 1–99;couponCode 可选,null=不使用,""=参数错误
- 输出:orderId 必有;status ∈ {CREATED, PAID, CANCELLED};未知值按未知处理
- 错误:
  | code | HTTP | 可重试 | 含义 |
  | ORDER_STATUS_INVALID | 409 | 否 | 状态不允许该操作 |
  | RATE_LIMITED | 429 | 是(退避) | 触发限流 |
  | INTERNAL | 500 | 是(≤2 次) | 服务端异常,带 traceId |
- 性能预期:P99 < 300ms(典型量级),峰值 QPS 200

一页写完比十页文档有用;缺一行,联调就有人来问。


10. 常见错误

错误 后果 改法
用 null 表示失败 调用方无法区分"没有数据"与"出错" 用错误码或 Optional/包装类型
错误码复用 一个 code 两种含义,调用方逻辑分叉 一码一义,新增不复用
静默截断返回条数 调用方以为拿全了,实际少了一半 返回 total 与 hasMore,或明确报错
不写超时默认值 各方默认不同,级联超时 契约里写死超时与放弃后的语义

11. 排错:五种"契约写了还是对不上"的情况

现象 原因 处理
联调时字段名对不上 契约只写了签名,没写字段语义 补字段表,标注空值含义
重试导致重复数据 契约没写幂等键或窗口 补幂等键,服务端做去重
上线后老客户端报错 加了必填字段或收紧校验 回退为可选,走新字段
文档与实际不符 两份真相,无人同步 结构由代码生成,语义同仓维护

12. 边界:什么情况不必写正式契约

场景 做法 理由
同进程内部方法 类型 + 注释即可 可重构,无跨团队成本
一次性脚本 不写 没有第二个调用方
实验性接口 内部约定,标注"随时可变" 承诺成本高于收益
已废弃接口 只维护废弃公告 不再演进

一句话:契约的成本由"是否有别人依赖"来付;有人依赖且变更要走流程,就必须写。


13. 自测

  1. 为什么说"签名不等于契约"?签名之外还有哪四类信息?
  2. 输入里"没传""传了空""查不到"三种空,分别该怎么表达?
  3. 错误码设计的三条硬要求是什么?为什么 message 不能被程序解析?
  4. 幂等要求"状态影响相同"还是"返回值相同"?重复请求的返回值有哪两种合法选择?
  5. "加枚举值"为什么可能是不兼容变更?
  6. 同一个方法拆成跨进程调用后,必须多承诺哪五件事?
S18-B04 · 接口与契约:对外承诺怎么写
https://www.clxhxhhr.top/posts/6000/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。