原文链接:Best practices for REST API design

译者按:关于 REST API 的介绍与最佳实践,亦可参见微软 Web API 设计

声明:本文的完成亦有 DeepL 的帮助。


REST API 是现今最常见的网络服务之一。它允许包括浏览器在内的多种客户端通过 REST API 与服务器进行通信。

因此,正确地设计 REST API 非常重要,这样我们就不会在后续的道路上出现问题。我们必须考虑到 API 用户的账号安全性、性能和易用性。

否则,我们就会给使用我们 API 的客户们带来问题,这并不令人愉快,也会影响人们使用我们的 API。如果我们不遵循普遍接受的惯例,那么我们就会给 API 的维护者和使用它们的客户带来困扰,因为它与大家所期望的不同。

在这篇文章中,我们将探讨如何设计 REST API,使其对任何使用它们的人来说都是容易理解的,是不会过时的,并且是安全和快速的,因为它们向客户提供的数据可能是保密的。

由于网络应用可能会被多种问题破坏,我们应该确保任何 REST API 都应使用标准的 HTTP 状态码,以优雅地处理错误,帮助用户处理问题。

用 JSON 接收与响应 #

REST API 应该接收 JSON 作为请求的有效载荷(payload),同时也应以 JSON 发送响应。JSON 是传输数据的标准。几乎所有的网络技术都可以使用它:JavaScript 有内置的方法,可以通过 Fetch API 或其他 HTTP 客户端对 JSON 进行编码和解码。服务器端的技术也有一些库可以解码 JSON,不需要做太多工作。

当然,还有其他传输数据的方式。XML 并没有得到框架的广泛支持,其常用替代通常是 JSON。我们在客户端——尤其是在浏览器中——不能特别容易地操作这些数据。光是做正常的数据传输就会有很多额外的工作。

表单数据(form data)很适合用于发送数据,特别是当我们要发送文件时。但是对于文本和数字,我们不需要表单数据来传输这些,因为——对大多数框架来说——我们只需要在客户端直接从中获取数据就可以传输 JSON。这是到目前为止最直接的做法。

为了确保当我们的 REST API 应用以 JSON 响应时,客户端会将其解释为 JSON,我们应该在请求发出后,将响应头(header)中的 Content-Type 设置为 application/json。很多服务器端应用框架都会自动设置响应头。一些 HTTP 客户端会根据 Content-Type 响应头来解析数据。

唯一的例外是,如果我们试图在客户端和服务器之间发送和接收文件。那么我们就需要处理文件响应,从客户端向服务器发送表单数据。但这就是另外一个话题了。

我们还应该确保我们的端点(endpoint)能够返回 JSON 作为响应。许多服务器端框架都将此作为一个内置功能。

让我们来看看一个接受 JSON payload 的 API 示例。这个例子将使用 Node.js 的 Express 后端框架。我们可以使用 body-parser 中间件 来解析 JSON 请求体,然后我们可以调用 res.json 方法,将我们想要返回的对象构造为 JSON 响应,如下所示。

const express = require("express");
const bodyParser = require("body-parser");

const app = express();

app.use(bodyParser.json());

app.post("/", (req, res) => {
  res.json(req.body);
});

app.listen(3000, () => console.log("server started"));

bodyParser.json() 将 JSON 请求体字符串解析为 JavaScript 对象,然后将其分配给 req.body 对象。

将响应中的 Content-Type 头设置为 application/json; charset=utf-8,不要做任何修改。这个方法适用于大多数其他后端框架。

在端点路径中使用名词而非动词 #

我们不应该在端点路径中使用动词。相反,我们应该使用名词作为路径名,该名词应代表我们要检索或操作的端点的实体。

这是因为我们的 HTTP 请求方法已经在用动词了。在我们的 API 端点路径中使用动词并没有益处,而且会使它变得不必要的长,因为它没有传达任何新的信息。选择的动词可以根据开发者的想法而变化。比如说,有些人喜欢用 “get”,有些人喜欢用 “retrieve”,所以让 HTTP GET 动词告诉我们什么和端点做什么就好了。

动作应该由我们所做的 HTTP 请求方法来表示。最常见的方法包括 GET、POST、PUT 和 DELETE。

GET 检索资源。POST 向服务器提交新数据。PUT 更新现有数据。DELETE 删除数据。这些动词映射到 CRUD 操作。

