如何更好地理解中間件和洋蔥模型

相信用過 Koa、Redux 或 Express 的小夥伴對中間件都不會陌生,特別是在學習 Koa 的過程當中,還會接觸到 「洋蔥模型」。php

本文阿寶哥將跟你們一塊兒來學習 Koa 的中間件,不過這裏阿寶哥不打算一開始就亮出廣爲人知的  「洋蔥模型圖」,而是先來介紹一下 Koa 中的中間件是什麼?html

1、Koa 中間件

在 @types/koa-compose 包下的 index.d.ts 頭文件中咱們找到了中間件類型的定義:ios

// @types/koa-compose/index.d.ts
declare namespace compose {
  type Middleware<T> = (context: T, next: Koa.Next) => any;
  type ComposedMiddleware<T> = (context: T, next?: Koa.Next) => Promise<void>;
}
  
// @types/koa/index.d.ts => Koa.Next
type Next = () => Promise<any>;

經過觀察 Middleware 類型的定義,咱們能夠知道在 Koa 中,中間件就是普通的函數,該函數接收兩個參數:context 和 next。其中 context 表示上下文對象,而 next 表示一個調用後返回 Promise 對象的函數對象。算法

瞭解完 Koa 的中間件是什麼以後,咱們來介紹 Koa 中間件的核心,即 compose 函數:api

function wait(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms || 1));
}

const arr = [];
const stack = [];

// type Middleware<T> = (context: T, next: Koa.Next) => any;
stack.push(async (context, next) => {
  arr.push(1);
  await wait(1);
  await next();
  await wait(1);
  arr.push(6);
});

stack.push(async (context, next) => {
  arr.push(2);
  await wait(1);
  await next();
  await wait(1);
  arr.push(5);
});

stack.push(async (context, next) => {
  arr.push(3);
  await wait(1);
  await next();
  await wait(1);
  arr.push(4);
});

await compose(stack)({});

對於以上的代碼,咱們但願執行完 compose(stack)({}) 語句以後,數組 arr 的值爲 [1, 2, 3, 4, 5, 6]。這裏咱們先不關心 compose 函數是如何實現的。咱們來分析一下,若是要求數組 arr 輸出指望的結果,上述 3 箇中間件的執行流程:數組

1.開始執行第  1 箇中間件,往 arr 數組壓入 1,此時 arr 數組的值爲 [1],接下去等待 1 毫秒。爲了保證 arr 數組的第 1 項爲 2,咱們須要在調用 next 函數以後,開始執行第 2 箇中間件。緩存

2.開始執行第 2 箇中間件,往 arr 數組壓入 2,此時 arr 數組的值爲 [1, 2],繼續等待 1 毫秒。爲了保證 arr 數組的第 2 項爲 3,咱們也須要在調用 next 函數以後,開始執行第 3 箇中間件。服務器

3.開始執行第 3 箇中間件,往 arr 數組壓入 3,此時 arr 數組的值爲 [1, 2, 3],繼續等待 1 毫秒。爲了保證 arr 數組的第 3 項爲 4,咱們要求在調用第 3 箇中間的 next 函數以後,要可以繼續往下執行。app

4.當第 3 箇中間件執行完成後,此時 arr 數組的值爲 [1, 2, 3, 4]。所以爲了保證 arr 數組的第 4 項爲 5,咱們就須要在第 3 箇中間件執行完成後,返回第 2 箇中間件 next 函數以後語句開始執行。koa

5.當第 2 箇中間件執行完成後,此時 arr 數組的值爲 [1, 2, 3, 4, 5]。一樣,爲了保證 arr 數組的第 5 項爲 6,咱們就須要在第 2 箇中間件執行完成後,返回第 1 箇中間件 next 函數以後語句開始執行。

6.當第 1 箇中間件執行完成後,此時 arr 數組的值爲 [1, 2, 3, 4, 5, 6]。

爲了更直觀地理解上述的執行流程,咱們能夠把每一箇中間件當作 1 個大任務,而後在以 next 函數爲分界點,在把每一個大任務拆解爲 3 個 beforeNext、next 和 afterNext 3 個小任務。

watermark,size_16,text_QDUxQ1RP5Y2a5a6i,color_FFFFFF,t_100,g_se,x_10,y_10,shadow_90,type_ZmFuZ3poZW5naGVpdGk=

