在掌握了核心的 CRUD 操作和资源管理模式之后,我们将目光转向更加复杂和精妙的高级模式。这些模式通常用于解决那些超出简单资源操作范畴的问题,如处理长时间运行的任务、实现实时通知、构建真正的超媒体驱动应用等。
高级模式并非每个 API 都必须实现,但了解它们能够扩展你的设计工具箱,让你在面对复杂业务场景时有更多的选择。 这节课我们将深入探讨 HATEOAS、异步操作、事件驱动、内容协商等高级话题,帮助你构建更加灵活、健壮和可演进的 API 系统。

HATEOAS 是 Hypermedia as the Engine of Application State 的缩写,中文可译为"超媒体作为应用状态引擎"。这是 REST 架构风格中最高级也最具争议性的约束,它要求服务器在响应中提供客户端可以执行的后续操作的链接,使客户端能够通过跟随这些链接来导航整个应用。
要理解 HATEOAS,我们可以类比浏览网页的体验。当你访问一个网站时,你不需要事先知道所有页面的 URL,网站会在每个页面中提供链接指向其他相关页面,你通过点击这些链接来浏览网站。HATEOAS 将同样的理念应用到 API 设计中:客户端不需要硬编码 API 的 URL 结构,服务器会在响应中告诉客户端接下来可以做什么以及如何做。
这种设计带来了显著的解耦优势。当 API 的 URL 结构发生变化时,只要服务器更新响应中的链接,遵循 HATEOAS 的客户端就能自动适应这些变化,而不需要修改客户端代码。这使得 API 可以独立演进,大大降低了版本管理的复杂性。
从更深层次来看,HATEOAS 代表了一种动态发现的理念。客户端从一个已知的入口点开始,通过解析响应中的链接逐步了解 API 的能力,而不是依赖于静态的、预先约定的 API 文档。这种方式使得 API 可以根据资源的当前状态动态调整可用的操作,例如一个已支付的订单不再显示"支付"链接,而显示"发货"或"退款"链接。
在响应中表示链接有多种标准和惯例。最直接的方式是在资源表述中添加一个 _links 或 links 字段,包含相关操作的链接集合。每个链接通常包含关系类型(rel)和目标 URL(href),有时还包含 HTTP 方法、媒体类型等附加信息。
HAL(Hypertext Application Language)是一种流行的超媒体格式,它定义了标准化的链接表示方式。在 HAL 格式中,资源包含 _links 对象存放链接,_embedded 对象存放嵌入的相关资源。这种格式简洁清晰,被许多 API 所采用。
JSON:API 规范提供了另一种超媒体格式,它不仅定义了链接的表示,还规范了资源的整体结构、关系的表达、分页的格式等方面。JSON:API 的设计目标是减少 API 设计中的决策负担,提供一套完整的、经过深思熟虑的规范。
Siren 是一种更加丰富的超媒体格式,它除了链接之外还支持"动作"(actions)的定义,可以描述非 GET 操作所需的表单结构,包括字段名称、类型、验证规则等。这使得客户端可以在不了解具体 API 文档的情况下构建正确的请求。
尽管 HATEOAS 在理论上非常优雅,但在实际应用中面临一些挑战,这也是为什么完全实现 HATEOAS 的 API 相对较少的原因。
首先是实现成本的考量。为每个响应计算和生成适当的链接需要额外的开发工作,特别是当链接需要根据资源状态、用户权限等因素动态变化时,这种复杂性会显著增加。对于许多项目来说,投入产出比可能并不理想。
其次是客户端开发的复杂性。真正利用 HATEOAS 的客户端需要能够解析和跟随链接,这比直接硬编码 URL 更加复杂。许多前端框架和移动开发工具并没有为超媒体驱动的交互提供良好的支持,开发者往往选择更简单直接的方式。
第三是与现有工具生态的兼容性。大多数 API 文档工具、测试工具、代码生成器都是基于 OpenAPI 等静态描述格式设计的,它们与 HATEOAS 的动态发现理念并不完全契合。
在实践中,许多 API 采用了部分的超媒体特性,如在响应中包含分页链接、相关资源链接等,而不追求完整的 HATEOAS 实现。这种务实的折中在保持 API 简洁的同时,也获得了超媒体的部分好处。
即使不追求完整的 HATEOAS 实现,在响应中包含一些有用的链接仍然是值得的实践。例如,分页响应中的 next、prev、first、last 链接;新创建资源响应中指向该资源的 self 链接;关联资源的引用链接等。这些链接可以显著提升 API 的易用性,而实现成本相对较低。
在真实的业务场景中,并非所有操作都能在毫秒级内完成。生成复杂报告、处理大型文件、执行批量数据迁移、调用外部服务等操作可能需要数秒甚至数分钟才能完成。对于这类长时间运行的操作,让客户端同步等待显然不是好的设计。
处理长时间运行操作的核心思路是将操作的提交和结果的获取分离。客户端提交操作请求后立即获得响应,这个响应不包含操作结果,而是包含一个用于查询操作状态的引用。客户端随后通过这个引用来轮询操作进度,直到操作完成并获取最终结果。
这个模式通常这样实现:客户端向服务器发起操作请求,服务器接受请求后创建一个"任务"或"作业"资源来跟踪这个操作,然后返回 202 Accepted 状态码和新创建的任务资源的位置。客户端可以对任务资源发起 GET 请求来查询操作状态,任务资源会显示诸如"pending"、"running"、"completed"、"failed"等状态。当操作完成时,任务资源还会包含操作结果或指向结果资源的链接。
202 Accepted 状态码专门用于表示请求已被接受但尚未完成处理,它是异步操作模式的关键组成部分。响应中的 Location 头部指向任务资源的位置,Content-Location 头部可以用于区分响应体描述的是请求的接受确认还是任务资源本身。
任务资源应该提供足够的信息让客户端了解操作的进展和最终结果。典型的任务资源包含以下信息:任务标识符、任务状态(排队中、处理中、已完成、失败等)、创建时间、开始时间、完成时间、进度百分比(如果可以计算)、操作结果或结果资源的链接、失败时的错误信息等。
任务状态的设计需要考虑操作的特点。对于简单的二态操作,只需要"处理中"和"已完成/失败"状态。对于复杂的多阶段操作,可能需要更细粒度的状态,如"验证中"、"处理中"、"后处理中"等。进度信息(如完成百分比或已处理条目数)对于长时间操作特别有用,能让用户了解大概还需要等待多久。
任务资源的生命周期管理也是需要考虑的问题。已完成的任务应该保留多长时间?是否需要让客户端能够取消正在进行的任务?这些决策取决于具体的业务需求和系统资源限制。
客户端获取操作结果有两种主要方式:轮询和回调。
轮询是最简单直接的方式:客户端定期向任务资源发起 GET 请求检查状态。这种方式实现简单,不需要服务器能够主动联系客户端,适用于各种网络环境。缺点是可能产生不必要的请求(任务尚未完成时的重复查询),并且存在延迟(任务完成后到下次轮询之间的等待时间)。
使用轮询时,服务器可以通过 Retry-After 响应头建议客户端的下次查询时间。服务器根据对操作预期耗时的了解,告诉客户端过多久再来查询是合适的。客户端应该尊重这个建议,避免过于频繁的请求给服务器造成不必要的负担。
回调(Webhook)方式下,客户端在提交操作时提供一个回调 URL,当操作完成时服务器主动向这个 URL 发送通知。这种方式更加高效,没有无谓的轮询请求,操作完成后客户端可以立即得到通知。但它要求客户端能够接收 HTTP 请求,这在某些环境下(如浏览器、移动应用)可能不可行或需要额外的基础设施支持。
在实践中,许多系统同时支持轮询和回调两种方式,让客户端根据自己的能力和需求选择合适的方式。

