原作者:Sean Goedecke。原文:Everything I know about good API design。
原文页面未标明可转载授权,本文仅基于原文结构整理中文译述,不构成逐段完整翻译。
现代软件工程的大部分工作都绕不开 API。这里的 API 不只包括公开的 HTTP 接口,也包括内部服务接口、只服务某个前端页面的私有接口、GraphQL 接口,甚至命令行工具暴露出来的非网络接口。
关于 API 设计,很多讨论会滑向过度复杂的问题:什么才是「REST」,HATEOAS 是否值得采用,JSON 和 XML 谁更好。Sean Goedecke 的观点更朴素:好的 API 让使用者少想、少踩坑、少被迫改代码。
API 设计是在熟悉感和长期弹性之间取舍
好的 API 往往很无聊。对 API 维护者来说,它是需要设计和打磨的产品;对 API 使用者来说,它只是完成其他目标的工具。使用者花在理解 API 上的时间越多,留给业务开发的时间就越少。
理想的 API 应该足够熟悉,开发者在读文档之前,就大概知道它会怎么工作。REST 之所以常见,不一定是因为它在技术上最优,而是因为它已经足够熟悉。
但 API 和普通软件系统有一个关键区别:发布之后很难再改。只要有人开始依赖某个字段、结构或语义,维护者的每次修改都可能打断下游系统。这个现实会迫使 API 设计者在两个目标之间取舍:
一方面,接口越简单越好;
另一方面,接口要为未来变化留出空间。
API 设计的很多难点,都来自这两个目标之间的冲突。
不要破坏下游用户
公共 API 的首要责任是不要破坏下游用户。新增字段通常可以接受,因为合理的 JSON consumer 应该忽略未知字段;但删除字段、修改字段类型、移动字段位置,都会让依赖这些字段的代码直接失败。
比如把 user.address 移到 user.details.address,在维护者看来可能只是结构更清晰;对下游来说,这就是一次 breaking change。
这里的原则接近 Linux 维护中的一句口号:WE DO NOT BREAK USERSPACE。API 维护者不应该因为接口看起来不够整洁,或某处历史包袱有点别扭,就要求所有下游使用者付出迁移成本。HTTP 里的 Referer 拼写一直没有改成 Referrer,正是因为兼容性比整洁更重要。
真要改接口,就用版本化,但把它当最后手段
有些情况下,breaking change 的技术收益足够高,维护者可能不得不改。负责任的做法是版本化:同时提供旧版本和新版本,让现有用户继续使用旧接口,新用户或愿意迁移的用户再切到新接口。
常见做法是在 URL 中放版本号,例如 /v1/... 和 /v2/...。也可以像 Stripe 那样通过 header 和账号默认版本来控制。形式不同,目标相同:用户应该能够按自己的节奏升级,而不是被维护者突然打断。
但版本化本身代价很高:
文档更难查,用户必须确认自己看到的是对应版本;
测试、调试、客服支持的表面积会快速扩大;
每个 endpoint 都可能变成多个公开版本;
即使后端有转换层,版本差异也会渗透到核心逻辑里。
因此版本化是必要时的工具,但不应该变成默认策略。更好的做法是在第一次设计时尽量保守,把 breaking change 留到确实无法避免时。
API 的成功主要取决于产品
API 自身不会创造价值。用户调用 OpenAI API,是为了使用模型推理能力;调用 Twilio API,是为了发送短信。没有人会因为一个 API 设计优雅而单独使用它。
如果产品足够有价值,一个难用的 API 也会被大量集成。Facebook、Jira 这类产品的 API 经常被抱怨,但只要业务必须集成它们,开发者仍然会花时间适配。
反过来,如果产品本身没有吸引力,API 再优雅也很难让人采用。API 质量通常是边际因素:当两个产品价值接近时,更好用、更稳定的 API 才会影响选择。
不过,「有没有 API」和「API 好不好」是两件事。对很多技术用户来说,完全没有 API 会直接阻止采购或集成。
产品模型差,API 通常也会差
API 往往会暴露产品的基本资源模型。Jira 的 API 会围绕 issue、project、user 之类资源展开;如果产品内部资源设计混乱,API 很难保持优雅。
UI 可以隐藏很多技术约束,但 API 会把这些约束直接暴露给调用方。比如一个评论系统如果内部用链表存储评论,页面上可以通过额外逻辑拼出看似正常的评论列表;但 API 设计时就会遇到问题:一次请求到底返回多少层评论,超长列表如何取,是否要用后台任务异步拉取。
这类问题会让 API 使用者被迫理解系统内部实现,而这通常是坏 API 的来源。好的 API 应该呈现产品的合理抽象,而不是把实现细节推给调用方。
认证要让起步足够简单
公共 API 应该支持长期有效的 API key。OAuth、短期 token 和更严格的凭证机制在安全上更好,也通常应该支持;但很多集成一开始只是一个简单脚本,API key 是让脚本跑起来的最低摩擦方式。
API 使用者并不一定都是职业工程师。销售、产品经理、学生、业余开发者都可能写一点代码来接入系统。如果一个 API 从第一步开始就要求完成 OAuth handshake,很多潜在用户会卡在门口。
对外 API 的设计不能只面向熟练工程师。降低上手成本,本身就是产品能力的一部分。
幂等性让重试变得安全
请求成功时,调用方知道操作已经完成;请求失败时,情况就复杂得多。422 这类验证错误通常说明操作还没发生,但 500、网络超时或连接中断并不能说明后端到底执行到哪一步。
如果调用方请求创建评论时超时,评论可能已经创建,也可能没有。如果盲目重试,可能会创建两条评论。评论重复还算小事,如果操作是转账、发药或其他高风险行为,重复执行就会变成严重事故。
解决方式是幂等性。常见做法是在请求里带上 idempotency key,后端收到带 key 的创建请求时,先检查这个 key 是否已经处理过:
如果处理过,直接返回之前的结果或不再重复执行;
如果没处理过,执行操作并保存这个 key。
这样调用方可以带着同一个 key 多次重试,而实际操作只会发生一次。
idempotency key 可以存到资源表,也可以存到 Redis 这类 key-value store。高风险场景需要更严格的原子性设计;但对很多已有的非幂等接口来说,补一个可用的幂等层也比完全没有保护好。
并不是所有请求都需要幂等 key。读请求重复执行通常无害;按资源 ID 删除时,资源 ID 本身就能起到类似作用。多数 API 可以把幂等 key 做成可选能力,但支付、资金、医疗等高风险操作应该更严格。
API 必须考虑安全阀和限流
UI 用户受手速限制,API 用户受代码速度驱动。一个在 UI 里很少被连续触发的昂贵流程,放到 API 里可能被循环调用到拖垮后端。
因此公开 API 应该有 rate limit,昂贵操作应该有更紧的限制。系统还应该保留按客户临时关闭 API 的能力,在某个集成异常放大流量时,能先保护后端。
响应里也应该包含限流元数据。例如 X-Limit-Remaining 和 Retry-After 这类 header 可以帮助调用方写出更克制的客户端。服务端给出清晰信号后,也更容易采用严格的限流策略。
大列表默认考虑分页
几乎所有 API 都会返回列表,有些列表可能非常长。直接返回全部记录,会让数据库查询、应用层序列化和网络响应都承受不必要的压力。
最简单的分页方式是 page 或 offset,例如 /tickets?page=2 或 /tickets?offset=20。它实现容易,但在超大数据集上会越来越慢,因为数据库需要先跳过前面的记录。
对可能变大的数据集,cursor-based pagination 更稳妥。客户端拿上一页最后一条记录的 cursor,请求下一页;服务端通过索引定位 cursor 后继续取固定数量的记录。这样无论翻到多后面,查询成本都更稳定。
小数据集可以继续用 page 或 offset;但如果数据未来可能膨胀,最好一开始就采用 cursor。否则等规模问题出现后再迁移,成本会很高。
列表响应里最好直接返回 next_page 或类似字段。不要让客户端自己推导下一页该怎么请求。
昂贵字段默认关闭
如果某些响应字段很贵,就不要默认返回。比如用户订阅状态需要后端再调用另一个服务获取,可以让 /users/:id 默认不返回 subscription,只有请求带上 include_subscription 或 includes: ["subscription"] 时才返回。
GraphQL 的一部分价值也来自这个思路:调用方声明自己需要哪些数据,后端只返回这些数据。
但 Sean 对 GraphQL 保持谨慎,原因主要有三点:
对非工程师和许多工程师来说,GraphQL 的入门门槛明显高于
GET /users/1;允许用户构造任意查询,会增加缓存和边界情况处理难度;
后端实现通常比普通 REST API 更繁琐。
GraphQL 不是不能用,但不应该只因为它灵活就默认采用。很多接口用 REST 加可选字段已经足够。
内部 API 可以宽松一点,但不是不用设计
前面的讨论主要针对公共 API。内部 API 的环境不同:使用者通常是同事,技术能力更接近维护者;breaking change 也更容易协调,因为服务数量更少,维护者往往能直接修改所有调用方。
内部 API 也可以采用更复杂的认证方式,因为使用者和运行环境都更可控。
但内部 API 仍然可能引发事故。关键操作依然需要幂等性,昂贵操作依然需要限流或保护机制。内部不等于可以随意设计,只是迁移和沟通成本更低。
可以带走的原则
API 很难设计,因为它一旦发布就不灵活,但使用时又必须容易理解。
公共 API 的第一责任是不要破坏下游用户。
版本化可以帮助做 breaking change,但会带来长期维护和用户迁移成本。
API 成功与否主要取决于产品本身,API 质量通常只是边际因素。
糟糕的产品模型通常会产生糟糕的 API。
认证要支持简单 API key,降低第一步集成成本。
会产生副作用的请求应该支持幂等 key,让重试安全。
API 调用以代码速度发生,因此必须有限流、安全阀和清晰的限流元数据。
可能变大的列表优先使用 cursor-based pagination。
昂贵字段应该默认关闭,通过 include 参数按需打开。
GraphQL 能解决一部分灵活性问题,但也会引入复杂度。
内部 API 的约束不同,但仍然需要认真处理幂等、限流和事故边界。
REST 与 SOAP、JSON 与 XML 的选择,通常不如兼容性、产品模型、幂等性、限流和分页直接影响使用成本。OpenAPI schema 很有用;如果团队更适合用 Markdown 写清楚文档,也可以沿用 Markdown。