理解Koa中間件和洋蔥模型

2021-02-19 前端迷

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

本文阿寶哥將跟大家一起來學習 Koa 的中間件,不過這裡阿寶哥不打算一開始就亮出廣為人知的  「洋蔥模型圖」,而是先來介紹一下 Koa 中的中間件是什麼?

一、Koa 中間件

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

// @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 函數:

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 函數之後,要能夠繼續往下執行。

4.當第 3 個中間件執行完成後,此時 arr 數組的值為 [1, 2, 3, 4]。因此為了保證 arr 數組的第 4 項為 5,我們就需要在第 3 個中間件執行完成後,返回第 2 個中間件 next 函數之後語句開始執行。

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 個小任務。

在上圖中,我們從中間件一的 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 任務編排

在 77.9K 的 Axios 項目有哪些值得借鑑的地方 這篇文章中,阿寶哥參考 Axios 攔截器的設計模型,抽出以下通用的任務處理模型:

在該通用模型中,阿寶哥是通過把前置處理器和後置處理器分別放到 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`);
});

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)

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

1.3.2 dispatch(1)

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

1.3.3 dispatch(2)

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

1.3.4 dispatch(3)

由上圖可知,當 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.1 洋蔥模型簡介

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

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

2.2 洋蔥模型應用

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

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

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

// 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 的中間件機制。

三、參考資源

相關焦點

  • 學習 koa 源碼的整體架構,淺析koa洋蔥模型原理和co原理
    如果你簡歷上一不小心寫了熟悉koa,面試官大概率會問:1、koa洋蔥模型怎麼實現的。2、如果中間件中的next()方法報錯了怎麼辦。3、co的原理是怎樣的。等等問題導讀文章通過例子調試koa,梳理koa的主流程,來理解koa-compose洋蔥模型原理和co庫的原理,相信看完一定會有所收穫。本文目錄本文學習的koa版本是v2.11.0。克隆的官方倉庫的master分支。
  • 從零開始搭建koa後臺基礎框架
    ,中間件的執行順序是從外到內,再從內到外,也就是洋蔥模式。中間件的執行過程是依靠app.use()進行傳遞的,你可以簡單的理解為自己編寫的函數,依次去執行即可。每一個中間件會在app調用是傳入2個參數,分別為: ctx和nextctx:Koa Context 將 node 的 request 和 response 對象封裝在一個單獨的對象裡面,其為編寫 web 應用和 API 提供了很多有用的方法。
  • 最新Node.js框架:Koa 2 實用入門
    Koa2是目前Node.js世界最火的web框架,無論從性能,還是流程控制上,koa 2和它的後宮(中間件)都是非常好的解決方案。
  • 簡單說說Nodejs框架Koa和Express
    express和koa的橫向對比express作為nodejs的一個老牌框架在使用簡單強大,文檔詳細。而Koa作為express的原版人馬打造的一個基於es6的全新框架這幾年也是大熱 大有超過express之勢,    核心Koa模塊只是中間件內核。而Express包含一個完整的應用程式框架,具有路由和模板等功能。Koa確實有這些功能的選項,但它們是單獨的模塊。因此,Koa的模塊化程度更高;您只需包含所需的模塊即可。
  • 前端如何正確使用中間件?
    現在的中間件都是使用的洋蔥模型,洋蔥模型的大致示意圖是這樣的:按照這張圖,中間件的執行順序是:middleware1 -> middleware2 -> middleware3 -> middleware2 -> middleware1處理順序是先從外到內,再從內到外,這就是中間件的洋蔥模型。
  • 學習 redux 源碼整體架構,深入理解 redux 及其中間件原理
    接下來,我們寫個中間件例子,來調試中間件相關源碼。5. Redux 中間件相關源碼中間件是重點,面試官也經常問這類問題。中間件圖解接下來調試,在以下語句打上斷點和一些你覺得重要的地方打上斷點。把中間件函數都混入了參數getState和dispatch。
  • ...發布,TypeScript 版的 Node.js Koa 框架 - OSCHINA - 中文開源...
    Tkoa是使用 typescript 編寫的 koa 框架! 儘管它是基於 typescript 編寫,但是你依然還是可以使用一些 node.js 框架和基於 koa 的中間件。不僅如此,你還可以享受 typescript 的類型檢查系統和方便地使用 typescript 進行測試!安裝TKoa 需要 >= typescript v3.1.0 和 node v7.6.0 版本。
  • TKoa 1.0.1 發布,TypeScript 版的 Node.js Koa 框架
    Tkoa是使用 typescript 編寫的 koa 框架! 儘管它是基於 typescript 編寫,但是你依然還是可以使用一些 node.js 框架和基於 koa 的中間件。不僅如此,你還可以享受 typescript 的類型檢查系統和方便地使用 typescript 進行測試!安裝TKoa 需要 >= typescript v3.1.0 和 node v7.6.0 版本。
  • 「1分鐘--前端06」nodejs,express,koa
    ;(2)修改請求和響應對象;(3)調用堆棧的下一個中間件;通俗的理解,就像一個管道,新的管道可以對流過的請求,數據做處理;3.中間件分類:五類,原理相同,用法不同而已;(1)應用級中間件;(2)路由級;(3)錯誤處理類;(4)內置(5)第三方;4.原理:(1)源碼中與中間件相關的三個文件(2)application.js中的use方法,把我們app.use註冊的中間件和路由方法交給了Router類來處理
  • 這位「華人AI前10大牛科學家」,如何用「AI中間件」跨越人工智慧和...
    雖然最終他結束了這一項目,投入人工智慧技術學習和研發,但和一般人印象中埋頭實驗室的技術人不同,張本宇對於商業,一直保持著自己的敏感度和理解。在2014年,創業的念頭再次在張本宇的心中升起。在當時,雖然國內的創投領域對於人工智慧還了解不深,但是張本宇覺得,時機已經到了。2014年,深度學習已經取得了長足進展,在圖像處理和語音識別、自然語言理解等領域,已經開始應用。
  • 素質理論:素質層級與洋蔥模型!
    洋蔥模型是在冰山模型基礎上演變而來的。美國學者理察·博亞特茲對麥克利蘭的素質理論進行了深入和廣泛的研究,提出了「素質洋蔥模型」,展示了素質構成的核心要素,並說明了各構成要素可被觀察和衡量的特點。洋蔥模型,是把勝任素質由內到外概括為層層包裹的結構,最核心的是動機,然後向外依次展開為個性、自我形象與價值觀、社會角色、態度、知識、技能。越向外層,越易於培養和評價;越向內層,越難以評價和習得。美國學者R.博亞特茲(Richard Boyatzis)對麥克利蘭的素質理論進行了深入和廣泛的研究。
  • .NET Core中間件的註冊和管道的構建(2)---- 用UseMiddleware擴展方法註冊中間件類
    但如果我們這個中間件比較複雜,依賴很多其他模塊,那麼我們在註冊的時候需要構造依賴模塊的實例,並在中間件類的構造函數中把這些依賴傳進去。這加強了中間件和依賴模塊之間的耦合度。從上面的SimpleMiddleware我們可以看到這個類沒有任何顯示的繼承關係,那麼我們在寫一個中間件類時需要注意哪些約束呢?我們只要看一下UseMiddleware註冊中間件的過程就明白了。下面是對UseMiddleware()方法的分析,對代碼分析不感興趣的可以跳過直接看後面的結論和測試。
  • 作為HR,我不允許你沒聽過「洋蔥模型」!
    勝任力素質模型:勝任力素質(Competence quality),是指某一個體為了完成某項任務或達成某個績效目標所需具備的相應素質組合,可以區分為行為素質和潛在素質,今天小編以冰山模型和洋蔥模型為例洋蔥模型:洋蔥模型,是把勝任素質由內到外概括為層層包裹的結構,最核心的是動機,然後向外依次展開為個性、自我形象與價值觀、社會角色、態度、知識、技能。越向外層,越易於培養和評價;越向內層,越難以評價和習得。
  • 克麗發現:中間件企業智能中樞對話寫實
    廠商和廠商的理解不同,用戶和用戶的理解不同,用戶和廠商的理解不同,合作夥伴和廠商和用戶的理解也不同,聽起來像繞口令,但是的確是真實情況(觀眾笑)。我想請用戶談談對中間件的認識。  顧炳中:中間件的概念各廠商之間沒有太大的區別,一般是基於應用和 作業系統層之間的軟體。
  • 第三屆信也科技技術沙龍:揭秘消息中間件的原理與實踐
    本次沙龍的主題為《消息中間件核心原理揭秘與最佳實踐》,主辦方信也科技邀請了來自攜程旅遊、中通科技、信也科技等多家知名網際網路公司的資深專家,共聚一堂為現場觀眾傳道、授業、解惑,給大家帶來了一場精彩的消息中間件分享大會。信也科技布道師、基礎組件架構研究員赫傑輝主持了本次活動。
  • 看完這篇,還怕面試官問消息中間件麼?
    註:文內內容為依據本人理解創作,如果錯誤,請留言告知。先說需求如果現有A、B兩個應用程式,B應用希望從A應用獲取到自己感興趣的信息,A和B部署在不同的機房,可能還有C、D、E等更多的這樣的應用程式需要A的這些消息,這就是我們常說的消息中間件的點對點、發布訂閱模式。
  • SOA測試專家觀點:中間件測試漸行漸難
    導讀:從一位SOA測試專家的觀點來看,2011年敏捷開發和相關的開源測試軟體有了顯著上升。同時中間件測試繼續變得越來越複雜。「今年我所看到的是開源測試正在成為商業測試的替代品,同時面向服務架構上也是這樣,」 Frank Cohen介紹,他是PushToTest公司的CEO。
  • Node.js + Express中間件詳解
    使用中間件 Express是一種路由和中間件Web框架,它具有自己的最小功能:Express應用程式本質上是一系列中間件函數調用。中間件功能可以執行以下任務: 如果當前的中間件函數沒有結束請求 - 響應周期,則必須調用next()以將控制傳遞給下一個中間件函數。否則,請求將被掛起。Express應用程式可以使用以下類型的中間件: 您可以使用可選的裝載路徑加載應用程式級和路由器級中間件。