
本文首发于 刘星的个人网站 www.liuxing.io

RESTful API 是目前最流行的 API 设计规范,它用于 Web 数据接口的设计。它允许包括浏览器在内的各种客户端与服务器进行通信。因此正确是设计我们的 RESTful 是相当重要的!我们的 API 必须安全、高性能、同时易于使用。
在本文我们将探讨如何设计出易于使用并且安全快速的的 RESTful API 。
RESTful API 即是基于 Rest 构建的 API 。那么在开始之前,我们先来看看 REST 是什么?
REST 与技术无关,它代表的是一种软件架构风格,REST 它是 Representational State Transfer 的简称,中文的含义是: 表现层状态转移(转移:通过 HTTP 动词实现)。即 URL 定位资源,HTTP 动词操作( GET,POST,PUT,DELETE )描述操作。
RESTful API 应该接受 JSON 格式的请求,并返回的响应体也应该是 JSON 格式的。JSON 是一种数据传输标准,主流编程语言几乎都能很好的支持它。同时在浏览器中我们的 Javascript 也能很轻松方便的操作这些数据。所以,以 JSON 格式编写的 RESTful API 具有简单、易读、易用的特点。
为了确保当我们的 RESTful API 服务使用 JSON 格式响应,我们应该将其响应头的Content-Type设置为application/json。
让我们来看一个接收 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对象。
RESTful API 是面向资源的 API,HTTP 动词操作( GET,POST,PUT,DELETE )描述操作。
我们不应该在 URL 路径中使用动词。我们应该使用要操作的实体的名词作为路径名。因为我们的 HTTP 请求方法本身就是动词,就能描述要进行的操作,如常见的方法包括 GET,POST,PUT 和 DELETE,这些请求方法即可完成 CRUD。
GET 检索资源。
POST 将新数据提交到服务器。
PUT 更新现有数据。
DELETE 删除数据。
例如,我们有个文章(/articles/)资源。我们对其进行 CRUD 的 RESTful API 如下:
/articles/来获取文章列表/articles/ 添加新文章/articles/:id 更新给定 ID 的文章/articles/:id 删除具有给定 ID 的文章我们通过 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')); 在上面的示例代码中,我们定义了 API 来操作文章(articles)资源。如我们所见,API URL 路径中使用的都是名词,作为动词的请求方法说明了 API 的操作意图。
我们应该使用复数名词来命名集合。
通常,我们想要取得的数据都是一个集合,而不是单个项目。同时数据库中的表也是具有多个条目的。所以我们的 API 也应该使用复数名词,这样更合乎情理。
在处理嵌套资源的 API 时,应该将嵌套资源附加到父资源的路径之后。
例如一个文章有评论列表,获取某个文章的评论列表的 API 则为:
GET /articles/:articleId/comments 我们可以使用 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')); 为了消除 API server 发生错误时用户的困惑,我们应该优雅地处理错误,并返回指示发生了具体错误的 HTTP 响应代码以及明确的错误信息。这可以很好的为 API 使用者提供了足够的信息来了解所发生的问题。
常见的错误 HTTP 状态代码包括:
400 错误的请求 这意味着客户端输入验证失败。401 未经授权 - 这意味着用户无权访问资源。通常在用户未通过身份验证时返回。403 禁止访问 - 表示用户已通过身份验证,但不允许其访问资源。404 Not Found 表示找不到资源。500 内部服务器错误 这是一般服务器错误。它可能不应该明确地抛出。502 错误的网关 - 这表明来自上游服务器的无效响应。503 服务不可用 这表示服务器端发生了意外情况(可能是服务器过载,系统某些部分发生故障等)。我们应该抛出服务错误相对应的错误码。例如,如果我们要拒绝客服端发起的请求,则应在 Express API 中返回如下所示的 400 响应:
const express = require('express'); const bodyParser = require('body-parser'); const app = express(); // existing users const users = [ { email: '[email protected]' } ] 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,将获得 400 响应状态代码,并带有一条'User already exists' 的错误消息,让用户知道该用户已经存在。利用这些信息,用户可以通过使用其他 email 来创建新用户。
通常错误代码需要附带明确错误消息,以便用户有足够的信息来了解自己遇到了什么问题。
通常我们的数据都会非常庞大。我们不可能一次全部返回,这会非常慢也可能导致系统崩溃。因此,我们需要有过滤,分页数据的方式。过滤和分页都可以通过减少消耗服务器资源来提高性能。这些功能相当基础且重要。
分页、过滤、排序查询都功能都应该使用查询参数来实现。如:
/employees?page=1&pageSize=10&firstName=Xing 下面这就是一个带有过滤查询的示例:
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')); 客户端和服务器之间的大多数通信应该是私有的。因此,必须使用 SSL/TLS 进行安全保护。现在加载 SSL 成本是相当低的。我们没有理由不使用它。
同时,不同的用户具有不同的数据访问权限。例如,普通用户不应该能够访问其他用户的信息。他们也不应该能够访问管理员的数据。
可以适当添加缓存服务,从缓存中返回常用数据,而不是每次都从数据库去读取。缓存的好处是可以更快地获取数据,但是也让我们获取最新的数据变得复杂。缓存方式有很多如:Redis 、内存缓存( in-memory cache)等等,我们应该根据自己的应用具体情况来选择是不是该用缓存,使用哪种缓存机制。
这儿我们来使用 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')); 原则上我们应该尽量让 API 避免破坏性变更,保持向后兼容。但是经常有些时候破坏性的变更是不可避免的,这时版本化的 API 就派上用场了。当我们发布了不兼容或重大更改变,则可以将其发布在新版本中的 API 。
我们通常通过 URL 来实现版本化,及添加版本号在我们 API 路径的开头,例如:api.liuxing.io/v1 api.liuxing.io/v2
我们可以在 express 很简单的实现版本化的 RESTful API:
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')); 设计高质量 RESTful API 的最重要的一点是遵循 Web 标准和约定以保持一致性。JSON 、SSL/TLS 和 HTTP 状态代码都是现代 Web 的标准。性能也是重要的考虑因素。我们可以使用分页、缓存等手段来提升性能。可维护性可扩展性也是我们需要考虑的。
本文完
欢迎可以关注我的公众号,一起玩耍。有技术干货也有扯淡乱谈
左手代码右手砖,抛砖引玉
1 cxe2v 2021-04-15 11:02:09 +08:00 事实证明完全 RESTful 风格的 API 设计就是块砖 |
2 tcsky 2021-04-15 11:09:11 +08:00 |
4 fy 2021-04-15 14:27:33 +08:00 比较系统的入门文章,但是过于老生常谈了。 |
5 gdtdpt 2021-04-15 17:13:41 +08:00 借题问一下各位大佬们,在工作中经常会遇到一些非资源类的 Api,比如开始执行某个任务(xxxAction/execute)之类的,这个动作虽然可能在某些资源下,可以用 /resource/:resourceId/execute 这种方式,但是如果有多种类型的 action,是使用 /resource/:resourceId/:actionType/execute 好还是将 actionType 放到参数中好,或者有没有什么更优雅的方式? |
6 kiddyu 2021-04-15 19:21:46 +08:00 @gdtdpt #5 资源下的动作一般与这个资源下某个或某些属性有关,可以把这些 action 对应到这些属性,method 使用 PATCH,然后将更多的信息放到参数中 |
8 moen 2021-04-15 19:48:22 +08:00 PATCH 去哪了? |
9 h82258652 2021-04-15 19:54:24 +08:00 请教大佬们几个问题 RESTful 的小白问题。 1 、假设页面上有一个数据 c 是由页面上的另外两个数据 a 和 b 计算出来的(例如 c = a + b ),那么 API 传入和传出需要包含 c 吗?目前我这边是不传上来,服务端计算 c 的值,然后存储到数据库,返回的时候 dto 包含 c 字段。 2 、树状和列表状 API 同时出现的话,那么怎么设计?例如有个饮料分类,最多父子两级(树状返回),然后某个场景下需要返回全部二级(列表)。目前我是列表叫 /api/categories,树状 /api/categories/tree,但感觉怪怪的。 3 、HATEOAS 是不是忽悠人的概念。实现起来费力且不具备功能性。 |
10 huijiewei 2021-04-15 19:56:50 +08:00 没有感觉什么覆盖不来的 借口都会找,React 理解起来困难,2021 年了我还在 $('#dom').html('<span class="' + (isDisabled ? ' disabled' :' ') + '">' + okle + '</span>') ? 你看多直观? |
11 huijiewei 2021-04-15 20:01:12 +08:00 @h82258652 RESTful 一切以资源为中心。 1. C 传不传看你业务,我建议需要的资源都传并进行验证,不然前端算出的 C 有 BUG,用户提交以后数据和显示不一样岂不是很尴尬 2. 所以列表和树状有什么区别?都是同一个资源,只是表现形式不同而已。明白了吧 3. HATEOAS 只是 RESTful 的一个组件,难用就不用。总有更好的实践 |
13 3dwelcome 2021-04-15 20:04:12 +08:00 用着用着,到最后就变成了 POST 一把梭了。 |
14 ericls 2021-04-15 20:06:30 +08:00 via iPhone 只需要一个 resource: GraphqlResult. |
15 cnbattle 2021-04-15 20:50:28 +08:00 RESTful API 就是让后端省心 做个 CRUD boy, 然而 前端就有点难受了,需要点成本 |
16 xuanbg 2021-04-15 21:04:40 +08:00 名词复数,我就遇到 knowledge……尼玛不分单数复数,就离谱。好不容易想明白了修改密码和重置密码的问题,但是在用户的封禁 /启用上面又陷入了绝境 |
18 clf 2021-04-16 10:39:36 +08:00 Restful API 是不满足全部的业务场景需要的,并不是所有场景都是 CRUD 。但凡写点复杂业务的系统,就无了。 |
19 gdtdpt 2021-04-16 10:49:21 +08:00 @yazoox 可能是后端很容易找到借口把工作推给前端。 我们这里有很多项目都是打着 REST 的名义将数据字段关联动作推给前端的,比如微服务下两个服务间 A 服务业务表保存了 B 服务业务表的 ID 做为逻辑上的外键,但是数据库中因为是不同的库所以没有约束。这时候后端就会借口说根据 REST 风格,我以为把数据相关的 B 服务业务表的 id 给前端了,至于 id 关联的数据,前端请自己到 B 服务请求数据回来。 这时候就出现了某个页面只有一个 table,但是加载过程中会发送 10 多次请求的情况,而且加载了几次后内存缓存的数据量大了,页面加载的时候有明显卡顿,这时候领导来一句“页面为什么这么卡,前端怎么写的,优化一下”,这时候就很难受,明明不是前端的锅。 成本大概就是完成以上流程花费的人力成本吧 |