创建REST API的最佳实践

你好!



这篇文章尽管标题纯真,但引起了关于Stackoverflow的冗长讨论,我们不能忽略它。试图把握巨大的潜力-清楚地讲述REST API的胜任设计-显然,作者在很多方面都取得了成功,但并不完全。无论如何,我们希望在讨论的程度上与原始竞争,以及我们将加入Express粉丝大军的事实。



享受阅读!



REST API是当今可用的最常见的Web服务类型之一。在他们的帮助下,包括浏览器应用程序在内的各种客户端都可以通过REST API与服务器交换信息。



因此,正确设计REST API非常重要,这样一来您就不会遇到任何问题。从使用者角度考虑API的安全性,性能和可用性。



否则,我们将使用我们的API给客户带来麻烦-令人沮丧和烦恼。如果我们不遵循通用约定,那么我们只会混淆那些维护我们的API的人以及客户,因为该体系结构将不同于每个人都希望看到的体系结构。



本文将探讨如何设计REST API,以使使用它们的每个人都可以轻松理解它们。我们将确保其耐用性,安全性和速度,因为通过此类API传输给客户端的数据可能是机密的。



由于网络应用程序失败的原因和选择有很多,因此我们必须确保任何REST API中的错误都得到妥善处理,并附带标准的HTTP代码,以帮助使用者解决问题。



接受JSON并返回JSON作为响应



REST API必须接受JSON作为请求有效负载,并且还必须发送JSON响应。 JSON是数据传输标准。几乎所有网络技术都可以使用它:JavaScript具有内置的方法,可以通过Fetch API或另一个HTTP客户端对JSON进行编码和解码。服务器端技术使用库来解码JSON,而您几乎不需要干预。



还有其他传输数据的方法。这样的XML并没有在框架中得到广泛的支持。通常,您需要将数据转换为更方便的格式,通常是JSON。在客户端,尤其是在浏览器中,处理这些数据并不是那么容易。您必须做很多额外的工作才能确保正常的数据传输。



表单对于传输数据非常方便,尤其是在我们要传输文件的情况下。但是,要以文本和数字形式传输信息,您可以不使用任何形式,因为大多数框架都允许在不进行其他处理的情况下发送JSON-只需在客户端获取数据即可。这是处理它们的最直接的方法。



为确保客户端将从REST API接收的JSON完全解释为JSON,在发出请求后,应将Content-Type响应标头设置为一个application/json。许多服务器端应用程序框架会自动设置响应头。一些HTTP客户端查看Content-Type响应标头,并根据那里指定的格式解析数据。



唯一的例外发生在我们尝试发送和接收在客户端和服务器之间传输的文件时。然后,您需要处理作为响应收到的文件,并将表单数据从客户端发送到服务器。但这是另一篇文章的主题。



我们还需要确保JSON是来自端点的响应。许多服务器框架都内置了此功能。



让我们以接受JSON有效负载的API为例。本示例使用Node.jsExpress后端框架。我们可以使用程序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对象,然后将结果分配给object 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 用给定的一篇文章更新一篇文章idDELETE方法 /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端点。



集合称为复数名词



集合应命名为复数名词。我们不经常需要从集合中仅获取一项,因此我们需要保持一致并在集合名称中使用复数名词。



复数还用于与数据库中的命名约定保持一致。通常,一个表不包含一个记录,而是包含许多记录,并且该表具有相应的名称。



使用端点时,/articles在命名所有端点时使用复数。



使用分层对象时嵌套资源



处理嵌套资源的端点的路径应采用以下结构:将嵌套资源添加为父资源名称之后的路径名。

我们需要确保代码中的资源嵌套与数据库表中的信息嵌套完全相同。否则,可能会造成混乱。



例如,如果我们想在某个端点接收有关新文章的评论,则必须将路径/评论附加到path的末尾/articles。在这种情况下,假定我们将注释实体视为article数据库中的子实体



例如,您可以使用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'));


在上面的代码中,可以在path上使用GET方法'/articles/:articleId/comments'我们会收到comments与匹配的文章的评论articleId,然后将其返回以作为回应。我们'comments'在路径段之后添加'/articles/:articleId'以指示这是子资源/articles



