「译介」REST 十诫

2022-05-15, 星期日, 18:21

译介Dev

原文 https://treblle.com/how-it-works,作者 Vedran Cindrić 是 Treblle 创始人。Treblle 可以为 API 创建符合 OpenAPI 规范的文档,提供测试、实时监控和记录等服务。译者最早在 InfoQ 读到了这篇文章,于是结合 InfoQ 的译本草草补充翻译了一遍。

过去十年里开发 API 是我的主要工作之一。从简单的单一客户端 API 到复杂的多端 API,还有一些第三方公司比如 Stripe、Twilio 的 API,它们基本都符合 REST 模式,但又有各自的一些特点。

REST 的流行有多种原因:易于理解、灵活、可扩展、开发生态完善。不过我还要提一嘴:它的老对手 SOAP 实在是太可怕了。懂的都懂 —— 糟糕的客户端、XML、古怪的身份验证。感谢老天爷,最后 JSON 和 REST 赢了。

当然现在也有一些新下场的玩家吸收了 REST 模型的闪光点,没错我就是在说 GraphQL。GraphQL 也基于 JSON,同样有着 REST 中较好的那些特质:灵活、性能优异、可扩展。我不喜欢 GraphQL 的原因主要有两点:1. 它是 Facebook 开发的;2. 它把设计 API 的责任转嫁到了客户端。前端开发者和 APP 开发者是设计数据库查询 API 的合适人选吗?让后端开发者来设计 APP,修车工来维修飞机,兽医给人做手术,可以,但不合适。

依我见接下来一段时间 REST 仍将是丛林之王。唯一的问题是直到今天这个方案都没有一个合适的标准或协议。相反,它是一组「架构约束」,这是一种不想将标准称之为「标准」的高级说辞。这就激发了许多人的想象力,他们倾向于根据自己的理解去做这些事,不管理解是对的错的。为了避免这种误解,我写下了这篇 REST 十诫。

1. 务实

如果您正在构建一个 REST API,使用 JSON。

  • JSON 更容易使用、书写和阅读
  • JSON 更快,占用更少的空间
  • JSON 不需要专门的依赖来解析
  • 每一种有意义的程序设计语言都能良好地支持 JSON

如果这话太抽象了,去随便下个 XML 文件试着解析一下吧(译者注:再试着生成一下)。

2. 有条不紊

人们常常用「安全」和「幂等」这样的黑话来描述 API。「安全」意味着请求不会改变或者破坏一些东西,而「幂等」意味着可以向同一个端点发送多次请求,每次都会获得相同的结果。通常「安全」的方法也是「幂等」的,反之则不一定。

GET

读取数据时应该使用 GET 方法。我们知道 GET 方法只能用来读取数据,并且每次都会返回同样的数据。GET 显然是「安全」和「幂等」的。

POST

发出 POST 请求意味着你将在数据库创建一个新行,在某处写点什么,或者从无到有创造一些东西。我推荐客户端以 application/json 的形式发送数据,这样我们就能保持一致,符合 JSON 精神,而且发送 JSON 数据可以让你轻松地构造出真正复杂的请求。最后,POST 操作是不「安全」的,因为它们的确会在服务器端改变一些东西,比如向同一个端点发出两次请求会创建不同的资源。

PUT

PUT 请求最常被用于更新。它可以用于创建新的记录,但当时的设想是,客户端必须是一个 ID,为新的资源定义了一个 ID。所以在你需要更新一个资源时,请简单地使用 PUT。PUT 显然不是「安全」的操作,不过它是「幂等」的。

DELETE

我想说这个是不言自明的。这当然不是一个「安全」的操作,但有人说它是「幂等」的,有人说它不是。

PATCH

PATCH 请求用于再次更新资源,但与 PUT 不同的是,它只需要更新改变的数据,而 PUT 能够并且应当对全部的资源进行更新。

3. 语义化

