如何使用 ThinkJS 優雅的編寫 RESTful API

公子發表於2020-09-28

RESTful 是目前比較主流的一種用來設計和編排服務端 API 的一種規範。在 RESTful API 中,所有的介面操作都被認為是對資源的 CRUD,使用 URI 來表示操作的資源,請求方法表示具體的操作,響應狀態碼錶示操作結果。之前使用 RESTful 的規範寫過不少 API 介面,我個人認為它最大的好處就是幫助我們更好的去規劃整理介面,如果還是按照以前根據需求來寫介面的話介面的複用率不高不說,整個專案也會變得非常的雜亂。

檔案即路由是 ThinkJS 的一大特色,比如 /user 這個路由等價於 /user/index,會對應到 src/controller/user.js 中的 indexAction 方法。那麼就以 /user 這個 API 為例,在 ThinkJS 中要建立 RESTful 風格的 API 需要以下兩個步驟:

<!--more-->

  1. 執行命令 thinkjs controller user -r 會建立路由檔案 src/controller/user.js
  2. src/config/router.js 中使用自定義路由標記該路由為 RESTful 路由

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

這樣我們就完成了一個 RESTful 路由的初始化,這個資源的所有操作都會被對映成路由檔案中對應請求方法的 Action 函式中,例如:

  • 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 介面時對我有幫助的一些知識點,希望對大家開發專案會有所幫助。

表結構梳理

拿到需求之後千萬不要急著先敲鍵盤,一定要把表結構整理好。其實說是表結構,實際上就是對資源的整理。以 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 介面中來。

想清楚有哪些資源能幫助你更好的建立表,接下來就要想清楚資源之間的關係了,它能幫助你更好的建立表結構。一般資源之間會存在以下幾類關係:

  • 一對一:如果一位 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 基礎實踐》,這裡就不多做補充了。

相關文章