中间件模式在前端框架中的应用

概述

中间件模式在前端框架中的有着广泛的应用,无论是浏览器端还是Node端都有用武之地,毫无疑问它是一种经典且实用的设计模式。

事实上,中间件模式并非23种设计模式中的一种,但它又确实非常实用,本文姑且将其当做一种设计模式来讨论。

基本概念

简单来说,所谓的中间件就是一系列的处理逻辑(在前端框架或者工程中,通常是指一系列符合设计者定义规范的函数),对于某个特定场景下的对象实体,程序会依次调用该系列处理逻辑进行处理。

一般来说,脱离某种特定的场景不太好去描述中间件模式的具体实现,中间件的”格式”会根据不同的场景而不同,有的是一系列的普通函数,也有的是异步函数,也有的是供中间件管理模块使用的回调函数,还有的是一些高阶函数。

但总而言之,中间件模式的基本结构都可以用下图描述。

图1. 中间件模式的运作流程

特定的数据通过一个又一个的中间进行处理,而这些中间件的功能完全可以根据开发者自己的业务逻辑进行定制实现。

框架中的应用

为了更好地理解中间件模式,本文选取了三个中间件模式在前端框架中的典型实现,包括浏览器端和Node端。

express

express是Node端最普及的框架,Node帮助JavaScript在后端开辟了一个新天地,其中express的普及功不可没。

在express中也有中间件的设计,它使得我们能用更方便以及更容易维护的方式对一次请求和响应进行处理,一个最基本的express应用层中间件用法如下。

1
2
3
4
5
6
// 代码1
// 全部请求有效
app.use(function (req, res, next) {
console.log('Time:', Date.now())
next()
})

为了提供更便捷的用法,express内置了对路由的处理,当开发者需要指定路由、或者制定请求方法对应的中间件时,可以使用如下写法。

1
2
3
4
5
6
// 代码2
// 指定路由有效
app.use('/user/:id', function (req, res, next) {
console.log('Request Type:', req.method)
next()
})
1
2
3
4
5
6
// 代码3
// 仅对指定路由的get方法有效
// 注意这里没有调用next,因此按照中间件的加载顺序执行到这里之后,将不会继续执行后续的中间件
app.get('/user/:id', function (req, res, next) {
res.send('USER')
})

express在内部处理的过程中,省略路由的中间件和当前的路由中间件也会被加载到路由中间件上,只是其对应的路由是'/',也就是全部。

对于所有路由中间件,express都会将其包装成一个Layer对象,然后将该对象存入Routestack属性中。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 代码4
// https://github.com/expressjs/express/blob/master/lib/router/index.js#L464
// ...
var layer = new Layer(path, {
sensitive: this.caseSensitive,
strict: false,
end: false
}, fn);

layer.route = undefined;

this.stack.push(layer);
// ...

代码4来自于express中的use方法,其中path是指定的路径,fn则是我们定义的中间件,它在Layer的构造函数中会被赋值给thishandle属性。

由于本文并非深入express源码,所以这里略过一些express在初始化和请求来时的一些处理细节,这里仅需要知道,express创建的app在处理一个请求时,最终会调用到Route对象的dispatch方法(感兴趣的同学可以一步一步往下调试),这里我们来看Route.prototype.dispatch方法的实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// 代码5
// https://github.com/expressjs/express/blob/master/lib/router/route.js#L98
// ...
Route.prototype.dispatch = function dispatch(req, res, done) {
var idx = 0;
var stack = this.stack;
if (stack.length === 0) {
return done();
}

var method = req.method.toLowerCase();
if (method === 'head' && !this.methods['head']) {
method = 'get';
}

req.route = this;

next();

function next(err) {
// signal to exit route
if (err && err === 'route') {
return done();
}

// signal to exit router
if (err && err === 'router') {
return done(err)
}

var layer = stack[idx++];
if (!layer) {
return done(err);
}

if (layer.method && layer.method !== method) {
return next(err);
}

if (err) {
layer.handle_error(err, req, res, next);
} else {
layer.handle_request(req, res, next);
}
}
};
// ...

上面就是dispatch方法的全部内容,可以看到除了一些校验之外,最重要的莫过于其中的next方法,它在这里被定义并被调用(函数声明会被提升到作用域顶部)。

next方法中,我们拿到了当前Route对象中的第一个layer,除去校验,我们很容易就看到layer.handler_request(req, res, next)这行代码,从字面意义上而言,显然它是用于调用当前”层”的用于处理请求,同时在第三个参数中,它还将next函数传入了其中。

