KOA2 compose 串聯中間件實現(洋蔥模型)

前言

Koa 是當下主流 NodeJS 框架,以輕量見長,而它中間件機制與相對傳統的 Express 支持了異步,因此編碼時常用 async/await,提升了可讀性,使代碼變得更優雅,上一篇文章 NodeJS 進階 —— Koa 源碼分析,也對 「洋蔥模型」 和實現它的 compose 進行分析,因爲我的以爲 compose 的編程思想比較重要,應用普遍,因此本篇藉着 「洋蔥模型」 的話題,打算用四種方式來實現 compose編程

洋蔥模型案例

若是你已經使用 Koa 對 「洋蔥模型」 這個詞必定不陌生,它就是 Koa 中間件的一種串行機制,而且是支持異步的,下面是一個表達 「洋蔥模型」 的經典案例。數組

const Koa = require("koa");

const app = new Koa();

app.use(asycn (ctx, next) => {
    console.log(1);
    await next();
    console.log(2);
});

app.use(asycn (ctx, next) => {
    console.log(3);
    await next();
    console.log(4);
});

app.use(asycn (ctx, next) => {
    console.log(5);
    await next();
    console.log(6);
});

app.listen(3000);

// 1
// 3
// 5
// 6
// 4
// 2複製代碼

上面的寫法咱們按照官方推薦,使用了 async/await,但若是是同步代碼不使用也沒有關係,這裏簡單的分析一下執行機制,第一個中間件函數中若是執行了 next,則下一個中間件會被執行,依次類推,就有了咱們上面的結果,而在 Koa 源碼中,這一功能是靠一個 compose 方法實現的,咱們本文四種實現 compose 的方式中實現同步和異步,並附帶對應的案例來驗證。bash

準備工做

在真正建立 compose 方法以前應該先作些準備工做,好比建立一個 app 對象來頂替 Koa 建立出的實例對象,並添加 use 方法和管理中間件的數組 middlewaresapp

文件:app.js
// 模擬 Koa 建立的實例
const app = {
    middlewares: []
};

// 建立 use 方法
app.use = function(fn) {
    app.middlewares.push(fn);
};

// app.compose.....

module.exports = app;複製代碼

上面的模塊中導出了 app 對象,並建立了存儲中間件函數的 middlewares 和添加中間件的 use 方法,由於不管用哪一種方式實現 compose 這些都是須要的,只是 compose 邏輯的不一樣,因此後面的代碼塊中會只寫 compose 方法。框架

Koa 中 compose 的實現方式

首先介紹的是 Koa 源碼中的實現方式,在 Koa 源碼中實際上是經過 koa-compose 中間件來實現的,咱們在這裏將這個模塊的核心邏輯抽取出來,用咱們本身的方式實現,因爲重點在於分析 compose 的原理,因此 ctx 參數就被去掉了,由於咱們不會使用它,重點是 next 參數。koa

一、同步的實現

文件:app.js
app.compose = function() {
    // 遞歸函數
    function dispatch(index) {
        // 若是全部中間件都執行完跳出
        if (index === app.middlewares.length) return;

        // 取出第 index 箇中間件並執行
        const route = app.middlewares[index];
        return route(() => dispatch(index + 1));
    }

    // 取出第一個中間件函數執行
    dispatch(0);
};複製代碼

上面是同步的實現,經過遞歸函數 dispatch 的執行取出了數組中的第一個中間件函數並執行,在執行時傳入了一個函數,並遞歸執行了 dispatch,傳入的參數 +1,這樣就執行了下一個中間件函數,依次類推,直到全部中間件都執行完畢,不知足中間件執行條件時,會跳出,這樣就按照上面案例中 1 3 5 6 4 2 的狀況執行,測試例子以下(同步上、異步下)。異步

文件:sync-test.js
const app = require("./app");

app.use(next => {
    console.log(1);
    next();
    console.log(2);
});

app.use(next => {
    console.log(3);
    next();
    console.log(4);
});

app.use(next => {
    console.log(5);
    next();
    console.log(6);
});

app.compose();
// 1
// 3
// 5
// 6
// 4
// 2複製代碼
文件:async-test.js
const app = require("./app");

// 異步函數
function fn() {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve();
            console.log("hello");
        }, 3000);
    });
}

app.use(async next => {
    console.log(1);
    await next();
    console.log(2);
});

app.use(async next => {
    console.log(3);
    await fn(); // 調用異步函數
    await next();
    console.log(4);
});

app.use(async next => {
    console.log(5);
    await next();
    console.log(6);
});

app.compose();複製代碼

