Express 歷史悠久相比 Koa 學習資料多一些,其自帶 Router、路由規則、View 等功能,更接近於 Web FrameWork 的概念。Koa 則相對輕量級,更像是對 HTTP 的封裝,自由度更多一些,官方 koajs/koa/wiki 提供了一些 Koa 的中間件,可以自行組合。
本文重點從 Handler 處理方式、中間件執行機制、響應機制多個維度來看待 Express、Koa 的區別。
Handler 處理方式
這個是 Express、Koa(koa1、koa2)的重點區別:
Express
Express 使用普通的回調函數,一種線性的邏輯,在同一個線程上完成所有的 HTTP 請求,Express 中一種不能容忍的是 Callback,特別是對錯捕獲處理起來很不友好,每一個回調都擁有一個新的調用棧,因此你沒法對一個 callback 做 try catch 捕獲,你需要在 Callback 裏做錯誤捕獲,然後一層一層向外傳遞。
Koa1
目前我們使用的是 Koa2,Koa1 是一個過度版,因此也有必要了解下,它是利用 generator 函數生成器 + co 來實現的 “協程響應”。
先說下 Generator 和協程,協程是處於線程的環境下,同一時刻一個線程只能執行一個協程,相比線程它更加輕量級,沒有了線程的創建、銷燬,上下文切換等消耗,它不受操作系統管理,由具體的應用程序所控制,Generator 也是在 ES6 中所實現,它由函數的調用者給予授權執行,因此也稱爲 “半協程/像協程”,完全的協程是所有的函數都可控制。
在說下 co,Generator 加上 co 這個必殺器,完全乾掉了回調函數這種寫法,co 是什麼呢?它是一種基於 Promise 對象的 Generator 函數流程自動管理,可以像寫同步代碼一樣來管理我們的異步代碼。
Koa2(現在 Koa 默認的)
Koa2 這個現在是 Koa 的默認版本,與 Koa1 最大的區別是使用 ES7 的 Async/Await 替換了原來的 Generator + co 的模式,也無需引入第三方庫,底層原生支持,Async/Await 現在也稱爲 JS 異步的終極解決方案。
Koa 使用的是一個洋蔥模型,它的一個特點是級聯,通過 await next() 控制調用 “下游” 中間件,直到 “下游” 沒有中間件且堆棧執行完畢,最終在流回 “上游” 中間件。這種方式有個優點特別是對於日誌記錄(請求->響應耗時統計)、錯誤處理支持都很完美。
因爲其背靠 Promise,Async/Await 只是一個語法糖,因爲 Promise 是一種鏈式調用,當多個 then 鏈式調用中你無法提前中斷,要麼繼續像下傳遞,要麼 catch 拋出一個錯誤。對應到 Koa 這個框架也是你只能通過 await next() 來控制是否像下流轉,或者拋出一個錯誤,無法提前終止。
上面說到無法提前終止,後來有看過 Teambiton 嚴清老師自己實現的一個框架 Toa,基於 Koa 進行開發,它的其中一個特點是可以通過 context.end() 提前終止,感興趣的可以去看看 toajs/toa
中間件實現機制
Koa 中間件機制
Koa (>=v7.6)默認支持 Async/Await,在 Koa 中多個異步中間件進行組合,其中一個最核心的實現是 koa-compse 這個組件,下面一步一步的進行實現。
從三個函數開始做爲例子開始封裝一個類似於 koa-compse 的組合函數:
async function f1(ctx, next) {
console.log('f1 start ->');
await next();
console.log('f1 end <-');
}
async function f2(ctx, next) {
console.log('f2 start ->');
await next();
console.log('f2 end <-');
}
async function f3(ctx) {
console.log('f3 service...');
}
如果是按照 Koa 的執行順序,就是先讓 f1 先執行、f1 的 next 參數是 f2、f2 的 next 參數是 f3,可以看到 f3 是最後一個函數,處理完邏輯就結束,模擬實現:
-
行 {1} 定義一箇中間件的集合
-
行 {2} 定義 use 方法,像中間件集合裏 push 中間件,可以看成類似於 app.use()
-
行 {3} 依次掛載我們需要的執行的函數 f1、f2、f3
-
行 {5} 執行 next1(),也即先從 f1 函數開始執行
-
行 {4.3} 定義 next1 執行函數,middlewares[0] 即 f1 函數,其函數內部調用 f2,我們在行 {4.2} 定義 next2 執行函數
-
行 {4.2} 定義 next2 執行函數,middlewares[1] 即 f2 函數,其函數內部要調用 f3,我們再次定義 next3 執行函數
-
行 {4.1} 定義 next1 執行函數,middlewares[2] 即 f3 函數,因爲其是最後一步,到這裏也就結束了
const ctx = {}
const middlewares = []; // {1} 定義一箇中間件的集合
const use = fn => middlewares.push(fn); // {2} 定義 use 方法
// {3}
use(f1);
use(f2);
use(f3);
// {4}
const next3 = () => middlewares[2](ctx); // {4.1}
const next2 = () => middlewares[1](ctx, next3); // {4.2}
const next1 = () => middlewares[0](ctx, next2); // {4.3}
// {5}
next1()
// 輸出結果
// f1 start ->
// f2 start ->
// f3 service...
// f2 end <-
// f1 end <-
上面輸出結果是我們所期望的,但是如果我們在新增一個 f4 呢,是不是還得定義呢?顯然這樣不是很合理,我們需要一個更通用的方法來組合我們這些函數,通過上面例子,可以看出是由規律性的,可以通過遞歸遍歷來實現,實現如下:
-
行 {1} {2} 爲邊界處理,首先 middlewares 是一個數組,其次數組中的每個元素必須爲函數
-
行 {4} 定義 dispatch 函數這裏是我們實現的關鍵
-
行 {5} i 爲當前執行到中間件集合 middlewares 的哪個位置了,如果等於 middlewares 的長度,也就執行完畢直接返回;
-
行 {6} 取出當前遍歷到的函數定義爲 fn
-
行 {7} 執行函數 fn,傳入 dispatch 函數且 i+1,但是注意一定要 bind 下,因爲 bind 會返回一個函數,並不會立即執行,什麼時候執行呢?也就是當前 fn 函數裏的 await next() 執行時,此時這個 next 也就是現在 fn 函數傳入的 dispatch.bind(null, (i + 1))
-
行 {8} 中間的任一個中間件出現錯誤,就直接返回
/**
* 中間件組合函數,可以參考 https://github.com/koajs/compose/blob/master/index.js
* @param { Array } middlewares
*/
function compose(ctx, middlewares) {
// {1}
if (!Array.isArray(middlewares)) throw new TypeError('Middlewares stack must be an array!')
// {2}
for (const fn of middlewares) {
if (typeof fn !== 'function') throw new TypeError('Middleware must be composed of functions!')
}
return function() {
const len = middlewares.length; // {3} 獲取數組長度
const dispatch = function(i) { // {4} 這裏是我們實現的關鍵
if (len === i) { // {5} 中間件執行完畢
return Promise.resolve();
} else {
const fn = middlewares[i]; // {6}
try {
// {7} 這裏一定要 bind 下,不要立即執行
return Promise.resolve(fn(ctx, dispatch.bind(null, (i + 1))));
} catch (err) {
// {8} 返回錯誤
return Promise.reject(err);
}
}
}
return dispatch(0);
}
}
const fn = compose(ctx, middlewares);
fn();
進行測試,是我們期望的結果,它的執流程爲 f1 -> f2 -> f3 -> f2 -> f1,剛開始從 f1 往下游執行,直到 f3 最後一箇中間件執行完畢,在流回到 f1,這種模式另外一個名字就是最著名的 “洋蔥模型”;
f1 start ->
f2 start ->
f3 service...
f2 end <-
f1 end <-
以上就是 Koa 中間件 Compose 的核心實現,關於 Koa 的更多內容可參見 Github 源碼。
Express 中間件機制
筆者這裏看到是 Express 4.x 版本,其中一個重大改變是移除了內置中間件 Connect,詳情參考 遷移到 Express 4.x。
我們通常說 Express 是線性的,那麼請看下面代碼:
const Express = require('express')
const app = new Express();
const sleep = () => new Promise(resolve => setTimeout(function(){resolve(1)}, 2000))
const port = 3000
function f1(req, res, next) {
console.log('f1 start ->');
next();
console.log('f1 end <-');
}
function f2(req, res, next) {
console.log('f2 start ->');
next();
console.log('f2 end <-');
}
async function f3(req, res) {
//await sleep();
console.log('f3 service...');
res.send('Hello World!')
}
app.use(f1);
app.use(f2);
app.use(f3);
app.get('/', f3)
app.listen(port, () => console.log(`Example app listening on port ${port}!`))
控制檯執行 curl localhost:3000 輸出如下,有點迷惑了,不是線性嗎?爲什麼和我們上面講 Koa 的輸出順序是一致呢?不也是洋蔥模型嗎?
f1 start ->
f2 start ->
f3 service...
f2 end <-
f1 end <-
少年,先莫及,再看一段代碼。
上面我們的 f3 函數其中註釋了一條代碼 await sleep() 延遲執行,現在讓我們打開這個註釋。
async function f3(req, res) {
await sleep(); // 改變之處
console.log('f3 service...');
res.send('Hello World!')
}
控制檯再次執行 curl localhost:3000,發現順序發生了改變,上游中間件並沒有等待 f3 函數執行完畢,就直接執行了。
f1 start ->
f2 start ->
f2 end <-
f1 end <-
f3 service...
下面試圖復現其執行過程,可以看到 f1、f2 爲同步代碼,而 f3 爲異步,說了這麼多,答案終於出來了。
Express 中間件實現是基於 Callback 回調函數同步的,它不會去等待異步(Promise)完成,這也解釋了爲什麼上面的 Demo 我加上異步操作,順序就被改變了。
在 Koa 的中間件機制中使用 Async/Await(背後全是 Promise)以同步的方式來管理異步代碼,它則可以等待異步操作。
f1 (req, res) {
console.log('f1 start ->');
f2 (req, res) { // 第一個 next() 地方
console.log('f2 start ->');
async f3 (req, res) { // 第二個 next() 地方
await sleep(); // 改變之處
console.log('f3 service...');
res.send('Hello World!')
}
console.log('f2 end <-');
}
console.log('f1 end <-');
}
Express 中間件源碼解析
看過 Express 的源碼,再去看 Koa 的源碼,你會發現 Koa 是真的簡潔精煉,Express 的源碼看起來還是有點繞,需要時間去梳理,下面貼兩個重點實現的地方,詳情可參考 Express 4.x 源碼,感興趣的可以看下。
-
中間件掛載
初始化時主要通過 proto.use 方法將中間件掛載到自身的 stack 數組中
// https://github.com/expressjs/express/blob/4.x/lib/router/index.js#L428
proto.use = function use(fn) {
var offset = 0;
var path = '/';
...
var callbacks = flatten(slice.call(arguments, offset));
if (callbacks.length === 0) {
throw new TypeError('Router.use() requires a middleware function')
}
for (var i = 0; i < callbacks.length; i++) {
var fn = callbacks[i];
if (typeof fn !== 'function') {
throw new TypeError('Router.use() requires a middleware function but got a ' + gettype(fn))
}
// add the middleware
debug('use %o %s', path, fn.name || '<anonymous>')
var layer = new Layer(path, {
sensitive: this.caseSensitive,
strict: false,
end: false
}, fn);
layer.route = undefined;
this.stack.push(layer); // 中間件 route 的 layer 對象的 route 爲 undefined,區別於路由的 router 對象
}
return this;
};
-
中間件的執行
Express 中間件的執行其中一個核心的方法爲 proto.handle 下面省略了很多代碼。詳情參見源碼 Express 4.x,如何進行多箇中間件的調用呢?proto.handle 方法的核心實現定義了 next 函數遞歸調用取出需要執行的中間件。
// https://github.com/expressjs/express/blob/dc538f6e810bd462c98ee7e6aae24c64d4b1da93/lib/router/index.js#L136
proto.handle = function handle(req, res, out) {
var self = this;
...
next();
function next(err) {
...
// find next matching layer
var layer;
var match;
var route;
while (match !== true && idx < stack.length) {
layer = stack[idx++]; // 取出中間件函數
match = matchLayer(layer, path);
route = layer.route;
if (typeof match !== 'boolean') {
// hold on to layerError
layerError = layerError || match;
}
if (match !== true) {
continue;
}
if (!route) {
// process non-route handlers normally
continue;
}
...
}
...
// this should be done for the layer
self.process_params(layer, paramcalled, req, res, function (err) {
if (err) {
return next(layerError || err);
}
if (route) {
return layer.handle_request(req, res, next);
}
trim_prefix(layer, layerError, layerPath, path);
});
}
function trim_prefix(layer, layerError, layerPath, path) {
...
if (layerError) {
layer.handle_error(layerError, req, res, next);
} else {
// 這裏進行函數調用,且遞歸
layer.handle_request(req, res, next);
}
}
};
響應機制
Koa 響應機制
在 Koa 中數據的響應是通過 ctx.body 進行設置,注意這裏僅是設置並沒有立即響應,而是在所有的中間件結束之後做了響應,源碼中是如下方式寫的:
const handleResponse = () => respond(ctx);
fnMiddleware(ctx).then(handleResponse)
function respond(ctx) {
...
res.end(body);
}
這樣做一個好處是我們在響應之前是有一些預留操作空間的,例如:
async function f1(ctx, next) {
console.log('f1 start ->');
await next();
ctx.body += 'f1';
console.log('f1 end <-');
}
async function f2(ctx, next) {
console.log('f2 start ->');
await next();
ctx.body += 'f2 ';
console.log('f2 end <-');
}
async function f3(ctx) {
ctx.body = 'f3 '
console.log('f3 service...');
}
fn().then(() => {
console.log(ctx); // { body: 'f3 f2 f1' }
});
Express 響應機制
在 Express 中我們直接操作的是 res 對象,在 Koa 中是 ctx,直接 res.send() 之後就立即響應了,這樣如果還想在上層中間件做一些操作是有點難的。
function f2(req, res, next) {
console.log('f2 start ->');
next();
res.send('f2 Hello World!') // 第二次執行
console.log('f2 end <-');
}
async function f3(req, res) {
console.log('f3 service...');
res.send('f3 Hello World!') // 第一次執行
}
app.use(f2);
app.use(f3);
app.get('/', f3)
注意:向上面這樣如果執行多次 send 是會報 ERR_HTTP_HEADERS_SENT 錯誤的。
總結
本文從 Handler 處理方式、中間件執行機制的實現、響應機制三個維度來對 Express、Koa 做了比較,通常都會說 Koa 是洋蔥模型,這重點在於中間件的設計。但是按照上面的分析,會發現 Express 也是類似的,不同的是Express 中間件機制使用了 Callback 實現,這樣如果出現異步則可能會使你在執行順序上感到困惑,因此如果我們想做接口耗時統計、錯誤處理 Koa 的這種中間件模式處理起來更方便些。最後一點響應機制也很重要,Koa 不是立即響應,是整個中間件處理完成在最外層進行了響應,而 Express 則是立即響應。