Skip to content

优秀API设计

翻译自:Everything I know about good API design

现代软件工程师所做的大部分工作都涉及API:与程序通信的公共接口,比如Twilio的这个API。我在API上花费了大量时间,既构建也使用它们。我写过面向第三方开发者的公共API、供内部使用的私有API(或由单个前端页面消费)、REST和GraphQL API,甚至非网络接口,如命令行工具的接口。

就像设计优秀的软件系统一样,我认为关于API设计的许多建议都过于花哨。人们纠结于什么是“真正的”REST,或者HATEOAS是否是个好主意,等等。这篇文章是我尝试记录我所知道的关于设计优秀API的一切。

设计API是熟悉性和灵活性之间的平衡

如果这对系统来说是真实的——它确实是——那么对API来说更是如此:优秀的API是无聊的。一个有趣的API是一个糟糕的API(或者至少如果它不那么有趣,它会更好)。对于构建API的开发人员来说,API是他们花费时间设计和打磨的复杂产品。但对于使用API的开发人员来说,API是他们用来实现其他目标的工具。任何他们花在思考API而不是目标上的时间都是浪费的。从他们的角度来看,理想的API应该如此熟悉,以至于在阅读任何文档之前[^2],他们或多或少就知道如何使用它。

然而,与大多数软件系统的一个大不同是API很难更改。一旦你发布了一个API并且人们开始使用它,对接口的任何更改都会破坏用户的软件。当然,更改是可能的。但(如下所述)每次更改都会带来严重的成本:每次你强迫用户更新软件时,他们都会认真考虑使用更稳定的不同API。这给API构建者提供了强烈的激励,让他们仔细设计并第一次就做对。

这种张力为构建API的工程师带来了一个有趣的动态。一方面,他们想构建最简单的API。另一方面,他们想做聪明的事情来保持长期灵活性。大体上,API设计是关于在这两个不相容目标之间找到平衡。

我们不破坏用户空间(WE DO NOT BREAK USERSPACE)

当你需要更改API时会发生什么?添加性更改——例如,在响应中添加一个新字段——通常没问题。有些消费者如果收到比预期更多的字段会崩溃,但在我看来这是不负责任的。你应该期望API消费者忽略意外字段(合理的JSON解析类型语言默认这样做)。

但是,你不能删除或更改字段的类型。你不能更改现有字段的结构(例如,在JSON响应中将user.address移动到user.details.address)。如果你这样做,依赖这些字段的每一段代码都会立即崩溃。该代码的消费者会将其报告为错误,而代码的维护者(当他们弄清楚时)会理所当然地愤怒,因为你故意破坏了他们的软件。

这里的原理类似于Linus Torvalds著名的口号我们不破坏用户空间。作为API的维护者,你有一种神圣的责任来避免伤害下游消费者。这个规范如此强大,因为如此多的软件依赖于如此多的API(这些API又依赖于上游API,等等)。一个足够上游的粗心API维护者可以破坏下游数百或数千个软件。

你永远不应该仅仅因为更整洁或有点尴尬而更改API。HTTP规范中的“referer”头著名地拼错了单词“referrer”,但他们没有更改它,因为我们不破坏用户空间。

在不破坏用户空间的情况下更改API

老实说,很难想到API真正需要破坏性更改的例子。但有时更改的技术价值足够高,你决定硬着头皮做。在这些情况下,你如何负责任地更改API?答案是版本控制。

API版本控制意味着“同时提供旧版本和新版本的API”。现有消费者可以继续使用旧版本,而新消费者可以选择加入新版本。最简单的方法是在API URL中包含类似/v1/的内容。OpenAI的聊天API在v1/chat/completions,所以如果他们想彻底重新设计结构,他们可以在v2/chat/completions中这样做,并保持现有消费者工作。

一旦新旧版本同时工作,你可以开始告诉用户升级到新版本。这需要很长时间:几个月甚至几年。即使有网站上的横幅、文档、自定义电子邮件和API响应头,当你最终移除旧版本时,你仍然会收到很多愤怒的用户,因为你的软件被破坏了。但至少你已经尽力了。

有许多其他方法来做API版本控制。Stripe API在头中进行版本控制,并让账户在UI中设置默认版本。但原理相同——任何Stripe API的消费者都可以确信Stripe不会决定破坏他们的应用程序,并且他们可以按自己的节奏升级版本。