这是有道理的,因为注释是子对象articles,并且假定每篇文章都有自己的注释集。否则,此结构可能会使用户感到困惑,因为它通常用于访问子对象。在POST,PUT和DELETE端点上使用相同的原理。构造路径名时,它们都使用相同的结构嵌套。



整洁的错误处理并返回标准错误代码



为避免API发生错误时造成混淆,请仔细处理错误并返回HTTP响应代码以指示发生了哪个错误。这为API维护人员提供了足够的信息来理解问题。错误会使系统崩溃,这是不可接受的,因此,如果不进行处理就不能将其遗留下来,API使用者必须处理此类处理。



最常见的HTTP错误代码是:



  • 400错误的请求-指示从客户端收到的输入验证失败。
  • 401未经授权-表示用户尚未登录,因此没有访问资源的权限。通常,此代码在用户未通过身份验证时发出。
  • 403禁止访问-表示用户已通过身份验证,但无权访问该资源。
  • 404 Not Found-表示找不到资源
  • 500 Internal server error是服务器错误,可能不应该明确抛出。
  • 502错误的网关-指示来自上游服务器的无效回复消息。
  • 503服务不可用-表示服务器端发生了意外情况,例如服务器过载,某些系统元素出现故障等。


您应该确切地发出与阻止我们的应用程序的错误相对应的代码。例如,如果我们要拒绝接收作为请求有效负载的数据,则根据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'));


在上面的代码中,我们在users数组中保存了一个已知电子邮件的现有用户的列表。



此外,如果尝试发送具有email用户中已经存在的值的有效负载,则会收到代码为400的响应以及一条消息,'User already exists'指示该用户已存在。有了这些信息,用户会变得更好-用列表中尚未显示的电子邮件地址替换该电子邮件地址。



错误代码应始终附有消息,这些消息应具有足够的信息来纠正错误,但又不能太详细,以致打算窃取我们的信息或使系统崩溃的攻击者可以使用此信息。



每当我们的API无法正常关闭时,我们都必须通过发送错误信息来谨慎处理错误,以使用户更容易纠正这种情况。



允许对数据进行排序,过滤和分页



REST API的基础可能会增长很多。有时,有太多的数据,以至于无法一次恢复所有数据,因为这会减慢系统运行速度,甚至导致系统崩溃。因此,我们需要一种过滤项目的方法。



我们还需要分页数据(分页)的方法,以便一次只返回几个结果。我们不想花太多时间尝试一次提取所有请求的数据的资源。



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



这是一个小示例,其中API可以接受带有各种参数的查询字符串。让我们按项目字段过滤项目:



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

const app = express();

//      
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为此具有特殊的语法。



最后,我们对每个查询参数值应用过滤器,以查找要返回的项目。



完成此操作后,我们将结果作为响应返回。因此,当使用查询字符串向以下路径发出GET请求时:



/employees?lastName=Smith&age=30


我们得到:



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


作为返回的响应,因为过滤已开启lastNameage



同样,您可以接受页面查询参数,并返回一组位置从(page - 1) * 20的记录page * 20



同样,在查询字符串中,您可以指定执行排序的字段。在这种情况下,我们可以按这些单独的字段对它们进行排序。例如,我们可能需要从如下网址中提取查询字符串:



http://example.com/articles?sort=+author,-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'));

//      
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'));


上面的代码简单地是指apicacheapicache.middleware,导致:



app.use(cache('5 minutes'))


这足以应用程序范围的缓存。例如,我们在五分钟内缓存了所有结果。随后,可以根据需要调整此值。



API版本控制



我们需要使用不同版本的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 = [];
  //      
  res.json(employees);
});

app.get('/v2/employees', (req, res) => {
  const employees = [];
  //       
  res.json(employees);
});

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


我们仅将版本号添加到通向端点的路径的开头。



结论



设计高质量REST API的主要收获是遵循Web的标准和约定来保持一致性。JSON,SSL / TLS和HTTP状态代码是现代网络上的必备功能。



性能同样重要。您可以增加它,而不必一次返回太多数据。此外,您可以使用缓存来避免一遍又一遍地请求相同的数据。



端点路径必须一致命名。您应该在名称中使用名词,因为HTTP方法名称中包含动词。嵌套资源路径必须跟随父资源路径。他们应该传达我们收到或操纵的内容,以便我们不必另外查阅文档来了解正在发生的事情。



All Articles