逐行分析Koa中間件機制

0.背景

自從koa框架發佈,已經有不少前端同行們對它的源碼進行了解讀。在知乎、掘金、Github上,已有很多文章講了它的ctx等API實現、中間件機制概要、錯誤處理等細節,但對於中間件機制中的細節作逐行分析的文章仍是比較少,本文將採用詳細的逐行分析的策略,來討論Koa中間件機制的細節。javascript

PS:本次Koa源碼分析基於2.7.0版本。前端

1. 從入口開始

大部分狀況下使用Koa,都是這樣的,假定咱們的demo 入口文件叫app.jsjava

// app.js
const Koa = require('koa');
const app = new Koa();
複製代碼

require在查找第三方模塊時,會查找該模塊下package.json文件的main字段。查看koa倉庫目錄下下package.json文件,能夠看到模塊暴露的出口是lib目錄下的application.js文件git

{
  "main": "lib/application.js",
}
複製代碼

而lib/application文件中所暴露的出口github

module.exports = class Application extends Emitter {}
複製代碼

能夠看到,在app.js 中引用koa時,變量Koa就是指向該Application類。json

2.如何響應請求

(已經瞭解Koa如何響應請求的同窗,能夠跳過本節,直接看第3節)數組

好,如今給app.js增長一點內容:監聽3004端口,打印一行日誌,返回瀏覽器

const Koa = require('koa');
const app = new Koa();

const final = (ctx, next) => {
  console.log('Request-Start');
  ctx.body = { text: 'Hello World' };
}

app.use(final);

app.listen(3004);

// 啓動app.js,就能夠看到返回的結果
複製代碼

以上這段代碼中,ctx.body 如何實現並非本文的重點,只要知道它的做用是設置響應體的數據,就能夠了服務器

在本節裏,須要搞清楚的問題有兩個:app

  • app.use 的做用是掛載中間件,它作了什麼?
  • app.listen 的做用是監聽端口,它作了哪些工做?

回到剛剛的lib/application文件,能夠看到Application上掛載了use方法

use(fn) {
    // 類型判斷
    if (typeof fn !== 'function') throw new TypeError('middleware must be a function!');
    
    // 兼容v1版本的koa
    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);
    }
    // 中間省略部分無關代碼
    this.middleware.push(fn);
    return this;
  }
複製代碼

在官方文檔裏,中間件的類型是函數,所以use方法的第一行完成了參數類型的檢查。

而第二段代碼,則判斷是否爲Generator函數,若是是的話,就提示開發者Generator類型的中間件即將被廢棄,並經過convert方法將該中間件的類型從Generator函數轉換成普通函數。

爲何會有這麼一段代碼呢?由於在Koa的v1版本和v0版本,使用的異步控制方案是Generator+Promise+Co,所以將中間件定義成了Generator Function。但自從Koa v2版本起,它的異步控制方案就開始支持Async/Await,所以中間件也用普通函數就能夠了。

這裏用到了幾個函數庫,只要理解它們的做用和原理概要便可,有興趣能夠自行查看(但不看也不影響你理解後面的內容)

  • isGeneratorFunction:判斷是否爲Generator函數,判斷方法包括Object.prototype.call、Function.prototype.call、Object.getPrototypeOf等。
  • deprecate:給出API即將被棄用的提示信息。
  • convert:即koa-convert,做用是加入了一層函數嵌套,並使用Co自動執行原Generator函數

最後一段代碼的做用是把傳入的函數,push到this.middleware屬性的尾部,而在Application對象的構造函數裏,能夠看到這麼一行代碼

this.middleware = [];
複製代碼

它是用來存儲中間件的。

OK,中間件經過use方法存儲好了,那麼如何使用呢?這就要先講一下Koa所實現的「請求響應機制」做爲基礎知識,來看剛剛說的app.listen方法,它也被掛載在Application類上

listen(...args) {
    // 略去無關代碼
    const server = http.createServer(this.callback());
    return server.listen(...args);
  }
複製代碼

很眼熟有沒有~

只要你看過任意一份Node服務端開發入門的教程,都會知道this.callback()返回的值,即http.createServer的參數,它的格式必定以下

(req, res) => {
	// Do Sth.
}
複製代碼

即它是一個以請求Request對象和響應Response對象爲參數的函數。好,來看callback函數

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

    // 省略一些錯誤處理代碼
    const handleRequest = (req, res) => {
      // ctx上下文對象構建代碼,對理解響應機制不重要
      const ctx = this.createContext(req, res);
      return this.handleRequest(ctx, fn);
    };

    return handleRequest;
  }
複製代碼