在上圖中,咱們從中間件一的 beforeNext 任務開始執行,而後按照紫色箭頭的執行步驟完成中間件的任務調度。在 77.9K 的 Axios 項目有哪些值得借鑑的地方 這篇文章中,阿寶哥從 任務註冊、任務編排和任務調度 3 個方面去分析 Axios 攔截器的實現。一樣,阿寶哥將從上述 3 個方面來分析 Koa 中間件機制。

1.1 任務註冊

在 Koa 中,咱們建立 Koa 應用程序對象以後,就能夠經過調用該對象的 use 方法來註冊中間件:

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

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

其實 use 方法的實現很簡單,在 lib/application.js 文件中,咱們找到了它的定義:

// lib/application.js
module.exports = class Application extends Emitter {  
  constructor(options) {
    super();
    // 省略部分代碼 
    this.middleware = [];
  }
  
 use(fn) {
   if (typeof fn !== 'function') throw new TypeError('middleware must be a function!');
   // 省略部分代碼 
   this.middleware.push(fn);
   return this;
  }
}

由以上代碼可知,在 use 方法內部會對 fn 參數進行類型校驗,當校驗經過時,會把 fn 指向的中間件保存到 middleware 數組中,同時還會返回 this 對象,從而支持鏈式調用。

1.2 任務編排

阿寶哥參考 Axios 攔截器的設計模型,抽出如下通用的任務處理模型:

watermark,size_16,text_QDUxQ1RP5Y2a5a6i,color_FFFFFF,t_100,g_se,x_10,y_10,shadow_90,type_ZmFuZ3poZW5naGVpdGk=

在該通用模型中,阿寶哥是經過把前置處理器和後置處理器分別放到 CoreWork 核心任務的先後來完成任務編排。而對於 Koa 的中間件機制來講,它是經過把前置處理器和後置處理器分別放到 await next() 語句的先後來完成任務編排。

// 統計請求處理時長的中間件
app.use(async (ctx, next) => {
  const start = Date.now();
  await next();
  const ms = Date.now() - start;
  console.log(`${ctx.method} ${ctx.url} - ${ms}ms`);
});
watermark,size_16,text_QDUxQ1RP5Y2a5a6i,color_FFFFFF,t_100,g_se,x_10,y_10,shadow_90,type_ZmFuZ3poZW5naGVpdGk=

1.3 任務調度

經過前面的分析,咱們已經知道了,使用 app.use 方法註冊的中間件會被保存到內部的 middleware 數組中。要完成任務調度,咱們就須要不斷地從 middleware 數組中取出中間件來執行。中間件的調度算法被封裝到 koa-compose 包下的 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) {
  // 省略部分代碼
  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);
      }
    }
  };
}

compose 函數接收一個參數,該參數的類型是數組,調用該函數以後會返回一個新的函數。接下來咱們將之前面的例子爲例,來分析一下 await compose(stack)({}); 語句的執行過程。

1.3.1 dispatch(0)
watermark,size_16,text_QDUxQ1RP5Y2a5a6i,color_FFFFFF,t_100,g_se,x_10,y_10,shadow_90,type_ZmFuZ3poZW5naGVpdGk=

由上圖可知,當在第一個中間件內部調用 next 函數,其實就是繼續調用 dispatch 函數,此時參數 i 的值爲 1。

1.3.2 dispatch(1)
watermark,size_16,text_QDUxQ1RP5Y2a5a6i,color_FFFFFF,t_100,g_se,x_10,y_10,shadow_90,type_ZmFuZ3poZW5naGVpdGk=

由上圖可知,當在第二個中間件內部調用 next 函數,仍然是調用 dispatch 函數,此時參數 i 的值爲 2。

1.3.3 dispatch(2)
watermark,size_16,text_QDUxQ1RP5Y2a5a6i,color_FFFFFF,t_100,g_se,x_10,y_10,shadow_90,type_ZmFuZ3poZW5naGVpdGk=

由上圖可知,當在第三個中間件內部調用 next 函數,仍然是調用 dispatch 函數,此時參數 i 的值爲 3。

1.3.4 dispatch(3)
watermark,size_16,text_QDUxQ1RP5Y2a5a6i,color_FFFFFF,t_100,g_se,x_10,y_10,shadow_90,type_ZmFuZ3poZW5naGVpdGk=

由上圖可知,當 middleware 數組中的中間件都開始執行以後,若是調度時未顯式地設置 next 參數的值,則會開始返回 next 函數以後的語句繼續往下執行。當第三個中間件執行完成後,就會返回第二中間件 next 函數以後的語句繼續往下執行,直到全部中間件中定義的語句都執行完成。

