返回博客

REST API 设计:从约定到契约的思维转变

我以前觉得 API 设计就是一堆规范条文,背下来照做就行。GET 查、POST 建、PUT 全量替换、PATCH 局部更新,复数名词、kebab-case,返回对应的状态码——这些东西似乎只是品味问题,执行与否无关大局。

后来维护了几个「野生」API,彻底改观了这种想法。

「200 包打天下」的代价

最常见的反模式:所有请求一律返回 200,失败与否靠响应体里的 success: false 字段区分。

{ "code": 200, "success": false, "message": "用户不存在" }

乍看没问题。但接入方写的是这样的代码:

const res = await fetch("/api/users/999");
if (!res.ok) throw new Error("请求失败");
// 实际上 res.ok === true,异常永远不会抛

HTTP 状态码是客户端(包括浏览器缓存、代理、SDK、监控工具)都能感知的第一手信号。把它压平成 200,等于剥夺了整条链路的自动感知能力:缓存不该缓的、监控告警失效、前端通用错误处理拦截不到。恢复这个感知能力,比任何文档都有价值。

原则:HTTP 状态码是契约的一部分,不是可选的装饰

400 验证失败、401 未认证、403 无权限、404 不存在、409 冲突、422 语义错误、429 限流——每一个都有精确的语义。让整条工具链理解你的 API 状态,而不是把判断逻辑全塞给调用方。

分页:选错了类型,数据一致性会悄悄崩

内部后台用 offset 翻页无可厚非:数据量小,用户需要跳页,LIMIT N OFFSET M 直接可用。

但对面向用户的无限滚动列表(feeds、消息流、搜索历史)用 offset 翻页,会遇到一个很隐蔽的问题——并发写入期间的「数据漂移」:用户在翻到第 3 页时,第 1 页插入了新数据,原来第 3 页的第 1 条现在是第 4 页的最后一条,于是用户永远看不到它,或者看到了两遍。

游标(cursor)翻页从根本上解决这个问题:每次查询从上次结束的位置向后取,不受并发插入的影响。代价是不能跳页,但「无限滚动」本来就不需要跳页。

| 场景 | 推荐分页类型 | |------|------------| | 管理后台、报表,小数据量 | Offset | | feeds、消息流、大数据量 | Cursor | | 搜索结果(用户期待页码) | Offset | | 对外公开 API | Cursor 为主 |

版本化:不要过早,也不要拖太晚

最常见的两个极端:一开始就搞 /api/v1//api/v2/ 的多版本管理(结果每个版本都一样,白白维护两份代码),或者等到真正需要破坏性变更时才发现没有任何版本隔离,强行改了老客户端直接炸。

版本化的触发条件很清晰:

  • 不需要新版本:加新字段、加新端点、加可选查询参数——向后兼容的扩展不破坏旧客户端
  • 需要新版本:删字段、改字段类型、改 URL 结构、改认证方式——任何会让旧客户端行为改变的修改

路径版本化(/api/v1//api/v2/)比 Header 版本化更实用:可以直接在浏览器里测、CDN 和代理能感知、日志更容易分析。

弃用老版本时,提前 6 个月在响应头里加 Sunset: <日期>,到期后返回 410 Gone。这比「说好要升级但一直在用旧版」好太多。

错误响应:机器可读 + 人可读要同时做到

错误信息给人看,错误码给程序看。两者不是二选一:

{
  "error": {
    "code": "validation_error",
    "message": "请求验证失败",
    "details": [
      { "field": "email", "message": "必须是有效的邮箱地址", "code": "invalid_format" },
      { "field": "age", "message": "必须在 0 到 150 之间", "code": "out_of_range" }
    ]
  }
}

code 字段让前端/调用方写 if (err.code === 'validation_error') 而不是解析人类语言;message 给开发者调试;details 精确到字段,省去反复沟通「到底哪个字段有问题」。

对外永远不暴露堆栈 trace、SQL 错误、内部路径。这不只是安全问题——更是让调用方依赖的是稳定的语义,而不是随实现细节漂移的字符串。

一句话心法

API 的调用者包括浏览器、代理、缓存、监控、SDK、你六个月后的自己——设计时把这些隐形读者都算进去,「约定」就自然升格为「契约」。