每个 REST API 从端点到输入参数到 JSON 键都应该能被普通人理解。规则很简单:

  • 用名词,不要用动词
  • 用复数形式,不要用单数形式
  • 尽量使用单个单词

GET /getUser 不是个有趣的方法,GET /users 就够了(还有 GET /users/:id)。

如果不可避免地使用多个单词,用连字符 -。看在老天爷的份上,在 URI 中只使用小写字母。

JSON 键在请求和响应数据中的命名规则存在着大量争议,特别是考虑到有三种实践:camelCasesnake_casespinal-case。没有人可以阻止你使用其中任何一种,不过 Stripe、PayPal 和 Facebook 都选择了 snake_case。还有一些类似的研究显示,snake_case 在可读性上要优于 camelCase

4. 安全

很多人都没有为自己的 API 做访问控制,因为他们觉得实施、使用和维护都非常烦琐。这大可不必,因为实现不记名令牌只需要两分钟,而且不必牵扯到数据库。要是情况变复杂了,还可以转向 JWT 或 OAuth。

使用访问控制有很多好处:首先可以控制 API 的访问权限,可以使用 API 密钥来跟踪集成情况,是不是有人滥用 API,或者客户端行为是不是正常。你甚至可以通过 API 调用来收集客户端、用户和 API 的统计数据。

你的 API 应当在 HTTPS 上运行,它保护用户免受中间人攻击,并对客户端和 API 之间的通信进行加密。不要传回那些用不到的敏感数据(用户地址、电话号码、身份证号码或者其他形式的身份认证)。如果非要传输,就保证访问 API 和接收响应的人是真正的用户。

我想谈谈 UUID 与 ID 之争。我长期以来是 ID 的粉丝,因为它们更短、更快,但 UUID 增加的安全性和隐私优势更为重要。

最后是基础设施安全。如果你使用 AWS 或 Azure,API 网关在防火墙和 DDoS 攻击检测方面可以提供额外的安全性。如果你运行在传统的服务器上,下面是两个小建议:

  1. 坚持更新你的 NGINX 包、NPM 包……各种各样的软件包。
  2. Apache 等服务器软件默认会在每个请求中发送一个响应标头,它将告知潜在攻击者你正在使用哪个版本。把它关掉。

通常情况下,每个季度都应该召开一次安全会议,讨论如何改进安全、怎样改进,以及怎样保证安全。

5. 有条理

「我的 API 不多,而且只有一个用户,因此我不会做版本管理」。然而在 API 中使用版本管理,是你能尽早做出的最佳决策。

一个正在使用或开发中的应用经常会有一些小升级,比如修改模型、数据结构或者业务流程。没有启用版本管理,你的每次 Git 提交都要确保没有破坏任何东西,还要了解某个版本的应用程序的将会如何表现。

软件开发世界里最普遍的版本管理方案是 major.minor.patch。在我看来 PATCH 部分有点多余。如果你改变了 API 的核心内容,例如认证、核心模式、流程等等,那就做大版本升级。增加或者删除某些小功能,小范围地修改数据结构或类似的东西则适用于小版本和补丁版本。

你可以通过 URI 路径、请求头、查询参数等方法实现版本控制,这些方法各有利弊。我推荐使用基于 URI 的方法,从许多层面上来说也是最容易理解的方法。

api.domain.com/v1/auth/login
api.domain.com/v1.2/auth/login
api.domain.com/v1.4.5/auth/login
api.domain.com/v2/auth/login

6. 保持一致性

一致性是将平庸转化为卓越的原因

我的目标首先是资源/模型的一致性,然后延伸到其他领域,如命名、URI、HTTP 代码。 API 可以归结为资源,一种资源可以是任何东西,用户、文章、书籍、产品等等。每一种资源都可以包含多个属性、对象或数组。基于你的数据库设计或是其他业务逻辑,资源是结构化的。

API 端点返回完全不同的结构听上去很有吸引力,好像经过了细致的优化,但是你最好不要这么做。如果你在每个端点上发送不同的东西,那么使用者就会吃苦头。如果你没有数据,就传输一个空值、对象或者数组。