为了更好地理解express中的中间件设计,这里我们继续深入。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 代码6
// https://github.com/expressjs/express/blob/master/lib/router/layer.js#L86
Layer.prototype.handle_request = function handle(req, res, next) {
var fn = this.handle;

if (fn.length > 3) {
// not a standard request handler
return next();
}

try {
fn(req, res, next);
} catch (err) {
next(err);
}
};

Layerhandle_request请求中,我们将handle取了出来(前文提过handle就是我们定义的中间件函数),并将请求对象、响应对象,和next函数传入其中,至此express中间件的核心设计就完成了。

有的同学可能还没理解中间件是怎样依次被调用的。回到dispatch方法中,如果我们继续调用next函数,则会拿到下一个layer,又会执行下一个”层”的handle,所以如果next函数中又会调用next函数,那这个执行过程就我们开始的那种图一样的,可是next方法又在什么时候会被调用呢?

请回想一下express中间件的定义方法,再看看上面的代码1、代码2和代码3,是的,我们需要手动调用它,如果我们不在中间件中调用next,那下一个中间件就不会被执行了。

koa

koa应该是除了express之外,最流行的Node端框架了,它采用async/await的语法,相比express而言更容易处理异步代码,但它的功能也更单一,它的设计理念就是提供一个简单的架子,具体业务逻辑都需要用户自己去实现或者在三方库中做取舍,它甚至连路由都不愿意管。

也因此,中间件的设计也是koa框架的重中之重。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 代码7
// 中间件1,请求处理时长日志
app.use(async (ctx, next) => {
await next()
const rt = ctx.response.get('X-Response-Time')
console.log(`${ctx.method} ${ctx.url} - ${rt}`)
})

// 中间件2,记录请求处理时长
app.use(async (ctx, next) => {
const start = Date.now()
await next()
const ms = Date.now() - end
ctx.set('X-Response-Time', `${ms}ms`)
})

// 中间件3,处理请求
app.use(async ctx => {
ctx.body = 'Hello World'
})

可以发现除了中间件的格式之外,其基本用法和express相差无几,koa中的ctx中存储了请求的上下文,包括当前请求和响应对象,包括会话上下文,应用上下文等都可以从中拿到。

除此之外,async/await语法的引入就是其中最重要的区别了,koa的中间件全部是异步函数。

我们接下来深入koa源码,去看看这里面又有些什么样的魔法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 代码8
// https://github.com/koajs/koa/blob/master/lib/application.js#L105
// ...
use(fn) {
if (typeof fn !== 'function') throw new TypeError('middleware must be a function!');
if (isGeneratorFunction(fn)) {
deprecate('Support for generators will be removed in v3. ' +
'See the documentation for examples of how to convert old middleware ' +
'https://github.com/koajs/koa/blob/master/docs/migration.md');
fn = convert(fn);
}
debug('use %s', fn._name || fn.name || '-');
this.middleware.push(fn);
return this;
}
// ...

直接找到其中的use方法,同样不用理会校验,可以发现这里的use比express中的还要简单,它就是将其放到middleware,其他什么也没做(显然middleware也是一个数组)。

如果我们对koa的初始化过程和处理请求的过程一行行调试,我们会发现一个callback的方法,它返回的是一个函数,该函数是koa中所有的请求处理的入口,其源码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 代码9
// https://github.com/koajs/koa/blob/master/lib/application.js#L126
// ...
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;
}
// ...

可以看到这里的middleware被传入了一个compose函数中,然后返回的fn就是会被所有请求调用,显然所有秘密都在这个compose之中,但这个compose并不在koa库的源码中,它被独立成了一个名叫koa-compose的库。

koa-compose只有一个文件,返回的也只有一个函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 代码10
// https://github.com/koajs/compose/blob/master/index.js
// ...
function compose (middleware) {
if (!Array.isArray(middleware)) throw new TypeError('Middleware stack must be an array!')
for (const fn of middleware) {
if (typeof fn !== 'function') throw new TypeError('Middleware must be composed of functions!')
}

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)
}
}
}
}

以上就是koa-compose的全部代码。除去校验,我们同样可以看到类似地结构,其中的dispatch.bind(null, i + 1)就是中间件中的next函数,它返回的是一个Promise对象。只不过这里所有的中间件都通过Promise.resolve(注意中间件是异步函数)配合下标index的方式组合成了一个中间件调用链中一个一个的节点,而这个调用链中的节点也是依靠中间件的中的await next()来实现连接并依序调用的。

redux

看完了中间件模式在Node框架中的应用,我们再来看看在前端框架中有哪些地方也用到了它。

