redux, koa, express 中介軟體實現對比解析

nanjixiong發表於2018-09-13

如果你有 express ,koa, redux 的使用經驗,就會發現他們都有 中介軟體(middlewares)的概念,中介軟體 是一種攔截器的思想,用於在某個特定的輸入輸出之間新增一些額外處理,同時不影響原有操作。

最開始接觸 中介軟體是在服務端使用 expresskoa 的時候,後來從服務端延伸到前端,看到其在redux的設計中也得到的極大的發揮。中介軟體的設計思想也為許多框架帶來了靈活而強大的擴充套件性。

本文主要對比redux, koa, express 的中介軟體實現,為了更直觀,我會抽取出三者中介軟體相關的核心程式碼,精簡化,寫出模擬示例。示例會保持 express, koaredux 的整體結構,儘量保持和原始碼一致,所以本文也會稍帶講解下express, koa, redux 的整體結構和關鍵實現:

示例原始碼地址, 可以一邊看原始碼,一邊讀文章,歡迎star!

本文適合對express ,koa ,redux 都有一定了解和使用經驗的開發者閱讀

服務端的中介軟體

expresskoa 的中介軟體是用於處理 http 請求和響應的,但是二者的設計思路確不盡相同。大部分人瞭解的expresskoa的中介軟體差異在於:

  • express採用“尾遞迴”方式,中介軟體一個接一個的順序執行, 習慣於將response響應寫在最後一箇中介軟體中;
  • koa的中介軟體支援 generator, 執行順序是“洋蔥圈”模型。

所謂的“洋蔥圈”模型:

redux, koa, express 中介軟體實現對比解析

不過實際上,express 的中介軟體也可以形成“洋蔥圈”模型,在 next 呼叫後寫的程式碼同樣會執行到,不過express中一般不會這麼做,因為 expressresponse一般在最後一箇中介軟體,那麼其它中介軟體 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 呼叫 nodejshttp.createServer 建立web服務,可以看到這裡 var server = http.createServer(this) 其中 thisapp 本身, 然後真正的處理程式即 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 例項,代理了很多requestresponse的屬性和方法,作為全域性物件傳遞
  • request.js koa 對原生 req 物件的封裝
  • response.js koa 對原生 res 物件的封裝

request.jsresponse.js 沒什麼可說的,任何 web 框架都會提供reqres 的封裝來簡化處理。所以主要看一下 context.jsapplication.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 即,將protosetHeader方法代理到protores屬性上,其它類似。

// 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, 傳入當前stateaction返回新的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 之前,它的實現思路就和expresskoa 有些不同了,它沒有通過封裝 store.dispatch, 在它前面新增 中介軟體處理程式,而是通過遞迴覆寫 dispatch ,不斷的傳遞上一個覆寫的 dispatch 來實現。

每一個 redux 中介軟體的形式為 store => next => action => { xxx }

這裡主要有兩層函式巢狀:

  • 最外層函式接收引數store, 對應於 applyMiddleware.js 中的處理程式碼是 const chain = middlewares.map(middleware => middleware(middlewareAPI)), middlewareAPI 即為傳入的store 。這一層是為了把 storeapi 傳遞給中介軟體使用,主要就是兩個api:

    1. getState, 直接傳遞store.getState.
    2. 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 的高階方法,這個方法一般被稱為 createStoreenhance 方法,內部即增加了對中介軟體的應用,你會發現這個方法和中介軟體第二層 (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得以發揮作用。

總結

總體而言,expresskoa 的實現很類似,都是next 方法傳遞進行遞迴呼叫,只不過 koapromise 形式。redux 相較前兩者有些許不同,先通過遞迴向外覆寫,形成執行時遞迴向裡呼叫。

總結一下三者關鍵異同點(不僅限於中介軟體):

  1. 例項建立: express 使用工廠方法, koa是類
  2. koa 實現的語法更高階,使用ES6,支援generator(async await)
  3. koa 沒有內建router, 增加了 ctx 全域性物件,整體程式碼更簡潔,使用更方便。
  4. koa 中介軟體的遞迴為 promise形式,express 使用while 迴圈加 next 尾遞迴
  5. 我更喜歡 redux 的實現,柯里化中介軟體形式,更簡潔靈活,函數語言程式設計體現的更明顯
  6. reduxdispatch 覆寫的方式進行中介軟體增強

最後再次附上 模擬示例原始碼 以供學習參考,喜歡的歡迎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, 只要不是按洋蔥圈的方式使用

相關文章