除了用于异步操作的回调,Webhook 还广泛用于实现事件驱动的集成模式。当系统中发生特定事件时,服务器主动将事件通知推送给感兴趣的订阅者,这种模式在现代分布式系统和第三方集成中扮演着重要角色。
Webhook 本质上是一种"反向 API":不是客户端调用服务器的 API,而是服务器调用客户端提供的 API。当订阅的事件发生时,服务器向客户端预先注册的 URL 发送 HTTP POST 请求,请求体包含事件的详细信息。
典型的 Webhook 集成流程是这样的:客户端首先通过 API 注册一个 Webhook 订阅,指定感兴趣的事件类型和接收通知的 URL。当相关事件发生时,服务器构造事件负载并向注册的 URL 发送 POST 请求。客户端的服务器接收请求,处理事件,返回成功响应。如果客户端无法接收或处理失败,发送方通常会实施重试策略。
Webhook 相比轮询的优势是显而易见的。客户端不需要持续查询可能的变化,只在真正有事件发生时才收到通知,这大大减少了双方的资源消耗。事件的传递也更加及时,几乎是实时的,而不是受限于轮询间隔。
Webhook 订阅本身可以作为 REST 资源来管理。典型的端点设计包括:创建订阅(POST /webhooks)、列出订阅(GET /webhooks)、获取订阅详情(GET /webhooks/{id})、更新订阅(PATCH /webhooks/{id})、删除订阅(DELETE /webhooks/{id})。
订阅资源通常包含以下信息:订阅标识符、目标 URL、订阅的事件类型列表、认证凭据(如密钥、签名算法)、创建时间、状态(活跃、暂停、失败等)、最近投递状态等。
高级的 Webhook 系统还会提供事件历史和重发功能。客户端可以查询历史上发送的事件、它们的投递状态、响应内容等。如果某些事件投递失败,客户端可以请求重新发送,这在排查问题和恢复数据时非常有用。
Webhook 的安全性是设计中必须重点关注的方面。由于服务器会主动向客户端 URL 发送请求,需要确保这种机制不被滥用。
首先是 URL 验证。在接受 Webhook 订阅时,服务器应该验证客户端确实控制着注册的 URL。常见的验证方式是向该 URL 发送一个包含随机挑战码的请求,客户端必须正确回应挑战码才能完成订阅。这防止了攻击者将 Webhook 指向他们不控制的 URL(可能造成 DDoS 攻击)。
其次是请求签名。每个 Webhook 请求应该包含服务器生成的签名,让客户端能够验证请求确实来自预期的服务器,而不是伪造的。签名通常基于 HMAC,使用订阅时协商的密钥和请求内容计算得出。客户端在处理 Webhook 前应该验证签名的正确性。
第三是传输安全。Webhook 请求应该总是通过 HTTPS 发送,确保传输过程中的机密性和完整性。服务器应该拒绝注册 HTTP(非 HTTPS)的回调 URL。
处理 Webhook 时,接收方应该快速返回响应(通常是 200 OK),表示已收到通知,然后异步处理事件内容。如果事件处理耗时较长,在 HTTP 请求中同步处理可能导致超时,发送方会认为投递失败并触发重试,造成重复处理。因此,最佳实践是将接收到的事件放入消息队列,立即返回成功响应,由后台进程异步处理事件。
事件负载应该包含足够的信息让消费者理解发生了什么,同时又不过于冗长。
一个良好设计的事件通常包含:事件标识符(用于去重和追踪)、事件类型(如 order.created、user.updated)、发生时间、相关资源的标识符、变化前后的状态快照或只包含变化的增量。
关于包含多少数据,有两种不同的策略。一种是"胖事件",在事件负载中包含资源的完整当前状态,消费者无需再调用 API 获取详情。另一种是"瘦事件",只包含资源标识符和事件类型,消费者如需详细信息要自行调用 API。胖事件减少了额外的 API 调用,但可能传输不必要的数据;瘦事件更加轻量,但增加了后续调用的需求。许多系统采用折中方案,包含最常用的关键字段,同时提供获取完整数据的链接。
CloudEvents 是一个正在兴起的事件格式标准,旨在提供事件数据的通用描述方式。它定义了一组核心属性(如事件类型、来源、时间等)和扩展机制,使得不同系统产生的事件可以被统一处理。采用 CloudEvents 格式可以提高事件的互操作性,便于使用通用的事件处理工具和中间件。
REST 架构原生支持同一资源以多种格式表述,而内容协商是客户端和服务器就使用哪种格式达成一致的机制。理解和正确实现内容协商,可以使 API 更加灵活,能够服务于不同需求的客户端。