考虑到我们上面讨论的两个原则,我们应该创建像 GET /articles/ 这样的路由来获取新闻文章。同样,POST /articles/ 用于添加新的文章,PUT /articles/:id 用于用给定的 id 更新文章。DELETE /articles/:id 用于删除给定 ID 的现有文章。

/articles 代表一个 REST API 资源。例如,我们可以使用 Express 添加以下端点来操作文章,如下所示。

const express = require("express");
const bodyParser = require("body-parser");

const app = express();

app.use(bodyParser.json());

app.get("/articles", (req, res) => {
  const articles = [];
  // 获取文章的代码
  res.json(articles);
});

app.post("/articles", (req, res) => {
  // 添加新文章的代码
  res.json(req.body);
});

app.put("/articles/:id", (req, res) => {
  const { id } = req.params;
  // 更新文章的代码
  res.json(req.body);
});

app.delete("/articles/:id", (req, res) => {
  const { id } = req.params;
  // 删除文章的代码
  res.json({ deleted: id });
});

app.listen(3000, () => console.log("server started"));

在上面的代码中,我们定义了操作文章的端点。我们可以看到,路径名中没有任何动词。所有的都是名词。动词在 HTTP 动词中。

POST、PUT 和 DELETE 端点都以 JSON 作为请求体,也都以 JSON 作为响应返回,包括 GET 端点。

使用名词复数来命名集合 #

我们应该用名词复数来命名集合。我们通常不会只想得到一个单项,所以我们的命名应该是一致的,我们应该用名词复数。

我们使用名词复数是为了和我们数据库中的内容保持一致。表通常有多个条目,并且在命名时会反映这一点,所以为了与它们保持一致,我们应该使用与 API 访问的表相同的语言。

对于 /articles 端点,我们的所有端点都是复数形式,所以我们不必将其改为复数。(译者注:此处似乎指的是上面代码之中的端点)

分层对象的资源嵌套 #

在处理嵌套资源的端点的路径时,应把嵌套资源追加为父资源后面的路径上。

我们必须确保我们考虑的嵌套资源与我们数据库表中的资源相匹配。否则会非常混乱。

比如说,如果我们想要一个端点来获取一篇新闻文章的评论,我们应该将 /comments 追加到 /articles 路径的末尾。这假设了我们在数据库中把评论(comments)作为文章(articles)的一个子节点。

例如,我们可以在 Express 中使用以下代码来实现。

const express = require("express");
const bodyParser = require("body-parser");

const app = express();

app.use(bodyParser.json());

app.get("/articles/:articleId/comments", (req, res) => {
  const { articleId } = req.params;
  const comments = [];
  // 通过 articleId 获取评论的代码
  res.json(comments);
});

app.listen(3000, () => console.log("server started"));

在上面的代码中,我们可以在路径 '/articles/:articleId/comments' 上使用 GET 方法。我们获取由 articleId 标识的文章的评论,然后在响应中返回。我们在 '/articles/:articleId' 路径后添加 'comments',以表明它是 /articles 的子资源。

这是有意义的,因为评论(comments)是文章的子对象,假设每篇文章都有自己的评论。否则,就会让用户感到困惑,因为这个结构一般被认为是用来访问子对象的。同样的原理也适用于 POST、PUT 和 DELETE 端点。它们的路径名都可以使用同一种嵌套结构。

优雅地处理错误并返回标准的错误代码 #

为了消除 API 用户在发生错误时的困惑,我们应该优雅地处理错误,并返回 HTTP 响应代码,说明发生了什么样的错误。这样可以让 API 的维护者有足够的信息来了解发生的问题。我们不希望错误使我们的系统崩溃,所以我们可以不处理它们,这意味着 API 消费者必须处理它们。

常见的错误 HTTP 状态码包括:

  • 400 Bad Request - 这意味着客户端的输入没有通过验证。
  • 401 Unauthorized - 这意味着用户没有被授权访问资源。它通常在用户未认证时返回。
  • 403 Forbidden - 这表示用户已通过认证,但不允许访问资源。
  • 404 Not Found - 这表示找不到资源。
  • 500 Internal server error - 这是一个通用的服务器错误。它可能不应该被明确地抛出。
  • 502 Bad Gateway - 这表示上游服务器发来了无效响应。
  • 503 Service Unavailable - 这表示在服务器端发生了一些意想不到的事情(可能是任何事情,比如服务器过载,系统的某些部分失效,等等)。

