逐行分析Koa中介軟體機制

螞蟻保險體驗技術發表於2019-03-05

0.背景

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

PS:本次Koa原始碼分析基於2.7.0版本。

1. 從入口開始

大部分情況下使用Koa,都是這樣的,假定我們的demo 入口檔案叫app.js

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

require在查詢第三方模組時,會查詢該模組下package.json檔案的main欄位。檢視koa倉庫目錄下下package.json檔案,可以看到模組暴露的出口是lib目錄下的application.js檔案

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

而lib/application檔案中所暴露的出口

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

可以看到,在app.js 中引用koa時,變數Koa就是指向該Application類。

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.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方法起了至關重要的作用。

而這正是的中介軟體模型,即洋蔥圈模型的實現

逐行分析Koa中介軟體機制

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大錘

相關文章