能夠看到這段代碼就作了兩件事:

  • 用compose函數對middleware數組作處理。
  • 返回handleRequest給http.createServer做爲參數,所以每次請求發過來的時候,內部會執行this.handleRequest

compose的實現涉及到中間件的執行流程,這裏先記住,它返回的是一個函數,該函數的執行結果是一個Promise對象,具體實如今下一節會說明。咱們先看this.handleRequest函數

handleRequest(ctx, fnMiddleware) {
    const res = ctx.res;
    // 錯誤處理
    res.statusCode = 404;
    const onerror = err => ctx.onerror(err);
    const handleResponse = () => respond(ctx);
    onFinished(res, onerror);
    return fnMiddleware(ctx).then(handleResponse).catch(onerror);
  }
複製代碼

這段代碼完成了三件事情:

  • 錯誤處理:onerror函數
  • onFinished監聽response執行完成,以用來作一些資源清理工做。
  • 執行傳入的fnMiddleware

前二者本文暫時不討論,由於並不影響對於中間件執行機制的理解,因此只談最後這件事。

fnMiddleware是什麼呢?回顧剛剛的分析過程,能夠意識到fnMiddleware,就是被compose處理過獲得的fn函數

const fn = compose(this.middleware);
複製代碼

它的返回結果是一個Promise,在resolved以後,就開始執行handleResponse函數,開始組織響應。

好,響應機制到這裏就分析完畢了(後面響應如何具體實現暫時不須要在乎),開始介紹中間件的執行流程。

3.中間件如何執行

3.1 基本執行邏輯

剛纔說到,compose函數對this.middleware,也就是中間件數組作了處理工做,返回了一個fnMiddleware函數。好,來看看這個compose究竟是什麼

const compose = require('koa-compose');
複製代碼

找到koa-compose,開始翻它的源碼,發現該模塊的出口函數以下(下面這段代碼太長了,能夠先不看,本文會分塊說清楚)

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!')
  }

  return function (context, next) {
    // last called middleware #
    let index = -1
    return dispatch(0)
    
    function dispatch (i) {
      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, dispatch.bind(null, i + 1)));
      } catch (err) {
        return Promise.reject(err)
      }
    }
  }
}
複製代碼

好,咱們從頭開始看。

先是一段類型檢查

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!')
  }
複製代碼

檢查數組類型及數組裏每一個元素的類型(PS:我的以爲,這裏最好給提示一下到底是第幾個中間件類型錯了)

接下來返回了一個函數,這個函數就是以前提到的fnMiddleware函數。

return function (context, next) {
    // last called middleware #
    let index = -1
    return dispatch(0)

    // i表示預期想要執行哪一個中間件
    function dispatch (i) {
			// 暫時先省略
    }
  }
複製代碼

fnMiddleware兩個參數的含義,也很好理解,看剛纔fnMiddleware被執行的位置就能夠知道:

  • context:上下文對象,被Application對象實例上的this.createContext方法創造出來,表示是一次請求的上下文,但koa-compose只對它進行了透傳,不詳細理解也不要緊,
  • next:目前是undefined,後面會說明,它是用來表示全部中間件走完以後,最後執行的一個函數。

好,剛剛說到,每次請求的時候,fnMiddleware都會被執行,那麼來看它的執行過程。

首先,標識了一個變量index,等下講dispatch函數的時候會看到它的做用 —— 用於標識「上一次執行到了哪一個中間件」。

其次,以0爲參數,執行了dispatch函數,它的代碼以下:

function dispatch (i) {
     
      // 校驗預期執行的中間件,其索引是否在已經執行的中間件以後
      if (i <= index) return Promise.reject(new Error('next() called multiple times'))
     
      // 經過校驗,將「已執行的中間件的索引」標記爲新的「預期執行的中間件的索引」
      index = i
     
      // 取預期執行的中間件函數
      let fn = middleware[i]
      
      // 預期執行的中間件索引,已經超出了middleware邊界,說明中間件已經所有執行完畢,開始準備執行以前傳入的next
      if (i === middleware.length) fn = next
     
      // 沒有fn的話,直接返回一個已經reolved的Promise對象
      if (!fn) return Promise.resolve()
      try {
        // 對中間件的執行結果包裹一層Promise.resolve
        return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
      } catch (err) {
        return Promise.reject(err)
      }
    }
複製代碼

上面的註釋看不太懂也不要緊,咱們一行一行來看,並配上一個Demo來理解,等看完了逐行解析,再回過頭來看也來得及。

先放Demo代碼:

const Koa = require('koa');
const app = new Koa();

const one = (ctx, next) => {
  console.log('1-Start');
  next();
  console.log('1-End');
}