咱們發現若是案例中按照 Koa 的推薦寫法,即便用 async 函數,都會經過,可是在給 use 傳參時可能會傳入普通函數或 async 函數,咱們要將全部中間件的返回值都包裝成 Promise 來兼容兩種狀況,其實在 Koacompose 最後返回的也是 Promise,是爲了後續的邏輯的編寫,可是如今並不支持,下面來解決這兩個問題。async

注意:後面 compose 的其餘實現方式中,都是使用 sync-test.jsasync-test.js 驗證,因此後面就再也不重複了。

二、升級爲支持異步

文件:app.js
app.compose = function() {
    // 遞歸函數
    function dispatch(index) {
        // 若是全部中間件都執行完跳出,並返回一個 Promise
        if (index === app.middlewares.length) return Promise.resolve();

        // 取出第 index 箇中間件並執行
        const route = app.middlewares[index];

        // 執行後返回成功態的 Promise
        return Promise.resolve(route(() => dispatch(index + 1)));
    }

    // 取出第一個中間件函數執行
    dispatch(0);
};複製代碼

咱們知道 async 函數中 await 後面執行的異步代碼要實現等待,帶異步執行後繼續向下執行,須要等待 Promise,因此咱們將每個中間件函數在調用時最後都返回了一個成功態的 Promise,使用 async-test.js進行測試,發現結果爲 1 3 hello(3s後) 5 6 4 2函數

Redux 舊版本 compose 的實現方式

一、同步的實現

文件:app.js
app.compose = function() {
    return app.middlewares.reduceRight((a, b) => () => b(a), () => {})();
};複製代碼

上面的代碼看起來不太好理解,咱們不妨根據案例把這段代碼拆解開,假設 middlewares 中存儲的三個中間件函數分別爲 fn1fn2fn3,因爲使用的是 reduceRight 方法,因此是逆序歸併,第一次 a 表明初始值(空函數),b 表明 fn3,而執行 fn3 返回了一個函數,這個函數再做爲下一次歸併的 a,而 fn2 做爲 b,依次類推,過程以下。源碼分析

// 第 1 次 reduceRight 的返回值,下一次將做爲 a
() => fn3(() => {});

// 第 2 次 reduceRight 的返回值,下一次將做爲 a
() => fn2(() => fn3(() => {}));

// 第 3 次 reduceRight 的返回值,下一次將做爲 a
() => fn1(() => fn2(() => fn3(() => {})));複製代碼

由上面的拆解過程能夠看出,若是咱們調用了這個函數會先執行 fn1,若是調用 next 則會執行 fn2,若是一樣調用 next 則會執行 fn3fn3 已是最後一箇中間件函數了,再次調 next 會執行咱們最初傳入的空函數,這也是爲何要將 reduceRight 的初始值設置成一個空函數,就是防止最後一箇中間件調用 next 而報錯。

通過測試上面的代碼不會出現順序錯亂的狀況,可是在 compose 執行後,咱們但願進行一些後續的操做,因此但願返回的是 Promise,而咱們又但願傳入給 use 的中間件函數既能夠是普通函數,又能夠是 async 函數,這就要咱們的 compose 徹底支持異步。

二、升級爲支持異步

文件:app.js
app.compose = function() {
    return Promise.resolve(
        app.middlewares.reduceRight(
            (a, b) => () => Promise.resolve(b(a)),
            () => Promise.resolve();
        )()
    );
};複製代碼

參考同步的分析過程,因爲最後一箇中間件執行後執行的空函數內必定沒有任何邏輯,但爲遇到異步代碼能夠繼續執行(好比執行 next 後又調用了 then),都處理成了 Promise,保證了 reduceRight 每一次歸併的時候返回的函數內都返回了一個 Promise,這樣就徹底兼容了 async 和普通函數,當全部中間件執行完畢,也返回了一個 Promise,這樣 compose 就能夠調用 then 方法執行後續邏輯。

Redux 新版本 compose 的實現方式

一、同步的實現

文件:app.js
app.compose = function() {
    return app.middlewares.reduce((a, b) => arg => a(() => b(arg)))(() => {});
};複製代碼

Redux 新版本中將 compose 的邏輯作了些改動,將本來的 reduceRight 換成 reduce,也就是說將逆序歸併改成了正序,咱們不必定和 Redux 源碼徹底相同,是根據相同的思路來實現串行中間件的需求。

我的以爲改爲正序歸併後更難理解,因此仍是將上面代碼結合案例進行拆分,中間件依然是 fn1fn2fn3,因爲 reduce 並無傳入初始值,因此此時 afn1bfn2

