找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

520

积分

0

好友

74

主题
发表于 昨天 22:50 | 查看: 3| 回复: 0

规则 #0:不要过于纠结术语

大多数开发人员今天将“REST”理解为基于 HTTP 的 API,其 URL 是名词。这个术语最初的意思略有不同,但语言是会变化的。不要担心什么是或不是 REST,专注于构建实用且有效的API

规则 #1:使用复数名词表示集合

这是一个任意的约定,但它已经广为接受,我发现违反这一规则通常是“这个 API 会有问题”的一个早期迹象。

# 正确
GET /products              # 获取所有产品
GET /products/{product_id} # 获取一个产品

# 错误
GET /product/{product_id}

没有技术上的理由表明哪一个更好。这正是为什么你应该遵循客户端开发人员已经期望的通用约定。

规则 #2:不要添加不必要的路径段

一个常见的错误似乎是试图将你的关系模型构建到你的 URL 结构中。Etsy 的新 API 中就充满了这种问题。

# 正确
GET /v3/application/listings/{listing_id}

# 错误
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}

{listing_id}是全局唯一的;没有理由将{shop_id}放在 URL 中。除了用额外的杂乱无章来烦扰你的开发人员外,当你的不变量在未来发生变化时,它不可避免地会引发问题——比如,一个列表移动到不同的商店,或者可以列在多个商店中。

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

GET /shops/{shop_id}/listings              # 正常,符合预期
GET /shops/{shop_id}/listings/{listing_id} # 有人试图保持“一致性”?
GET /listings/{listing_id}                 # 一个更好的端点

这并不是说复合 URL 没有意义——当你真正有复合键时,应该使用它们。

# 当 {option_id} 不是全局唯一时
GET /listings/{listing_id}/options/{option_id}

规则 #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:不要将数组作为顶级响应返回

端点的顶级响应应该始终是一个对象,而不是数组。

# 正确
GET /things 返回:
{ "data": [{ ...thing1...}, { ...thing2...}] }

# 错误
GET /things 返回:
[{ ...thing1...}, { ...thing2...}]

问题是,当你返回数组时,很难进行向后兼容的更改。对象允许你进行添加性的更改。

在这个特定例子中,常见的演变将是添加分页。你可以始终添加totalCounthasMore字段,旧客户端将继续工作。如果你的端点返回顶级数组,你将需要一个全新的端点。

规则 #5:不要返回映射结构

我经常看到 JSON 响应中使用映射结构来表示集合。相反,应该返回一个对象数组。

# 错误
GET /things 返回:
{
    "KEY1": { "id": "KEY1", "foo": "bar" },
    "KEY2": { "id": "KEY2", "foo": "baz" },
    "KEY3": { "id": "KEY3", "foo": "bat" }
}

# 正确(同时注意规则 #4 的应用)
GET /things 返回:
{
    "data": [
        { "id": "KEY1", "foo": "bar" },
        { "id": "KEY2", "foo": "baz" },
        { "id": "KEY3", "foo": "bat" }
    ]
}

JSON 中的映射结构是糟糕的:

  • 键信息是多余的,并且会在传输过程中增加噪声。
  • 不必要的动态键会给使用静态类型语言的人带来麻烦。
  • 你认为的“自然”键可能会改变,或者客户端可能希望有不同的分组方式。

在大多数语言中,将对象数组转换为映射结构只需要一行代码。如果客户端希望高效地随机访问对象集合,他们可以创建这种结构。你不需要将其放在传输中。

返回映射结构最糟糕的一点是,你的概念键可能会随着时间的推移而改变,而唯一的方法是破坏向后兼容性。OpenAPI 是一个警示性的例子——从 v3 到 v4 的变化中充满了不必要的破坏性变化,因为它们过度依赖映射结构而不是数组结构。

# OpenAPI v3 结构
{
    "paths": {
        "/speakers": {
            "post": { ...关于端点的信息...}
        }
    }
}

# 提议的 OpenAPI v4 结构,通过添加一个新的映射层(例如 “createSpeaker”)来命名请求。
{
    "paths": {
        "/speakers": {
            "requests": {
                “createSpeaker”: {
                    "method": "post",
                    ...其余端点信息...
                }
            }
        }
    }
}

如果这是一个更扁平的列表结构,向对象添加一个名称是非破坏性的更改:

# 假设的扁平数组结构,使用字段而不是映射键
{
    "requests": [
        {
            name: “createSpeaker”,    // 添加这个字段是非破坏性的
            path: “/speakers”,
            method: “post”,
            ...等等...
        }
    ]
}

映射规则的例外情况

映射规则的例外是简单的键值对,例如 Stripe 的元数据。

# 可接受
{
    "key1": "value1",
    "key2": "value2"
}