假如我们现在有 文章 资源,文章 有时候会有 评论,有时候没有。有时候需要加载 评论,而有时侯不需要。

加载一篇文章和评论,就像这样:

{
   "status":true,
   "message":"Getting details for article with UUID: 5b8f6db5-7848-490e-95a7-f7146dd2e30c",
   "article":{
      "title":"Sample title 1",
      "description":"Sample description 1",
      "uuid":"eec33d99-e955-408e-a64a-abec3ae052df",
      "comments":[
         {
            "text":"Great article",
            "user":{
               "name":"John Doe",
               "uuid":"5b8f6db5-7848-490e-95a7-f7146dd2e30c"
            }
         },
         {
            "text":"Nice one",
            "user":{
               "name":"Jane Doe",
               "uuid":"2ececb69-d208-46c2-b560-531cb716d25d"
            }
         }
      ]
   }
}

如果在下载一系列的文章,或是刚创建了一篇没有任何评论的文章,应该返回:

{
   "status":true,
   "message":"Article list was a success",
   "articles":[
      {
         "title":"Sample title 1",
         "description":"Sample description 1",
         "uuid":"eec33d99-e955-408e-a64a-abec3ae052df",
         "comments":[]
      },
      {
         "title":"Sample title 2",
         "description":"Sample description 2",
         "image":"https://domain.com/image-2,jpg",
         "uuid":"b8ee70a8-1128-4670-9368-83953fdf722b",
         "comments":[]
      }
   ]
}

如果客户端以为这里应该有个评论数组,他们仍然会得到一个数组。这样一来,你不必更改你的模型,也不会删除任何东西。

你只是通过保持一致,就为自己和他人节省了大量的时间。

7. 优雅

HTTP 状态代码几乎可以在任何情况下使用:

  • 信息型响应(1xx)
  • 成功响应(2xx)
  • 重定向响应(3xx)
  • 客户端错误响应(4xx)
  • 服务端错误响应(5xx)

从 OK、未授权、未找到、内部服务器错误到「我是茶壶」,这些状态都有对应的响应代码,而且都被广泛地接受和了解。401 Unauthorized 意味着客户端没有发送正确的认证信息,所以大家都知道这是客户端故障,必须通过客户端来解决。

有了 HTTP 状态代码,我们还要在出问题时提供尽可能多的信息。为此需要做许多工作。首先,我们要能预见 API 失败,调用方会做什么?有没有非法调用?因此第一步是要进行严密的数据校验,特别是创建内容之前。你需要检查这些数据是否有效,例如验证 ID 真实存在,数值在预期范围之内。

假定你已经有了一个端点,它接受 user_id 参数并返回用户资料。使用这种策略会做以下事情:

  1. 检查请求中是否有 user_id 参数,如果没有,返回 400 Bad Request
  2. 检查给定的 user_id 是否真的存在于系统中,如果没有,返回 404 Not Found
  3. 如果 user_id 返回一个有效结果,响应 200 OK

REST API 有一个通用的错误响应模型。如果我们已经有了这种模型,客户端开发人员就可以据此向用户提供更详细的解释。

想象一下,有一位用户从手机发送了一封无效电子邮件。它以某种方式被传送到 API,API 自然会响应 400 Bad Request。与此同时,API 应当发出一种通用的错误响应模式,使客户端能够将任意或全部的信息显示给终端用户。如果是这样的话,你很有可能会返回一个错误信息:「输入的电子邮件地址无效」,客户端可以读取并将其显示给用户。

确保你能够涵盖从数据校验到服务器故障的所有问题,要实现这个目标,最好能找到一种适用于各种情景的通用错误模式。我推荐你采用下列方法:

{
   "title":"Invalid data",
   "message":"The data you entered is not valid",
   "errors":[
      {
        "field": "email",
        "message":"The email address you provided is not real"
      },
      {
        "field": "phone_number",
        "message":"You did not enter a phone number at all"
      }
   ]
}

