KOA2 compose 串聯中介軟體實現(洋蔥模型)
前言
Koa
是當下主流 NodeJS 框架,以輕量見長,而它中介軟體機制與相對傳統的Express
支援了非同步,所以編碼時經常使用async/await
,提高了可讀性,使程式碼變得更優雅,上一篇文章ofollow,noindex">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
的方式中實現同步和非同步,並附帶對應的案例來驗證。
準備工作
在真正建立compose
方法之前應該先做些準備工作,比如建立一個app
物件來頂替Koa
創建出的例項物件,並新增use
方法和管理中介軟體的陣列middlewares
。
檔案: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
引數。
1、同步的實現
檔案: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 來相容兩種情況,其實在Koa
中compose
最後返回的也是 Promise,是為了後續的邏輯的編寫,但是現在並不支援,下面來解決這兩個問題。
注意:後面compose
的其他實現方式中,都是使用sync-test.js
和async-test.js
驗證,所以後面就不再重複了。
2、升級為支援非同步
檔案: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 的實現方式
1、同步的實現
檔案:app.js
app.compose = function() { return app.middlewares.reduceRight((a, b) => () => b(a), () => {})(); };複製程式碼
上面的程式碼看起來不太好理解,我們不妨根據案例把這段程式碼拆解開,假設middlewares
中儲存的三個中介軟體函式分別為fn1
、fn2
和fn3
,由於使用的是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
則會執行fn3
,fn3
已經是最後一箇中間件函數了,再次調next
會執行我們最初傳入的空函式,這也是為什麼要將reduceRight
的初始值設定成一個空函式,就是防止最後一箇中間件呼叫next
而報錯。
經過測試上面的程式碼不會出現順序錯亂的情況,但是在compose
執行後,我們希望進行一些後續的操作,所以希望返回的是 Promise,而我們又希望傳入給use
的中介軟體函式既可以是普通函式,又可以是async
函式,這就要我們的compose
完全支援非同步。
2、升級為支援非同步
檔案: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 的實現方式
1、同步的實現
檔案:app.js
app.compose = function() { return app.middlewares.reduce((a, b) => arg => a(() => b(arg)))(() => {}); };複製程式碼
Redux
新版本中將compose
的邏輯做了些改動,將原本的reduceRight
換成reduce
,也就是說將逆序歸併改為了正序,我們不一定和Redux
原始碼完全相同,是根據相同的思路來實現序列中介軟體的需求。
個人覺得改成正序歸併後更難理解,所以還是將上面程式碼結合案例進行拆分,中介軟體依然是fn1
、fn2
和fn3
,由於reduce
並沒有傳入初始值,所以此時a
為fn1
,b
為fn2
。
// 第 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
時不會報錯。
2、升級為支援非同步
下面有個更艱鉅的任務,就是將上面的程式碼更改為支援非同步,實現如下。
檔案: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
這種優雅又高逼格的程式碼,路漫漫其修遠兮,願你在技術的路上 “一去不復返”。