翻译|REST API 设计最佳实践
2021年3月18日 · 5205 字 · 11 分钟 · 翻译 Stackoverflow
原文链接: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
}
]
作为返回的响应,因为我们是按 lastName
和 age
过滤的。
同样,我们也可以接受 page
查询参数,并返回一组从 (page - 1) * 20
到 page * 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 方法表示我们要采取的行动。嵌套资源的路径应该在父资源的路径之后。它们应该告诉我们,我们正在获取或操作什么,而非我们需要阅读额外的文档来理解它在做什么。