背景
最近在开发公司官网项目时,使用到了Eggjs
+React
的SSR方案,众所周知,官网等门户网站对于SEO和首屏优化非常重视,因此结合该场景考虑,最终决定使用SSR服务端渲染方案,也是在技术调研的时候发现了一个比较完善的基于eggjs
封装的一个SSR框架,在初步尝试和测试了一段时间后,最终决定了这个方案。
想必大家都知道eggjs
是基于koa
封装实现的,而对于koa
而言,最知名的莫过于他的洋葱模型中间件方案,这是一个很巧妙的设计,也是经常接触的一个知识点。因此对于其实现原理和运行逻辑也很值得我们进一步探索,做到知其然、知其所以然。
简单介绍
Koa 是一个由 Express 原班人马打造的新的 web 框架,Koa 本身并没有捆绑任何中间件,只提供了应用(Application
)、上下文(Context
)、请求(Request
)、响应(Response
)四个模块(源码中可以发现)。原本 Express 中的路由(Router
)模块已经被移除,改为通过中间件的方式实现。相比较 Express,Koa 能让使用者更大程度上构建个性化的应用。
Koa 是一个中间件框架,本身没有捆绑任何中间件。本身支持的功能并不多,功能都可以通过中间件拓展实现。通过添加不同的中间件,实现不同的需求,从而构建一个 Koa 应用。
中间件的基本使用
const Koa = require('Koa')
const app = new Koa()
// async 函数
app.use(async (ctx, next) => {
const start = Date.now()
await next()
const ms = Date.now() - start
console.log(`${ctx.method} ${ctx.url} - ${ms}ms`)
})
// 普通函数
app.use((ctx, next) => {
const start = Date.now()
return next().then(() => {
const ms = Date.now() - start
console.log(`${ctx.method} ${ctx.url} - ${ms}ms`)
})
})
app.listen(3001, () => {
console.log(`Server port is 3000.`)
})
通过官方示例,可以初步了解到,Koa 的中间件就是函数,可以是 async
函数,或是普通函数。而next()
函数则是一个异步promise函数。
中间件的执行顺序
// 最外层的中间件
app.use(async (ctx, next) => {
await console.log(`第 1 个执行`)
await next()
await console.log(`第 6 个执行`)
})
// 第二层中间件
app.use(async (ctx, next) => {
await console.log(`第 2 个执行`)
await next()
await console.log(`第 5 个执行`)
})
// 最里层的中间件
app.use(async (ctx, next) => {
await console.log(`第 3 个执行`)
ctx.body = 'Hello world.'
await console.log(`第 4 个执行`)
})
通过示例,可以了解到,中间件的执行顺序受 next()
函数影响,以 next()
为界分为上下两部分,next()
上面的部分为从上到下顺序执行,直到执行到最深处 ctx
上下文执行返回结果后(无next函数),再从下到上执行,直到执行到最外层。
这样看可能不太好理解,我们换种写法,把关注点集中在 next()
函数和ctx
上下文,再看一遍:
// 最外层的中间件
app.use(async (ctx, next) => {
// 这里是针对ctx.request做一些处理
ctx.request.query.name = ctx.request.query.name + '_query1'
await next()
// 这里是针对ctx.response做一些处理
ctx.response.body = ctx.response.body + '_query1'
ctx.res.end(ctx.response.body)
})
// 第二层中间件
app.use(async (ctx, next) => {
ctx.request.query.name = ctx.request.query.name + '_query2'
await next()
ctx.response.body = ctx.response.body + '_query2'
})
// 最里层的中间件
app.use(async (ctx, next) => {
const query = ctx.request.query
// console.log(query) => { name: 'zhangsan_query1_query2' }
ctx.response.body = 'hello world'
})
// 请求参数如下:
// http://localhost:3001?name=zhangsan
// 返回结果如下:
// hello world_query2_query1
简单分析可以发现,我们以next
函数为分界线,next
函数的上面部分可以理解为request
请求的流程(从外到内),next
函数下面的部分可以理解为response
响应的流程(从内到外)。
从表现上来看,我觉得这和递归的模式还挺相似的,开始都是先一层层往里调用,直到调用到最后一层,开始执行,得到结果,返回给上一层,然后再从最后一层往回执行,直到回到第一层,得到最终的结果。
话说和JS事件流的表现也挺像的:捕获、冒泡。
洋葱模型
Koa 中间件采用的是洋葱圈模型,每次执行下一个中间件传入两个参数 ctx
和 next
,参数 ctx
是由 koa 传入的,封装了 request
和 response
对象,可以通过它访问 request
和 response
,next
就是进入下一个要执行的中间件。
在洋葱模型中,每一层相当于一个中间件,用来处理特定的功能,比如错误处理、Session
处理等等。其处理顺序先是 next()
前请求(Request
,从外层到内层)然后执行 next()
函数,最后是 next()
后响应(Response
,从内层到外层),也就是说每一个中间件都有两次处理时机。
kao为什么使用洋葱模型
按照传统逻辑分析,一个中间件函数应该是自上而下的执行,执行结束后再执行下一个中间件,即从头到尾按顺序链式调用。
但是这样会产生一些问题,比如:
- 如果只链式执行一次,怎么能保证前面的中间件能使用之后的中间件所添加的东西呢?
- 如何正确划分请求前和请求后的关联逻辑?
简要说明:
问题一:如果不是next
分层这种执行方式,对于普通的链式调用,在执行下一个中间件并对数据做了一些特殊处理之后,怎么做到让上一个中间件获取到该特殊数据后并且再次执行呢,以及如何避免对其他中间件的影响和整个应用的执行呢?
问题二:以对一个数据库的查询时间做计算来说明,中间件以next
分层,上面为开始请求逻辑部分,标记开始的时间,然后执行next
函数进入下一个中间件,调用数据库查询相关的中间件功能函数,执行结束后,来到了next
函数的下面部分,这里为返回结果,标记结束请求的时间,两数相减即可,非常的简单,功能划分也是很清晰。对于中间件的各种添加、拓展等等,都可以很好集成进去,并做到功能的纯净。
可以发现使用洋葱模型可以很好(优雅)的解决这些问题。
中间件的使方式
中间件的使方式非常简单,只需要在 app.use(fn)
中添加中间件函数即可。
该函数接受两个参数:ctx
——上下文、next
——下一个中间件函数。
const Koa = require('Koa')
const app = new Koa()
const fn = async (ctx, next) => {
const start = Date.now()
await next()
const ms = Date.now() - start
console.log(`${ctx.method} ${ctx.url} - ${ms}ms`)
}
app.use(fn)
源码分析
首先,我们按照使用kao的过程来分析源码。
创建kao实例
const Koa = require('Koa')
const app = new Koa()
既然是通过 new
的方式,那肯定是一个构造函数。可以发现源码中是一个class
类。
class Application extends Emitter {
constructor (options) {
super()
this.middleware = []
// ...
}
// ...
}
koa内中间件的管理方式是通过维护一个数组队列来实现的。
添加中间件
app.use(fn)
use (fn) {
// if (typeof fn !== 'function') throw new TypeError('middleware must be a function!')
// debug('use %s', fn._name || fn.name || '-')
this.middleware.push(fn)
return this
}
核心代码,通过把fn
中间件函数按顺序push
进this.middleware
数组队列中。
再看listen
app.listen(3001, () => {
console.log(`Server port is 3000.`)
})
如下:
listen (...args) {
debug('listen')
// 使用node的http模块的createServer创建服务
const server = http.createServer(this.callback())
return server.listen(...args)
}
创建服务的时候,传入了callback
函数的返回值,看下callback
函数
// const compose = require('koa-compose')
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
}
重点为第一行,使用了compose
函数,处理中间件的核心代码,它返回的是一个promise
函数。
然后把执行上下文ctx
和compose
处理中间件函数返回的promise
函数fn
传入handleRequest
函数,并调用执行。
handleRequest (ctx, fnMiddleware) {
// const res = ctx.res
// res.statusCode = 404
// const onerror = err => ctx.onerror(err)
// const handleResponse = () => respond(ctx)
// onFinished(res, onerror)
return fnMiddleware(ctx).then(handleResponse).catch(onerror)
}
可以发现fnMiddleware
函数(即compose
处理中间件函数后返回的promise
函数)是接受了一个ctx
执行上下文作为参数并执行的。
OK,进入主题,compose
函数~
compose猜想
在此之前,我们先简单思考下compose
函数的作用,并尝试自己实现一个compose
函数。
- 要把上下文ctx对象和下一个中间件next传给当前的中间件
- 必须要等待下一个中间件执行完,再执行当前中间件的后续逻辑
const middleware = []
const fn1 = async (ctx, next) => {
console.log('fn1-next前')
await next()
console.log('fn1-next后')
}
const fn2 = async (ctx, next) => {
console.log('fn2-next前')
await next()
console.log('fn2-next后')
}
const fn3 = async (ctx, next) => {
console.log('fn3-next前')
await next()
console.log('fn3-next后')
}
const fn4 = async (ctx, next) => {
console.log('fn4')
}
function use(fn) {
middleware.push(fn)
}
use(fn1)
use(fn2)
use(fn3)
use(fn4)
let i = 0
function run(ctx) {
let current = middleware[i]
current(ctx, middleware[++i])
}
run({})
// 执行结果如下:
// fn1-next前
// fn2-next前
// TypeError: next is not a function
可以看到,代码执行报错了——TypeError: next is not a function
简要分析:
- 当
i=0
时,middleware[i]
是fn1
,即current
函数,执行current
函数相当于fn1(ctx, middleware[++i])
,遇到++i
自增1,middleware[i]
是fn2
,此时函数为fn1(ctx, fn2)
。 fn1
执行next
时,其实执行的是fn2
,这时可以发现fn2
是没有参数传入的,即ctx
和next
都为undefined
,所以next
函数报错。
const fn2 = async (ctx, next) => {
console.log('fn2-next前', ctx, next)
await next()
console.log('fn2-next后')
}
// fn2-next前 undefined undefined
因此,我们只要保证之后的中间件函数调用时,ctx
和next
都有值,就可以正常执行。
这里我们可以通过bind
绑定上下文,使用bind
绑定函数的上下文时,并不会立即调用执行该函数(call
、apply
是立即调用执行),其返回的是一个可执行函数,所以不影响函数的正常调用执行。
function run(ctx) {
let current = middleware[i]
current(ctx, middleware[++i].bind(null, ctx, middleware[i + 1]))
}
// 执行结果如下:
// fn1-next前
// fn2-next前
// fn3-next前
// TypeError: next is not a function
可以看到,代码执行依旧报错了——TypeError: next is not a function
,这是因为在fn3
中调用next
的时候,此时next
也是未传入的。
可以发现还存在的一个问题就是:该方法无法根据中间件的数量进行自动调用并传递参数。
当前我们是在外部手动触发一次调用执行的,能否考虑把执行逻辑交给中间件控制调用呢?并自动管理调用的顺序?
以此实现,中间件函数如果还存在就继续调用,不存在就结束返回。
再改造一下:
function run(ctx) {
// 通过包装一个dispatch函数,再结合bind
// 使得该函数可以被中间件自动调用并传递参数
function dispatch(i) {
let current = middleware[i]
if (!current) return
return current(ctx, dispatch.bind(null, i + 1))
}
// 默认从第一个中间件开始
return dispatch(0)
}
// 执行结果如下:
// fn1-next前
// fn2-next前
// fn3-next前
// fn4
// fn3-next后
// fn2-next后
// fn1-next后
这样就解决了next
作为第二个参数传入的问题,并同时做为调用下一个中间件的执行函数。
最后就是包装一个Promise
了,也比较简单,直接看看koa-compose
源码是怎么实现的。
koa-compose
compose
函数引用的是 koa-compose
这个库。
function compose (middleware) {
// ...
return function (context, next) {
// last called middleware #
let index = -1
// 一开始的时候传入为 0,后续会递增
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
// 当 fn 为空的时候,就会开始执行 next() 后面部分的代码
if (!fn) return Promise.resolve()
try {
// 执行中间件,留意这两个参数,都是中间件的传参,第一个是上下文,第二个是 next 函数
// 也就是说执行 next 的时候也就是调用 dispatch 函数的时候
return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
} catch (err) {
return Promise.reject(err)
}
}
}
}
代码不多,重在思想。(可以结合官方测试用例并debugger
理解)
其实看了koa
和koa-compose
源码后,你会发现其核心代码量确实不算多,很多代码也并不是很复杂的,但是其有些设计思想在某些地方是有点复杂的、很巧妙,需要仔细思考一番,如:洋葱模型的compose
函数这里。
这让我想到了redux-thunk,核心代码也很少,实现起来也很简单,但是功能却很强大。
主要在于编程思想,属于那种代码十几行,文档几百行的(“十行代码,百行思想”)。
总结
Koa 的洋葱模型指的是以 next()
函数为分割点,先由外到内执行 Request
的逻辑,再由内到外执行 Response
的逻辑。通过洋葱模型,将多个中间件之间通信等变得更加可行和简单。其实现的原理并不是很复杂,主要是 compose
方法。
kao的洋葱模型让我深深体会到什么叫“编程思想”,编程思想可以很复杂,但是实现可能并不复杂,但是却非常有用。