const two = (ctx, next) => {
  console.log('2-Start');
  next();
  console.log('2-End');
}

const final = (ctx, next) => {
  console.log('final-Start');
  ctx.body = { text: 'Hello World' };
  next();
  console.log('final-End');
}

app.use(one);
app.use(two);
app.use(final);

app.listen(3004);
複製代碼

能夠看到,這段代碼中有三個中間件,每一箇中間件都是同步方法,都調用了next函數。

剛纔說到,首先執行的是dipatch(i),且i爲0,而變量i的做用是「標識即將執行哪一個中間件」,那麼第一行代碼以下:

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

它對比了「「即將執行的中間件」索引」和「「上一次執行的中間件」的索引」,若是後者大,或者相等,就拋出一個錯誤,告訴調用者,next函數被執行了屢次。

這什麼意思呢?用剛剛的Demo舉個例子,若是我執行到了第2箇中間件,即two函數,即index爲1,這時候我發現傳入的i是1,這意思是讓我再執行一遍當前的中間件,這固然不行。同理,若是傳入的i是0,這是讓我去執行one中間件啊,。這顯然不合理啊!one中間件已經被執行過了,中間件就不應再執行了!

但是這關next函數被執行了屢次有什麼關係?請保持這個疑問,先繼續看下去。

如今i是0,index是-1。

index = i
let fn = middleware[i]
複製代碼

剛剛說,index用於標識上次執行到了哪一個中間件(-1表示第0個),i用於標識即將執行哪一個中間件(0表示第1個),那如今校驗經過了,就說明要執行的確實是下一個中間件,這時候要修改一下index這個「已執行標識」,以說明「剛剛這個「即將被執行」的中間件,如今正式被執行了」。

而且,用fn變量來保存這個「即將執行」的中間件。

接下來的兩句代碼:

if (i === middleware.length) fn = next
if (!fn) return Promise.resolve()
複製代碼

目前的變量i仍是0,而middleware長度是3,fn是第一個中間件one,因此兩句都不會執行,先行跳過。

try {
  // 原代碼是一行,爲了方便理解被我拆成了三行
  const next = dispatch.bind(null, i + 1);
  const fnResult = fn(context, next);
  return Promise.resolve(fnResult);
} catch (err) {
  return Promise.reject(err)
}
複製代碼

能夠看到這段代碼作了三件小事:

  • 一是定義了next函數,且綁定了執行上下文和第一個參數爲i+1,它的含義是「即將執行下一個函數」
  • 二是執行了fn函數,在i爲0的狀況下,即one中間件
  • 三是對one中間件執行的結果進行了Promise包裝,確保返回值是Promise對象,並完成了錯誤的處理。

而咱們知道,one中間件的格式以下:

const one = (ctx, next) => {
  console.log('1-Start');
  next();
  console.log('1-End');
}
複製代碼

因此, 對於one中間件來講,執行next,就至關於執行dispatch(1),因此每一箇中間件函數所傳入的next變量,都是對「下一個中間件執行行爲」的封裝。

那麼如今dispatch開始了第二次執行,傳入的i值成了1,這個過程請各位本身分析。

而當final中間執行的時候,如下語句中,i+1成了3。

dispatch.bind(null, i + 1)
複製代碼

因此若final中間件中執行了next函數,就會開始執行dispatch(3)

// 上次執行到第3箇中間件final,因此index是2, i 是3,校驗經過
if (i <= index) return Promise.reject(new Error('next() called multiple times'))

// 改index 爲 3
index = i
let fn = middleware[i]
// i爲3,middleware長度爲3,fn賦值爲next,而next是fnMiddleware執行時所傳入的第二個參數
if (i === middleware.length) fn = next

// fn是undefined,直接返回Promise
if (!fn) return Promise.resolve()

複製代碼

因此,當fnMiddleware執行時設置的then回調執行的時候,全部的中間件已經執行完畢了。

3.2 next屢次調用問題

把Demo改一改

const one = (ctx, next) => {
  console.log('1-Start');
  next();
  next();
  console.log('1-End');
}
複製代碼

前面說到,one中間件裏的next,至關於dispatch.bind(null, 1),因此兩次next調用,至關於執行了兩次dispatch(1):

  • 第一次調用時:i爲1,index爲0,i <= index 不成立,校驗經過。
  • 第二次調用時:i爲1,index爲1,i <= index 成立,拋錯提示。

因此這一層i <= index和它所拋出的next() called multiple times錯誤,就是爲了防止在當前中間件裏屢次執行next,從而產生重複調用行爲。

3.3 提早終止

把one中間件恢復原狀,修改two中間件:

