自從koa框架發佈,已經有不少前端同行們對它的源碼進行了解讀。在知乎、掘金、Github上,已有很多文章講了它的ctx等API實現、中間件機制概要、錯誤處理等細節,但對於中間件機制中的細節作逐行分析的文章仍是比較少,本文將採用詳細的逐行分析的策略,來討論Koa中間件機制的細節。javascript
PS:本次Koa源碼分析基於2.7.0版本。前端
大部分狀況下使用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
(已經瞭解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
回到剛剛的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,所以中間件也用普通函數就能夠了。
這裏用到了幾個函數庫,只要理解它們的做用和原理概要便可,有興趣能夠自行查看(但不看也不影響你理解後面的內容)
最後一段代碼的做用是把傳入的函數,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的實現涉及到中間件的執行流程,這裏先記住,它返回的是一個函數,該函數的執行結果是一個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);
}
複製代碼
這段代碼完成了三件事情:
前二者本文暫時不討論,由於並不影響對於中間件執行機制的理解,因此只談最後這件事。
fnMiddleware是什麼呢?回顧剛剛的分析過程,能夠意識到fnMiddleware,就是被compose處理過獲得的fn函數
const fn = compose(this.middleware);
複製代碼
它的返回結果是一個Promise,在resolved以後,就開始執行handleResponse函數,開始組織響應。
好,響應機制到這裏就分析完畢了(後面響應如何具體實現暫時不須要在乎),開始介紹中間件的執行流程。
剛纔說到,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被執行的位置就能夠知道:
好,剛剛說到,每次請求的時候,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)
}
複製代碼
能夠看到這段代碼作了三件小事:
而咱們知道,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回調執行的時候,全部的中間件已經執行完畢了。
把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 <= index和它所拋出的next() called multiple times錯誤,就是爲了防止在當前中間件裏屢次執行next,從而產生重複調用行爲。
把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的中間件的第二個參數,實際上表示該中間件對下一個中間件的執行權。
咱們修改一下代碼,來模擬一個異步場景
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方法起了相當重要的做用。
而這正是的中間件模型,即洋蔥圈模型的實現
至此,我能夠歸納v2版本的中間件執行機制的特色:
因此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大錘