熟悉react/redux生态的同学,对redux的中间件肯定不陌生,当我们需要处理异步action的时候,我们有redux-thunk、redux-saga,还有其他各种各样的中间件帮助我们扩展redux的功能。

那redux中的中间件又是如何实现的呢?

其实在之前的文章中我们有提到过,但这里我们从中间件模式的角度再讲一遍,读者可以结合其他的几个例子,更进一步地理解这种设计。

先来回顾一下redux中中间件的使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 代码11
/**
* Logs all actions and states after they are dispatched.
*/
const logger = store => next => action => {
console.group(action.type)
console.info('dispatching', action)
let result = next(action)
console.log('next state', store.getState())
console.groupEnd(action.type)
return result
}

let store = createStore(todoApp, applyMiddleware(logger))

看上去redux中的中间件要比koa和express中的中间件复杂很多,实际上它就是一个高阶函数,虽然看上去很高大上,但实际上它也逃不脱中间件模式的基本设计。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 代码12
// https://github.com/reduxjs/redux/blob/master/src/applyMiddleware.js
// ...
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
}
}
}

直接看applyMiddleware源码吧,相信大家都已经知道这是redux中用于添加中间件的方法,类似于express和koa中的use。

以上就是applyMiddleware的全部代码,首先可以看到它返回的其实也是一个函数,它接收了一个createStore的参数,实际上这个createStore就是redux中暴露出来的APIcreateStore,这样做的目的是因为applyMiddleware返回过来的是一个扩展器(enhancer),它能够被原生的createStore所应用,并对自身进行扩展。

上面一段话有点绕,但实际上并不难,对于createStoreapplyMiddleware配合使用不太熟悉的同学,可以先去看一下redux这一部分的源码,或者看一下参考6,先了解一下这一部分的知识(没有多少代码)。

接下来来看applyMiddleware中返回的函数,其中首先创建了一个store,这和我们的常规用法一模一样,然后定义了一个会抛出错误的dispatch,这看上去似乎毫无意义,但是这是出于历史遗留原因才这样做的,以前的redux中间件中可以调用dispatch方法,新的版本不再支持这种做法,但为了更友好地兼容旧版本的中间件,这里Dan Abramov决定手动抛出一个错误出来。我们只需要知道在中间件中不能调用dispatch就好,所以后面的middlewareAPI实际上就只有getState能用。

接下来就是最关键的两行代码,middleware本身是一个数组,这里使用数组的map方法将middlewareAPI传入,对每个中间件调用了一遍,然后返回了一个数组chain

不难看出middleware中的原始数据是[store => next => action => {/*...*/}, store => next => action => {/*...*/}],现在变成了[next => action => {/*...*/}, next => action => {/*...*/}]

当然,现在chain中的每个元素都能访问到middlewareAPI(也就是被消去的store)中的getState了。

接下来,我们又看到了熟悉的compose。实际上这也是redux暴露出来的一个API。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 代码13
// https://github.com/reduxjs/redux/blob/master/src/compose.js
// ...
export default function compose(...funcs) {
if (funcs.length === 0) {
return arg => arg
}

if (funcs.length === 1) {
return funcs[0]
}

return funcs.reduce((a, b) => (...args) => a(b(...args)))
}

回忆一下compose的用法,它其实就是接收了一个数组,将其做了如下变换。

1
2
3
4
// 代码14
a(b(c(d(...yourArgs))));
// 等价于
compose(a, b, c, d)(...yourArgs);

因此,中间件数组也变成了chain[0](chain[1](store.dispatch)),注意这里的dispatch是从store中取的,是可用的dispatch,实际上也就是中间件调用链中最尾端的next

这样,我们的中间件就将每一次action都层层包裹了起来,我们也可以通过中间件劫持每一次dispatch出来的action。

总结

从中间件模式的设计原理和应用可以看出,当我们需要对某个(或者某个系列的)对象进行顺序处理,且支持自定义处理的逻辑修改时,我们就可以用到这种设计模式,它通过将所有中间件用合适的方式链接起来,然后通过next函数决定是否使用后继的中间件。

它在前端的世界中应用很广泛,但它的设计根据业务逻辑的不同而不同(比如koa中的中间件是异步的方法,redux中的中间件又是高阶函数),并且在组装的时候不同的中间件又有不同的组装方式。

参考

  1. Middleware design pattern in Node.js: Connect - Stack Overflow
  2. expressjs/express - GitHub
  3. koajs/koa - GitHub
  4. koajs/compose - GitHub
  5. reduxjs/redux - GitHub
  6. Redux源码解析 - 传不习乎