titlemessage 提供错误的大致原因。大多数情况下终端开发者只需要通过一个 alert 提示框展示错误消息的摘要。但是提供详细的错误信息将有助于你和其他基于 API 工作的开发者了解到底是什么出了问题。

所以一定要记得,你要尽可能去预测问题。提供细节说明为什么事情会失败,并且使用普遍理解的 HTTP 响应代码的语言。

8. 聪明

一个聪明的 API 首先要保护自己最有价值的资源——数据库。这意味着它在把数据写入数据库前应该清洗并剔除不良数据。一定要校验从客户端发来的内容,排除一切看上去不合适的东西,不过也要给用户一个清楚的解释。

任何优秀、聪明的 API 都能独立处理复杂的流程。 将一个用户注册到你的应用中,在客户端侧可能只是一个 API 调用,但在后端 API 可以处理所有可能的工作:在 MailChimp 通讯上注册,向 Firebase 存储推送令牌,向用户发送欢迎邮件等等。客户端段不应该为这些事情调用多个 API 端点。如果我们把所有东西都打包到一个端点,那么你就可以很容易地在任何时间点改变流程,而客户端甚至无需知晓。

确定 API 是否经过了优化以满足跨平台的需求。当你处理多个平台或设备,如 iPhone、安卓手机、电脑、电视和类似设备时,API 应该能够适应它们。这就意味着 API 必须具有足够的灵活性,以应对可能与其他客户端的不同输入,同时也可以让客户端继续工作。

尽可能减少客户端的依赖性。一个聪明的 API 总是会把客户端的缺陷考虑进去,并且尽量改正。

9. 精益求精

如果 API 不能迅速地进行优化,那它算得了什么?你的 API 不应该脱整个系统的后腿,就这么简单。有很多事情可以做,以下是其中的一些:

快速和优化始于数据库层面。 每次有人抱怨他们的 API 很慢,十有八九与数据库有关:糟糕的表设计、复杂的查询、慢吞吞的基础设置甚至是未加缓存。你应当总是优化数据库结构、查询、索引以及其他与数据库交互的内容,除此之外还要尽可能提供缓存,缓存数据有时能使加载时间减半。如果你的 API 负责向用户展示他们的个人资料或者类似的内容,它们是不会每 5 分钟就发生变化的。

另外一个影响性能的因素是通过 API 向客户端发送大量数据。确保只传输了客户端需要的数据。这意味着如果你不加载关系,就返回一个空数组,如果你不加载计数,就返回 0 等等。

你可以做的另一件非常简单的事情是启用压缩,减少响应大小以提高性能,最流行的是 Gzip 压缩。

译者注:Gzip 压缩对文本类资源的效果较好(一般可压缩至原体积的 30%),如果使用 NGINX,可在 httpserverlocation 模块下配置。

10. 体贴入微

当你完成并提交最后一行代码时,也许会觉得工作已经结束了,但事实并非如此。对于其他许多人来说,旅程才刚刚开始。

有许多人只是等你干完了才会动手,为了让他们做好工作,你需要进行充分的准备:确保 API 正常工作,确保文档很好,更重要的是准备好提供集成支持。无论文档编写的多么出色,在集成过程之中与之后都会出现问题。

希望我能够简单的说明一下你在构建 REST API 时可能会遇到的疑惑和担忧。我必须指出的是,REST 并不是一个标准,因此没有人能说你的错误。不过,请考虑一下:作为开发人员,我们每天都在寻求让代码更好、更漂亮、更高效的模式,何不对 API 也这么做?如果我们开始遵循一些模式,那么我们都会享受一个更健康、更漂亮的生态系统。

最后,我要引述 Uncle Ben 的话来结束本文:「With great power comes great responsibility」(能力越大,责任越大)。当我们谈论 REST API 时,这句话再正确不过了。