V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
liuxing
V2EX  ›  Node.js

RESTful API 设计最佳实践

  •  
  •   liuxing · 2021-04-15 10:48:31 +08:00 · 4517 次点击
    这是一个创建于 1323 天前的主题,其中的信息可能已经有所发展或是发生改变。

    本文首发于 刘星的个人网站 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 )描述操作。

    确保接受并响应 JSON 数据格式

    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对象。

    在 API 路径中使用名词代替动词

    RESTful API 是面向资源的 API,HTTP 动词操作( GET,POST,PUT,DELETE )描述操作。

    我们不应该在 URL 路径中使用动词。我们应该使用要操作的实体的名词作为路径名。因为我们的 HTTP 请求方法本身就是动词,就能描述要进行的操作,如常见的方法包括 GET,POST,PUT 和 DELETE,这些请求方法即可完成 CRUD

    • GET 检索资源。

    • POST 将新数据提交到服务器。

    • PUT 更新现有数据。

    • DELETE 删除数据。

    例如,我们有个文章(/articles/)资源。我们对其进行 CRUD 的 RESTful API 如下:

    • 使用 GET /articles/来获取文章列表
    • 使用 POST /articles/ 添加新文章
    • 使用 PUT /articles/:id 更新给定 ID 的文章
    • 使用 DELETE/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'));
    

    使用标准的 http 状态码

    为了消除 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 来创建新用户。

    通常错误代码需要附带明确错误消息,以便用户有足够的信息来了解自己遇到了什么问题。

    每当我们的 API 未成功调用时,都应通过发送明确的错误信息来帮助用户采取纠正措施来完成操作。

    添加过滤,排序和分页功能

    通常我们的数据都会非常庞大。我们不可能一次全部返回,这会非常慢也可能导致系统崩溃。因此,我们需要有过滤,分页数据的方式。过滤和分页都可以通过减少消耗服务器资源来提高性能。这些功能相当基础且重要。

    分页、过滤、排序查询都功能都应该使用查询参数来实现。如:

    /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 就派上用场了。当我们发布了不兼容或重大更改变,则可以将其发布在新版本中的 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 的标准。性能也是重要的考虑因素。我们可以使用分页、缓存等手段来提升性能。可维护性可扩展性也是我们需要考虑的。

    更多推荐

    本文完

    欢迎可以关注我的公众号,一起玩耍。有技术干货也有扯淡乱谈

    左手代码右手砖,抛砖引玉

    19 条回复    2021-04-16 10:49:21 +08:00
    cxe2v
        1
    cxe2v  
       2021-04-15 11:02:09 +08:00   ❤️ 3
    事实证明完全 RESTful 风格的 API 设计就是块砖
    tcsky
        2
    tcsky  
       2021-04-15 11:09:11 +08:00   ❤️ 2
    jmyz0455
        3
    jmyz0455  
       2021-04-15 13:50:58 +08:00
    @cxe2v 请问这句话如何理解?
    fy
        4
    fy  
       2021-04-15 14:27:33 +08:00
    比较系统的入门文章,但是过于老生常谈了。
    gdtdpt
        5
    gdtdpt  
       2021-04-15 17:13:41 +08:00
    借题问一下各位大佬们,在工作中经常会遇到一些非资源类的 Api,比如开始执行某个任务(xxxAction/execute)之类的,这个动作虽然可能在某些资源下,可以用 /resource/:resourceId/execute 这种方式,但是如果有多种类型的 action,是使用 /resource/:resourceId/:actionType/execute 好还是将 actionType 放到参数中好,或者有没有什么更优雅的方式?
    kiddyu
        6
    kiddyu  
       2021-04-15 19:21:46 +08:00
    @gdtdpt #5 资源下的动作一般与这个资源下某个或某些属性有关,可以把这些 action 对应到这些属性,method 使用 PATCH,然后将更多的信息放到参数中
    cxe2v
        7
    cxe2v  
       2021-04-15 19:26:09 +08:00
    @jmyz0455 就是没有太高的价值,实际业务中无法使用 RESTful 覆盖全部情况
    moen
        8
    moen  
       2021-04-15 19:48:22 +08:00
    PATCH 去哪了?
    h82258652
        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 是不是忽悠人的概念。实现起来费力且不具备功能性。
    huijiewei
        10
    huijiewei  
       2021-04-15 19:56:50 +08:00
    没有感觉什么覆盖不来的

    借口都会找,React 理解起来困难,2021 年了我还在 $('#dom').html('<span class="' + (isDisabled ? ' disabled' :' ') + '">' + okle + '</span>') ?

    你看多直观?
    huijiewei
        11
    huijiewei  
       2021-04-15 20:01:12 +08:00   ❤️ 1
    @h82258652 RESTful 一切以资源为中心。

    1. C 传不传看你业务,我建议需要的资源都传并进行验证,不然前端算出的 C 有 BUG,用户提交以后数据和显示不一样岂不是很尴尬
    2. 所以列表和树状有什么区别?都是同一个资源,只是表现形式不同而已。明白了吧
    3. HATEOAS 只是 RESTful 的一个组件,难用就不用。总有更好的实践
    huijiewei
        12
    huijiewei  
       2021-04-15 20:03:38 +08:00
    @gdtdpt URL PATH 里面建议只表现出资源即可
    3dwelcome
        13
    3dwelcome  
       2021-04-15 20:04:12 +08:00
    用着用着,到最后就变成了 POST 一把梭了。
    ericls
        14
    ericls  
       2021-04-15 20:06:30 +08:00 via iPhone
    只需要一个 resource: GraphqlResult.
    cnbattle
        15
    cnbattle  
       2021-04-15 20:50:28 +08:00
    RESTful API 就是让后端省心 做个 CRUD boy, 然而 前端就有点难受了,需要点成本
    xuanbg
        16
    xuanbg  
       2021-04-15 21:04:40 +08:00
    名词复数,我就遇到 knowledge……尼玛不分单数复数,就离谱。好不容易想明白了修改密码和重置密码的问题,但是在用户的封禁 /启用上面又陷入了绝境
    yazoox
        17
    yazoox  
       2021-04-16 09:21:02 +08:00
    @cnbattle 此话怎讲?前端用 fetch or axios 也还是挺方便的吧。你说的“难受”,“成本”指的是...?
    clf
        18
    clf  
       2021-04-16 10:39:36 +08:00
    Restful API 是不满足全部的业务场景需要的,并不是所有场景都是 CRUD 。但凡写点复杂业务的系统,就无了。
    gdtdpt
        19
    gdtdpt  
       2021-04-16 10:49:21 +08:00   ❤️ 1
    @yazoox 可能是后端很容易找到借口把工作推给前端。
    我们这里有很多项目都是打着 REST 的名义将数据字段关联动作推给前端的,比如微服务下两个服务间 A 服务业务表保存了 B 服务业务表的 ID 做为逻辑上的外键,但是数据库中因为是不同的库所以没有约束。这时候后端就会借口说根据 REST 风格,我以为把数据相关的 B 服务业务表的 id 给前端了,至于 id 关联的数据,前端请自己到 B 服务请求数据回来。
    这时候就出现了某个页面只有一个 table,但是加载过程中会发送 10 多次请求的情况,而且加载了几次后内存缓存的数据量大了,页面加载的时候有明显卡顿,这时候领导来一句“页面为什么这么卡,前端怎么写的,优化一下”,这时候就很难受,明明不是前端的锅。

    成本大概就是完成以上流程花费的人力成本吧
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   1335 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 27ms · UTC 23:32 · PVG 07:32 · LAX 15:32 · JFK 18:32
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.