HTTP 提供了完整的内容协商机制。客户端通过 Accept 请求头表明自己能够处理的响应格式,通过 Accept-Language 表明语言偏好,通过 Accept-Encoding 表明支持的压缩方式,通过 Accept-Charset 表明字符编码偏好。服务器根据自身能力和客户端偏好,选择最合适的响应格式,并通过 Content-Type、Content-Language 等响应头告知实际使用的格式。
Accept 头部支持优先级权重(q 参数),使客户端可以表达细微的偏好。例如,Accept: application/json, application/xml;q=0.9, */*;q=0.8 表示客户端最希望收到 JSON 格式,其次是 XML,最后可以接受任何格式。服务器应该尊重这些权重,在可能的情况下返回客户端最偏好的格式。
当服务器无法满足客户端的格式要求时,应该返回 406 Not Acceptable 状态码,告知客户端请求的格式不可用。响应体中可以列出服务器支持的格式,帮助客户端调整请求。
JSON 已经成为 RESTful API 事实上的标准格式,几乎所有现代 API 都以 JSON 作为主要或唯一的格式。它的优势包括轻量、人类可读、解析高效,以及在 JavaScript 环境中的原生支持。JSON 的媒体类型是 application/json。
XML 曾经是 Web 服务的主流格式,虽然在 RESTful API 中使用减少,但在某些企业环境和遗留系统集成中仍然重要。XML 支持更复杂的数据结构、命名空间和模式验证,这在某些场景下是优势。XML 的媒体类型是 application/xml 或 text/xml。
HTML 作为 API 的响应格式可能不太常见,但在某些场景下非常有用,特别是当 API 同时服务于浏览器直接访问和程序化访问时。返回 HTML 可以提供人类可读的资源视图,便于调试和探索。
二进制格式如 Protocol Buffers、MessagePack、CBOR 等提供更高效的序列化,适用于对传输效率有严格要求的场景,如移动应用、IoT 设备等。这些格式通常需要双方预先共享模式定义,不像 JSON/XML 那样自描述。
除了标准的通用媒体类型,API 可以定义自己的媒体类型来表达特定的语义或版本信息。自定义媒体类型通常使用 application/vnd. 前缀,如 application/vnd.mycompany.user.v2+json。
自定义媒体类型的一个重要用途是版本控制。通过在媒体类型中包含版本信息,可以使用内容协商机制来处理 API 版本选择,而不需要在 URL 中包含版本号。客户端通过 Accept 头指定希望使用的版本,服务器返回相应版本的表述。这种方式被认为更符合 REST 的理念,但实践中采用的不如 URL 版本控制广泛。
媒体类型还可以用于表达超媒体格式。例如,application/hal+json 表示使用 HAL 格式的 JSON,application/vnd.api+json 是 JSON:API 规范的媒体类型。使用这些类型向客户端表明响应遵循特定的超媒体规范,客户端可以使用相应的库来解析。
在分布式系统中,多个客户端可能同时尝试修改同一资源,这就需要并发控制机制来防止数据冲突和丢失更新。HTTP 提供了原生的条件请求机制,可以优雅地实现乐观并发控制。
ETag(实体标签)是服务器为资源的特定版本生成的标识符。每当资源内容发生变化,其 ETag 也应该变化。ETag 可以是资源内容的哈希值、数据库记录的版本号、或者任何能够唯一标识资源版本的字符串。
客户端首次获取资源时,响应中包含 ETag 头部。当客户端想要更新资源时,在请求中包含 If-Match 头部,值为之前获得的 ETag。服务器在处理更新请求前检查当前资源的 ETag 是否与 If-Match 的值匹配。如果匹配,说明资源自客户端上次获取后没有被修改,可以安全地应用更新;如果不匹配,说明资源已被其他客户端修改,服务器返回 412 Precondition Failed 状态码,拒绝更新。
这种机制被称为乐观并发控制,因为它假设冲突不常发生,只在实际提交更新时检查冲突。相比悲观锁(在读取时就锁定资源),乐观锁在低冲突场景下效率更高,也更适合 HTTP 这种无状态的协议。
If-None-Match 是另一个与 ETag 配合使用的条件头部,用于条件 GET 请求。客户端携带之前获得的 ETag,如果资源没有变化(ETag 匹配),服务器返回 304 Not Modified 而不传输响应体,节省带宽。这是 HTTP 缓存机制的重要组成部分。
Last-Modified 是另一种版本控制机制,使用资源的最后修改时间作为版本标识。服务器在响应中包含 Last-Modified 头部,客户端在后续请求中使用 If-Modified-Since 或 If-Unmodified-Since 头部。
相比 ETag,Last-Modified 的精度较低(通常只到秒级),在高并发场景下可能不够精确。但它的优势是实现简单,不需要计算哈希或维护版本号,只需要记录修改时间即可。
许多系统同时提供 ETag 和 Last-Modified 两种机制。ETag 用于精确的版本控制,Last-Modified 作为备选或用于粗粒度的缓存控制。
当服务器检测到并发冲突(返回 412 状态码)时,客户端需要有策略来解决冲突。
最简单的策略是提示用户:通知用户资源已被他人修改,让用户决定是放弃自己的修改、覆盖他人的修改,还是手动合并两者的修改。这种方式将决策权交给用户,适用于冲突不太频繁且用户有能力判断的场景。
自动重试是另一种策略:客户端重新获取最新的资源,自动将用户的修改应用到新版本上,然后重新提交。这种方式对于简单的、不冲突的修改可以工作,但如果两个客户端修改了相同的字段,自动合并可能产生错误的结果。
某些场景下可以实现更智能的自动合并。例如,如果知道两个修改是独立的(修改了不同的字段),可以安全地合并。类似于版本控制系统中的三方合并,需要比较原始版本、当前服务器版本和客户端修改版本,找出可以自动合并的部分和需要人工解决的冲突。

为了保护系统资源和确保服务质量,API 通常需要对请求进行限流。限流机制限制客户端在特定时间窗口内可以发起的请求数量,防止单个客户端(无论是有意还是无意)消耗过多资源而影响其他用户。
限流可以在多个维度实施。最常见的是基于客户端身份的限流,根据 API 密钥、用户账户或 IP 地址来识别客户端,为每个客户端分配独立的配额。这确保一个客户端的高使用量不会影响其他客户端。
基于端点的限流对不同的 API 端点应用不同的限制。某些端点可能消耗更多服务器资源(如复杂的搜索、报告生成),应该有更严格的限制;而简单的读取操作可以允许更高的频率。
时间窗口的选择影响限流的粒度。常见的时间窗口包括每秒、每分钟、每小时、每天等。滑动窗口算法提供比固定窗口更平滑的限流效果,避免在窗口边界处产生请求尖峰。
令牌桶和漏桶是两种经典的限流算法。令牌桶允许一定程度的突发请求(消耗积累的令牌),同时控制长期的平均速率;漏桶则以恒定速率处理请求,平滑输入流量。不同的算法适用于不同的使用模式和业务需求。
当客户端的请求被限流时,服务器应该返回 429 Too Many Requests 状态码。响应中应该包含有用的信息帮助客户端理解和适应限流规则。
标准的限流相关头部包括 Retry-After,指示客户端应该等待多久再重试。许多 API 还使用以下自定义头部(虽然尚未标准化,但已成为惯例):X-RateLimit-Limit 表示时间窗口内的最大请求数,X-RateLimit-Remaining 表示当前窗口内剩余的请求数,X-RateLimit-Reset 表示限流窗口重置的时间。
客户端应该监控这些头部,在接近限额时主动降低请求频率,而不是等到被限流后才反应。好的客户端实现会实施退避策略,在收到 429 响应后等待适当时间再重试,并且随着连续失败次数增加而延长等待时间(指数退避)。
配额是一种长期的使用限制,通常按天或按月计量,与短期的速率限制相辅相成。配额可以基于请求数量、数据传输量、特定操作的次数等多种指标。
对于商业 API,配额通常与定价计划挂钩。免费计划可能有较低的配额,付费计划提供更高或无限的配额。API 应该提供端点让客户端查询自己的配额使用情况和剩余额度。
配额即将耗尽时,API 可以通过响应头部、Webhook 通知或电子邮件等方式提醒用户。超出配额后的行为也需要明确定义:是直接拒绝请求(返回 429 或 403)、还是允许临时超额并在下期扣减、还是自动升级到更高的计划等。
在设计限流策略时,需要考虑对用户体验的影响。过于严格的限流可能阻碍正常使用,过于宽松则失去保护作用。建议从宽松的限制开始,监控实际使用模式,然后根据数据逐步调整。同时提供清晰的文档说明限流规则,让用户能够据此设计他们的应用。
GraphQL 是 Facebook 于 2015 年开源的一种 API 查询语言,它提供了一种与 REST 不同的 API 设计范式。虽然 GraphQL 有时被视为 REST 的竞争者,但在许多场景下两者可以互补使用。
GraphQL 的核心创新是让客户端精确指定需要的数据结构。客户端在查询中声明需要哪些字段,服务器只返回这些字段,不多不少。这解决了 REST API 中常见的过度获取(返回客户端不需要的数据)和获取不足(需要多次请求才能获得所需数据)问题。
GraphQL 使用强类型的模式来定义 API 的能力。模式描述了可用的类型、字段、查询和变更操作。这种强类型设计使得工具可以提供代码补全、类型检查、文档生成等功能,提升了开发体验。
单一端点是 GraphQL 的另一个特点。不像 REST API 有多个资源端点,GraphQL API 通常只有一个端点(通常是 /graphql),所有的查询和变更都通过这个端点处理。查询本身包含了要执行的操作和需要的数据。
REST 和 GraphQL 各有优势,选择取决于具体场景。
REST 的优势包括:利用 HTTP 缓存机制更加直接(每个资源有独立的 URL,可以单独缓存);概念简单,学习曲线较平缓;与 Web 基础设施的兼容性更好;对于简单的 CRUD 场景,REST 可能更加直观。
GraphQL 的优势包括:客户端可以精确控制返回的数据,减少过度获取;单次请求可以获取多个关联资源的数据,减少请求次数;强类型模式提供了更好的工具支持和类型安全;对于数据需求复杂多变的前端应用特别有用。
GraphQL 也有其挑战:缓存实现更复杂;查询优化和防止滥用(如过深的嵌套查询)需要额外工作;学习成本相对较高;现有的 REST 工具和实践不能直接迁移。
许多现实世界的系统采用混合架构,同时提供 REST 和 GraphQL 接口。
一种常见的模式是在内部服务使用 REST API,对外提供 GraphQL 网关。GraphQL 层聚合多个内部 REST 服务的数据,为前端提供统一、灵活的查询接口。这种架构让后端保持简单,同时给前端提供 GraphQL 的灵活性。
另一种模式是为不同的使用场景提供不同的接口。简单的资源操作使用 REST(利用其缓存优势和简单性),复杂的数据查询使用 GraphQL(利用其灵活性)。两种接口访问相同的底层数据和业务逻辑,只是提供不同的交互方式。
选择哪种架构取决于团队的技术栈、客户端的需求、性能要求等多种因素。没有放之四海而皆准的最佳方案,关键是理解每种选择的权衡,做出适合自己场景的决策。
我们这节课探讨了 RESTful API 设计中的一系列高级实践,比如通过 HATEOAS 让 API 具备“自我导航”能力、用异步任务与事件回调应对长时间或跨服务的业务处理,借力 Webhook 实现服务间的主动推送,以及通过内容协商、条件请求、并发控制等机制让 API 更好地适应多变的应用需求和分布式环境。 同时,合理的限流和配额保障了系统能够稳定、公平地运行。
我们还了解了 GraphQL 与 REST 的不同优势,其实它们可以互为补充而非对立。只有灵活掌握这些深入的设计理念,你才能在面对真实复杂业务时,给出既专业又贴合业务的 API 设计方案。在下一节课中,我们将走进微服务架构下的 API 网关世界,看看它如何助力整个系统的演进与管理。