深入理解 Koa2 中介軟體機制

陳惠超發表於2018-01-17

我們知道,Koa 中介軟體是以級聯程式碼(Cascading) 的方式來執行的。類似於回形針的方式,可參照下面這張圖:

深入理解 Koa2 中介軟體機制

今天這篇文章就來分析 Koa 的中介軟體是如何實現級聯執行的。 在 koa 中,要應用一箇中介軟體,我們使用 app.use():

app
  .use(logger())
  .use(bodyParser())
  .use(helmet())
複製程式碼

先來看看use() 是什麼,它的原始碼如下:

  use(fn) {
    if (typeof fn !== 'function') throw new TypeError('middleware must be a function!');
    if (isGeneratorFunction(fn)) {
      deprecate('Support for generators will be removed in v3. ' +
                'See the documentation for examples of how to convert old middleware ' +
                'https://github.com/koajs/koa/blob/master/docs/migration.md');
      fn = convert(fn);
    }
    debug('use %s', fn._name || fn.name || '-');
    this.middleware.push(fn);
    return this;
  }
複製程式碼

這個函式的作用在於將呼叫 use(fn) 方法中的引數(不管是普通的函式或者是中介軟體)都新增到 this.middlware 這個陣列中。

Koa2 中,還對 Generator 語法的中介軟體做了相容,使用 isGeneratorFunction(fn) 這個方法來判斷是否為 Generator 語法,並通過 convert(fn) 這個方法進行了轉換,轉換成 async/await 語法。然後把所有的中介軟體都新增到了 this.middleware ,最後通過 callback() 這個方法執行。callback() 原始碼如下:

  /**
   * Return a request handler callback
   * for node's native http server.
   *
   * @return {Function}
   * @api public
   */

  callback() {
    const fn = compose(this.middleware);

    if (!this.listeners('error').length) this.on('error', this.onerror);

    const handleRequest = (req, res) => {
      const ctx = this.createContext(req, res);
      return this.handleRequest(ctx, fn);
    };

    return handleRequest;
  }
複製程式碼

原始碼中,通過 compose() 這個方法,就能將我們傳入的中介軟體陣列轉換並級聯執行,最後 callback() 返回this.handleRequest()的執行結果。返回的是什麼內容我們暫且不關心,我們先來看看 compose() 這個方法做了什麼事情,能使得傳入的中介軟體能夠級聯執行,並返回 Promise

compose() 是 koa2 實現中介軟體級聯呼叫的一個庫,叫做 koa-compose。原始碼很簡單,只有一個函式,如下:

/**
 * Compose `middleware` returning
 * a fully valid middleware comprised
 * of all those which are passed.
 *
 * @param {Array} middleware
 * @return {Function}
 * @api public
 */

function compose (middleware) {
  if (!Array.isArray(middleware)) throw new TypeError('Middleware stack must be an array!')
  for (const fn of middleware) {
    if (typeof fn !== 'function') throw new TypeError('Middleware must be composed of functions!')
  }

  /**
   * @param {Object} context
   * @return {Promise}
   * @api public
   */

  return function (context, next) {
    // 記錄上一次執行中介軟體的位置 #
    let index = -1
    return dispatch(0)
    function dispatch (i) {
      // 理論上 i 會大於 index,因為每次執行一次都會把 i遞增,
      // 如果相等或者小於,則說明next()執行了多次
      if (i <= index) return Promise.reject(new Error('next() called multiple times'))
      index = i
      // 取到當前的中介軟體
      let fn = middleware[i]
      if (i === middleware.length) fn = next
      if (!fn) return Promise.resolve()
      try {
        return Promise.resolve(fn(context, function next () {
          return dispatch(i + 1)
        }))
      } catch (err) {
        return Promise.reject(err)
      }
    }
  }
}
複製程式碼

可以看到 compose() 返回一個匿名函式的結果,該匿名函式自執行了 dispatch() 這個函式,並傳入了0作為引數。

來看看 dispatch(i) 這個函式都做了什麼事? i 作為該函式的引數,用於獲取到當前下標的中介軟體。在上面的 dispatch(0) 傳入了0,用於獲取 middleware[0] 中介軟體。

首先顯示判斷 i<==index,如果 true 的話,則說明 next() 方法呼叫多次。為什麼可以這麼判斷呢?等我們解釋了所有的邏輯後再來回答這個問題。

接下來將當前的 i 賦值給 index,記錄當前執行中介軟體的下標,並對 fn 進行賦值,獲得中介軟體。

