我們知道,Koa 中介軟體是以級聯程式碼(Cascading) 的方式來執行的。類似於回形針的方式,可參照下面這張圖:
今天這篇文章就來分析 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)
,並傳遞了 context
和 next
函式兩個引數。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
的語法寫中介軟體的時候,執行流程大致如下:
- 先執行第一個中介軟體(因為
compose
會預設執行dispatch(0)
),該中介軟體返回Promise
,然後被Koa
監聽,執行對應的邏輯(成功或失敗) - 在執行第一個中介軟體的邏輯時,遇到
await next()
時,會繼續執行dispatch(i+1)
,也就是執行dispatch(1)
,會手動觸發執行第二個中介軟體。這時候,第一個中介軟體await next()
後面的程式碼就會被 pending,等待await next()
返回Promise
,才會繼續執行第一個中介軟體await next()
後面的程式碼。 - 同樣的在執行第二個中介軟體的時候,遇到
await next()
的時候,會手動執行第三個中介軟體,await next()
後面的程式碼依然被 pending,等待await
下一個中介軟體的Promise.resolve
。只有在接收到第三個中介軟體的resolve
後才會執行後面的程式碼,然後第二個中間會返回Promise
,被第一個中介軟體的await
捕獲,這時候才會執行第一個中介軟體的後續程式碼,然後再返回Promise
- 以此類推,如果有多箇中介軟體的時候,會依照上面的邏輯不斷執行,先執行第一個中介軟體,在
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