REST API有关幂等性等11条最佳实践

(and-how-not-to)-design-REST-APIs
在我的职业生涯中,我使用了数百个 REST API 并制作了数十个。由于我经常在 API 设计中看到相同的错误,因此我认为写下一组最佳实践可能会更好。

规则#1:集合时一定要使用复数名词
这是一个任意的约定,但它是公认的,而且我发现违规往往是“这个 API 将有粗糙边缘”的主要指标。

# GOOD
GET /products              # get all the products
GET /products/{product_id} # get one product

# BAD
GET /product/{product_id}

规则#2:不要添加不必要的路径段
一个常见的错误似乎是试图将关系模型构建到 URL 结构中。Etsy 的新API充满了这样的东西

# GOOD
GET /v3/application/listings/{listing_id}

# BAD
PATCH /v3/application/shops/{shop_id}/listings/{listing_id}
GET /v3/application/shops/{shop_id}/listings/{listing_id}/properties
PUT /v3/application/shops/{shop_id}/listings/{listing_id}/properties/{property_id}

没有理由{shop_id}成为 URL 的一部分。

我已经看到这个错误一次又一次地重复出现。我只能假设这是某人强迫症的表现:

GET /shops/{shop_id}/listings              # normal, expected
GET /shops/{shop_id}/listings/{listing_id} # someone trying to be "consistent"?
GET /listings/{listing_id}                 # a much better endpoint

这并不是说复合 URL 没有意义 - 当您真正拥有复合键时才使用它们。

规则 #3:不要在 url 中添加 .json 或其他扩展名
这似乎是 Rails 的某种默认行为,因此它间歇性地出现在公共 API 中。Shopify在这里感到羞耻。

  • URL 是资源标识符,而不是表示形式。将表示信息添加到 URL 意味着“事物”没有规范的 URL。客户端可能无法通过 URL 唯一地识别“事物”。
  • “JSON”甚至不是表示的完整规范。例如,什么传输编码?
  • HTTP 已经提供了标头 ( Accept、Accept-Charset、Accept-Encoding、Accept-Language) 来协商表示。
  • 将常用文本放在 URL 末尾会让编写客户端的人感到厌烦。
  • 无论如何,JSON 应该是默认值。

早在 2000 年代,可能会有一些关于客户是否需要 JSON 还是 XML 的问题,但在 2020 年代这个问题已经得到解决。返回 JSON,如果客户端想要协商其他内容,请依赖标准 HTTP 标头。

规则 #4:不要将数组作为顶级响应返回
来自端点的顶级响应应该始终是一个对象,而不是一个数组。

# GOOD
GET /things returns:
{ "data": [{ ...thing1...}, { ...thing2...}] }

# BAD
GET /things returns:
[{ ...thing1...}, { ...thing2...}]

问题在于,当您返回数组时,很难进行向后兼容的更改。对象允许您进行附加更改。
在这个特定示例中,明显的共同演变是添加分页。您可以随时添加totalCount或hasMore字段,老客户端将继续工作。如果您的端点返回顶级数组,您将需要一个全新的端点。

规则 #5:不要返回映射结构
我经常看到 JSON 响应中用于集合的映射结构。相反,返回一个对象数组。

# BAD
GET /things returns:
{
    "KEY1": { "id": "KEY1", "foo": "bar" },
   
"KEY2": { "id": "KEY2", "foo": "baz" },
   
"KEY3": { "id": "KEY3", "foo": "bat" }
}

# GOOD (also note application of Rule #4)
GET /things returns:
{
   
"data": [
        {
"id": "KEY1", "foo": "bar" },
        {
"id": "KEY2", "foo": "baz" },
        {
"id": "KEY3", "foo": "bat" }
    ]   
}

JSON 中的映射结构很糟糕:

  • 关键信息是冗余的,会给线路增加噪音
  • 不必要的动态键给使用类型语言工作的人带来了麻烦
  • 无论您认为“自然”键是什么,都可以改变,或者客户可能想要不同的分组

在大多数语言中,将对象数组转换为映射是一件简单的事。如果您的客户想要有效地随机访问对象集合,他们可以创建该结构。您不需要将其放在电线上。

返回映射结构的最糟糕的事情是您的概念键可能会随着时间的推移而改变,而迁移的唯一方法是破坏向后兼容性。OpenAPI 是一个警示故事 - v3 到 v4充满了不必要的重大更改,因为它们严重依赖于映射结构而不是数组结构。

# OpenAPI v3 structure
{
    "paths": {
       
"/speakers": {
           
"post": { ...information about the endpoint...}
        }
    }
}

# Proposed OpenAPI v4 structure, which names requests by adding a new 
# map layer (eg
"createSpeaker").
{
   
"paths": {
       
"/speakers": {
           
"requests": {
               
"createSpeaker": {
                   
"method": "post",
                    ...rest of the endpoint info...
                }
            }
        }
    }
}

如果这是一个更扁平的列表结构,则向对象添加名称是一个不间断的更改:

# Hypothetical flat array structure, using fields instead of map keys
{
    "requests": [
        {
            name:
"createSpeaker",    // adding this field is nonbreaking
            path:
"/speakers",
            method:
"post",
            ...etc...
        }
    ]
}

规则 #6:请对所有标识符使用字符串
始终使用字符串作为对象标识符,即使您的内部表示形式(即数据库列类型)是数字。只需将数字字符串化即可。

# BAD
{ "id": 123 }

# GOOD
{
"id": "123" }

优秀的 API 将比您、您的实现代码以及创建它的公司更长久。届时,您的基础设施可能会在不同的技术平台上重写、迁移到新数据库,或与包含冲突 ID 的另一个数据库合并。

字符串 ID 非常灵活。字符串可以对版本信息或段 ID 范围进行编码。字符串可以对复合键进行编码。数字 ID 给未来的开发人员带来了束缚。

我曾经开发过一个系统(由于数据库合并),该系统必须通过给一组正 ID 和其他负 ID 来分段数字 ID 范围。除了一般的丑陋之外,您只能进行一次这种分割。

额外的好处是,如果所有 ID 字段都是字符串,则使用类型化语言的客户端开发人员无需考虑使用哪种类型。只需使用字符串即可!

规则 #7:一定要为您的标识符添加前缀
如果您的应用程序非常复杂,您最终会得到许多不同的对象类型。对于您和您的客户端开发人员来说,保持不透明的 ID 都是一项心理挑战。通过使不同类型的 ID 具有自描述性,您可以显着改善 API 的人机工程学。

  • Stripe 的标识符有两个字母加下划线的前缀:in_1MVpWEJVZPfyS2HyRgVDkwiZ
  • Shopify 的 graphql 标识符看起来像 URL(尽管它们的 REST API ID 是数字,嘘):gid://shopify/FulfillmentOrder/1469358604360

使用什么格式并不重要,只要 1) 它们在视觉上不同并且 2) 它们不会改变。

当您可以立即区分“订单行项目 ID”、“履行订单行项目 ID”和“发票项目行项目 ID”之间的区别时,每个人都会对支持负载的减少感到满意。

规则 #8:不要使用 404 来表示“未找到”
HTTP 规范规定,应使用 404 来表示未找到资源。按照字面解释,如果向不存在的 ID 提出 GET/PUT/DELETE 等请求,则应返回 404。请不要这样做--听我说完。

当调用(例如)GET /things/{thing_id}请求一个不存在的东西时,响应应表明:1)服务器理解了您的请求;2)没有找到该东西。遗憾的是,404 响应并不能保证 #1。有很多层软件会对请求返回 404,其中有些可能是你无法控制的:

  • 配置错误的客户端点击了错误的 URL
  • 配置错误的代理(客户端和服务器端)
  • 负载平衡器配置错误
  • 服务器应用程序中的路由表配置错误

返回 HTTP 404 表示 "未找到内容",这与返回 HTTP 500 几乎一样--它可能意味着内容不存在,也可能意味着出了问题;客户端无法确定是哪种情况。

这不是一个小问题。分布式系统最难的一点就是保持一致性。假设你想从两个系统(Alpha 和 Bravo)中删除一个资源,而你只有一个简单的 REST API(没有两阶段提交):

  • 在单个数据库事务中,SystemAlpha 删除 Thing123 并查询 NotifyBravo 作业
  • NotifyBravo 作业运行,在 SystemBravo 上调用 DELETE /things/Thing123

这样做是可行的,因为队列会重试作业,直到成功为止。但它也可能重试已经成功的作业;队列是至少重试一次,而不是完全重试一次。

由于成功执行的 DELETE 作业无论如何都会重试,因此作业必须将 "未找到 "响应视为成功。如果将 404 作为成功处理,而堆栈中的失败返回 404,作业就会从队列中删除,删除也不会传播。我在现实生活中就遇到过这种情况。

当删除一个不存在的东西时,你可以简单地让 DELETE 返回 200(或 204)OK,这是有道理的,而且我认为这是 DELETE 可以接受的答案。但 GET、PUT、PATCH 和其他方法也存在类似的问题。

你可以使用 404,但返回一个自定义的错误正文,并要求客户端检查错误正文是否正确。这会给懒惰的客户端程序员带来麻烦。当客户最终看到不一致的数据时,这可能是 "你的错",也可能不是,但他们给你打的支持电话将是真实的。

我的建议是选择另一种 400 级错误代码,客户可以将其理解为 "我知道你要什么,但我没有"。我使用的是 410 GONE。这略微偏离了 410 的原意("以前存在,但现在没有了"),但实际上没有人会使用这个错误,而且它也很容易解释,也不会有未来的 HTTP 规范会重新使用你编造的 4XX 号码的风险。

但几乎任何策略都比返回 404(实体未找到)要好。

规则#10:一定要使用结构化错误格式
如果您正在为一个简单的网站构建后端,您可能可以忽略此部分。但是,如果您正在构建具有多层 REST 服务的大型系统,则可以通过预先建立标准错误格式来为自己省去很多麻烦。

