之前开发 web 端,使用 Egg 框架,期间实现的一些功能例如:权限检测、操作日志上报等都是基于框架的 middleware 机制件完成的。虽然最后完成了功能,但其实对中间件真正的实现机制、运行时序还不能做到完全的理解。
Egg 是基于 Koa 实现的,Koa 的代码量非常少,加起来也就 1000 多行,涉及到中间件核心的部分,也就不到 100 行,如果有耐心可以直接读 Koa 源码 学习。
我在读完 Koa 中间件代码的后,其实也就只能算是看懂,但是并不知道这个机制是怎么一点一点演变过来的,所以接下来我就试图通过我自己的理解,来讲述一下这个演变的过程。
一、最初阶段
一个最简单的 web 框架,只用实现 request response 之间的部分,即:
- 接收 request
- 处理 dispose (这一部分)
- 返回 response
例如这是一个简单的 dispose:
const dispose = () => {
console.log('处理请求,然后返回')
}
dispose()
直到渐渐有了一个需求,我们需要在 dispose 前后加一些日志,这时代码演变成了这样:
const dispose = () => {
console.log('日志 >>>')
console.log('处理请求,然后返回')
console.log('日志 <<<')
}
dispose()
这样代码有一个很大的问题,就是非 dispose 的逻辑侵入到了 dispose 的函数中,所以再改一下:
const dispose = () => {
console.log('处理请求,然后返回')
}
// 这里将 log 放在 dispose 外部,不侵入 dispose 的实现
console.log('日志 >>>')
dispose()
console.log('日志 <<<')
再将前后的 log 进行封装,最终代码演变成了这样:
const dispose = () => {
console.log('处理请求,然后返回')
}
const log = next => {
console.log('日志 >>>')
next()
console.log('日志 <<<')
}
const disposeWithLog = log(dispose)
disposeWithLog()
二、中级阶段
除了日志需求,又收到还需要加入打点需求,那么同理实现;
const track = next => {
console.log('打点 >>>')
next()
console.log('打点 <<<')
}
cosnt disposeWithTrackWithLog = track(disposeWithLog)
disposeWithTrackWithLog()
渐渐的,类似这种需求越来越多,必须要实现一套机制来支持这种需求,我们试图用一个函数来合并所有的类似 log、track 这样的子流程,此时我们管这些子流程叫做中间件。最终达到 合并之后按顺序执行,执行顺序为先进后出,也就是经典的多层嵌套洋葱圈模型:
const combine = (middleware1, middleware2) => next => {
return middleware1(() => {
middleware2() => {
next()
}
})
}
const disposeWithTrackWithLog = combine(log, track)(next)
对于 combine 实现,发现 middlewareX(() => {…}) 部分其实上是一样的,所以可以使用递归,来优化 combine 代码,做到对 middlewares 数组的支持,实现如下:
const combine = middlewares => next => {
const dispatch = mids => {
const [middleware, ...rest] = mids
middleware(() => {
if(rest.length === 0) {
next()
}
else {
dispatch(rest)
}
})
}
return () => dispatch(middlewares)
}
在 koa 中,combine 的过程叫做 compose,并且在此基础上,加入上下文 ctx 这个常用的变量,最终的实现如下:
// 中间件
const log = (next, ctx) => {
console.log('日志 >>>')
next()
console.log('日志 <<<')
}
// 中间件
const track = (next, ctx) => {
console.log('打点 >>>')
next()
console.log('打点 <<<')
}
// 中间件组合
const compose = middlewares => (next, ctx) => {
const dispatch = mids => {
const [middleware, ...rest] = mids
middleware(() => {
if(rest.length === 0) {
next(ctx)
}
else {
dispatch(rest, ctx)
}
})
}
return () => dispatch(middlewares)
}
const newDispose = compose([log, track])(dispose, 'ctx')
newDispose()
最终,中间件的机制基本就完成了,Koa 的实现大致就是这样,除此之外,Koa 还增加了 promise,但其实原理还是一样的;
三、终极版本
当我还在回味 koa 的实现的时候,又想到 Redux 也有类似的机制,在读了 Redux 的源码之后,感叹其实现的核心代码更加简洁;
仔细看上面的中级阶段代码的实现逻辑,其实就是每次从中间件数组中移出第一个元素用来执行,并且把剩下的中间件当做参数传入继续递归执行。这种剥壳式的调用方式,联想到一个函数,Array.reduce,Redux 就是使用 reduce 实现的终级优雅版本的 compose 函数:
const compose = middlewares => {
return middlewares.reduce(result, middlreware => (...args) => result(middleware(...args)))
}
这里 Redux 没有把 next 封装到 compose 中,可以借助这个高灵活性,通过小改动,可以轻松加入 ctx 做到和 Koa 一样支持上下文,并且还可以扩展支持 action 参数,Redux 这个中间件实现方式可以说是非常优雅了:
const compose = middlewares => {
// 这里我个人认为 next, ...rest 这种实现兼容性更强,而不是 Redux 中的 ...args 的实现,不知道作者怎么想的;
// 我把这个改动提了一个 pr 给 Redux,但是被拒了,个人感觉这个应该是 Redux 的一个 bug,因为 Redux 的实现并不能保证每个中间件接收到的参数都是相同的;
return middlewares.reduce(result, middlreware => (next, ...rest) => result(middleware(next, ...rest), ...rest))
}
// 中间件
const log = (next, ctx) => action => {
console.log('日志 >>>')
next(action)
console.log('日志 <<<')
}
// 中间件
const track = (next, ctx) => action => {
console.log('打点 >>>')
next(action)
console.log('打点 <<<')
}
const newDispose = compose([log, track])(dispose, 'ctx')
newDispose('action')
Redux 也在其官网中,也描述了 middleware 的 演变过程,写的非常流畅易懂,有兴趣可以读一下,了解事物的演进过程,有助于更好的理解事物的本质。
Redux 用起来相当繁琐。类型提示也不够友好。
思路没啥问题的,加油
洋葱模型