index = i;
let fn = middleware[i]
複製程式碼

獲得中介軟體後,怎麼使用?

    try {
        return Promise.resolve(fn(context, function next () {
          return dispatch(i + 1)
        }))
      } catch (err) {
        return Promise.reject(err)
      }
複製程式碼

上面的程式碼執行了中介軟體 fn(context, next),並傳遞了 contextnext 函式兩個引數。context 就是 koa 中的上下文物件 context。至於 next 函式則是返回一個 dispatch(i+1) 的執行結果。值得一提的是 i+1 這個引數,傳遞這個引數就相當於執行了下一個中介軟體,從而形成遞迴呼叫。 這也就是為什麼我們在自己寫中介軟體的時候,需要手動執行

await next()
複製程式碼

只有執行了 next 函式,才能正確得執行下一個中介軟體。

因此每個中介軟體只能執行一次 next,如果在一箇中介軟體內多次執行 next,就會出現問題。回到前面說的那個問題,為什麼說通過 i<=index 就可以判斷 next 執行多次?

因為正常情況下 index 必定會小於等於 i。如果在一箇中介軟體中呼叫多次 next,會導致多次執行 dispatch(i+1)。從程式碼上來看,每個中介軟體都有屬於自己的一個閉包作用域,同一個中介軟體的 i 是不變的,而 index 是在閉包作用域外面的。

當第一個中介軟體即 dispatch(0)next() 呼叫時,此時應該是執行 dispatch(1),在執行到下面這個判斷的時候,

if (i <= index) return Promise.reject(new Error('next() called multiple times'))
複製程式碼

此時的 index的值是0,而 i 的值是1,不滿足 i<=index 這個條件,繼續執行下面的 index=i 的賦值,此時 index 的值為1。但是如果第一個中介軟體內部又多執行了一次 next()的話,此時又會執行 dispatch(2)。上面說到,同一個中介軟體內的 i 的值是不變的,所以此時 i 的值依然是1,所以導致了 i <= index 的情況。

可能會有人有疑問?既然 async 本身返回的就是 Promise,為什麼還要在使用 Promise.resolve() 包一層呢。這是為了相容普通函式,使得普通函式也能正常使用。

再回到中介軟體的執行機制,來看看具體是怎麼回事。 我們知道 async 的執行機制是:只有當所有的 await 非同步都執行完之後才能返回一個 Promise。所以當我們用 async 的語法寫中介軟體的時候,執行流程大致如下:

  1. 先執行第一個中介軟體(因為compose 會預設執行 dispatch(0)),該中介軟體返回 Promise,然後被 Koa 監聽,執行對應的邏輯(成功或失敗)
  2. 在執行第一個中介軟體的邏輯時,遇到 await next()時,會繼續執行 dispatch(i+1),也就是執行 dispatch(1),會手動觸發執行第二個中介軟體。這時候,第一個中介軟體 await next() 後面的程式碼就會被 pending,等待 await next() 返回 Promise,才會繼續執行第一個中介軟體 await next() 後面的程式碼。
  3. 同樣的在執行第二個中介軟體的時候,遇到 await next() 的時候,會手動執行第三個中介軟體,await next() 後面的程式碼依然被 pending,等待 await 下一個中介軟體的 Promise.resolve。只有在接收到第三個中介軟體的 resolve 後才會執行後面的程式碼,然後第二個中間會返回 Promise,被第一個中介軟體的 await 捕獲,這時候才會執行第一個中介軟體的後續程式碼,然後再返回 Promise
  4. 以此類推,如果有多箇中介軟體的時候,會依照上面的邏輯不斷執行,先執行第一個中介軟體,在 await next() 出 pending,繼續執行第二個中介軟體,繼續在 await next() 出 pending,繼續執行第三個中間,直到最後一箇中介軟體執行完,然後返回 Promise,然後倒數第二個中介軟體才執行後續的程式碼並返回Promise,然後是倒數第三個中介軟體,接著一直以這種方式執行直到第一個中介軟體執行完,並返回 Promise,從而實現文章開頭那張圖的執行順序。

通過上面的分析之後,如果你要寫一個 koa2 的中介軟體,那麼基本格式應該就長下面這樣:

async function koaMiddleware(ctx, next){
    try{
        // do something
        await next()
        // do something
    }
    .catch(err){
        // handle err
    }    
}
複製程式碼

最近正在使用 koa2 + React 寫一個部落格,有興趣的同學可以前往 GitHub 地址檢視:koa-blog-api

相關文章