1. 程式人生 > >Koa 中介軟體的執行

Koa 中介軟體的執行

<

Node.js 中請求的處理

討論 Koa 中介軟體前,先看原生 Node.js 中是如何建立 server 和處理請求的。

node_server.js

const http = require("http");
const PORT = 3000;

const server = http.createServer((req, res) => {
  res.end("hello world!");
});

server.listen(PORT);
console.log(`server started at http://localhost:${PORT}`);

Koa 中請求的處理

Koa 也是通過上面的 http.createServer 建立伺服器處理請求的返回 res。 但在 Koa 的封裝體系下,其提供了十分好用的中介軟體系統,可對請求 req 及返回 res 進行便捷地處理。

koa/lib/application.js#L64

  listen(...args) {
    debug('listen');
+    const server = http.createServer(this.callback());
    return server.listen(...args);
  }

Koa 中的 hello world:

server.js

const Koa = require("koa");
const app = new Koa();

app.use(async ctx => {
  ctx.body = "Hello World";
});

app.listen(3000);

Koa 中,涉及到對請求返回處理都是通過中介軟體完成的,像上面為樣,返回頁面一個 Hello World 文字,也是呼叫 app.useApplication 物件註冊了箇中間件來完成。

Koa 中介軟體編寫及使用

Koa 中中介軟體即一個處理請求的方法,通過呼叫 app.use(fn) 後,中介軟體 fn 被儲存到了內部一箇中間件陣列中。

koa/lib/application.js#L105

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;
  }

通過上面的程式碼可看到,註冊的中介軟體被壓入 Application 物件的 this.middleware 陣列。這裡有對傳入的方法進行判斷,區分是否為生成器([generator])方法,因為較早版本的 Koa 其中介軟體是通過生成器來實現的,後面有 async/await 語法後轉向了後者,所以更推薦使用後者,因此這裡有廢棄生成器方式的提示。

因為中介軟體中需要進行的操作是不可控的,完全有可能涉及非同步操作,比如從遠端獲取資料或從資料庫查詢資料後返回到 ctx.body,所以理論上中介軟體必需是非同步函式。

比如實現計算一個請求耗時的中介軟體,以下分別是通過普通函式配合 Promise 以及使用 async/await 方式實現的版本:

來自官方 README 中使用 Promise 實現中介軟體的示例程式碼

// Middleware normally takes two parameters (ctx, next), ctx is the context for one request,
// next is a function that is invoked to execute the downstream middleware. It returns a Promise with a then function for running code after completion.

app.use((ctx, next) => {
  const start = Date.now();
  return next().then(() => {
    const ms = Date.now() - start;
    console.log(`${ctx.method} ${ctx.url} - ${ms}ms`);
  });
});

來自官方 README 中使用 async/await 實現中介軟體的示例程式碼

app.use(async (ctx, next) => {
  const start = Date.now();
  await next();
  const ms = Date.now() - start;
  console.log(`${ctx.method} ${ctx.url} - ${ms}ms`);
});

可以看到,一箇中間件其簽名是 (ctx,next)=>Promise,其中 ctx 為請求上下文物件,而 next 是這樣一個函式,呼叫後將執行流程轉入下一個中介軟體,如果當前中介軟體中沒有呼叫 next,整個中介軟體的執行流程則會在這裡終止,後續中介軟體不會得到執行。以下是一個測試。

server.js

app.use(async (ctx, next) => {
  console.log(1);
  next();
});
app.use(async (ctx, next) => {
  console.log(2);
});
app.use(async (ctx, next) => {
  console.log(3);
  ctx.body = "Hello, world!";
});

執行後控制檯輸出:

$ node server.js
1
2

訪問頁面也不會看到 Hello, world! 因為設定響應的程式碼 ctx.body = "Hello, world!"; 所在的中介軟體沒有被執行。

compose

下面來看當多次呼叫 app.use 註冊中介軟體後,這些中介軟體是如何被順次執行的。

中介軟體的執行是跟隨一次請求的。當一個請求來到後臺,中介軟體被順次執行,在各中介軟體中對請求 requestresposne 進行各種處理。

所以從 Koa 中處理請求的地方出發,找到中介軟體執行的源頭。

通過檢視 lib/application.js 中相關程式碼:

lib/application.js#L127

  callback() {
+    const fn = compose(this.middleware);

    if (!this.listenerCount('error')) this.on('error', this.onerror);

    const handleRequest = (req, res) => {
      const ctx = this.createContext(req, res);
      return this.handleRequest(ctx, fn);
    };

    return handleRequest;
  }

可定位到儲存在 this.middleware 中的中介軟體陣列會傳遞給 compose 方法來處理,處理後得到一個函式 fn,即這個 compose 方法處理後,將一組中介軟體函式處理成了一個函式,最終在 handleRequest 處被呼叫,開啟了中介軟體的執行流程。

lib/application.js#L151

  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);
  }

compose 的簽名長這樣:compose([a, b, c, ...]),它來自另一個單獨的倉庫 koajs/compose,其程式碼也不復雜:

koajs/compose/index.js

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) {
    // 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);
      }
    }
  };
}

這個方法只做了兩件事,

  • 定義了一個 dispatch 方法,
  • 然後呼叫它 dispatch(0)

這裡中介軟體從陣列中取出並順次執行的邏輯便在 dispatch 函式中。

整體方法體中維護了一個索引 index 其初始值為 -1,後面每呼叫一次 dispatch 會加 1。當執行 dispatch(0) 時,從中介軟體陣列 middleware 中取出第 0 箇中間件並執行,同時將 dispatch(i+1) 作為 next 傳遞到下一次執行。

let fn = middleware[i];
return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));

所以這裡就能理解,為什麼中介軟體中必需呼叫 next,否則後續中介軟體不會執行。

這樣一直進行下去直到所有中介軟體執行完畢,此時 i === middleware.length,最後一箇中間件已經執行完畢,next 是沒有值的,所以直接 resolve 掉結束中介軟體執行流程。

if (i === middleware.length) fn = next;
if (!fn) return Promise.resolve();

回到中介軟體被喚起的地方:

lib/application.js

fnMiddleware(ctx)
  .then(handleResponse)
  .catch(onerror);

中介軟體完成後,流程到了 handleResponse

總結

從中介軟體執行流程可知道:

  • 中介軟體之間存在順序的問題,先註冊的先執行。
  • 中介軟體中需要呼叫 next 以保證後續中介軟體的執行。當然,如果你的中介軟體會根據一些情況阻止掉後續中介軟體的執行,那可以不呼叫 next,比如一個對請求進行許可權校驗的中介軟體可以這麼寫:
app.use(async (ctx, next) => {
  // 獲取許可權資料相關的操作...
  if (valid) {
    await next();
  } else {
    ctx.throw(403, "沒有許可權!");
  }
});

相關資源

  • Koa documentation
  • Node.js Documentation - HTTP Class: http.Server
  • MDN - function*
  • koajs/compose