我的错误格式往往看起来像这样,大致形状像(Java)异常:

{
  "message": "You do not have permission to access this resource",
 
"type": "Unauthorized",
 
"types": ["Unauthorized", "Security"],
 
"cause": { ...recurse for nested any exceptions... }
}

标准错误格式(具有嵌套原因)意味着您可以多层深度包装和重新抛出错误:

ServiceAlpha -> ServiceBravo -> ServiceCharlie -> ServiceDelta

如果 ServiceDelta 引发错误,ServiceAlpha 可以返回(或记录)完整的链,包括根本原因。这比梳理四个不同系统上的日志更容易调试 - 即使使用集中式日志记录。

规则#11:一定要提供幂等机制
幂等性是操作的属性,如果您多次执行该操作,则不会改变结果。您已经期望GET、PUT和DELETE操作是幂等的:

# GET doesn't change anything on the server
GET /orders/ORD123

# 如果对同一订单多次调用 PUT,zip 将保持不变
PUT /orders/ORD123/address
{"zip": "91202"

# If you call DELETE multiple times, the order stays deleted
DELETE /orders/ORD123

由于网络不可靠,我们遭遇了“二将军问题”。如果发生错误,客户端无法知道服务器上的操作是否成功完成。如果客户再次提交订单,我们可能会创建重复订单(“至少一次”)。如果客户不重新提交订单,我们可能会丢失订单(“最多一次”)。

为了获得非幂等操作的一次性行为,我们需要在客户端和服务器之间进行额外的协调。通常有两种好方法和一种蹩脚方法来支持这一点。

1、“幂等性键”或“客户端参考 ID”
让客户端通过 POST 提交唯一值,并在服务器上强制该值的唯一性。Stripe使用标头以这种方式工作。他们将幂等键存储 24 小时,为您提供 24 小时的保护,防止重复:

POST /v1/customers
Idemptency-Key: blahblahblahblah
{"name":"Bob Dobbs"}

同样,许多订单处理系统允许客户提交“客户参考 ID”,该 ID 与每个订单一起保存并包含在客户报告中。强制执行该值的唯一性可以防止永久重复订单。

确保 key/id 是一个字符串 - 请参阅规则 #6。

2、让客户选择 ID

如果客户端需要为每次提交选择一个唯一的幂等键,为什么不直接将其作为 ID 呢?

# Client picks the id
POST /things
{"id": "mything1"}

# The id can now be used
GET /things/mything1

这可以产生简单、符合人体工程学的 API - 尽管它增加了多租户系统中的实现复杂性(其中 ID 对于每个租户来说必须是唯一的)。

糟糕的选项:提供一个端点来列出最近的交易
如果 API 未提供任何有关幂等性的显式帮助,则这是客户端开发人员的解决方法:

  1. 每次提交之前,从服务器获取最近事务的列表。
  2. 查找与您打算提交的内容相匹配的现有交易(希望您有匹配的客户参考 ID)。

为此,客户端必须序列化所有创建操作 - 否则会出现竞争条件。它很慢,并且维护 N 小时的安全窗口意味着获取 N 小时的事务 - 在繁忙的系统上可能会令人望而却步。但是,如果您正在构建客户端并且 API 不提供另一种幂等机制,那么这就是您必须做的。

当冲突发生时...
既然您的 API 提供了一种(良好的)幂等机制,那么还有一个主要考虑因素:如何通知客户端存在冲突?有两个主要的思想流派:

1、返回错误
当客户端提交重复的幂等性密钥时,我喜欢返回 409 CONFLICT。这里有一个技巧 - 除非您使用用户提交的 ID(“让客户端选择 ID”),否则您需要在错误消息中包含现有 ID,或者提供一种通过幂等键查找 ID 的机制。

P

OST /things
{"idempotency_key": "blahblahblah", ...etc...}

# Response 409 CONFLICT
{
"message": "This is a duplicate", old_id": "THG1234"}

当客户端收到 409 CONFLICT 响应时,它会说“哦,已经完成”并记录创建的 ID。就像第一个 POST 返回且没有错误一样。

2、返回之前的响应
不要向客户端返回错误,而是向他们返回客户端应该第一次得到的确切响应。

这使得客户端变得更加愚蠢,因为他们不必显式地编写冲突错误处理程序。但是,它使服务器实现变得非常复杂:您需要将所有响应存储一段时间,并且需要验证客户端是否为每个请求发送了完全相同的参数。

Stripe选择了这条路线。我个人从来没有;为了给客户带来一点方便,需要做很多艰苦的工作。

有几种方法可以为非幂等操作启用幂等行为。只要您选择一些东西,您的客户就会很高兴。如果您不想考虑太多,请采用以下解决方案:

  • 让客户端在每次 POST/create 操作时提交幂等性键(也称为“客户参考 ID”)
  • 将其存储在具有唯一约束的数据库中
  • 违反唯一约束时返回 409 CONFLICT
  • 在 409 响应正文中提供原始 ID

亮点:通过允许具有相同参数的重复请求或在冲突时返回现有 ID,使 API 具有幂等性。