分析完 compose 函數的實現代碼,咱們來看一下 Koa 內部如何利用 compose 函數來處理已註冊的中間件。

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

// 響應
app.use(ctx => {
  ctx.body = '你們好,我是阿寶哥';
});

app.listen(3000);

利用以上的代碼,我就能夠快速啓動一個服務器。其中 use 方法咱們前面已經分析過了,因此接下來咱們來分析 listen 方法,該方法的實現以下所示:

// lib/application.js
module.exports = class Application extends Emitter {  
  listen(...args) {
    debug('listen');
    const server = http.createServer(this.callback());
    return server.listen(...args);
  }
}

很明顯在 listen 方法內部,會先經過調用 Node.js 內置 HTTP 模塊的 createServer 方法來建立服務器,而後開始監聽指定的端口,即開始等待客戶端的鏈接。

另外,在調用 http.createServer 方法建立 HTTP 服務器時,咱們傳入的參數是 this.callback(),該方法的具體實現以下所示:

// lib/application.js
const compose = require('koa-compose');

module.exports = class Application extends Emitter {  
  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;
  }
}

在 callback 方法內部,咱們終於見到了久違的 compose 方法。當調用 callback 方法以後,會返回 handleRequest 函數對象用來處理 HTTP 請求。每當 Koa 服務器接收到一個客戶端請求時,都會調用 handleRequest 方法,在該方法會先建立新的 Context 對象,而後在執行已註冊的中間件來處理已接收的 HTTP 請求:

module.exports = class Application extends Emitter {  
  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);
  }
}

好的,Koa 中間件的內容已經基本介紹完了,對 Koa 內核感興趣的小夥伴,能夠自行研究一下。接下來咱們來介紹洋蔥模型及其應用。

2、洋蔥模型

2.1 洋蔥模型簡介

watermark,size_16,text_QDUxQ1RP5Y2a5a6i,color_FFFFFF,t_100,g_se,x_10,y_10,shadow_90,type_ZmFuZ3poZW5naGVpdGk=

(圖片來源:https://eggjs.org/en/intro/egg-and-koa.html)

在上圖中,洋蔥內的每一層都表示一個獨立的中間件,用於實現不一樣的功能,好比異常處理、緩存處理等。每次請求都會從左側開始一層層地通過每層的中間件,當進入到最裏層的中間件以後,就會從最裏層的中間件開始逐層返回。所以對於每層的中間件來講,在一個 請求和響應 週期中,都有兩個時機點來添加不一樣的處理邏輯。

2.2 洋蔥模型應用

除了在 Koa 中應用了洋蔥模型以外,該模型還被普遍地應用在 Github 上一些不錯的項目中,好比 koa-router 和阿里巴巴的 midway、umi-request 等項目中。

介紹完 Koa 的中間件和洋蔥模型,阿寶哥根據本身的理解,抽出如下通用的任務處理模型:

watermark,size_16,text_QDUxQ1RP5Y2a5a6i,color_FFFFFF,t_100,g_se,x_10,y_10,shadow_90,type_ZmFuZ3poZW5naGVpdGk=

上圖中所述的中間件,通常是與業務無關的通用功能代碼,好比用於設置響應時間的中間件:

// x-response-time
async function responseTime(ctx, next) {
  const start = new Date();
  await next();
  const ms = new Date() - start;
  ctx.set("X-Response-Time", ms + "ms");
}

其實,對於每一箇中間件來講,前置處理器和後置處理器都是可選的。好比如下中間件用於設置統一的響應內容:

// response
async function respond(ctx, next) {
  await next();
  if ("/" != ctx.url) return;
  ctx.body = "Hello World";
}

儘管以上介紹的兩個中間件都比較簡單,但你也能夠根據本身的需求來實現複雜的邏輯。Koa 的內核很輕量,麻雀雖小五臟俱全。它經過提供了優雅的中間件機制,讓開發者能夠靈活地擴展 Web 服務器的功能,這種設計思想值得咱們學習與借鑑。

好的,此次就先介紹到這裏,後面有機會的話,阿寶哥在單獨介紹一下 Redux 或 Express 的中間件機制。

 

 

watermark,size_16,text_QDUxQ1RP5Y2a5a6i,color_FFFFFF,t_100,g_se,x_10,y_10,shadow_90,type_ZmFuZ3poZW5naGVpdGk=

相關文章
相關標籤/搜索