我不喜欢API版本控制。 我认为它最多是一种必要的邪恶,但它仍然是邪恶的。它对用户来说很困惑,他们不能轻松搜索你的API文档而不确保版本选择器匹配他们使用的版本。而且对维护者来说是一场噩梦。如果你有三十个API端点,你添加的每个新版本都会引入三十个新端点来维护。你很快就会最终拥有数百个需要测试、调试和客户支持的API。

当然,添加新版本不会使你的代码库大小翻倍。任何合理的API版本控制后端都会有类似转换层的东西,可以将响应转换为任何公共API版本。Stripe有类似的东西:实际的业务逻辑对所有版本都是相同的,因此只有参数序列化和反序列化需要知道版本控制。然而,像这样的抽象总是会泄漏。参见这位Stripe员工2017年的HN评论,指出一些版本控制更改需要在“核心代码”中到处添加条件逻辑。

简而言之,你应该只将API版本控制作为最后手段。

你的API的成功完全取决于产品

API本身什么都不做。它是用户和他们实际想要的东西之间的一层。对于OpenAI API,那是使用语言模型进行推理的能力。对于Twilio API,那是发送SMS消息。没有人因为API本身设计优雅而使用它。他们使用它是为了与你的产品交互。如果你的产品足够有价值,用户甚至会涌向一个糟糕的API。

这就是为什么一些最受欢迎的API使用起来是一场噩梦。Facebook和Jira以拥有可怕的API而闻名,但这没关系——如果你想与Facebook或Jira集成,你确实想,你需要花时间弄清楚它们。当然,如果这些公司有更好的API会很好。但当人们无论如何都会集成时,为什么投入时间和金钱来改进它?编写优秀的API真的很难。

我将在本文的其余部分给出很多具体建议,关于如何编写优秀API。但值得记住的是,大多数时候这并不重要。如果你的产品受欢迎,任何勉强可用的API都可以;如果不是,你的API有多好也不重要。API质量是一个边际特性:只有当消费者在两个基本等效的产品之间选择时才有意义。

顺便说一句,API的存在是一个完全不同的故事。如果一个产品根本没有API,那是一个大问题。技术用户会要求某种方式通过代码与他们购买的软件集成。

设计糟糕的产品通常会有糟糕的API

技术上的优秀API无法拯救没有人想使用的产品。然而,技术上的糟糕产品可能使构建优雅API几乎不可能。这是因为API设计通常跟踪产品的“基本资源”(例如,Jira的资源将是问题、项目、用户等)。当这些资源设置得 awkwardly 时,API也会变得 awkward。

例如,考虑一个博客系统,它将评论在内存中存储为链表(每个评论有一个next字段指向线程中的下一个评论)。这是一种存储评论的糟糕方式。将REST API bolted onto 这个系统的天真方式将是有一个像这样的接口:

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 serve as the幂等键。想想——如果你连续发送三个DELETE comments/32请求,它不会删除三个评论。第一个成功请求将删除ID为32的评论,其余请求在找不到已删除评论时会404。

对于大多数情况,幂等性应该是可选的。就像我上面说的,你想确保你的API对非工程师可访问(他们经常发现幂等性是一个棘手的概念)。在宏伟的计划中,让更多人使用你的API比偶尔的重复评论(来自没有阅读文档的用户)更重要。

安全性和速率限制

与你的UI交互的用户受他们手速的限制。如果有某个流程对你的后端服务来说很昂贵,恶意或粗心的用户只能以他们点击的速度触发该流程。API不同。你通过API暴露的任何操作都可以以代码的速度调用。

小心那些在单个请求中做大量工作的API。当我在Zendesk工作时,我们有一个API,让你将通知扇出到特定应用的所有用户。一些有进取心的第三方开发人员[^5]用它来构建一个应用内聊天系统,其中每条消息都向账户上的所有其他用户发送通知。对于有超过少数活跃用户的账户,这可靠地杀死了Apps后端服务器。

我们没有预料到人们会在这个API上构建聊天应用。但一旦它在那里,人们就做了他们想做的事。我参加过许多、许多事件呼叫,其中根本原因是某些手动的客户集成在做一些愚蠢的事情,比如:

  • 每分钟创建和删除相同记录数百次,没有真正原因
  • 无延迟地轮询一个大的/index端点,永远
  • 导入或导出大量数据,在错误情况下不退避

你应该在API上设置速率限制,对昂贵操作有更严格的限制。 保留暂时禁用特定客户API的能力也是明智的,这样如果后端系统真的被 hammered,你可以减轻压力。

在API响应中包含速率限制元数据。X-Limit-Remaining和Retry-After头给客户端他们需要的信息,以成为你的API的尊重消费者,并允许你设置比 otherwise 更严格的速率限制。