我们抛出的错误应该与我们的应用所遇到的问题相对应。例如,如果我们想拒绝请求 payload 中的数据,那么我们应该在 Express API 中返回一个 400 响应,如下所示:

const express = require("express");
const bodyParser = require("body-parser");

const app = express();

// 用户已存在
const users = [{ email: "abc@foo.com" }];

app.use(bodyParser.json());

app.post("/users", (req, res) => {
  const { email } = req.body;
  const userExists = users.find((u) => u.email === email);
  if (userExists) {
    return res.status(400).json({ error: "User already exists" });
  }
  res.json(req.body);
});

app.listen(3000, () => console.log("server started"));

在上面的代码中,我们在 user 数组中有一个现有用户列表,包含了给定的电子邮件。

那么如果我们尝试用已经存在于 users 中的 email 值来提交 payload,我们会得到一个 400 响应状态码,并附上 'User already exists' 的信息,让用户知道该用户已经存在。有了这些信息,用户可以将邮件改成数据库中尚不存在的邮件来纠正操作。

错误代码需要有信息伴随,这样维护者就有足够的信息来解决问题,但攻击者不能利用错误内容来进行我们的攻击,比如窃取信息或使系统瘫痪。

每当我们的 API 没有成功完成时,我们应该优雅地失败,发送一个带有信息的错误,以帮助用户做出纠正措施。

支持过滤、排序和分页 #

REST API 背后的数据库可能变得非常庞大。有时候,数据太多,不应该一次全部返回,因为太慢了,或者会让我们的系统崩溃。因此,我们需要有办法来过滤项目。

我们还需要对数据进行分页的方法,这样我们就能一次只返回几个结果。我们不希望因为一个请求,而占用资源太长时间。

过滤和分页都可以通过减少服务器资源的使用来提高性能。随着数据库中积累的数据越多,这些功能就越发重要。

这里有一个小例子,API 可以接受一个带有各种查询参数的查询字符串,让我们通过字段过滤出项目:

const express = require("express");
const bodyParser = require("body-parser");

const app = express();

// 数据库中的 employees 数据
const employees = [
  { firstName: "Jane", lastName: "Smith", age: 20 },
  //...
  { firstName: "John", lastName: "Smith", age: 30 },
  { firstName: "Mary", lastName: "Green", age: 50 },
];

app.use(bodyParser.json());

app.get("/employees", (req, res) => {
  const { firstName, lastName, age } = req.query;
  let results = [...employees];
  if (firstName) {
    results = results.filter((r) => r.firstName === firstName);
  }

  if (lastName) {
    results = results.filter((r) => r.lastName === lastName);
  }

  if (age) {
    results = results.filter((r) => +r.age === +age);
  }
  res.json(results);
});

app.listen(3000, () => console.log("server started"));

在上面的代码中,我们通过 req.query 变量来获取查询参数。然后,我们通过使用 JavaScript 解构语法将各个查询参数解构为变量,以提取属性值。最后,我们用每个查询参数值运行 filter 来定位我们想要返回的项目。

一旦我们完成了这些工作,我们就将 results 作为响应返回。因此,当我们用查询字符串向以下路径发出 GET 请求时

/employees?lastName=Smith&age=30

我们得到

[
  {
    "firstName": "John",
    "lastName": "Smith",
    "age": 30
  }
]

作为返回的响应,因为我们是按 lastNameage 过滤的。

同样,我们也可以接受 page 查询参数,并返回一组从 (page - 1) * 20page * 20 位置的条目。

我们还可以在查询字符串中指定要排序的字段。例如,我们可以从查询字符串中获取参数,其中包含我们要对数据进行排序的字段。然后,我们可以按照这些单独的字段进行排序。

例如,我们可能想从一个 URL 中提取查询字符串,比如。

http://example.com/articles?sort=+author,-datepublished

其中 + 表示升序,- 表示降序。因此,我们按照作者姓名的字母顺序和 datepublished 从最新的到最旧的排序。

保持良好的安全实践 #

客户端和服务器之间的大部分通信应该是私密的,因为我们经常发送和接收私人信息。因此,使用 SSL/TLS 来保证安全是必须的。

