摘要:作为开发者,我们每天都在跟各种各样的 API 打交道,不论是调用 OpenAI 的接口,还是使用短信服务,API 就像软件世界的数字神经系统,是各种服务和数据的重要管道。
作为开发者,我们每天都在跟各种各样的 API 打交道,不论是调用 OpenAI 的接口,还是使用短信服务,API 就像软件世界的数字神经系统,是各种服务和数据的重要管道。
不过,不同的 API,给开发者的实际体验,差距也许是巨大的。
如何设计出一套「好」的 API?这个问题似乎和「如何设计好的软件系统」一样,充满了花哨的理论和复杂的范式。
但实际上,API 的设计,远不止技术选型与功能拼装。在 Sean Goedecke 看来,API 设计是一门在灵活性与稳定性、简洁性与前瞻性之间权衡的工程艺术。
这篇文章基于 Sean 的实践体会,并结合大厂的开发经验,讲述 API 接口背后的设计哲学。建议先收藏,再细读。看完之后,你对 API 的理解肯定会再上一个台阶。
好的系统设计,往往是「无聊」的;好用的 API,同样也是「无聊」的。
这里的「无聊」不是功能贫乏,而是行为可预期、命名合乎常识、边界清晰明确,熟悉到几乎没有学习门槛。
对使用者来说,API 只是达成目标的工具;他们花在理解 API 本身的额外精力,基本都算浪费。最理想的状态是:开发者在没打开文档之前,也能凭直觉八九不离十地写出第一段调用代码。
这种「直觉」来自于熟悉的通用设计模式,能最大程度地降低开发者的学习成本。
但另一个现实更棘手:API 一旦发布,变更成本极高。任何破坏性的变更都可能触发大量下游应用服务的宕机,没有哪个开发者愿意使用频繁变更的 API 接口。
这就给 API 的设计提出了挑战:一方面,要尽可能简单、直接、符合直觉;另一方面,为了应对可能得需求变化,又需要通过巧妙的设计来保持灵活性。
因此,API 设计的根本目的和最终挑战,就是要在「上手容易」与「可持续演进」之间找到一个平衡点。
这是一句来自 Linux 内核社区的名言。意思很直白:新版本不能把老的用户程序(或下游集成)直接整崩。现代软件生态是一个层层依赖的巨大网络,上游一次不经意的调整,可能引发多米诺效应,让成百上千个服务同时出问题。
这也应该成为 API 构建者的铁律。
那么,什么算破坏性变更?
一般而言,增加性的变更是安全的。比如在返回的 JSON 数据中新增某个字段。除非对 API 的解析过分严格,否则设计合理的客户端会忽略那些未知的字段。
但是,下列行为是绝对禁止的:
删除已有字段:依赖该字段的代码会立即报错。修改字段类型:比如把用户 ID 从数字 123 改成字符串 "user-123"。调整字段结构:例如把 user.address 挪到 user.details.address。不要因为「看起来更整洁」或「当初设计有点别扭」就直接对 API 进行破坏性变更。
一个经典的例子是 HTTP 头里 Referer 的拼写,这其实是单词 referrer 的错误拼写。但几十年过去了,这个错误并没有被修正。原因就是不能轻易破坏既有生态——不破坏用户空间是一种承诺。
在极少的情况下,如果确有必要做出破坏性变更时,版本化是合理的路径。提供 API 版本控制意味着同时支持新旧两个版本的 API 服务:
现有用户继续用旧版,业务不受影响;新用户或准备升级的团队选择新版。常见做法是在 URL 中带版本号(如 /v1/...)。比如 OpenAI 的 Chat Completions 暂挂在 v1/chat/completions;如果未来需要大改,可以在 v2/... 下重构,同时保持 v1 可用。
另一种是 Stripe 的做法:通过请求头控制版本(如 Stripe-Version: 2024-04-10),并允许在后台设置账户的默认 API 版本。
无论 URL 路径还是请求头,目标是一致的:让使用者有安全感,能按自己的节奏升级服务。
尽管版本控制是行业通用的做法,但 Sean 认为「版本控制」其实是「必要之恶」:它虽然能兜底,但也会带来巨大的复杂度:
对用户:认知负担上升。查文档时必须确保阅读与使用的是同一版本。对维护者:运维压力倍增。如果你有 30 个端点,多一个版本就意味着多一组测试和支持事项。成熟的 API 实现会在后端加一个翻译层,把统一的内部模型映射到不同版本的外部协议格式。然而抽象总会泄漏:一些差异最终还是要落到核心逻辑里处理条件分支。
所以如果不到万不得已,尽量不要新增 API 版本。
在深入探讨具体的 API 设计技巧之前,我们需要先理解一件事情:API 是否成功,完全取决于产品。
换句话说:API 本身不创造价值,它只是连接价值的桥梁。
选择用 OpenAI API,是为了获得强大的模型推理能力;选择用 Twilio API,是为了稳定的通信能力;选择用 Stripe API,是为了可靠的在线支付。没有人会因为「接口设计优雅」而选择某个产品。如果产品价值够大,即便 API 不好用,大家也会硬着头皮接入。Facebook、Jira 的 API 在开发者社区里名声不算好,但如果想用它们的能力,你终究得适配它们的规则。
这并不是在否认 API 设计的意义——一个顺手的 API 能显著降低集成成本、提升开发者体验,并在势均力敌的竞争中提供边际优势。
但请记住:API 的采用前提始终是产品本身的价值。
反过来,一个技术上很糟糕的产品,很难产出优雅的 API,因为 API 通常是对核心资源与业务关系的直接映射。当这些资源的内部实现本身就很笨拙时,API 自然也会受到影响。
实践指南:构建稳健且友好的 API1) 认证:从简单开始,拥抱 API Key你应该允许用户使用长生命周期的 API Key 来访问 API,尽管 API key 的安全性不如各种 OAuth 瓶颈。
几乎所有 API 集成都是从一个小脚本起步,而 API Key 是启动门槛最低的方案,首要目标是让开发者尽可能简单地开始使用你的产品。
更重要的是,尽管 API 需要通过代码访问调用,但 API 的用户不一定都是软件工程师——也可能是销售、产品、学生或爱好者。要求他们一上来就走完整套 OAuth 流程,只会把人挡在门外。
建议:
必须提供 API Key 方式降低试用门槛;可选支持 OAuth,满足更高安全要求的生产级场景。当一个 API 请求成功时,很容易知道它已经完成了任务。但当它失败时呢?
422 多半表示校验未过,没有执行实际操作;500 或超时则很模糊——请求可能已经被执行,只是响应途中失败。这在支付等高风险写操作里尤其致命。
幂等性的目标是:同一语义的请求可安全地重试,而不会产生重复执行的效果。最简单做法是给写请求附带一个幂等键(如 Idempotency-Key),由客户端生成唯一值(比如 UUID)。
当服务端处理时:
收到带幂等键的「创建」请求;查询该键是否处理过;如果处理过:直接返回当时保存的成功结果;如果未处理:执行业务,记录(键 → 结果)的映射,再返回成功。这样,客户端遇到超时或 500 时,即便将带同一幂等键的请求重试任意次,都不会产生「重复执行」的副作用。
实现上,用 Redis/数据库小表就够用了,设置合理的 TTL(例如数小时)就能覆盖绝大多数重试窗口。
哪些请求需要幂等?
GET:天然是安全的,重复读取不会造成影响,所以不需要;DELETE:通常以资源 ID 定位,第一次删成功,后续删返回 404,也没有额外副作用;POST:最需要设计幂等性;PUT/PATCH:PUT 倾向于幂等,PATCH 视语义而定(如自增计数就不是)。不过,Sean 也建议,在大多数情况下,幂等性应该是可选的,不要因为概念负担挡住更多人上手。
用户通过 UI 操作受限于手速;但 API 则可以以运行代码的速度被疯狂调用。
因此,要特别小心单次请求中会执行大量工作的 API。
Sean 分享了他在 Zendesk 工作时的一个经历:有个 API 能向某应用的所有用户广播通知,结果有第三方拿它做了「应用内聊天」,每条消息都群发通知。结果对于拥有大量活跃用户的账户,这种调用方法直接把后端服务器搞垮了。
我们没有预料到人们会用这个 API 来构建聊天应用。但实际上,一旦 API 发布出去,人们就会用它做任何他们想做的事情。
现实里,很多事故都来自客户自己在脚本里的「创造性用法」:
频繁、无意义地创建/删除同一批记录;毫无间隔地轮询大列表端点;失败后不采用指数退避,而是无脑地发起重试。应对策略:
速率限制:为整体与高成本端点设定合理阈值;熔断与拉闸:保留对单个账户临时禁用或降级的能力,以便快速缓解压力;配额元数据:在响应头返回 X-RateLimit-Limit、X-RateLimit-Remaining、Retry-After 等,帮助客户端实现更合理的调用节奏。长列表是不可避免的。一次性返回所有数据既不现实,也不安全。因此,API 必须分页。
最直观的做法是偏移量分页(offset/limit),但这种做法在数据量大时会出现严重性能问题:数据库为 OFFSET 100000 之类的查询需要从头跳过十万行,越到后面越慢。
更靠谱的是基于游标(cursor-based)分页:客户端拿到第一页后,记住最后一条记录的标识(如 ID=32),下一页请求带上 cursor=32&limit=10。服务端可直接使用索引执行 WHERE id > 32 ORDER BY id LIMIT 10。这种方式无论在第 1 页还是第 10 万页,性能都稳定。
建议:
那些计算昂贵的字段应默认关闭、按需开启。
例如 /users/:id 默认不返回订阅信息;当请求带 ?include=subscription 时才去做额外调用。
更通用的做法是使用 includes=posts&includes=subscription 这样的数组参数。
这正式 GraphQL 的理念:客户端声明所需字段,服务端一次性组装返回,而不是像 REST 那样通过访问不同的端点来获取不同资源。
但也要警惕三点:
学习门槛较高:对非专业工程师并不友好;查询形态难以控制:给予用户构建任意查询的自由,会使缓存变得更复杂,并增加需要处理的边缘情况;后端实现负担重:GraphQL 的后端实现比标准的 REST API 要繁琐得多。根据 Sean 的经验,尽管在某些确实需要高度自由查询的场景下,GraphQL 带来的灵活性足以抵消其成本。但在大多数场景里,按需加载已足够灵活。
上面的讨论都是针对外部 API,公司内部使用的 API 则有所不同:
内部 API 的用户一般都是专业的开发同事;当需要进行破坏性变更时,可以更简单地协调所用用户;可以要求更复杂的认证形式。但 API 设计的底线应该是不变的:幂等性、限流、熔断同样重要。如果不加以限制,内部 API 一样可能成为事故源头。
最后,Sean 将他关于 API 设计的核心思想总结为以下几点:
API 之所以难构建,是因为它们既要易于上手,又要保持稳定,难以变更。API 维护者的首要职责是绝不破坏用户空间。永远不要对公共 API 进行破坏性更改。版本控制可以让你进行变更,但会带来巨大的实现和采用成本,是万不得已的最后手段。如果产品足够有价值,API 设计得再烂也有人会用。如果产品设计得很糟糕,无论多仔细地设计 API,它很可能还是很烂。API 应该支持简单的 API 认证,因为许多用户并非专业工程师。执行写操作的请求(特别是像支付这样的高风险操作)应该包含某种幂等键,以确保重试的安全性。API 永远是事故的潜在来源,确保有速率限制和熔断开关。对于可能变得非常大的数据集,使用基于游标的分页。将高成本的字段设为可选且默认关闭。GraphQL 有点杀鸡用牛刀。内部 API 在某些方面有所不同,因为用户群体完全不同。归根到底,优秀的 API 设计是一种务实的、以用户为中心的工程实践,是在简单与灵活、当前与未来之间不断权衡的艺术。
而那些具体的工具与范式反而是次要的;理解背后的取舍与原则,才是让 API 长期好用、被开发者喜爱的根本。
来源:智慧芯片一点号