REST API 是当今可用的最常见的 Web 服务类型之一。它们允许包括浏览器应用程序在内的各种客户端通过 REST API 与服务器通信。因此,正确设计 REST API 非常重要,这样我们以后就不会遇到问题。我们必须考虑 API 使用者的安全性、性能和易用性。
否则,我们会为使用我们 API 的客户制造问题,这不愉快并且会影响人们使用我们的 API。如果我们不遵循普遍接受的约定,那么我们就会混淆 API 的维护者和使用它们的客户端,因为它与每个人的期望不同。
在本文中,我们将研究如何设计 REST API,使其对任何使用它们的人都易于理解、面向未来、安全且快速,因为它们向可能是机密的客户端提供数据。
REST API 是符合特定架构约束的应用程序编程接口,例如无状态通信和可缓存数据。它不是协议或标准。虽然可以通过多种通信协议访问 REST API,但最常见的是通过 HTTPS 调用它们,因此以下指南适用于将通过 Internet 调用的 REST API 端点。
注意:对于通过 Internet 调用的 REST API,您需要遵循REST API 身份验证的最佳实践。
REST API 应该接受 JSON 作为请求负载,并发送响应到 JSON。JSON 是传输数据的标准。几乎所有联网技术都可以使用它:JAVAScript 具有通过 Fetch API 或其他 HTTP 客户端对 JSON 进行编码和解码的内置方法。服务器端技术具有无需做太多工作即可解码 JSON 的库。
还有其他方法可以传输数据。如果不将数据自己转换为可以使用的东西(通常是 JSON),框架就不会广泛支持 XML。我们无法在客户端轻松操作这些数据,尤其是在浏览器中。只是为了进行正常的数据传输,最终需要做很多额外的工作。
表单数据有利于发送数据,特别是如果我们要发送文件。但是对于文本和数字,我们不需要表单数据来传输它们,因为对于大多数框架,我们可以通过直接在客户端获取数据来传输 JSON。这是迄今为止最直接的做法。
为确保当我们的 REST API 应用程序使用 JSON 响应时客户端将其解释为这样,我们应该Content-Type在响应标头中设置为Application/json在发出请求之后。许多服务器端应用程序框架会自动设置响应标头。一些 HTTP 客户端查看Content-Type响应标头并根据该格式解析数据。
唯一的例外是如果我们尝试在客户端和服务器之间发送和接收文件。然后我们需要处理文件响应并将表单数据从客户端发送到服务器。但这是另一个话题。
我们还应该确保我们的端点返回 JSON 作为响应。许多服务器端框架将此作为内置功能。
让我们看一个接受 JSON 有效负载的示例 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。
动词映射到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 = [];
// code to retrieve an article...
res.json(articles);
});
app.post('/articles', (req, res) => {
// code to add a new article...
res.json(req.body);
});
app.put('/articles/:id', (req, res) => {
const { id } = req.params;
// code to update an article...
res.json(req.body);
});
app.delete('/articles/:id', (req, res) => {
const { id } = req.params;
// code to delete an article...
res.json({ deleted: id });
});
app.listen(3000, () => console.log('server started'));
在上面的代码中,我们定义了操作文章的端点。正如我们所见,路径名中没有任何动词。我们只有名词。动词在 HTTP 动词中。
POST、PUT 和 DELETE 端点都将 JSON 作为请求体,它们都返回 JSON 作为响应,包括 GET 端点。
在设计端点时,将包含相关信息的端点分组是有意义的。也就是说,如果一个对象可以包含另一个对象,您应该设计端点来反映这一点。无论您的数据在数据库中的结构是否如此,这都是一种很好的做法。事实上,避免在端点中镜像数据库结构以避免给攻击者提供不必要的信息可能是明智的。
例如,如果我们希望端点获取新闻文章的评论,我们应该将/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 = [];
// code to get comments by articleId
res.json(comments);
});
app.listen(3000, () => console.log('server started'));
在上面的代码中,我们可以在 path 上使用 GET 方法'
/articles/:articleId/comments'。我们获取comments由标识的文章articleId,然后在响应中返回它。我们'comments'在'/articles/:articleId'路径段之后添加以表明它是 的子资源/articles。
这是有道理的,因为comments是 的子对象articles,假设每篇文章都有自己的评论。否则,用户会感到困惑,因为这种结构通常被认为是用于访问子对象。同样的原则也适用于 POST、PUT 和 DELETE 端点。它们都可以对路径名使用相同类型的嵌套结构。
但是,嵌套可能会走得太远。在大约第二或第三级之后,嵌套端点可能会变得笨拙。相反,请考虑将 URL 返回到这些资源,特别是如果该数据不一定包含在顶级对象中。
例如,假设您想返回特定评论的作者。你可以使用
/articles/:articleId/comments/:commentId/author. 但这已经失控了。而是在 JSON 响应中返回该特定用户的 URI:
"author": "/users/:userId"
为了在错误发生时消除 API 用户的困惑,我们应该优雅地处理错误并返回指示发生了哪种错误的 HTTP 响应代码。这为 API 的维护者提供了足够的信息来了解发生的问题。我们不希望错误导致我们的系统崩溃,所以我们可以不处理它们,这意味着 API 使用者必须处理它们。
常见的错误 HTTP 状态代码包括:
我们应该抛出与我们的应用程序遇到的问题相对应的错误。例如,如果我们想拒绝来自请求负载的数据,那么我们应该在 Express API 中返回一个 400 响应,如下所示:
const express = require('express');
const bodyParser = require('body-parser');
const app = express();
// existing users
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'));
在上面的代码中,我们users在给定电子邮件的数组中有一个现有用户的列表。
然后,如果我们尝试使用email中已存在的值提交有效负载users,我们将获得一个 400 响应状态码和一条'User already exists'消息,让用户知道该用户已经存在。使用该信息,用户可以通过将电子邮件更改为不存在的内容来更正操作。
错误代码需要带有消息,以便维护者有足够的信息来解决问题,但攻击者不能使用错误内容来进行我们的攻击,如窃取信息或关闭系统。
每当我们的 API 没有成功完成时,我们应该通过发送错误信息来帮助用户做出纠正措施,从而优雅地失败。
REST API 背后的数据库可能会变得非常大。有时,有太多的数据不应该一次全部返回,因为它太慢或者会导致我们的系统崩溃。因此,我们需要过滤项目的方法。
我们还需要对数据进行分页的方法,以便一次只返回几个结果。我们不想通过尝试一次获取所有请求的数据来占用资源太久。
过滤和分页都通过减少服务器资源的使用来提高性能。随着数据库中积累的数据越多,这些特征就越重要。
这是一个小示例,其中 API 可以接受具有各种查询参数的查询字符串,以便我们按字段过滤项目:
const express = require('express');
const bodyParser = require('body-parser');
const app = express();
// employees data in a database
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查询参数并返回位置 from (page - 1) * 20to中的一组条目page * 20。
我们还可以在查询字符串中指定要排序的字段。例如,我们可以从带有我们想要对其数据进行排序的字段的查询字符串中获取参数。然后我们可以按这些单独的字段对它们进行排序。
例如,我们可能想从如下 URL 中提取查询字符串:
http://example.com/articles?sort=+author,-datepublished
where+表示上升,-表示下降。所以我们按照作者姓名的字母顺序,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 data in a database
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中间件,apicache.middleware然后我们有:
app.use(cache('5 minutes'))
将缓存应用于整个应用程序。例如,我们将结果缓存五分钟。我们可以根据需要进行调整。
如果您使用缓存,您还应该Cache-Control在标题中包含信息。这将帮助用户有效地使用您的缓存系统。
如果我们对它们进行任何可能破坏客户端的更改,我们应该有不同版本的 API。可以像现在大多数应用程序一样,根据语义版本(例如,2.0.6 表示主要版本 2 和第六个补丁)进行版本控制。
这样,我们可以逐步淘汰旧的端点,而不是强迫每个人同时迁移到新的 API。v1 端点可以为不想改变的人保持活跃,而 v2 具有闪亮的新功能,可以为那些准备升级的人提供服务。如果我们的 API 是公开的,这一点尤其重要。我们应该对它们进行版本控制,这样我们就不会破坏使用我们 API 的第三方应用程序。
版本通常有做/v1/,/v2/等在API路径的开始增加。
例如,我们可以使用 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 = [];
// code to get employees
res.json(employees);
});
app.get('/v2/employees', (req, res) => {
const employees = [];
// different code to get employees
res.json(employees);
});
app.listen(3000, () => console.log('server started'));
我们只需将版本号添加到端点 URL 路径的开头即可对其进行版本控制。
设计高质量 REST API 最重要的一点是通过遵循 Web 标准和约定来保持一致性。JSON、SSL/TLS 和 HTTP 状态代码都是现代 Web 的标准构建块。
性能也是一个重要的考虑因素。我们可以通过不一次返回太多数据来增加它。此外,我们可以使用缓存,这样我们就不必一直查询数据。
端点的路径应该是一致的,我们只使用名词,因为 HTTP 方法表明我们想要采取的行动。嵌套资源的路径应该在父资源的路径之后。他们应该告诉我们我们正在获取或操作什么,而无需阅读额外的文档来了解它在做什么。