跳至主要内容
调用方在意的那几个 API 细节

调用方在意的那几个 API 细节

上周接一个内部服务,查订单列表。文档写着 GET /orders,我传 page=1 拿 20 条,传 page=2,返回的 20 条里有两条和第一页重复。折腾了半天才想明白:默认排序按 updated_at,中间有订单被改过,排序就整体挪位了。

接口没写错,它只是没说清排序稳不稳定。这种事我遇到过太多次,都集中在接入后的头两个小时,而设计者往往觉得「这不是显然的吗」。

默认值是最容易被忽略的接口约定

不传 page_size 会怎样?

我见过三种实现:返回 10 条、返回 20 条、返回全部。第三种在测试环境完全没问题,上线后一次拉回十几万行,调用方内存直接爆掉。设计者不是故意的,他只是觉得「不传就是不要限制」。

我现在写接口,默认值一定放在文档第一屏,并且遵守两条:默认值小,比如 20;有硬上限,比如 100,超过就报错,不静默截断。

静默截断是最难查的一类问题。你传 limit=5000,返回 1000 条,响应里没有任何提示,调用方会以为数据就这么多。我宁可它返回一个 400,告诉我「limit 最大 100」。

排序同理。分页接口必须给一个稳定的默认排序,通常是 (created_at, id) 这样的组合,保证任意两条记录之间有确定的先后。只按时间排,同一秒创建的数据就会抖。cursor 分页能缓解 offset 分页的重复问题,但它要求排序键唯一且不可变——这个前提得写出来,不然调用方以为 cursor 是万能的。

错误响应里,我最想看到的是「能不能重试」

调用方拿到失败响应,第一个问题永远是:重试一次有没有意义。

超时重试是必然的,因为超时的时候你不知道对面成没成功。这时候如果创建接口没有幂等键,客户端就只能干等或者人工核对。所以我倾向所有写操作都接受一个客户端生成的 Idempotency-Key,服务端记住它在多长时间窗口内对应哪个结果,并在文档里写明窗口长度和作用域。

错误体的结构,我一般会写成这样:

JSON6 行
1
2
3
4
5
6
{
"code": "ORDER_NOT_FOUND",
"message": "订单不存在或无权访问",
"retryable": false,
"request_id": "req_7f3a1c"
}

code 给程序判断,message 给人看,request_id 用来查日志。retryable 是我最近两年才加上的,加了之后接入方的重试逻辑明显少了很多瞎猜——以前他们只能按状态码猜,503 重试,500 也重试,其实 500 里有一半是参数问题,重试一万次也没用。限流也一样,429 最好带上 Retry-After,让客户端知道该等多久,而不是自己拍一个退避曲线。

反过来,最让我难受的是「所有错误都返回 200,body 里 code=0 表示成功」。这套做法有它的历史原因,但对调用方来说,每个请求都要判断两层,中间任何一层漏了就是线上事故。我写过这种接口,后来改了。

还有一个细节:参数校验失败的时候,别只说「参数错误」。告诉我是哪个字段、错在哪,最好给一个数组逐项列出字段名和原因。这一点花不了多少实现成本,但能省掉调用方一整轮沟通。

null、缺失和类型:小决定,长尾巴

JSON 里 "field": null 和字段根本不存在,在 JavaScript 里几乎没区别,在 Go 或者 Kotlin 里差别很大——前者能反序列化成零值,后者可能连结构体都填不满。

我的习惯是:字段永远存在,没有值就给 null,只有在语义上确实是多态的时候才省略字段。这样调用方的类型定义是稳定的。

类型上有几个坑我踩过。

id 一律用字符串。 十九位的雪花 ID 在 JavaScript 里当数字解析会丢精度,末尾几位直接变成 0,然后你拿着一个错误的 id 去查,查不到还以为数据被删了。这不是理论问题。

金额用最小单位的整数加币种,不用浮点。0.1 + 0.2 的事不用再讲一遍,但每年还是能看到 "amount": 19.99 的接口。

时间统一用 ISO 8601 或 UTC 时间戳,别用不带时区的 2024-05-01 10:00:00。调用方在另一个时区,他没法知道这是服务器本地时间还是 UTC。

枚举要预留扩展空间。 加一个新的枚举值,在服务端看来是兼容变更,在客户端看来可能是崩溃——如果他写了 switch 而没有 default 分支。所以文档里我会明确写一句:枚举值可能增加,请勿假设取值范围固定。

批量接口的部分失败,得提前想清楚

批量创建一百条,其中第三条失败了,返回什么?

一种做法是全成功或全失败,另一种是返回逐项结果。我倾向后者,尤其是批量操作本身耗时较长的时候——全回滚意味着调用方要重跑整个批次,而重跑又牵扯到幂等。

逐项结果的格式大致是每一项带上下标和状态:

JSON6 行
1
2
3
4
5
6
{
"results": [
{"index": 0, "status": "ok", "id": "1001"},
{"index": 1, "status": "error", "code": "DUPLICATE"}
]
}

用 index 而不是把顺序当成隐式约定,是因为返回的顺序不一定和请求一致,内部并发处理的时候尤其如此。

变更的边界

新增响应字段是兼容的,客户端忽略就好。但有几件事看着像新增,其实是破坏性的:新增一个必填的请求参数;把字段从可空改成不可空;修改默认分页大小;修改默认排序。

这几条我都在「小版本升级」里见过悄悄发生。所以我的判断标准是:判断一次变更是否兼容,不看它是不是「新增」,看它是否会让一个昨天还能正常工作的调用,今天返回不同的结果。

版本号放 URL 还是 header,我有偏好但谈不上定论。URL 上的 /v2 直观,日志里一眼能看出来;header 里的更干净,但对调试不友好。真正决定成败的不是版本号放在哪,而是你有没有一个地方明确记录每个版本之间的差异。


现在我看一个接口,会先翻到文档里「错误码」那一页,如果那一页只有一行「其他错误请联系管理员」,我大概能猜到后面要花多少时间。

下次你要发布一个新接口,可以先做一件小事:把默认分页大小、默认排序、以及三个最常见的错误码写进文档,然后再写实现。写到一半你可能会发现,有些参数你原本没打算给默认值,是因为你自己也没想清楚不传的时候该返回什么。

关于本文

由 Lu's Blog 撰写,采用 CC BY-NC 4.0 许可协议。

#API设计 #工程实践 #接口

评论