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, middleware => (...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, middleware => (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 的 演变过程,写的非常流畅易懂,有兴趣可以读一下,了解事物的演进过程,有助于更好的理解事物的本质。