const two = (ctx, next) => {
  console.log('2-Start');
  // next()
  console.log('2-End');
}
複製代碼

因此在下列代碼語句中,dispatch.bind(null,  i+1)(i爲1)雖然傳給了two函數,但two函數並無調用它

return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
複製代碼

因此final中間件就不會執行,因此瀏覽器訪問該服務器時,會展現Not Found錯誤。

因此在koa的中間件的第二個參數,實際上表示該中間件對下一個中間件的執行權。

3.4 異步機制

咱們修改一下代碼,來模擬一個異步場景

const one = async (ctx, next) => {
  console.log('1-Start');
  await next();
  console.log('1-End');
}

const final = (ctx, next) => {
  return new Promise(resolve => {
    setTimeout(() => {
      ctx.body = { text: 'Hello World' };
      resolve();
    }, 400);
  })
}

app.use(one);
app.use(final);
複製代碼

當one中間件執行next,也就是執行dispatch(1)時

try {
  // 原代碼是一行,爲了方便理解被我拆成了三行,i是1,
  const next = dispatch.bind(null, i + 1);
  
  // 這兒的fn是final中間件函數
  const fnResult = fn(context, next);
  // fnResult是個400ms以後狀態變成resolved的Promise
  return Promise.resolve(fnResult);
} catch (err) {
  return Promise.reject(err)
}
複製代碼

所以,中間件的one執行過程能夠簡化成下列僞代碼

const one = async (ctx, next) => {
  console.log('1-Start');
  await (
    // 這個Promise.resolve是在dispatch(1)中被執行的
    Promise.resolve(
      // 這個Promise是final中間件返回的
      new Promise(resolve => {
        setTimeout(() => {
          ctx.body = { text: 'Hello World' };
          resolve();
        }, 400);
      })
    )
  );
  console.log('1-End');
}
複製代碼

而Promise有個特性,若是Promise.resolve接受的參數,也是個Promise,那麼外部的Promise會等待該內部的Promise變成resolved以後,才變成resolved。能夠拿着下面這段代碼在瀏覽器控制檯裏跑一跑,就能理解這段

Promise.resolve(new Promise((resolve => {
	setTimeout(() => { 
    console.log('Inner Resolved');
    resolve()
  }, 1000);
})))
  .then(() => { console.log('Out Resolved')})

// 先輸出:Inner Resolved
// 後輸出:Out Resolved
複製代碼

回到上面的中間件執行過程,也就是one中間件函數代碼中間的await語句,會等待final中間件執行完畢以後再繼續執行,而在其中,Promise.resolve方法起了相當重要的做用。

而這正是的中間件模型,即洋蔥圈模型的實現

4.總結

至此,我能夠歸納v2版本的中間件執行機制的特色:

  • 存儲:以數組形式存儲中間件。
  • 狀態管理:全部的狀態變動,都交給ctx對象,無需跨中間件傳遞參數。
  • 流程控制:以遞歸的方式進行中間件的執行,將下一個中間件的執行權交給正在執行的中間件,即洋蔥圈模型。
  • 異步方案:用Promise包裹中間件的返回結果,以支持在上一個中間件內部實現Await邏輯。

因此Koa的中間件的格式很是統一

async function mw(ctx, next){
	// Do sth.
  await next();
  // Do something else
}
複製代碼

可是它的缺點也比較明顯:流程控制方案較弱

在Koa體系下,由於當前中間件只能掌握下一個中間件的執行權,所以沒法在運行時根據狀態來動態決定中間件的執行順序,只能經過靜態路由,或者把部分服務封裝成工具函數並在中間件文件中引入來解決。


關於咱們

咱們是螞蟻保險體驗技術團隊,來自螞蟻金服保險事業羣(杭州/上海)。咱們是一個年輕的團隊(沒有歷史技術棧包袱),目前平均年齡92年(去除一個最高分8x年-團隊leader,去除一個最低分97年-實習小老弟)。咱們支持了阿里集團幾乎全部的保險業務。18年咱們產出的相互寶轟動保險界,19年咱們更有多個重量級項目籌備動員中。現伴隨着事業羣的高速發展,團隊也在迅速擴張,歡迎各位前端高手加入咱們~

咱們但願你是:技術上基礎紮實、某領域深刻(Node/互動營銷/數據可視化等);學習上善於沉澱、持續學習;性格上樂觀開朗、活潑外向。

若有興趣加入咱們,歡迎發送簡歷至郵箱:shuzhe.wsz@alipay.com


本文做者:螞蟻保險-體驗技術組-漸臻

掘金地址:DC大錘

相關文章
相關標籤/搜索