如何使用 ThinkJS 优雅的编写 RESTful API

RESTful 是目前比较主流的一种用来设计和编排服务端 API 的一种规范。在 RESTful API 中,全部的接口操做都被认为是对资源的 CRUD,使用 URI 来表示操做的资源,请求方法表示具体的操做,响应状态码表示操做结果。以前使用 RESTful 的规范写过很多 API 接口,我我的认为它最大的好处就是帮助咱们更好的去规划整理接口,若是仍是按照之前根据需求来写接口的话接口的复用率不高不说,整个项目也会变得很是的杂乱。html

文件即路由是 ThinkJS 的一大特点,好比 /user 这个路由等价于 /user/index,会对应到 src/controller/user.js 中的 indexAction 方法。那么就以 /user 这个 API 为例,在 ThinkJS 中要建立 RESTful 风格的 API 须要如下两个步骤:mysql

<!--more-->git

  1. 运行命令 thinkjs controller user -r 会建立路由文件 src/controller/user.js
  2. src/config/router.js 中使用自定义路由标记该路由为 RESTful 路由github

    //src/config/router.js
    module.exports = [
      ['/user/:id?', 'rest']
    ];

这样咱们就完成了一个 RESTful 路由的初始化,这个资源的全部操做都会被映射成路由文件中对应请求方法的 Action 函数中,例如:sql

  • GET /user 获取用户列表,对应 getAction 方法
  • GET /user/:id 获取某个用户的详细信息,也对应 getAction` 方法
  • POST /user 添加一位用户,对应 postAction 方法
  • PUT /user/:id 更新一位用户资料,对应 putAction 方法
  • DELETE /user/:id 删除一位用户,对应 deleteAction 方法

然而每一个 RESTful 路由都须要去 router.js 中写一遍自定义路由未免过于麻烦。因此我写了一个中间件 think-router-rest,只须要在 Controller 文件中使用 _REST 静态属性标记一下就能够将其转换成 RESTful 路由了。数据库

//src/controller/user.js
module.exports = class extends think.Controller {
  static get _REST() {
    return true;
  }

  getAction() {}
  postAction() {}
  putAction() {}
  deleteAction() {}
}

简单的了解了一些入门知识以后,下面我就讲一些我日常开发 RESTful 接口时对我有帮助的一些知识点,但愿对你们开发项目会有所帮助。session

表结构梳理

拿到需求以后千万不要急着先敲键盘,必定要把表结构整理好。其实说是表结构,实际上就是对资源的整理。以 MySQL 为例,通常一类资源就会是一张表,好比 user 用户表,post 文章表等。当你把表罗列出来以后那么其实你的 RESTful 接口就已经七七八八了。好比你有一张 post 文章表,那么以后你的接口确定会有:框架

  • GET /post 获取文章列表
  • GET /post/1 获取 id=1 的文章信息
  • POST /post 添加文章
  • PUT /post/1 修改 id=1 的文章信息
  • DELETE /post/1 删除 id=1 的文章

固然不是全部的事情都这么完美,有时候接口的操做可能五花八门,这种时候咱们就要尽可能的去思考接口行为的本质是什么。好比说咱们要迁移文章给其它用户,这时候你就要思考它其实本质上就是修改 post 文章资源的 user_id 属性,最终仍是会映射到 PUT /post/1 接口中来。异步

想清楚有哪些资源能帮助你更好的建立表,接下来就要想清楚资源之间的关系了,它能帮助你更好的建立表结构。通常资源之间会存在如下几类关系:async

  • 一对一:若是一位 user 只能建立一篇 post 文章,则是一对一的关系。在 post 中可使用 user_id 字段来关联对应的 user 数据,在 user 中也可使用 post_id 来关联对应的文章数据。
  • 一对多:若是一位 user 能建立多篇 post 文章,则是一对多的关系。在 post 中可使用 user_id 字段来关联对应的 user 数据。
  • 多对多:若是一位 user 能够建立多篇 post 文章,一篇 post 文章也能够有多位 user,则是多对多的关系。多对多关系没办法经过一个字段来表示,这时候为了描述清楚多对多的关系,就须要一张中间表 user_post,用来作 userpost 表的关系映射。表内部的 user_id 表示 user 表 ID,post_id 则表示 post 表对应数据 ID。
mysql> DESCRIBE user;
+-------+--------------+------+-----+---------+----------------+
| Field | Type         | Null | Key | Default | Extra          |
+-------+--------------+------+-----+---------+----------------+
| id    | int(11)      | NO   | PRI | NULL    | auto_increment |
| name  | varchar(100) | YES  |     | NULL    |                |
+-------+--------------+------+-----+---------+----------------+
2 rows in set (0.01 sec)

mysql> DESCRIBE post;
+-------+---------+------+-----+---------+----------------+
| Field | Type    | Null | Key | Default | Extra          |
+-------+---------+------+-----+---------+----------------+
| id    | int(11) | NO   | PRI | NULL    | auto_increment |
| title | text    | YES  |     | NULL    |                |
+-------+---------+------+-----+---------+----------------+
2 rows in set (0.00 sec)

mysql> DESCRIBE user_post;
+---------+---------+------+-----+---------+----------------+
| Field   | Type    | Null | Key | Default | Extra          |
+---------+---------+------+-----+---------+----------------+
| id      | int(11) | NO   | PRI | NULL    | auto_increment |
| user_id | int(11) | NO   |     | NULL    |                |
| post_id | int(11) | NO   |     | NULL    |                |
+---------+---------+------+-----+---------+----------------+
3 rows in set (0.00 sec)

做为一款约定大于配置的 Web 框架,ThinkJS 默认规定了请求 RESTful 资源的时候,会根据当前资源 URI 找到对应的资源表,好比 GET /post 会找到 post 表。而后再进行查询的以后会进行自动的关联查询。例如当你在模型里标记了 postuser 是一对多的关系,且 post 表中存在 user_id 字段(也就是关联表表名 + _id),会自动关联获取到 project 对应的 user 数据。这在进行数据操做的时候会节省很是多的工做量。

登陆登出

当我第一次写 RESTful API 的时候,我就碰到了这个难题,日常你们都是使用 /login, /logout 来表示登陆和登出操做的,如何使用资源的形式来表达就成了问题。后来想了下登陆操做中涉及到的资源其实就是登陆后的 Token 凭证,本质上登陆就是凭证的建立与获取,登出就是凭证的删除。

  • GET /token:获取凭证,用来判断是否登陆
  • POST /token:建立凭证,用来进行登陆操做
  • DELETE /token:删除凭证,用来进行登出操做

权限校验

咱们日常写接口逻辑,其实会有很大一部分的工做量是用来作用户请求的处理。包括用户权限的校验和用户参数的校验处理等,这些逻辑其实和主业务场景没有太大的关系。为了将这些逻辑与主业务场景进行解耦,基于 Controller 层之上,ThinkJS 会存在一层 Logic 逻辑校验层。Logic 与 Controller 一一映射,并提供了一些经常使用的校验方法,咱们能够将权限校验,参数校验,参数处理等逻辑放在这里,让 Controller 只作真正的业务逻辑。

在 Logic 和 Controller 中,都存在 __before() 魔术方法,当前 Controller 内全部的 Action 执行以前都会先执行 __before() 操做。利用这个特性,咱们能够将一些通用的权限校验逻辑放在这里,好比最日常的登陆判断逻辑,这样就不须要在每一个地方都作判断了。

//src/logic/base.js
module.exports = class extends think.Logic {
  async __before() {
    //接口 CSRF 校验
    if (!this.isCli && !this.isGet) {
      const referrer = this.referrer(true);
      if (!/^xxx\.com$/.test(referrer)) {
        return this.fail('请不要在非其它网站中使用该接口!');
      }
    }

    // 非登陆接口须要作登陆校验
    const userInfo = await this.session('userInfo') || {};
    if(think.isEmpty(userInfo) && !/\/(?:token)\.js/.test(this.__filename)) {
      return this.ctx.throw(401, 'UnAuthorized');
    }
  }
}

//src/logic/user.js
const Base = require('./base.js');
module.exports = class extends Base {}

建立一个 Base 基类,全部的 Logic 经过继承该基类就都能享受到 CSRF 和登陆校验了。

问:全部的请求都会实例化类,因此 contructor 本质上也会在全部的 Action 以前执行,那为何还须要 __before() 魔术方法的存在呢?

答:constructor 构造函数虽然有前置执行的特性,可是没法在保证顺序的状况下执行异步操做。构造函数前是不能使用 async 标记的,而 __before() 是能够的,这也是它存在的缘由。

善用继承

在 RESTful API 中,咱们其实会发现不少资源是具备从属关系的。好比一个项目下的用户对应的文章,这句话中的三种资源 项目用户文章 就是从属关系。在从属关系中包括权限、数据操做等也都是具备从属关系的。好比说文章属于用户,非该用户的话天然是没法看到对应的文章的。而用户又从属于项目,其它项目的人是没法操做该项目下的用户的。这就是所谓的从属关系。

确立了从属关系以后咱们会发现越到下级的资源在对其操做的时候要判断的权限就越多。以刚才的例子为例,若是说咱们对项目资源进行操做的话,咱们须要判断该用户是否在项目中。而若是要对项目下的用户文章进行操做的话,除了须要判断用户是否在项目中,还须要判断该文章是不是当前用户的。

在这个例子中咱们能够发现:资源关系从属的话权限校验也会是从属关系,从属关系中级别越深的资源须要判断的权限越多。面向对象语言中,继承是一个比较重要的功能,它最大的好处就是能帮助咱们进行逻辑的复用。经过继承,咱们能直接在子资源中复用父资源的校验逻辑,避免重复劳动。

//src/logic/base.js
module.exports = class extends think.Logic {
  async __before() {
    const userInfo = this.session('userInfo') || {};
    this.userInfo = this.ctx.state.userInfo = userInfo;
    if(think.isEmpty(userInfo)) {
      return this.ctx.throw(401);
    }
  }
}

//src/logic/project/base.js
const Base = require('../base.js');
module.exports = class extends Base {
async __before() {
    await super.__before();

    const {team_id} = this.get();
    const {id: user_id} = this.userInfo;
    const permission = await this.model('team_user').where({team_id, user_id}).find();
    
    const {controller} = this.ctx;
    // 团队接口中只有普通用户只有权限调用获取邀请连接详细信息和接受邀请连接两个接口
    if(controller !== 'team/invitation' && (this.isGet && !this.id)) {
      if(think.isEmpty(permission)) {
        return this.fail('你没有权限操做该团队');
      }
    }
    
    this.userInfo.role_id = permission.role_id;
  }
}

//src/logic/project/user/base.js
const Base = require('../base');
module.eports = class extends Base {
  async __before() {
    await super.__before();
    
    const {role_id} = this.userInfo;
    if(!global.EDITOR.is(role_id)) {
      return this.fail('你没有权限操做该文章');
    }
  }
}

经过建立三个 Base 基类,咱们将权限校验进行了合理的拆分同时又能保证校验的完整性。同级别的路由只要继承当前层级的 Base 基类就能享受到通用的校验逻辑。

  • /project 路由对应的 Logic 由于继承了 src/logic/base.js 因此实现了登陆校验。
  • /project/1/user 路由对应的 Logic 由于继承了 src/logic/project/base.js 因此实现了登陆校验以及是否在是项目成员的校验。
  • /project/1/user/1/post 路由对应的 Logic 由于继承了 src/logic/project/user/base.js 因此实现了登陆校验、项目成员校验以及项目成员权限的校验。

瞧,套娃就这么简单!

数据库操做

从属的资源在表结构上也有必定的反应。仍是以以前的项目、用户和文章为例,通常来讲你的文章表里会存在 project_iduser_id 两个关联字段来表示文章与用户和项目资源的关系(简单假设都是一对多的关系)。那么这时候实际上你对项目下的文章操做实际上都须要传入 project_iduser_id 这两个 WHERE 条件。

ThinkJS 内部使用 think-model 来进行 SQL 数据库操做。它有一个特性是支持链式调用,咱们能够这样写一个查询操做。

//src/controller/project/user/post.js
module.exports = class extends think.Controller {
  async indexAction() {
    const ret = await this.model('post').where({project_id: 1}).where({user_id: 2}).select();
    return this.success(ret);
  }
}

利用这个特性,咱们能够对操做进行优化,在 constructor 的时候将当前 Controller 下的通用 WHERE 条件 project_iduser_id 传入。这样咱们在其它的 Action 操做的时候就不用每一个都传一变了,同时也必定规避了可能会漏传限制条件的风险。

//src/controller/project/user/post.js
module.exports = class extends think.Controller {
  constructor(ctx) {
    super(ctx);
    const {project_id, user_id} = this.get();
    this.modelInstance = this.model('post').where({project_id, user_id});
  }

  async getAction() {
    const ret = await this.modelInstance.select();
    return this.success(ret);
  }
}

后记

RESTful API 除了以上说的一些特性以外,它对响应状态码、接口的版本也有必定的规范定义。像 Github 这种 RESTful 实现比较好的网站还会实现 Hypermedia API 规范,在每一个接口中会返回操做其它资源时须要的 RESTful 路由地址,方便调用者进行链式调用。

固然 RESTful 只是实现 API 的一种规范,还有其它的一些实现规范,好比 GraphQL。关于 GraphQL 能够看看以前的文章《GraphQL 基础实践》,这里就很少作补充了。

相关文章
相关标签/搜索