API 是永久性的。这听起来可能有些夸张,直到你花了三周时间协调十四个客户端团队的破坏性变更,同时保持旧端点在兼容性适配器中运行,而这个适配器没人愿意维护,每个人都害怕删除。API 的永久性——一旦客户端依赖它们就会固化——正是使得API 设计错误成为值得认真研究的生产经验教训的原因。糟糕的代码可以悄悄重构。糟糕的 API 合约是一笔公共债务,每个季度都在产生利息。
以下八种模式来自那些在当时看起来合理、已发布到生产环境,然后在用户增长或需求变更时造成实际痛苦的设计。它们不是学术上的错误。它们是那些在周五下午看起来没问题,却在周二早晨变成事件工单的决定。
错误 1:从一开始就不进行版本控制
反对在需要之前添加版本控制的论点听起来很合理:你还不知道会有什么变化,在每个 URL 中添加 /v1/ 感觉为时过早,而且你总是可以稍后添加。问题是,”稍后” 在第一个外部客户端投入生产的那一刻就到来了,到那时,没有迁移你就不能自由地添加版本控制。你已经把自己锁死了。
三种常见的版本控制策略各有不同的权衡。URL 路径版本控制 — /v1/users、/v2/users — 是最明确的。它在基础设施层面很容易路由,在日志中易于理解,版本对任何阅读 URL 或 curl 命令的人来说都是可见的。代价是客户端必须在版本变更时更新基础 URL。这是 Stripe 采用的方法,其清晰度是 Stripe API 被认为是行业最佳设计之一的重要原因。
基于头的版本控制 使用请求头如 Accept: application/vnd.api+json;version=2 或自定义头如 API-Version: 2024-01-15。GitHub 的 REST API 使用基于日期的头版本控制方案。优点是 URL 保持稳定;缺点是在浏览器地址栏中版本控制变得不可见,没有适当的工具就更难测试。查询参数版本控制 — /users?version=2 — 处于一个尴尬的中间地带,大多数从业者已经远离它,主要是因为它容易被错误缓存且容易被意外省略。
对于大多数团队来说,正确的答案是 URL 路径版本控制。它的明确性避免了基于头的版本控制引入的细微错误和查询参数导致的缓存问题。无论你选择什么,都要在第一个外部客户端之前做出选择。
错误 2:不一致的命名约定
不一致性是一种 API 文档债务。您命名中的每一次不一致都是一个歧义,消费者必须通过反复试验或阅读他们本不需要阅读的源代码来解决。这种损害累积的速度比您预期的要快。
最常见的不一致性是在同一个 API 中混合使用 camelCase 和 snake_case。当不同的端点由不同的开发人员或在不同的时间构建时,就会发生这种情况。编写通用请求处理程序的客户端只有在他们的 snake_case 解析器默默地丢弃了一个 camelCase 字段时才会发现这种不一致性。JSON 没有规范约定 — JavaScript 环境倾向于使用 camelCase,Ruby 和 Python 环境倾向于使用 snake_case — 因此选择哪种格式不如保持一致重要。选择一种格式,并在序列化层或 linter 中强制执行,而不是通过约定和希望。
复数与单数端点命名同样重要。在同一个 API 中,/user 和 /users 作为两个不同的端点 — 一个返回单个用户,一个返回集合 — 会让人们产生真正的困惑,不知道 /order 是否与 /orders 同时存在。广泛采用的 REST 约定对集合使用复数(/users、/orders),对单个资源使用嵌套标识符(/users/{id}、/orders/{id})。这种约定存在的原因正是为了让开发人员不必记住每个资源是单数还是复数。
命名不一致也出现在布尔字段(is_active 与 active 与 enabled)、时间戳字段(created_at 与 createdAt 与 creation_time)以及不同端点之间名称不同的错误字段中。风格指南不是官僚主义 — 它是让 API 感觉像一个连贯产品,而不是恰好共享一个域的独立服务集合的机制。
错误 3:默认返回过多数据
一个返回资源所有信息的 API 端点似乎很慷慨。实际上,这是一种伪装成便利性的性能负担。当您的 /users/{id} 端点返回用户的个人资料、偏好、通知设置、账单历史、活动日志和关联的社交账户时,每个客户端 — 包括只需要用户姓名和头像的移动应用 — 都要为他们立即丢弃的数据支付序列化、传输和解析成本。
N+1 问题是一个密切相关陷阱。一个返回订单列表的 /orders 端点,其中每个订单都包含从数据库单独获取的完整客户对象,会发出一个订单列表查询,然后每个订单再发出一个客户查询。十个订单会变成十一个数据库查询。一百个订单会变成一百零一个。这不是扩展性问题——这是一个正确性问题,当有人首次使用真实数据集调用该端点时就会暴露出来。
缺少分页可能是最反复出现的 API 设计错误。一个返回所有交易的 /transactions 端点在开发阶段处理几百条记录时工作正常,但在生产环境中处理几十万条记录时会静默失败或灾难性崩溃。分页应该是默认设置,而不是可选功能。问题不在于是否要分页,而在于使用哪种策略:基于偏移的分页(?page=2&per_page=50)熟悉且简单,但在遍历过程中有记录插入或删除时会产生不正确的结果;基于游标的分页使用结果集中某个位置的不透明指针,并且在并发修改下保持稳定。对于经常添加记录的时间序列数据或信息流,基于游标的分页是正确的默认选择。Stripe 在其整个 API 中使用基于游标的分页,考虑到金融环境中一致性的重要性,这并非巧合。
字段选择——允许客户端指定他们需要的确切字段——在协议层面解决了过度获取问题。GraphQL 将其作为核心原语。REST API 可以通过 fields 查询参数实现:/users/{id}?fields=id,name,email。这不是过度工程;这是单个 API 端点既能服务于数据密集型仪表板,又能服务于带宽受限的移动应用程序,而无需维护单独端点变体的机制。
错误 4:糟糕的错误响应
没有响应体的通用 500 Internal Server Error 不是错误响应。它是一种伪装成响应的信息缺失。调试它需要应用程序日志或猜测,而这些对外部 API 消费者都不可用。
一个设计良好的错误响应应包含四个要素:准确反映问题类别的 HTTP 状态码、客户端可编程处理的机器可读错误码、解释问题所在的人类可读消息,以及足够用于重现或诊断问题的上下文。Stripe 的错误响应是值得模仿的标准。当支付失败时,Stripe 不仅返回状态码和消息,还会返回一个特定的 code 字段(card_declined、insufficient_funds、expired_card)、包含更详细信息的 decline_code,以及指向相关文档的 doc_url。每个错误都有一个机器可读的身份标识,客户端代码可以针对性地响应,而非泛泛处理。
状态码的选择比开发者通常认为的更重要。返回包含 error: true 字段的 200 OK 是一个错误,它迫使每个客户端在知道请求是否成功之前必须解析响应体——这完全违背了 HTTP 状态码的初衷。当请求语法正确但语义错误时,422 Unprocessable Entity 比 400 Bad Request 更合适。当资源已存在时,409 Conflict 比 400 提供更多信息。这些区别并非吹毛求疵——它们是允许客户端处理不同故障模式而无需为每个端点硬编码响应体解析的词汇。
验证错误响应值得特别关注。当表单提交验证失败时,客户端需要知道哪些字段失败以及原因,而不仅仅是”验证失败”。包含一个以字段名为键的结构化 errors 对象,每个字段对应一个错误消息数组,这样客户端应用程序就可以显示内联验证反馈,无需额外的 API 调用。
错误 5:将认证作为事后考虑
认证策略的决策常常被推迟到 API 功能完成之后,此时才以最小考虑的方式附加上去。这导致认证方案与实际用例不匹配,要么带来安全问题,要么给消费者带来不必要的复杂性。
API 密钥适用于客户端是已知可信系统而非终端用户的服务器间通信。它们实现简单且使用方便——只需在头部中放置一个秘密值。其弱点是它们通常具有长期有效性且无作用域限制,这意味着泄露的密钥在手动轮换前都能提供完全访问权限。对于开发者使用的公共 API,如果结合每个密钥的速率限制以及能够为每个账户签发具有不同权限范围的多重密钥的能力,API 密钥是一个合理的默认选择。
OAuth 2.0适用于当您的 API 代表最终用户操作、当第三方应用程序需要委托访问用户数据,或者当您需要用户明确授予的细粒度权限范围时。正确实现 OAuth 2.0 显著更加复杂,但这种复杂性是有充分理由的——带有 PKCE 的授权码流程提供了更简单方案无法提供的防拦截攻击保护。GitHub 的 API 使用 OAuth 实现第三方应用程序访问,使用个人访问令牌(API 密钥的一种复杂变体)实现开发者工具访问。这种区别是刻意设计的。
JWT(JSON Web Tokens)是一种令牌格式,而不是身份验证方案,尽管它们经常被混为一谈。JWT 在无状态身份验证中很有用,服务器可以在每次请求时避免数据库查找——令牌本身编码了服务器可以加密验证的声明。常见的失败模式是将 JWT 视为自动安全,而不理解负载仅仅是 base64 编码且任何人都可以读取,算法选择至关重要(none 和 HS256 具有不同的安全配置文件),并且撤销需要设置短期过期时间或服务器端令牌存储——这重新引入了 JWT 旨在消除的状态性。
错误 6:没有速率限制
没有速率限制的 API 会无意中导致拒绝服务。客户端重试循环中的错误、开发者在测试期间意外在紧密循环中调用端点、具有异常高流量的合法用户——任何这些情况在没有速率限制的情况下都可能使 API 性能下降,影响所有用户。这不是理论上的担忧。这就是为什么几乎所有拥有超过少数消费者的公共 API 都实施速率限制的原因。
生产环境中两种最实用的速率限制算法是令牌桶和滑动窗口。令牌桶算法维护一个以固定速率补充的虚拟令牌桶——每个请求消耗一个令牌,当桶为空时请求被拒绝。它能优雅地处理突发流量:一段时间内保持安静的客户端积累了令牌,可以在达到限制前发出一批请求。这比固定的刚性窗口更符合合法高价值客户端的行为模式。
滑动窗口算法在滚动时间周期内跟踪请求数量,而不是固定间隔。它消除了固定窗口的边界问题——客户端可以通过将请求分散在窗口边界上来使请求数量达到允许的两倍——代价是需要维护稍多的状态。对于大多数 API,使用一分钟或一小时窗口的滑动窗口是最清晰易懂且易于向消费者传达的实现方式。
速率限制应在响应头中提供有用信息。例如,Twilio 的 API 在每次响应中都会返回 X-Rate-Limit-Limit、X-Rate-Limit-Remaining 和 X-Rate-Limit-Reset 头信息。这使得行为良好的客户端能够实现自适应退避,而无需猜测。429 响应中的 Retry-After 头信息会明确告诉客户端何时可以重试。在响应头中发布速率限制信息,将速率限制从一种粗略的工具转变为 API 与客户端之间的协作。
错误 7:忽略幂等性
网络请求的失败方式难以与成功区分开来。请求超时了——服务器是否收到了请求但未能响应,或者请求从未到达?客户端在发送请求后断开连接——服务器是否在连接断开前处理了请求?对于读取操作,这种模糊性是无害的:重试请求会得到相同的结果。对于写入操作,特别是在金融或库存上下文中,盲目重试可能会创建重复记录、双重收费或不一致的状态。
幂等密钥通过为每个预期操作(而不是每个 HTTP 请求)提供唯一标识来解决这个问题。客户端为每个计划执行一次的操作生成一个唯一密钥(通常是 UUID),并将其包含在请求头中,如 Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000。服务器存储密钥和第一次成功处理的结果,并对于任何具有相同密码的后续请求返回存储的结果——而不重新处理该操作。Stripe 要求所有创建或修改财务数据的 POST 请求都使用幂等密钥。这不是一个可有可无的优化;构建可靠的金融集成的基本要求。
实现需要一个具有适当 TTL(幂等密钥保留 24 小时)的持久化存储来存储密钥-结果对。开销是适度的。替代方案——即”在网络条件罕见的情况下,您可能会收到重复收费”——对于正确性是业务要求而非性能考虑的操作来说是不可接受的。
错误 8:将内部模型与 API 响应耦合
最阴险的 API 设计错误是那种感觉最高效的错误:直接将内部域对象序列化为 API 响应。这种效率是真实的——没有映射层,没有转换代码,内部状态和外部表示之间保持即时一致。代价是您的 API 合约成为数据库模式和内部实现选择的直接反映,这意味着任何内部重构都可能成为外部客户端的潜在破坏性变更。
当你的 /users 端点返回原始数据库行时,重命名列需要要么进行 API 版本升级,要么与每个客户端协调变更。当你的内部用户模型开始分别存储 first_name 和 last_name,而不是作为一个单一的 name 字段时,这种结构变更会立即传播给 API 消费者,而这些消费者对此毫无发言权。当你在模型中添加像 password_hash 或 stripe_customer_id 这样的内部字段时,如果你的序列化不够有选择性,你可能会意外地在 API 响应中暴露它。
数据传输对象(DTO)——明确定义 API 响应结构的类或序列化模块——在内部表示和外部契约之间创建了一个明确的边界。API 响应中的每个字段都存在是因为有人选择包含它,而不是因为它恰好存在于内部模型中。GitHub 的 API 响应对象在内部重构过程中表现出显著的稳定性;序列化层会在变更到达 API 表面之前吸收这些内部变更。额外的代码不是开销——它是防止你的下一次数据库迁移变成下一次 API 事故的代码。
Stripe、GitHub 和 Twilio 如何设定标准
这三个最常被引用为值得研究的 API 设计模型并非偶然达到其质量水平。他们做出了特定且深思熟虑的选择,这些选择值得被命名。
Stripe 的 API 最引人注目的是其一致性。相同的模式——错误格式、分页、版本控制、幂等键——适用于每个端点。版本控制策略是基于日期的(2024-06-20)而非基于整数的,这传达出每个版本代表特定日期存在的 API,而非任意递增。新的 API 版本是按账户选择加入的,并且 Stripe 会保留旧版本数年。在一个十年间大幅扩展的 API 表面上维持这种一致性所需的纪律性是显著的。
GitHub 的 API 根据用例清晰地区分其 REST API 和 GraphQL API。REST API 使用可预测的基于资源的 URL 和一致的超媒体链接。GraphQL API 允许客户端精确指定他们需要的字段——直接解决了复杂查询的过度获取问题,否则这些查询需要多个 REST 请求。同时提供两者并非犹豫不决;这是认识到不同的消费者确实有不同需求的表现。
Twilio 的 API 从一开始就围绕开发者体验进行设计。错误信息是写给在凌晨 2 点处理事故时阅读的开发者的,而不是写给可能之后查阅文档的律师。速率限制头存在于每个响应中。Webhook 负载包含足够的上下文,使客户端能够处理事件而无需额外的 API 调用。这些细节累积成一个开发者信任并持续构建的 API,这也是良好 API 设计带来的商业成果。
“足够好”的 API:何时停止设计并发布
在另一个极端存在一种失败模式:在你了解 API 需要支持什么之前过度设计它。为一个还没有外部客户端的产品设计完整的超媒体 API 和 HATEOAS 链接,是在解决一个不存在的问题,同时推迟了理解客户端实际需求的工作。
API 质量的实用标准大约是这样的:API 是否正确、一致地处理实际需求,并且不会为消费者制造陷阱?它是否从一开始就支持版本控制?它是否有有意义的错误响应?它是否对集合进行分页?它是否根据威胁模型进行适当的身份验证?如果这些问题的答案是肯定的,那么其余的改进可以推迟到实际使用揭示出什么才是重要的。
要避免的陷阱是将”足够好可以发布”与”不会以后造成麻烦”混淆。速率限制、幂等性键和 DTO 边界不是可有可无的功能——它们是在 API 有大量使用前实现起来很简单,而在事后改造起来却真正昂贵的决策类别。可以安全等待的设计工作是润色:全面的字段选择、高级查询功能、复杂的缓存头。不能等待的设计工作是结构性的东西:版本控制、错误格式、身份验证模型,以及内部模型和外部契约之间的分离。
API 是少数几个工程成果之一,其中错误成本由你组织之外的人支付,时间线不受你控制,时间可能比你继续在这个代码库上工作的时间还要长。这种不对称性——即糟糕设计带来的痛苦被外部化并分散到所有消费者身上——就是为什么 API 设计比大多数内部工程决策需要更多关注的原因。第一次就把它做对。你的未来自我、你的消费者,以及任何继承这个代码库的人都会感激你。
常见问题
团队在生产环境中最常见的 API 设计错误是什么?
缺少版本控制是最普遍且最令人痛苦的错误,因为它在外部客户端出现之前是不可见的,而一旦需要回溯添加,就需要协调迁移。糟糕的错误响应紧随其后——它们几乎存在于每个早期 API 中,并在客户端集成和调试过程中造成显著摩擦。
REST API 是否应该总是使用 URL 路径版本控制?
URL 路径版本控制是公共 API 最广泛推荐的方法,因为它明确、易于在基础设施层路由,并且在日志和调试工具中可见。基于头的版本控制(如 GitHub 使用的方法)是一个合理的选择,特别对于那些 URL 稳定性对消费者很重要的 API。重要的是在选择策略并实施它,在任何外部客户端投入生产之前。
API 什么时候需要幂等性密钥?
幂等性密钥对于任何创建或修改状态的 POST 操作至关重要,因为重复处理会产生不正确的结果——金融交易、订单创建、账户配置以及类似的关键操作。读取操作本质上是幂等的。可以安全重复的写入操作(如将偏好设置设置为特定值)不需要幂等性密钥,但添加它们也无妨。
如何向 API 消费者传达速率限制?
速率限制信息应在每个请求的响应头中返回:总限制、剩余配额以及配额重置的时间。在 429 请求过多的响应中,Retry-After 头应指示客户端何时可以安全重试。发布这些信息可以使行为良好的客户端实现自适应的请求节奏,而不是反复达到限制。
API 设计中认证和授权有什么区别?
认证确定谁在发出请求——即调用者的身份。授权确定该调用者被允许做什么——哪些资源和操作可用。一个常见的 API 设计错误是将两者混为一谈:添加新端点并假设任何已认证的调用者都应该有权访问。授权应该是明确的,每个端点都指定所需的权限范围,并在 API 文档中清晰传达这些范围。