没有人会因为你使用这种结构而责怪你。但如果值不仅仅是简单的字符串,最好使用对象数组。

规则 #6:始终使用字符串作为所有标识符

始终使用字符串作为对象标识符,即使你的内部表示(例如数据库列类型)是数字。只需将数字转换为字符串。

# 错误
{ "id": 123 }

# 正确
{ "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(没有两阶段提交):

  1. 在单个数据库事务中,SystemAlpha 删除 Thing123 并将一个 NotifyBravo 作业入队
  2. NotifyBravo 作业运行,调用 SystemBravo 上的 DELETE /things/Thing123

这可以工作,因为队列会一直重试作业,直到成功。但它也可能重试已经成功的作业;队列是至少一次的,而不是 恰好一次。

由于已经成功执行的DELETE作业可能会重试,因此作业必须将“未找到”响应视为成功。如果你将 404 视为成功,并且你的堆栈中的一个故障返回了 404,你的作业将从队列中移除,你的删除操作将不会传播。我在现实生活中见过这种情况。

你可以简单地让DELETE在删除一个不存在的项目时返回 200(或 204)OK——这是有道理的,我认为这是DELETE的一个可以接受的答案。但类似的问题也存在于GETPUTPATCH和其他方法中。

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

我的建议是选择另一个 400 级别的错误代码,客户端可以将其解释为“我明白你在问什么,但我没有找到”。我使用 410 GONE。这与 410 的原始意图(“它曾经存在过,但现在不存在了”)略有偏离,但没有人真正使用这个错误,它相当容易理解,而且没有风险,未来的 HTTP 规范不会重新使用你编造的 4XX 数字。

但几乎任何策略都比为实体未找到返回 404 更好。

规则 #9:保持一致性

这部分内容主要是为了调侃 Shopify。他们的 REST API 中有 6 种微妙不同的地址模式:

  • DraftOrder 包含你期望的大多数地址字段,包括 name、first_name、last_name、province、province_code、country、country_code
  • Customer Address 添加了 country_name
  • Order 的 billing_address 添加了 latitude、longitude,但 shipping_address 没有
  • Checkout 的 billing_address 和 shipping_address 没有 name(但仍然有 first_name 和 last_name)
  • AssignedFulfillmentOrder 的 destination 缺少 name、province_code、country_code(但仍然有 first_name、last_name 和完整的 country 和 province 名称)
  • Location 有 name,但没有 first_name 或 last_name——至少这个还算合理

这真是令人抓狂。感觉像是 Shopify 的某个人在戏弄我们:“西蒙说”有country字段。“西蒙说”有country_name字段。有一个country_code字段。哈哈,给你一个空指针异常!

请务必尽你最大的努力保持具有相似含义的对象之间的字段一致性。如果你使用的是 Ruby 或 Python 这样的动态语言,要格外努力!

规则 #10:使用结构化的错误格式

如果你正在构建一个简单网站的后端,你可以忽略这一部分。但如果你正在构建一个拥有多个 REST 服务层级的大型系统,你可以通过提前建立一个标准的错误格式来节省自己很多麻烦。

我的错误格式通常看起来像这样,大致类似于一个(Java)异常:

{
  “message”: “你没有权限访问此资源”,
  “type”: “Unauthorized”,
  “types”: [“Unauthorized”, “Security”],
  “cause”: { …递归嵌套任何异常… }
}

标准的错误格式(带有嵌套的 cause)意味着你可以多层包装和重新抛出错误:

ServiceAlpha -> ServiceBravo -> ServiceCharlie -> ServiceDelta

如果 ServiceDelta 抛出一个错误,ServiceAlpha 可以返回(或记录)完整的链,包括根本原因。这比在四个不同的系统中梳理日志(即使有集中式日志)要容易得多。

规则 #11:提供幂等机制

我们公司的软件将订单路由到十几个不同的印刷公司,这些公司印刷并运输实体商品。我曾与不同的技术团队进行过完全相同的对话:

Jeff:我如何确保不会提交重复的订单?

印刷公司:难道你不能只提交一次订单吗?

叹气。不,恐怕我做不到。我总是回复的快速示例是这样的:

  1. 我提交订单
  2. 网络失败,我收到超时而不是 200 OK
  3. 我不知道订单是成功还是失败

但我需要一个更详细的答案,可以指给人们看,所以这里开始了。如果你为印刷公司工作,而我把你带到这儿,请不要介意!你并不孤单。

幂等简介

幂等是一种操作的属性,即如果你多次执行它,它不会改变结果。你已经期望GETPUTDELETE操作是幂等的:

# GET 不会改变服务器上的任何内容
GET /orders/ORD123

# 如果你多次对同一个订单调用 PUT,邮编保持不变
PUT /orders/ORD123/address
{“zip”: “91202”}

# 如果你多次调用 DELETE,订单仍然被删除
DELETE /orders/ORD123

创建操作,通常与POST相关联,是不同的。如果没有特殊处理,它们是非幂等的。

# 每次调用这个,我们都会创建一个新订单
POST /orders
{“product”: “飞盘”, “address”: {…等等…}}

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

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

好方法:使用“幂等键”或“客户端参考 ID”

让客户端在 POST 中提交一个唯一值,并在服务器上强制该值的唯一性。Stripe 就是这样做的,使用一个头部。他们将幂等键存储 24 小时,为你提供 24 小时的重复保护:

POST /v1/customers
Idempotency-Key: blahblahblahblah
{“name”:”鲍勃·多布斯”}

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

确保键/ID 是一个字符串——参见规则#6

好方法:让客户端选择 ID

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

# 客户端选择 ID
POST /things
{“id”: “mything1”}

# 现在可以使用该 ID
GET /things/mything1

这可以产生简单、易用的 API——尽管在多租户系统中(ID 必须对每个租户唯一)会增加实现复杂性。

糟糕的方法:提供一个列出最近交易的端点

如果 API 没有提供任何明确的幂等帮助,这是客户端开发人员的一种变通方法:

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

要使这有效,客户端必须序列化所有创建操作——否则会存在竞争条件。它很慢,而且维护一个 N 小时的安全窗口意味着要获取 N 小时的交易——在繁忙的系统上可能是禁止的。但如果你正在构建客户端,而 API 没有提供其他幂等机制,这就是你必须做的。

当发生冲突时……

现在你的 API 提供了一个(好的)幂等机制,还有一个主要的考虑因素:你如何告知客户端发生了冲突?主要有两种观点:

返回错误

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

POST /things
{“idempotency_key”: “blahblahblah”, …等等…}

# 响应 409 冲突
{“message”: “这是一个重复项”, “old_id”: “THG1234”}

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

返回之前的响应

而不是向客户端返回错误,将客户端第一次应该收到的确切响应返回给他们。

这允许客户端稍微“愚蠢”一些,因为他们不需要明确编写冲突错误处理程序。然而,这大大复杂了服务器端的实现:你需要存储一段时间内的所有响应,并且你需要验证客户端每次请求都发送了完全相同的参数。

Stripe 选择了这条路。我个人从未这样做过;为了客户端的一点便利,服务器端的工作量太大了。

总结

有几种方法可以为非幂等操作启用幂等行为。只要你选择某种方式,你的客户端就会很高兴。如果你不想太费脑筋,就采用这个解决方案:

  • 让客户端在每次 POST/创建操作中提交一个幂等键(即“客户参考 ID”)。
  • 在数据库中存储它,并设置唯一约束。
  • 当违反唯一约束时返回 409 冲突。
  • 在 409 响应正文中提供原始 ID。

规则 #12:使用 ISO8601 字符串表示时间戳

使用字符串表示时间戳,而不是像自纪元以来的毫秒数这样的数字。可读性很重要!有人一眼看到“2023-12-21T11:17:12.34Z”可能会注意到它是一个月后的日期;而有人一眼看到 1703157432340 则不会。

时间戳有一个 标准格式。它看起来就像上面的字符串(包括“T”)。你的所有客户端都可以轻松访问解析和生成这种格式的库例程。

所有时间戳都应该使用 UTC(“Z”)。

错误的数字时间戳论点包括:

  • “有多种日期/时间格式”——使用 ISO8601。
  • “添加/减去 UTC 很难”——它比从 1970 年开始计数秒要容易。
  • “解析数字更快”——如果你那么在乎性能,使用二进制格式而不是 JSON。

规则 #12a:对所有与日期/时间相关的值使用 ISO8601

ISO8601 标准化了许多其他与日期和时间相关的概念的格式,包括本地(无时区)日期和时间、持续时间以及间隔。使用它们。

规则 #12b:不要信任你的语言/平台默认值

许多开发平台默认不生成 ISO8601 格式。更糟糕的是,这些平台的默认格式通常会根据机器的区域设置和/或时区而变化!无论何时在你的 API 中格式化日期/时间值,都要验证输出。总是有办法生成 ISO8601 格式(提示:JavaScriptDate.toISOString()方法,Pythondatetime 模块也提供了标准格式化方法)。


希望这份规则能帮助你设计出更健壮、易用且面向未来的 REST API。API 设计是架构的体现,一个深思熟虑的接口,能够极大地提升开发效率和系统的长期可维护性。




上一篇:实时虚拟人生成实战:阿里Live Avatar框架解析与20FPS优化策略
下一篇:数字人系统实战:集成人脸识别与MemOS记忆实现拟真对话
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2025-12-8 22:56 , Processed in 1.150092 second(s), 44 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2025 云栈社区.

快速回复 返回顶部 返回列表