// 第 1 次 reduce 的返回值,下一次將做爲 a
arg => fn1(() => fn2(arg));

// 第 2 次 reduce 的返回值,下一次將做爲 a
arg => (arg => fn1(() => fn2(arg)))(() => fn3(arg));

// 等價於...
arg => fn1(() => fn2(() => fn3(arg)));

// 執行最後返回的函數鏈接中間件,返回值等價於...
fn1(() => fn2(() => fn3(() => {})));複製代碼

因此在調用 reduce 最後返回的函數時,傳入了一個空函數做爲參數,其實這個參數最後傳遞給了 fn3,也就是第三個中間件,這樣保證了在最後一箇中間件調用 next 時不會報錯。

二、升級爲支持異步

下面有個更艱鉅的任務,就是將上面的代碼更改成支持異步,實現以下。

文件:app.js
app.compose = function() {
    return Promise.resolve(
        app.middlewares.reduce((a, b) => arg =>
            Promise.resolve(a(() => b(arg)))
        )(() => Promise.resolve())
    );
};複製代碼

實現異步其實與逆序歸併是一個套路,就是讓每個中間件函數的返回值都是 Promise,並讓 compose 也返回 Promise。

使用 async 函數實現

這個版本是我在以前在學習 Koa 源碼時偶然在一位大佬的一篇分析 Koa 原理的文章中看到的(翻了半天實在沒找到連接),在這裏也拿出來和你們分享一下,因爲是利用 async 函數實現的,因此默認就是支持異步的,由於 async 函數會返回一個 Promise。

文件:app.js
app.compose = function() {
    // 自執行 async 函數返回 Promise
    return (async function () {
        // 定義默認的 next,最後一箇中間件內執行的 next
        let next = async () => Promise.resolve();

        // middleware 爲每個中間件函數,oldNext 爲每一箇中間件函數中的 next
        // 函數返回一個 async 做爲新的 next,async 執行返回 Promise,解決異步問題
        function createNext(middleware, oldNext) {
            return async () => {
                await middleware(oldNext);
            }
        }

        // 反向遍歷中間件數組,先把 next 傳給最後一箇中間件函數
        // 將新的中間件函數存入 next 變量
        // 調用下一個中間件函數,將新生成的 next 傳入
        for (let i = app.middlewares.length - 1; i >= 0; i--) {
            next = createNext(app.middlewares[i], next);
        }

        await next();
    })();
};複製代碼

上面代碼中的 next 是一個只返回成功態 Promise 的函數,能夠理解爲其餘實現方式中最後一箇中間件調用的 next,而數組 middlewares 恰好是反向遍歷的,取到的第一個值就是最後一箇中間件,而調用 createNext做用是返回一個新的能夠執行數組中最後一箇中間件的 async 函數,並傳入了初始的 next,這個返回的 async 函數做爲新的 next,再取到倒數第二個中間件,調用 createNext,又返回了一個 async 函數,函數內依然是倒數第二個中間件的執行,傳入的 next 就是上次新生成的 next,這樣依次類推到第一個中間件。

所以執行第一個中間件返回的 next 則會執行傳入的上一個生成的 next 函數,就會執行第二個中間件,就會執行第二個中間件中的 next,就這樣直到執行完最初定義的的 next,經過案例的驗證,執行結果與洋蔥模型徹底相同。

至於異步的問題,每次執行的 next 都是 async 函數,執行後返回的都是 Promise,而最外層的自執行 async 函數返回的也是 Promise,也就是說 compose 最後返回的是 Promise,所以徹底支持異步。

這個方式之所放在最後,是由於我的以爲很差理解,我是按照本身對這幾種方式理解的難易程度由上至下排序的。

總結

或許你看完這幾種方式會以爲,仍是 Koa 對於 compose 的實現方式最容易理解,你也可能和我同樣在感慨 Redux 的兩種實現方式和 async 函數實現方式是如此的巧妙,偏偏 JavaScript 在被別人詬病 「弱類型」、「不嚴謹」 的同時,就是如此的具備靈活性和創造性,咱們沒法判斷這是優勢仍是缺點(仁者見仁,智者見智),但有一點是確定的,學習 JavaScript 不要被強類型語言的 「墨守成規」 所束縛(我的觀點,強類型語言開發者勿噴),就是要吸取這樣巧妙的編程思想,寫出 compose 這種優雅又高逼格的代碼,路漫漫其修遠兮,願你在技術的路上 「一去不復返」。

原文出自:https://www.pandashen.com

相關文章
相關標籤/搜索