我对好的 API 设计的所有了解
现代软件工程师的工作1大多与 API 相关:用于与程序通信的公共接口,例如 Twilio 的 这个 API。我花了很多时间与API打交道,既参与开发也使用它们。我曾为第三方开发者编写过公共API,也为内部使用(或供单个前端页面调用)开发过私有API,还设计过REST和GraphQL API,甚至包括命令行工具等非网络接口。
就像设计良好的软件系统一样,我认为关于API设计的许多建议过于花哨。人们过于关注什么是“真正的”REST,或者HATEOAS是否是个好主意,等等。这篇文章是我试图将我所知道的关于设计良好API的一切写下来的尝试。
API设计是熟悉度与灵活性的平衡
如果这对于系统而言是正确的——而确实如此——那么对于API而言更是如此:优秀的API是平淡无奇的。一个有趣的API是一个糟糕的API(或者说,如果它少一些有趣,会更好)。对于开发者而言,API是他们花费时间设计和打磨的复杂产品。但对于使用它们的开发者而言,API是实现其他目标的工具。任何用于思考API而非目标的时间都是浪费。从他们的角度来看,理想的API应如此熟悉,以至于在阅读任何文档之前,他们就能大致知道如何使用它2。

然而,与大多数软件系统相比,API有一个重大区别:API很难更改。一旦发布API且用户开始使用,任何接口更改都会导致用户软件无法正常运行。当然,更改是可能的。但(如我下面将解释的)每次更改都会带来严重成本:每当迫使用户更新软件时,他们都会认真考虑使用更稳定的其他API。这为API构建者提供了强烈的动力,促使他们谨慎设计并一次性做好。
这种矛盾导致了构建API的工程师面临有趣的动态。一方面,他们希望构建尽可能简单的API。另一方面,他们希望通过巧妙设计来长期保持灵活性。从宏观角度看,API设计就是在这两个相互矛盾的目标之间寻找平衡。
我们不破坏用户空间
当需要对API进行修改时会发生什么?增量修改(例如在响应中添加新字段)通常是可以接受的。有些消费者在收到超出预期的字段时会出错,但在我看来这是不负责任的。您应假设 API 消费者会忽略意外字段(支持 JSON 解析的类型化语言默认会这样做)。
然而,您不能删除或更改字段类型。您不能更改现有字段的结构(例如,将 JSON 响应中的 user.address
移动到 user.details.address
)。如果你这样做,依赖这些字段的每一行代码都会立即崩溃。这些代码的消费者会将其报告为 bug,而代码维护者(当他们弄清楚原因后)有理由对你故意破坏他们的软件感到愤怒。
这里的原则类似于林纳斯·托瓦兹(Linus Torvalds)的著名口号我们不破坏用户空间。作为API的维护者,你几乎有神圣的职责去避免伤害你的下游用户。这个规范如此严格,是因为如此多的软件依赖于如此多的API(这些API又依赖于上游API,依此类推)。一个上游API维护者的一时疏忽,就可能破坏数百或数千个下游软件。
您绝不应仅因更改会让代码更简洁,或因现有实现略显笨拙而修改 API。HTTP 规范中的“referer”头部是“referrer”一词的拼写错误,但他们从未修改它,因为我们不能破坏用户空间。
修改API而不破坏用户空间
坦白说,很难想到API真的需要破坏性更改的例子。但有时更改的技术价值足够高,你决定咬紧牙关还是要进行更改。在这种情况下,如何负责任地修改API?答案是_版本控制_。
API 版本控制意味着“同时提供 API 的旧版本和新版本”。现有用户可以继续使用旧版本,而新用户可以选择使用新版本。实现这一目标的最简单方法是在 API 网址中添加类似 /v1/
的前缀。OpenAI 的聊天 API 位于 v1/chat/completions,因此如果他们需要彻底重构 API 结构,可以将新版本迁移至 v2/chat/completions
,同时确保现有用户继续正常使用。
一旦新旧版本同时运行,就可以开始通知用户升级到新版本。这个过程需要很长时间:数月甚至数年。即使在网站上添加横幅、文档、自定义邮件以及 API 响应头部提示,当最终移除旧版本时,仍会有一部分用户因软件无法正常运行而感到愤怒。但至少你已经尽了最大努力。
API版本控制还有许多其他实现方式。Stripe API通过头部进行版本控制,并允许用户在界面中设置默认版本。但核心原则相同——任何使用Stripe API的用户都可以确信Stripe不会单方面破坏他们的应用程序,且可按自身节奏进行版本升级。
我不喜欢 API 版本控制。我认为它充其量是一种必要的恶,但它仍然是恶。它会让用户感到困惑,因为他们无法在不确认版本选择器与所用版本匹配的情况下轻松搜索你的 API 文档。而对于维护者来说,这简直是噩梦。如果你有三十个 API 端点,每次添加新版本都会引入三十个新的端点需要维护。你很快就会面临数百个需要测试、调试和客户支持的 API。
当然,添加新版本并不会使你的代码库规模翻倍。任何合理的 API 版本控制后端都会有一个类似于翻译层的机制,可以将响应转换为任何公开的 API 版本。Stripe 有类似的实现:所有版本的业务逻辑保持一致,因此只需在参数序列化和反序列化时考虑版本差异。然而,此类抽象层总会存在泄露风险。参见这位 Stripe 员工在 2017 年发表的 HN 评论,指出某些版本变更需要在“核心代码”中添加条件逻辑。
简而言之,你应该只在万不得已时才使用 API 版本控制。
API 的成功完全取决于产品
API 本身并不能做任何事情。它是用户与他们真正想要的东西之间的一个层。对于 OpenAI API,那就是使用语言模型进行推理的能力。对于Twilio API,则是发送短信。没有人会因为API本身设计得如此优雅而使用它。他们使用它来_与你的产品互动_。如果你的产品足够有价值,用户即使面对一个糟糕的API也会蜂拥而至。
这就是为什么一些最受欢迎的 API 难以使用。Facebook 和 Jira 以糟糕的 API 闻名,但这无关紧要——如果你想与 Facebook 或 Jira 集成(而你确实想),你就需要花时间去弄清楚它们。当然,如果这些公司能提供更好的 API 当然更好。但既然人们无论如何都会与之集成,为什么还要花时间和金钱去改进它呢?编写良好的API是非常困难的。
我在本文的其余部分将提供大量关于如何编写良好API的具体建议。但值得记住的是,大多数时候这并不重要。如果你的产品具有吸引力,任何勉强能用的API都足够;如果产品不具吸引力,无论API多么优秀都无济于事。API质量是一个边际特性:它只在消费者在两个基本等同的产品之间做出选择时才重要。
顺便说一句,API的_存在_是一个完全不同的故事。如果一个产品根本没有API,那是一个大问题。技术用户会要求通过代码与他们购买的软件进行集成。
设计糟糕的产品通常会有糟糕的API
一个技术上优秀的API无法拯救一个无人问津的产品。然而,一个技术上糟糕的产品几乎不可能构建出一个优雅的API。这是因为API设计通常与产品的“基本资源”相关联(例如,Jira的基本资源包括问题,项目、users 等)。当这些资源被不合理地设置时,API 也会变得不合理。
以一个博客系统为例,该系统将评论以链表形式存储在内存中(每个评论都有一个 next
字段,指向线程中的下一个评论)。这是存储评论的糟糕方式。将 REST API 粗暴地附加到此系统上的简单方法是创建一个类似于以下的接口:
GET /comments/1 -> { id: 1, body: “...”, next_comment_id: 2 }
或者更糟糕的是,像这样:
GET /comments -> {body: “...”, next_comment: { body: “...”, next_comment: {...}}}
这可能看起来是个愚蠢的例子,因为实际操作中你只需遍历链表并返回API响应中的评论数组。但即使你愿意做额外的工作,你需要遍历到多深?在一个有数千条评论的线程中,是否根本无法获取前几百条之后的任何评论?你的评论获取API是否必须使用后台任务,迫使接口变成类似以下形式:
POST /comments/fetch_job/1 -> { job_id: 589 }
GET /comments_job/589 -> { status: ‘complete’, comments: [...] }
这就是一些最糟糕的API产生的方式。可以在UI中巧妙隐藏的技术限制在API中被赤裸裸地暴露出来,迫使API消费者理解比他们合理应该了解的更多的系统设计。
认证
您应该允许用户使用长期有效的API密钥访问您的API。是的,API密钥的安全性不如OAuth等短期凭证(您也应支持此类凭证)。但这并不重要。每个与API的集成最初都以简单脚本的形式开始,而使用API密钥是让简单脚本快速运行的最便捷方式。您希望尽可能降低工程师的入门门槛。
尽管 API 的使用者(几乎可以说是定义上的)会编写代码,但你的许多用户并非专业工程师。他们可能是销售人员、产品经理、学生、业余爱好者等。当你在科技公司作为工程师构建 API 时,很容易想象自己是在为像自己这样的人构建:全职、有能力、专业的软件工程师。但事实并非如此。你是在为一个非常广泛的人群构建它,其中许多人并不擅长编写或阅读代码。如果你的 API 要求用户执行任何困难的操作——比如进行 OAuth 握手——许多用户会感到困难。
幂等性和重试
当API请求成功时,你知道它完成了预期的操作。那么当请求失败时呢?某些类型的失败会告诉你发生了什么:422通常表示在请求验证阶段失败,即在任何操作执行之前3。但500错误呢?超时错误呢?
这对于需要执行操作的 API 操作至关重要。例如,如果你通过 Jira API 创建问题评论,而请求出现 500 错误或超时,是否应该重试?你无法确定评论是否已创建,因为错误可能发生在该操作之后。如果重试,可能会导致发布两条评论。当涉及的风险高于一条Jira评论时,这个问题尤为重要。例如,如果你在转账一定金额,或者在分发药物?
解决方案是_幂等性_,这是一个专业术语,意为“请求应可安全重试且不会产生重复数据”。实现这一功能的标准方法是在请求中支持“幂等性密钥”(例如,参数或标头中的一些用户定义字符串)。当 API 服务器收到带有幂等性密钥的“创建评论”请求时,它会首先检查是否之前见过这个幂等性密钥。如果之前见过,则不做任何操作;否则,它会创建评论,然后保存幂等性键。这样,你可以发送任意多次重试请求,只要它们都带有相同的幂等性键,操作只会执行一次。
如何存储密钥?我见过有人以某种持久化、资源特定的方式存储密钥(例如作为comments
表中的列),但我认为这并非严格必要。最简单的方法是将它们存储在Redis或其他类似的键值存储中(使用幂等性密钥作为键)。UUID 足够唯一,因此无需按用户进行范围限定,但你也可以这样做。如果你不处理支付相关操作,甚至可以设置它们在几小时后过期,因为大多数重试操作都会立即执行。
每个请求都需要幂等性键吗?其实读取请求不需要,因为重复读取无害。删除请求通常4也不需要,因为若按资源ID删除,该ID本身即可作为幂等性键。想想看——如果你连续发送三个 DELETE comments/32
请求,它不会删除三个评论。第一个成功的请求会删除 ID 为 32 的评论,而剩下的请求在找不到已删除的评论时会返回 404。
对于大多数情况,幂等性应该是可选的。如我之前所说,你需要确保 API 对非工程师用户(他们往往认为幂等性是个复杂概念)友好。从整体来看,让更多人使用你的 API 比偶尔出现因用户未阅读文档而导致的重复评论更重要。
安全与速率限制
与 UI 互动的用户受限于手动操作的速度。如果某个流程对后端来说成本很高,恶意或粗心的用户只能以他们点击的速度触发该流程。API 不同。通过 API 暴露的任何操作都可以以代码的速度调用。
要小心那些在单个请求中执行大量工作的 API。在我任职于 Zendesk 期间,我们有一个 API,允许你将通知分发给某个应用的所有用户。一些有创意的第三方开发者5利用此API构建了一个应用内聊天系统,其中每条消息都会向该账户中的其他所有用户发送通知。对于拥有超过少量活跃用户的账户,这会可靠地导致应用后端服务器崩溃。
我们并未预料到有人会基于此API构建聊天应用。但一旦该API发布,人们便会按自己的意愿使用它。我参与过许多次故障排查会议,其根本原因往往是某些自定义客户集成在执行一些毫无意义的操作,例如:
- 每分钟重复创建和删除同一条记录数百次,且毫无必要
- 持续轮询一个大型
/index
端点,且中间没有延迟 - 在发生错误时不进行退避处理,大量导入或导出数据
你应该为 API 设置速率限制,并对耗时操作设置更严格的限制。同时,保留临时禁用特定客户 API 的能力也是明智的,这样当后端系统承受巨大压力时,你可以减轻其负担。
在 API 响应中包含速率限制元数据。X-Limit-Remaining
和 Retry-After
头部可为客户端提供所需信息,使其成为 API 的友好使用者,并允许您设置比常规更严格的速率限制。
分页
几乎每个 API 都需要处理大量记录。有时记录列表非常庞大(例如 Zendesk 的 /tickets
API 可能包含数百万张工单)。如何处理这些记录?
直接使用 SELECT * FROM tickets WHERE...
这种简单查询会耗尽可用内存(无论是在数据库层面还是在尝试序列化百万条记录的应用层)。你无法在单次请求中返回所有工单。因此必须使用分页。
最简单的分页方式是使用页码(或更通用的“偏移量”)。当访问 /tickets
时,你将获得账户中的前十条工单。要获取更多记录,需访问 /tickets?page=2
或 /tickets?offset=20
。这种实现方式较为简单,因为服务器只需在数据库查询末尾添加 OFFSET 20 LIMIT 10
即可。但这种方法无法应对极大量记录的场景。关系型数据库每次都需要遍历偏移量,因此每返回一页的响应速度都会比上一页稍慢。当偏移量达到数十万时,这将变成一个严重的问题。
解决方案是“基于光标的分页”。与其传递 offset=20
来获取第二页,不如从第一页的最后一张票据(例如 ID 为 32 的票据)开始,传递 cursor=32
。API 随后将返回下一批十张票据,从票据编号 32 开始。与使用 OFFSET
不同,查询语句变为 WHERE id > Cursor ORDER BY id LIMIT 10
。无论您处于集合的开头还是数万张票据之后,该查询速度都相同,因为数据库可以立即找到光标票据的(索引)位置,而非需要遍历整个偏移量。尽管消费者可能更难理解,但当遇到扩展问题时,你可能不得不切换到基于 Cursor 的分页,而这种更改的成本通常非常高。然而,我认为在其他情况下使用基于页面或偏移量的分页是可以的。这可避免消费者自行计算下一页编号或 Cursor 位置。作为更通用的方法,您可以使用 includes
数组参数包含所有可选字段。这常用于关联记录(例如,您可以在用户请求中传递 includes: [posts]
以在响应中获取用户的帖子)。
这是 GraphQL设计理念的一部分,即通过单一查询获取所需所有数据,而非针对每项操作调用不同接口,后端会自动解析查询内容6。
我并不太喜欢 GraphQL,原因有三。首先,它对非工程师(甚至许多工程师)来说完全难以理解。一旦你学会了它,它就像其他工具一样,但入门门槛远高于 GET /users/1
。其次,我不喜欢给用户自由编写任意查询的权限。这会让缓存变得更复杂,并增加你需要考虑的边界情况数量。第三,根据我的经验,后端实现比标准的REST API要复杂得多。
我对不喜欢GraphQL的看法并不那么强烈。我可能在各种场景下使用它大约六个月,远非专家。我确信在某些用例中,它提供的灵活性足以抵消成本。但目前我只会在大约必须使用的情况下才使用它。
内部 API
我之前所说的一切都关于 公共 API。那么内部 API 呢:仅由公司内部同事使用的 API?我之前做出的某些假设对内部 API 不适用。例如,你的消费者通常是专业的软件工程师。此外,你也可以安全地进行破坏性更改,因为(a)你的用户数量通常少一个数量级,(b)你有能力为所有用户部署新代码。你可以要求任何复杂程度的身份验证。
然而,内部 API 仍然可能引发问题,并且关键操作仍需具备幂等性。
总结
- API 难以构建,因为它们缺乏灵活性但必须易于采用
- API 维护者的首要职责是不要破坏用户空间。绝不要对公共 API 进行破坏性更改
- 对 API 进行版本控制可以让你进行更改,但会带来显著的实现和采用障碍
- 如果你的产品足够有价值,API 的质量如何并不重要,人们还是会使用它
- 如果你的产品设计得足够糟糕,无论你如何精心设计 API,它很可能还是会很糟糕
- 你的 API 应该支持简单的 API 密钥进行身份验证,因为你的许多用户可能不是专业工程师
- 执行操作的请求(特别是高风险操作如支付)应包含某种幂等性密钥以确保重试安全
- 你的 API 总是会成为事故的来源。确保你已经设置了速率限制和杀死开关
* 对于可能变得非常大的数据集,使用基于 Cursor 的分页
我还没写过什么?我没怎么写过REST与SOAP的对比,或者JSON与XML的对比,因为我认为这些内容并不特别重要。我喜欢REST和JSON,但对此没有特别强烈的看法。我也没有提到OpenAPI规范——它是一个有用的工具,但我认为如果你愿意,直接用Markdown编写API文档也完全没问题。
- 嗯,在我所在的领域(大型科技SaaS公司)。↩
- 这就是为什么REST是API中如此常见的模式。它未必比其他方式更好,但到目前为止,它已经足够熟悉,以至于消费者无需阅读你的API文档就能理解它。↩
- 某些类型的 API(如 SOAP)会返回 200 状态码并附带
<Fault>
XML 元素,但原理相同。↩ - 除非你有一个奇怪的非 ID 范围操作,比如“删除最近的记录”。↩
- 他后来被聘请到应用程序团队,我在那里与他合作了几年。↩
- GraphQL 理念的另一部分是让不同的后端服务为单个 API 的不同部分提供服务,而这种方式对 API 消费者是透明的。↩
本文文字及图片出自 Everything I know about good API design
“永远不要破坏用户空间”的提醒很好,但人们从未提及这句话的另一半:“我们可以且将会在不经警告的情况下破坏内核API”。
这说明提醒并非“永远不要以破坏他人为代价更改API”,而是更微妙的“声明什么是稳定的,并永远不要破坏这些”。
即使内核不破坏用户空间,GNU libc 也会这样做,而且是“随时随地”这样做,因此无论内核维护者如何努力,Linux 用户空间最终都会被破坏。简单来说,在/为较新版本的 libc 编译的程序和库与较旧版本的 libc 不兼容,或者根本无法在较旧版本的 libc 上运行,因此_一切_都需要同步升级。
有点讽刺且有点好笑的是,Windows 早在几十年前就通过 redistributables 解决了这个问题。
GNU libc 具有相当好的向后兼容性,因此如果你不想在广泛的版本范围内运行,可以链接到尽可能旧的 libc 版本(这确实需要一些努力,令人烦恼)。通常是 GUI 库之类的东西更麻烦,因为它们会破坏兼容性,而旧版本不再包含在发行版中,与你的应用程序一起分发它们仍然可能遇到协议兼容性问题。
另一方面,静态链接的可执行文件非常稳定——拥有这个选项确实很好。
据我所知,在不发布源代码的情况下静态链接 GNU 的 libc.a 违反了 LGPL 协议。这可能导致 95% 的在 Linux 上运行专有软件的公司无法继续使用。
musl libc 采用更宽松的许可证,但听说其性能不如 GNU libc。人们可以期待 LLVM libc[1],这样整个工具链将从编译器驱动到 C/C++ 标准库都采用 Clang/LLVM。届时就能实现从用户代码到 libc 实现的全程程序优化,清除冗余代码并压缩二进制文件大小。
[1]: https://libc.llvm.org/
据我所知,在 LGPL 许可下,只要你同时包含应用程序的对象代码副本,并提供用户如何重新链接到不同 glibc 的说明,静态链接 glibc 是技术上合法的。你不需要包含那些 .o 文件的源代码。
但我认为我从未见过有人实际这样做。
Musl可能是静态链接的更好选择,因为GNU libc依赖动态链接实现某些重要功能。
Windows redistributables对用户来说非常烦人。我记得无数次应用程序要求我访问微软官方页面下载它们,而且很难找到正确的按钮来获取所需内容。感觉像是将负担转嫁给用户。
你可以(等效地)随应用程序分发特定的 libc.so 文件。我认为除了 GNU 极端主义者外,没有人认为这会使你的应用程序受到 GPL 许可的约束。
没错,众所周知Linux没有稳定的公共驱动程序API,我认为这是谷歌推出Fuchsia操作系统的动机
因此Linux在两个方向上都带有明确立场——面向用户空间和面向硬件——但方向相反
尽管作者似乎不太喜欢基于版本的API,但我始终建议从应用程序的最初阶段就开始将其融入。
你无法预测未来,而很有可能会有一些不可控的因素迫使你进行破坏性更改。
我同意作者关于不添加“v1”的观点,因为它很少有用。
随着API的增长,实际情况是-
首先,团队会尽可能扩展现有端点,添加新字段/选项而不破坏兼容性。
然后,一旦需要进行向后不兼容的操作,他们很可能也会重新考虑端点命名,因此会直接创建带有新名称的新端点。(而不是将任何东西命名为“v2”)。
如果整个API需要重新设计,团队更可能决定废弃整个服务/API,然后推出一个名称不同的全新且更优服务来替代它。
因此,最终几乎不会有任何端点名称中包含“/v2”。我在这个行业工作了25年,只见过一次服务同时拥有“/v1”和“/v2”的命名。
> 因此,实际上很少有端点会在名称中包含“/v2”。
这是一个有趣的经验问题——取100个最常用的HTTP API,看看它们在进行向后不兼容的更改时会怎么做,以及有哪些版本可用。也许一个大语言模型(LLM)能弄清楚这一点。
有趣的是,在v1到v2的版本更新中,有一些值得注意的选择,
https://www.dropbox.com/developers/reference/migration-guide
他们使用了一种名为stone的自定义规范语言(https://github.com/dropbox/stone)。
我认为在后续添加版本号并无不妥。假设你的API是/api/posts,那么下一版本只需改为/api/v2/posts。
这是下游的问题。集成商并未被强制要求在v1中包含版本号,因此使用v2时所需的重新工作量将高于如果从一开始就在方案中包含版本号的情况。
这里,通过在文件中搜索 /v1/ 并显示所有 API 端点,然后确保没有遗漏任何内容,会容易得多。
我认为作者并非指在最初的端点中不包含 /v1。关键在于应尽可能避免引入/v2,因为每次修复 bug 都需维护两个版本,这意味着要在两处进行相同的代码修改,或在现有或新增的条件逻辑上叠加额外条件逻辑。支持多版本的代码库往往变得杂乱无章,通常意味着/v1在设计时未考虑未来兼容性。
不同意。从一开始就将版本控制嵌入其中,意味着它们很可能会被使用,而这是一种糟糕的做法。
如果未来被迫进行破坏性更改,难道不能为该函数使用不同的名称吗?
看看Win32 API中许多函数的“Ex”变体,这就是典型的例子!
版本化的API允许你确保某个版本只有一种实现方式,而不是5种,其中4种不再受支持但无法移除。你可以移除旧版本而不破坏遗留系统。
可发现性。
/v1/downloadFile
/v2/downloadFile
检查 v3 版本要比
/api/downloadFile
/api/downloadFileOver2gb
/api/downloadSignedFile
等等,等等。
难道使用名称(例如 Over2gb)比直接说 v2 更容易理解吗?这是在v1/downloadFile被迫进行破坏性更改的情况下。
我只见过两次服务会创建/v2。
通常是为了宣布/v1的破产,并强制所有人迁移到/v2(如果这甚至可能的话)。
许多Unix/Linux系统调用API都有2.0及以上版本
例如dup()、dup2()、dup3()和pipe()、pipe2()等
LWN有一篇文章:https://lwn.net/Articles/585415/
该文章讨论了通过设计未来 API 时使用标志位掩码来避免此问题,以便未来可以扩展 API。
我所在的公司有一个较旧的 API,因此它在头文件中定义,但目前已更新到 v6 版本。这对于多年来发生的变化非常有用。
如果只修改一两个函数,似乎没问题。但核心数据类型的更改可能导致全部崩溃,因此添加前缀“/v2/”可能更干净。
你可以这样做,但与在URI、媒体类型或头部中使用“版本”开关相比,这会极大增加复杂性。
Cursor-based pagination(使用上一页最后一个对象的ID)会返回未查看的新项目列表。这对于无限滚动非常有用。
> 你的许多用户可能不是专业工程师。他们可能是销售人员、产品经理、学生、业余爱好者等等。
这不仅适用于身份验证。如果你在商业环境中工作,你的API将被最随机的一群用户使用。他们可能能够通过谷歌搜索如何用Python调用你的API,但无法完成将UTC转换为本地时区等操作。
我认为这里唯一让我不同意的是,内部用户只是普通用户。是的,他们可能更懂技术——或者很可能是其他程序员,但他们也很忙。他们经常在开发自己的项目,没有时间或能力去处理你的API变更。
如果可能的话,请花时间先在内部测试你的API,再对外开放。一旦开放,你就必须遵守“绝不破坏用户体验”的承诺。
对于内部用户,你可能有工具可以联系他们并引导他们迁移。你可以实际淘汰旧版本API,使API版本控制成为一个有吸引力的解决方案。我既参与过API版本控制,也观察过在不默认使用它的情况下,组织如何将其作为实用工具来运用。
我认为版本控制仍然有助于解决这个问题。
对于内部用户,你可以采取许多措施来避免造成负担——其中最有帮助的往往是与利益相关者协作制定规范,并向他们提供工作副本。即使这是一个动态文档,让他们有一个参考框架也非常有用(只要你的办公室政治不会让他们因不喜欢正在进行的部分而给你制造麻烦。)
他们建议将幂等性密钥存储在Redis中。如果可能的话,你应该在写入变异时,将它们存储在你要写入的系统中,作为单个事务的一部分。
> 应该如何存储密钥?我见过有人以某种持久化、资源特定的方式存储(例如作为评论表中的列),但我认为这并非严格必要。最简单的方法是将其存储在Redis或其他类似的键值存储中(以幂等性密钥作为键)。
我不确定将密钥存储在Redis中如何在所有故障情况下实现幂等性。算法是什么?假设处理请求的服务器正在执行条件写入(如SET key 1 NX),并发现该键已存在。此时该如何处理?是否应跳过创建评论?不能假设评论已提前创建,因为在将键存储到Redis与实际在数据库中创建评论之间,该过程可能已被中断。
尝试存储幂等性密钥必须与操作负载一起原子提交(并在失败时回滚),即它必须始终是资源特定的标识符。从所有实际用途来看,幂等性密钥就是正在执行的操作(请求)的标识符,无论是“评论创建”还是“评论更新”。
请不要添加另一个组件来引入幂等性,否则可能会出现奇怪的抽象泄露行为,或者在不理解交付保证的情况下直接导致故障。更好的做法是支持某种标签或元数据与写入操作绑定,以便用户可以在其端跟踪进度并将其与现有数据一起存储。
“永远不要破坏用户空间”的提醒至关重要且常被忽视……咳咳,Spotify、Reddit和Twitter就是例子。
今天看到“API”的人大多只认为“这是个网页应用,我发送请求,传递一些参数并设置一些头部,然后从返回的头部检查一些设置,再解析返回的数据”。
但“API”的完整形式是“应用程序接口”(Application Programming Interface)。它最初是为_应用程序_设计的,而应用程序就是……带有用户界面的程序!这一概念可追溯至20世纪40年代,直到1990年代才开始被广泛应用。API已存在超过80年。关于这一主题的书籍和论文的出版时间,比现在阅读本文的许多人还要早。
那些更早的API可能是什么样子的?它们在处理什么?它们的目的是什么?那些程序员是如何解决问题的?这与你有什么关系?
> 然而,一个技术上欠佳的产品几乎不可能构建出优雅的API。这是因为API设计通常与产品的“基本资源”相匹配(例如,Jira的基本资源包括问题、项目、用户等)。当这些资源被设计得不够合理时,API也会随之变得笨拙。
我对奇怪资源的一个问题是,它们感觉像是多余的抽象。这使得人类难以直观地阅读和理解,尤其是对这些API集的新手来说。此外,这在发生故障时也使得故障排除变得困难得多。
我仍然认为/v1和/v2之间存在断裂,我不相信你会永远保留v1,否则你将永远无法引入这个借口。
我希望引入更多字段或标志来控制行为作为参数,而不是要求用户为单个新API更改整个基础URL。
我喜欢这个模式。
当一个 API 承诺支持 /v1 时,这并不意味着当 /v2 或 /v3 发布时会废弃 /v1,它只是意味着我们承诺继续支持较旧的 URI 策略和响应。
/v2 和 /v3 为您提供了一种灵活性,可以在不影响现有客户的情况下进行改进。
作为一名开发过大量底层网络 API 的工程师,我认为作者提到了些许值得关注的常见主题。
版本控制等因素对二进制 UDP API(即协议)的重要性,与对任何 Web API 而言同样关键。
还有人记得“API”这个词曾经也指代与通过HTTP发送和接收JSON无关的东西吗?在某些情况下,你甚至可以创建用户可以本地安装并离线使用的工具。
我认为将库和框架的用户(开发者)接口称为API是很常见的,比如“Python的日志库有一个奇怪的API”,所以我认为API并没有被狭隘地定义为仅指网络API。
我一直不明白为什么库也使用API这个词。据我所知,库是一组特定于某个领域的函数,例如统计库。那么为什么还需要使用API这个词?你已经知道它是一个库。
对于端点来说情况稍有不同。你不知道它们是面向用户还是面向程序员。
我很好奇有人对这个问题有好的见解。我渴望学习。
要使用代码,你需要一个接口。一个用于编程的接口。具体来说,用于构建应用程序。
为什么输入/输出边界的类型重要?
对我来说,API 是函数原型。DLL 是库
它代表“应用程序编程接口”,所以我认为将其应用于进程内接口和进程间接口都是合理的
有些应用程序运行在一个进程中,而其他应用程序则跨越多个进程和机器。虽然存在明显差异,但两者也有足够的共同点,可以谈论“API”
大家都认为在本地计算机上运行常规软件是特殊情况,因此必须称为“本地优先”。
> 通过HTTP发送和接收JSON
在我所在的圈子里,这通常(可能不准确地)被称为REST API。
功能会以SDK的形式提供,文档则以MS Help .chm文件形式存在。
API 用于提供访问性——从外部访问应用程序内部的交互和数据。
通信的格式和协议从未固定。
除了今天的 REST API,SOAP、WSDL、WebSockets 都能以某种形式提供 API。
CORBA
令人不寒而栗……
有没有经过时间考验的、设计良好的公共 API 示例?
我一直觉得 Amiga 的 API 搭配标签列表很酷。你可以轻松扩展 API/ABI 而不破坏二进制层(当然,前提是你在调用时就接受标签列表作为参数)。
我对API版本控制的看法有点不同,但能理解这个观点。我坚决反对将幂等性视为可选项:它绝非可选。你不必要求每个请求都包含幂等性令牌,但应提供指定幂等性令牌的选项。Stripe API客户端就是个好例子,它们会自动为你生成幂等性令牌。
以下是这份清单中缺失但对我来说曾很重要的一些内容:
截止时间。你的 API 应允许指定请求不再有效的截止时间。API 实现可利用此截止时间取消任何待处理操作。
相关概念:背压和依赖服务。你的 API 应设计为不因无谓的重试而过载其依赖服务。部分重试可能有用,但总体而言,API 应快速将错误状态反馈给调用方。
3. 静态稳定性。API 背后的系统应设计为静态故障,即使变异操作失败,仍能保留部分功能。
> 您应允许用户使用长期有效的 API 密钥。
唉……我希望这不是事实。遗憾的是,到目前为止还没有出现替代方案。
还有其他选项允许使用自然轮换的密钥进行长期访问,无需 OAuth,且仅增加少量复杂性,这些复杂性可以通过一个 Bash 脚本管理。刷新令牌/承载令牌组合非常强大,其安全属性比裸 API 密钥强得多。
> 刷新令牌/承载令牌组合非常强大,其安全属性远强于裸API密钥
我一直不明白为什么。
刷新令牌仅在客户端代表用户访问 API 时才真正需要。刷新令牌用于跟踪特定的用户授权,且每个客户端用户都需要一个刷新令牌。
如果客户端代表自身访问 API(这更适合用 API 密钥替代),则可以使用 client_credentials 配合客户端密钥认证或 JWT 承载认证。
这是一种非常特定的刷新令牌形式,但并非唯一模型。您可以直接将“API密钥”设为该刷新令牌。将其提交至认证端点,获取新的刷新令牌和承载令牌,并无效化之前有效的承载令牌。承载令牌会自然过期,如果您仍在使用它,只需立即使用刷新令牌;如果过了几天或几周,您仍然可以使用它。
无需涉及 OIDC 或第三方即可获得所有这些好处。密钥不能被多个同时运行的客户端使用,它们会自然过期并随时间轮换,您可以轻松审计其使用情况(主要由于最后两个原则)。
如果 API 密钥无需保持无状态,每个 API 密钥都可以成为具有完整权限和有效性查询的刷新令牌。
没错。
刷新周期的分离是为可扩展性进行的优化。如果你不需要规模,就不需要这样做。(而且你需要非常大的规模才能达到这个需求。)
补充一点,他们说的是访问令牌还是刷新令牌?不能只是一个令牌,因为当它过期时,你必须手动从门户更新它或重新进行相同的身份验证过程,这两种情况都不好。
“长期有效”的具体时间范围是什么?根据我的经验,访问令牌的有效期几乎总是为一周,而刷新令牌的有效期则在6个月到一年之间。
> 每个与你的API的集成都始于一个简单的脚本,而使用API密钥是让简单脚本运行的最简单方式。你希望让工程师尽可能轻松地开始使用。
> …您正在为非常广泛的人群构建它,其中许多人并不擅长编写或阅读代码。如果您的 API 要求用户执行任何困难的操作——比如进行 OAuth 握手——许多用户会感到困难。
听起来他们正在讨论具体的新手引导流程。我其实很喜欢这个想法,因为我确实也遇到过不少困难,只是为了让这个该死的东西正常工作。
从安全角度来看可能不是最佳选择,但像仅限测试环境或速率限制这样的缓解措施对我来说似乎已经足够。
确实,我喜欢使用那些可以在门户中生成令牌供应用程序发起请求的集成。这种场景下授权是个难题——该令牌能访问哪些资源往往不够清晰。
我认为他们指的是刷新令牌或API密钥(如PAT)。只需在请求头中传递一个值即可生效,无需令牌流。且该密钥有效期可达数月并可被撤销。
若使用第三方API,最常见的认证方式是将静态密钥放入“Authorization” HTTP头部。
OAuth流程在服务器间通信中并不常见。
在我理想的世界里,我会用证书替换API密钥,并采用双向TLS进行认证。
据我所知,OAuth流程在S2S通信中相当常见。通常这些流程基于客户端凭证,您请求令牌的方式与您所述相同(在授权头中使用静态密钥),而非需要登录操作的授权授予流程。
是的,但两者其实差异不大,对吧?您理论上可以将访问令牌的生成移至独立的受保护环境,但这会大幅增加复杂性并引入大量潜在故障场景。
我的意思是……在2025年添加一个OAuth层会增加那么多复杂性吗?如果你在编写脚本,通常会有一些语言原生的包,如果你使用Postman,你需要生成你的认证URL(或使用用户名/密码作为客户端ID/密钥)。
如果你有敏感资源,它们本就会被授权机制阻挡。我见过的一个例外是访问沙箱环境,这些环境只需点击按钮即可轻松生成。
不,我的意思是,当你使用API密钥获取刷新令牌,或者刷新令牌本身成为长期密钥时,OAuth层其实没有带来太多好处,与API密钥相比并没有明显优势。
需要某种方式来打破“共享密钥”模型。相互 TLS 是一种至少正在获得一些关注的方式。
在你的理想世界中,你是 API 的主要生产者还是消费者?
我讨厌mTLS API,因为它们通常意味着我需要改变服务打包和部署的方式。但正如你所说,如果一切都是mTLS,我不会介意。
> 在你的理想世界中,你是API的主要生产者还是消费者?
两者都是。mTLS部署是关键问题,但情况正在逐步改善。AWS负载均衡器现在支持mTLS,它们终止TLS连接、验证证书,并将证书信息放入HTTP头部。Google Cloud Platform和CloudFlare也支持mTLS。