如果你有 express
,koa
, redux
的使用經驗,就會發現他們都有 中介軟體(middlewares)
的概念,中介軟體
是一種攔截器的思想,用於在某個特定的輸入輸出之間新增一些額外處理,同時不影響原有操作。
最開始接觸 中介軟體
是在服務端使用 express
和 koa
的時候,後來從服務端延伸到前端,看到其在redux
的設計中也得到的極大的發揮。中介軟體
的設計思想也為許多框架帶來了靈活而強大的擴充套件性。
本文主要對比redux
, koa
, express
的中介軟體實現,為了更直觀,我會抽取出三者中介軟體
相關的核心程式碼,精簡化,寫出模擬示例。示例會保持 express
, koa
,redux
的整體結構,儘量保持和原始碼一致,所以本文也會稍帶講解下express
, koa
, redux
的整體結構和關鍵實現:
示例原始碼地址, 可以一邊看原始碼,一邊讀文章,歡迎star!
本文適合對express ,koa ,redux 都有一定了解和使用經驗的開發者閱讀
服務端的中介軟體
express
和 koa
的中介軟體是用於處理 http
請求和響應的,但是二者的設計思路確不盡相同。大部分人瞭解的express
和koa
的中介軟體差異在於:
express
採用“尾遞迴”方式,中介軟體一個接一個的順序執行, 習慣於將response
響應寫在最後一箇中介軟體中;- 而
koa
的中介軟體支援generator
, 執行順序是“洋蔥圈”模型。
所謂的“洋蔥圈”模型:
不過實際上,express
的中介軟體也可以形成“洋蔥圈”模型,在 next
呼叫後寫的程式碼同樣會執行到,不過express
中一般不會這麼做,因為 express
的response
一般在最後一箇中介軟體,那麼其它中介軟體 next()
後的程式碼已經影響不到最終響應結果了;
express
首先看一下 express 的實現:
入口
// express.js
var proto = require('./application');
var mixin = require('merge-descriptors');
exports = module.exports = createApplication;
function createApplication() {
// app 同時是一個方法,作為http.createServer的處理函式
var app = function(req, res, next) {
app.handle(req, res, next)
}
mixin(app, proto, false);
return app
}
複製程式碼
這裡其實很簡單,就是一個 createApplication
方法用於建立 express
例項,要注意返回值 app
既是例項物件,上面掛載了很多方法,同時它本身也是一個方法,作為 http.createServer
的處理函式, 具體程式碼在 application.js 中:
// application.js
var http = require('http');
var flatten = require('array-flatten');
var app = exports = module.exports = {}
app.listen = function listen() {
var server = http.createServer(this)
return server.listen.apply(server, arguments)
}
複製程式碼
這裡 app.listen
呼叫 nodejs
的http.createServer
建立web
服務,可以看到這裡 var server = http.createServer(this)
其中 this
即 app
本身, 然後真正的處理程式即 app.handle
;
中介軟體處理
express
本質上就是一箇中介軟體管理器,當進入到 app.handle
的時候就是對中介軟體進行執行的時候,所以,最關鍵的兩個函式就是:
- app.handle 尾遞迴呼叫中介軟體處理 req 和 res
- app.use 新增中介軟體
全域性維護一個stack
陣列用來儲存所有中介軟體,app.use
的實現就很簡單了,可以就是一行程式碼 ``
// app.use
app.use = function(fn) {
this.stack.push(fn)
}
複製程式碼
express
的真正實現當然不會這麼簡單,它內建實現了路由功能,其中有 router
, route
, layer
三個關鍵的類,有了 router
就要對 path
進行分流,stack
中儲存的是 layer
例項,app.use
方法實際呼叫的是 router
例項的 use
方法, 有興趣的可以自行去閱讀。
app.handle
即對 stack
陣列進行處理
app.handle = function(req, res, callback) {
var stack = this.stack;
var idx = 0;
function next(err) {
if (idx >= stack.length) {
callback('err')
return;
}
var mid;
while(idx < stack.length) {
mid = stack[idx++];
mid(req, res, next);
}
}
next()
}
複製程式碼
這裡就是所謂的"尾遞迴呼叫",next
方法不斷的取出stack
中的“中介軟體”函式進行呼叫,同時把next
本身傳遞給“中介軟體”作為第三個引數,每個中介軟體約定的固定形式為 (req, res, next) => {}
, 這樣每個“中介軟體“函式中只要呼叫 next
方法即可傳遞呼叫下一個中介軟體。
之所以說是”尾遞迴“是因為遞迴函式的最後一條語句是呼叫函式本身,所以每一箇中介軟體的最後一條語句需要是next()
才能形成”尾遞迴“,否則就是普通遞迴,”尾遞迴“相對於普通”遞迴“的好處在於節省記憶體空間,不會形成深度巢狀的函式呼叫棧。有興趣的可以閱讀下阮老師的尾呼叫優化
至此,express
的中介軟體實現就完成了。
koa
不得不說,相比較 express
而言,koa
的整體設計和程式碼實現顯得更高階,更精煉;程式碼基於ES6
實現,支援generator(async await)
, 沒有內建的路由實現和任何內建中介軟體,context
的設計也很是巧妙。
整體
一共只有4個檔案:
- application.js 入口檔案,koa應用例項的類
- context.js
ctx
例項,代理了很多request
和response
的屬性和方法,作為全域性物件傳遞 - request.js
koa
對原生req
物件的封裝 - response.js
koa
對原生res
物件的封裝
request.js
和 response.js
沒什麼可說的,任何 web 框架都會提供req
和res
的封裝來簡化處理。所以主要看一下 context.js
和 application.js
的實現
// context.js
/**
* Response delegation.
*/
delegate(proto, 'res')
.method('setHeader')
/**
* Request delegation.
*/
delegate(proto, 'req')
.access('url')
.setter('href')
.getter('ip');
複製程式碼
context
就是這類程式碼,主要功能就是在做代理,使用了 delegate
庫。
簡單說一下這裡代理的含義,比如delegate(proto, 'res').method('setHeader')
這條語句的作用就是:當呼叫proto.setHeader時,會呼叫proto.res.setHeader 即,將proto
的 setHeader
方法代理到proto
的res
屬性上,其它類似。
// application.js 中部分程式碼
constructor() {
super()
this.middleware = []
this.context = Object.create(context)
}
use(fn) {
this.middleware.push(fn)
}
listen(...args) {
debug('listen')
const server = http.createServer(this.callback());
return server.listen(...args);
}
callback() {
// 這裡即中介軟體處理程式碼
const fn = compose(this.middleware);
const handleRequest = (req, res) => {
// ctx 是koa的精髓之一, req, res上的很多方法代理到了ctx上, 基於 ctx 很多問題處理更加方便
const ctx = this.createContext(req, res);
return this.handleRequest(ctx, fn);
};
return handleRequest;
}
handleRequest(ctx, fnMiddleware) {
ctx.statusCode = 404;
const onerror = err => ctx.onerror(err);
const handleResponse = () => respond(ctx);
return fnMiddleware(ctx).then(handleResponse).catch(onerror);
}
複製程式碼
同樣的在listen
方法中建立 web
服務, 沒有使用 express
那麼繞的方式,const server = http.createServer(this.callback());
用this.callback()
生成 web
服務的處理程式
callback
函式返回handleRequest
, 所以真正的處理程式是this.handleRequest(ctx, fn)
中介軟體處理
建構函式 constructor
中維護全域性中介軟體陣列 this.middleware
和全域性的this.context
例項(原始碼中還有request,response物件和一些其他輔助屬性)。和 express
不同,因為沒有router
的實現,所有this.middleware
中就是普通的”中介軟體“函式而非複雜的 layer
例項,
this.handleRequest(ctx, fn);
中 ctx
為第一個引數,fn = compose(this.middleware)
作為第二個引數, handleRequest
會呼叫 fnMiddleware(ctx).then(handleResponse).catch(onerror);
所以中間處理的關鍵在compose
方法, 它是一個獨立的包koa-compose
, 把它拿了出來看一下里面的內容:
// compose.js
'use strict'
module.exports = compose
function compose (middleware) {
return function (context, next) {
let index = -1
return dispatch(0)
function dispatch (i) {
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)
}
}
}
}
複製程式碼
和express
中的next
是不是很像,只不過他是promise
形式的,因為要支援非同步,所以理解起來就稍微麻煩點:每個中介軟體
是一個async (ctx, next) => {}
, 執行後返回的是一個promise
, 第二個引數 next
的值為 dispatch.bind(null, i + 1)
, 用於傳遞”中介軟體“的執行,一個個中介軟體向裡執行,直到最後一箇中介軟體執行完,resolve
掉,它前一個”中介軟體“接著執行 await next()
後的程式碼,然後 resolve
掉,在不斷向前直到第一個”中介軟體“ resolve
掉,最終使得最外層的promise
resolve
掉。
這裡和
express
很不同的一點就是koa
的響應的處理並不在"中介軟體"中,而是在中介軟體執行完返回的promise
resolve
後:
return fnMiddleware(ctx).then(handleResponse).catch(onerror);
通過
handleResponse
最後對響應做處理,”中介軟體“會設定ctx.body
,handleResponse
也會主要處理ctx.body
,所以koa
的”洋蔥圈“模型才會成立,await next()
後的程式碼也會影響到最後的響應。
至此,koa的中介軟體實現就完成了。
redux
不得不說,redux
的設計思想和原始碼實現真的是漂亮,整體程式碼量不多,網上已經隨處可見redux
的原始碼解析,我就不細說了。不過還是要推薦一波官網對中介軟體部分的敘述 : redux-middleware
這是我讀過的最好的說明文件,沒有之一,它清晰的說明了 redux middleware
的演化過程,漂亮地演繹了一場從分析問題
到解決問題
,並不斷優化的思維過程。
總體
本文還是主要看一下它的中介軟體實現, 先簡單說一下 redux
的核心處理邏輯, createStore 是其入口程式,工廠方法,返回一個 store
例項,store
例項的最關鍵的方法就是 dispatch , 而 dispatch
要做的就是一件事:
currentState = currentReducer(currentState, action)
即呼叫reducer
, 傳入當前state
和action
返回新的state
。
所以要模擬基本的 redux
執行只要實現 createStore
, dispatch
方法即可。其它的內容如 bindActionCreators
, combineReducers
以及 subscribe
監聽都是輔助使用的功能,可以暫時不關注。
中介軟體處理
然後就到了核心的”中介軟體" 實現部分即 applyMiddleware.js:
// applyMiddleware.js
import compose from './compose'
export default function applyMiddleware(...middlewares) {
return createStore => (...args) => {
const store = createStore(...args)
let dispatch = () => {
throw new Error(
`Dispatching while constructing your middleware is not allowed. ` +
`Other middleware would not be applied to this dispatch.`
)
}
const middlewareAPI = {
getState: store.getState,
dispatch: (...args) => dispatch(...args)
}
const chain = middlewares.map(middleware => middleware(middlewareAPI))
dispatch = compose(...chain)(store.dispatch)
return {
...store,
dispatch
}
}
}
複製程式碼
redux
中介軟體提供的擴充套件是在 action
發起之後,到達 reducer
之前,它的實現思路就和express
、 koa
有些不同了,它沒有通過封裝 store.dispatch
, 在它前面新增 中介軟體處理程式
,而是通過遞迴覆寫 dispatch
,不斷的傳遞上一個覆寫的 dispatch
來實現。
每一個 redux
中介軟體的形式為 store => next => action => { xxx }
這裡主要有兩層函式巢狀:
-
最外層函式接收引數
store
, 對應於applyMiddleware.js
中的處理程式碼是const chain = middlewares.map(middleware => middleware(middlewareAPI))
,middlewareAPI
即為傳入的store
。這一層是為了把store
的api
傳遞給中介軟體使用,主要就是兩個api
:getState
, 直接傳遞store.getState
.dispatch: (...args) => dispatch(...args)
,這裡的實現就很巧妙了,並不是store.dispatch
, 而是一個外部的變數dispatch
, 這個變數最終指向的是覆寫後的dispatch
, 這樣做的原因在於,對於redux-thunk
這樣的非同步中介軟體,內部呼叫store.dispatch
的時候仍然後走一遍所有“中介軟體”。
-
返回的
chain
就是第二層的陣列,陣列的每個元素都是這樣一個函式next => action => { xxx }
, 這個函式可以理解為接受一個dispatch
返回一個dispatch
, 接受的dispatch
是後一箇中介軟體返回的dispatch
. -
還有一個關鍵函式即 compose, 主要作用是
compose(f, g, h)
返回() => f(g(h(..args)))
現在在來理解 dispatch = compose(...chain)(store.dispatch)
就相對容易了,原生的 store.dispatch
傳入最後一個“中介軟體”,返回一個新的 dispatch
, 再向外傳遞到前一箇中介軟體,直至返回最終的 dispatch
, 當覆寫後的dispatch
呼叫時,每個“中介軟體“的執行又是從外向內的”洋蔥圈“模型。
至此,redux中介軟體就完成了。
其它關鍵點
redux
中介軟體的實現中還有一點實現也值得學習,為了讓”中介軟體“只能應用一次,applyMiddleware
並不是作用在 store
例項上,而是作用在 createStore
工廠方法上。怎麼理解呢?如果applyMiddleware
是這樣的
(store, middlewares) => {}
那麼當多次呼叫 applyMiddleware(store, middlewares)
的時候會給同一個例項重複新增同樣的中介軟體。所以 applyMiddleware
的形式是
(...middlewares) => (createStore) => createStore
,
這樣,每一次應用中介軟體時都是建立一個新的例項,避免了中介軟體重複應用問題。
這種形式會接收 middlewares
返回一個 createStore
的高階方法,這個方法一般被稱為 createStore
的 enhance
方法,內部即增加了對中介軟體的應用,你會發現這個方法和中介軟體第二層 (dispatch) => dispatch
的形式一致,所以它也可以用於compose
進行多次增強。同時createStore
也有第三個引數enhance
用於內部判斷,自增強。所以 redux
的中介軟體使用可以有兩種寫法:
第一種:用 applyMiddleware 返回 enhance 增強 createStore
store = applyMiddleware(middleware1, middleware2)(createStore)(reducer, initState)
複製程式碼
第二種: createStore 接收一個 enhancer 引數用於自增強
store = createStore(reducer, initState, applyMiddleware(middleware1, middleware2))
複製程式碼
第二種使用會顯得直觀點,可讀性更好。
縱觀 redux
的實現,函數語言程式設計體現的淋漓盡致,中介軟體形式 store => next => action => { xx }
是函式柯里化作用的靈活體現,將多引數化為單引數,可以用於提前固定 store
引數,得到形式更加明確的 dispatch => dispatch
,使得 compose
得以發揮作用。
總結
總體而言,express
和 koa
的實現很類似,都是next
方法傳遞進行遞迴呼叫,只不過 koa
是promise
形式。redux
相較前兩者有些許不同,先通過遞迴向外覆寫,形成執行時遞迴向裡呼叫。
總結一下三者關鍵異同點(不僅限於中介軟體):
- 例項建立:
express
使用工廠方法,koa
是類 koa
實現的語法更高階,使用ES6
,支援generator(async await)
koa
沒有內建router
, 增加了ctx
全域性物件,整體程式碼更簡潔,使用更方便。koa
中介軟體的遞迴為promise
形式,express
使用while
迴圈加next
尾遞迴- 我更喜歡
redux
的實現,柯里化中介軟體形式,更簡潔靈活,函數語言程式設計體現的更明顯 redux
以dispatch
覆寫的方式進行中介軟體增強
最後再次附上 模擬示例原始碼 以供學習參考,喜歡的歡迎star, fork!
回答一個問題
有人說,express
中也可以用 async function
作為中介軟體用於非同步處理? 其實是不可以的,因為 express
的中介軟體執行是同步的 while
迴圈,當中介軟體中同時包含 普通函式
和 async 函式
時,執行順序會打亂,先看這樣一個例子:
function a() {
console.log('a')
}
async function b() {
console.log('b')
await 1
console.log('c')
await 2
console.log('d')
}
function f() {
a()
b()
console.log('f')
}
複製程式碼
這裡的輸出是 'a' > 'b' > 'f' > 'c'
在普通函式中直接呼叫async
函式, async
函式會同步執行到第一個 await
後的程式碼,然後就立即返回一個promise
, 等到內部所有 await
的非同步完成,整個async
函式執行完,promise
才會resolve
掉.
所以,通過上述分析 express
中介軟體實現, 如果用async
函式做中介軟體,內部用await
做非同步處理,那麼後面的中介軟體會先執行,等到 await
後再次呼叫 next
索引就會超出!,大家可以自己在這裡 express async 開啟註釋,自己嘗試一下。
2020-03-10 問題修正
express的簡化模擬有誤
while(idx < stack.length) {
mid = stack[idx++];
mid(req, res, next);
}
複製程式碼
已經修正為
mid = stack[idx++];
mid(req, res, next);
複製程式碼
所以最後那個問題的實驗結果也是錯誤的,結論是express的中介軟體可以使用 async, 只要不是按洋蔥圈的方式使用