如何使用 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 基礎實踐》,這裏就很少作補充了。

相關文章
相關標籤/搜索