SSL 证书加载到服务器上并不难,而且其是免费或花费很少的。我们应使我们的 REST API 通过安全通道,而不是在公开地通信。

人们不应该能够访问到他们所请求的信息之外的信息。例如,一个普通用户不应该能够访问另一个用户的信息。他们也不应该能够访问管理员的数据。

为了执行最小权限原则,我们需要添加角色检查,或者针对单一角色,或者为每个用户设置更细的角色。

如果我们选择把用户分成几个角色群,那么这些角色的权限应该覆盖他们所需要的所有权限,而不是更多。如果我们对每个用户可以访问的功能有更细化的权限,那么我们要确保管理员可以相应地添加和删除每个用户的这些功能。另外,我们还需要添加一些预设的角色,可以应用于一组用户,这样我们就不必对每个用户都手动操作了。

缓存数据以提高性能 #

我们可以添加缓存,从本地内存缓存中返回数据,而不是每次要检索用户请求的一些数据时,都要查询数据库。缓存的好处是,用户可以更快的获得数据。但是,用户得到的数据可能是过时的。这也可能导致在生产环境中调试时出现问题,因为我们一直看到旧的数据。

缓存解决方案有很多种,比如 Redis、内存缓存等等。我们可以随着需求的变化,改变数据的缓存方式。

例如,Express 有 apicache 中间件,不需要太多的配置就可以为我们的应用添加缓存功能。我们可以像这样在服务器中添加一个简单的内存缓存。

const express = require("express");
const bodyParser = require("body-parser");
const apicache = require("apicache");
const app = express();
let cache = apicache.middleware;
app.use(cache("5 minutes"));

// 数据库中的 employees 数据
const employees = [
  { firstName: "Jane", lastName: "Smith", age: 20 },
  //...
  { firstName: "John", lastName: "Smith", age: 30 },
  { firstName: "Mary", lastName: "Green", age: 50 },
];

app.use(bodyParser.json());

app.get("/employees", (req, res) => {
  res.json(employees);
});

app.listen(3000, () => console.log("server started"));

上面的代码只是用 apicache.middleware 来引用 apicache 中间件,然后我们用:

app.use(cache("5 minutes"));

来将缓存应用于整个应用。比如说,我们将结果缓存 5 分钟。我们可以根据自己的需要调整。

版本化我们的 API #

如果我们要对 API 进行任何可能破坏客户端的修改,我们便应有不同的版本。版本划分可以像现在大多数应用一样,根据语义版本进行(例如,2.0.6 表示主要版本 2 和第 6 个补丁)。

这样一来,我们可以逐步淘汰旧的端点,而不是强迫大家同时转移到新的 API 上。v1 端点可以为那些不想改变的人保持活跃,而 v2 则可以凭借其闪亮的新功能为那些准备升级的人服务。如果我们的 API 是公开的,这一点尤其重要。我们应该对它们进行版本调整,这样就不会破坏使用我们 API 的第三方应用。

版本化通常是在 API 路径的开头加上 /v1//v2/ 等。

例如,我们可以对 Express 进行如下操作。

const express = require("express");
const bodyParser = require("body-parser");
const app = express();
app.use(bodyParser.json());

app.get("/v1/employees", (req, res) => {
  const employees = [];
  // 获取 employees 的代码
  res.json(employees);
});

app.get("/v2/employees", (req, res) => {
  const employees = [];
  // 另一些获取 employees 的代码
  res.json(employees);
});

app.listen(3000, () => console.log("server started"));

我们只需将版本号添加到端点 URL 路径的开头,就可以对它们进行版本控制。

结束语 #

设计高质量的 REST API 最重要的思考是,通过遵循 Web 标准和约定来获取一致性。JSON、SSL/TLS 和 HTTP 状态码都是现代网络的标准构件。

性能也是一个重要的考虑因素。我们可以通过不一次性返回太多数据来提高它的性能。此外,我们还可以使用缓存,这样我们就不必一直查询数据。

端点的路径应该是一致的,我们只使用名词,因为 HTTP 方法表示我们要采取的行动。嵌套资源的路径应该在父资源的路径之后。它们应该告诉我们,我们正在获取或操作什么,而非我们需要阅读额外的文档来理解它在做什么。