多維度分析 Express、Koa 之間的區別

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 源碼,感興趣的可以看下。

  1. 中間件掛載

初始化時主要通過 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;
};
  1. 中間件的執行

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 則是立即響應。

轉自https://mp.weixin.qq.com/s/rGZA_e-Nq2P9lPqBFq13Yw

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章