分页

几乎每个API都必须服务一长串记录。有时是非常长的列表(例如,Zendesk的/ticketsAPI可以包含数百万张票)。你如何服务这些记录?

一个天真的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。该查询同样快,无论你是在集合的开头还是数十万张票中,因为数据库可以立即找到你的游标票的(索引)位置,而不必计数整个偏移。

对于可能最终变得很大的数据集,你应该总是使用基于游标的分页。 尽管消费者更难掌握,当你遇到扩展问题时,你可能必须更改为基于游标的分页,而进行该更改的成本通常很高。然而,我认为否则使用页面或基于偏移的分页是可以的。

通常明智的做法是在API列表响应中包含一个next_page字段。这节省了消费者自己计算下一页号或游标的麻烦。

可选字段和GraphQL

如果你的API响应的部分服务起来很昂贵,使它们可选。 例如,如果获取用户的订阅状态需要你的后端进行API调用,考虑让你的/users/:id端点不返回订阅,除非请求传递一个include_subscription参数。作为更一般的方法,你可以有一个includes数组参数,包含所有可选字段。这通常用于关联的记录(例如,你可以传递includes: [posts]到你的用户请求,以在响应中获取用户的帖子)。

这是GraphQL背后思想的一部分,一种API风格,其中你不是为每个操作点击不同的端点,而是 crafting 一个包含你需要的所有数据的单一查询,后端解决它[^6]。

我不太喜欢GraphQL,原因有三。首先,它对非工程师(和许多工程师)完全难以理解。一旦你学会它,它就像任何其他工具一样,但入门障碍比GET /users/1高得多。其次,我不喜欢给用户 crafting 任意查询的自由。它使缓存更复杂,并增加了你必须考虑的 edge cases 数量。第三,在我的经验中,后端实现比你的标准REST API fiddly 得多。

我对不喜欢GraphQL的感觉不那么强烈。我在各种上下文中花了大概六个月时间使用它,远非专家。我确信有 use cases 它给你足够的灵活性值得成本。但现在我只会在绝对必须的地方使用它。

内部API

到目前为止我所说的一切都是关于公共API的。内部API呢:只由特定公司的同事使用的API?我上面做的一些假设对内部API不成立。例如,你的消费者通常是专业软件工程师。也可以安全地进行破坏性更改,因为(a)你通常有数量级更少的用户,和(b)你有能力为所有这些用户发布新代码。你可以要求任何复杂形式的认证。

然而,内部API仍然可能是事件的来源,并且仍然需要关键操作的幂等性。

总结

  • API难以构建,因为它们不灵活但必须易于采用
  • API维护者的主要责任是不破坏用户空间。永远不要对公共API进行破坏性更改
  • 版本控制你的API让你进行更改,但强加了 significant 实现和采用障碍
  • 如果你的产品足够有价值,你的API有多好并不重要,人们无论如何都会使用它
  • 如果你的产品设计足够糟糕,无论你多仔细设计API,它都可能很糟糕
  • 你的API应该支持简单的API密钥进行认证,因为许多你的用户不是专业工程师
  • 采取操作的请求(特别是高风险的行动如支付)应该包括某种幂等键以使重试安全
  • 你的API将始终是事件的来源。确保你有速率限制和 killswitches 就位
  • 对于可能最终变得非常大的数据集,使用基于游标的分页
  • 使昂贵的字段可选且默认关闭,但(在我看来)GraphQL是过度的
  • 内部API在某些方面不同(因为你的消费者非常不同)

我没有写什么?我没有多写REST vs SOAP,或JSON vs XML,因为我不认为这些东西特别重要。我喜欢REST和JSON,但我不强烈感觉。我也没有提到OpenAPI schema——它是一个有用的工具,但我也认为如果你愿意,只在Markdown中编写API文档也可以。


  1. 嗯,在我的领域(大型科技SaaS)。↩
  2. 这就是为什么REST是API的常见模式。它不一定比其他方式更好,但此时它足够熟悉,消费者可以在不阅读你的API文档的情况下弄清楚它。↩
  3. 某些类型的API(如SOAP)会 instead 响应200带有<Fault> XML元素,但原理相同。↩
  4. 除非你有一些奇怪的非ID范围操作,如“删除最近的记录”。↩
  5. 他后来被聘用到Apps团队,我在那里与他共事几年。↩
  6. GraphQL思想的另一部分是让不同的后端服务服务单个API的不同部分,以对API消费者不透明的方式。↩