这是我参与11月更文挑战的第22天,活动详情查看:2021最后一次更文挑战」
本文为 koa 依赖系列文章最后一篇,前面的文章也可以在站内查看:
- koa 中依赖的库 parseurl
- Koa 依赖的库 type-is 和 content-disposition
- Koa 依赖的库 accepts、content-type 和 cache-content-type
- Koa 依赖的库 encodeurl 和 escape-html
- Koa 中依赖的库 statuses
- Koa 依赖的库 cookies
- Koa 依赖的库 on-finished 和 destroy
- Koa 依赖的库 http-errors 和 http-assert
- Koa 依赖的库 fresh 和 vary
- Koa 依赖的库 only 和 delegates
koa 最核心的依赖就是 koa-compose,它构建了 koa 基于中间件的模型架构,因此 koa-compose 源码可以结合 koa 本身一起阅读,我很久之前写过一篇 koa 源码的阅读文章,因此本篇重点在 compose 的内部流程。
node.js 实现 http server 本质是调用 http 模块的 createServer 方法,在 createServer 中回调请求和响应两个参数,这里请求和响应对象是基于 http 协议实现的,因此我们可以使用这两个对象进行应用层开发。
koa 内部创建 http server 的代码位于 listen 中,这里传入的回调是 this.callback():
listen (...args) {
debug('listen')
const server = http.createServer(this.callback())
return server.listen(...args)
}
callback 方法返回的是内部定义的 handleRequest 函数,这个函数调用的是 handleRequest 方法,方法的两个参数一个是 createServer 回调的请求和响应对象封装的 context(封装成 context 可以把传两个参数变成只传递一个,在上面还可以添加一系列辅助方法,本文重点在 compose 流程,因此这里 createContext 不做展开),另一个就是 compose (koa-compose)返回的 fn:
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
}
查看 handleRequest 方法,可以看到 fn 是一个函数,接收一个 context 作为参数,返回一个 promise:
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)
}
而 this.middleware 是一个数组,数组内容是使用 use 方法添加的,而且都是函数:
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
}
至此我们知道 koa-compose 导出的是一个函数,它接收一个函数数组作为参数,返回一个新函数 fn,这个新函数接收 context 作为参数,返回一个 promise。
现在来看 koa-compose 的源码,移除参数校验逻辑剩下的内容如下:
function compose (middleware) {
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)
}
}
}
}
可以看到 fn 返回的 promise 是调用 dispatch(0) 得到的,之后看 dispatch 内部逻辑,可以看到实际上这是一个递归函数:
- fn 不存在时结束
- fn 存在时,调用 fn,在 fn 中传入 dispatch(i + 1):fn(context, dispatch.bind(null, i + 1))
举个例子,现有中间件 [a, b, c],
- diapatch(0),fn = a,返回 a(ctx, diapatch(1))
- dispatch(1),fn = b,返回 b(ctx, diapatch(2))
- diapatch(2),fn = c,返回 c(ctx, diapatch(3))
- diapatch(3),此时 i === middleware.length,fn = next,由于 koa 这里 fnMiddleware(ctx) 没有传递 next,因此 fn = undefined,直接返回
因此中间件 a,b,c 的签名都是 fn(ctx, next),next 为下一个中间件,何时调用下一个中间件的控制权在当前中间件,因此 a 大概是这样的:
async function a(ctx, next) {
// before ...
await next();
// after ...
}
因为 a 可以控制 b 的执行时机,所以 a 可以把 b 放在自己逻辑的任意位置,同样 b、c 也可以,这样整个调用过程就是 a → b → c → b → a,只有拿到 next 的结果,调用才会完成,这样就形成了洋葱圈模型。
因为实际执行交给了上一层控制,如果重复调用 next 就会产生非预期的执行,因此这里的 index 就是用来控制执行次数的。index 初始为 -1,每次记录前一次的 index,由于 dispatch 每次传递的参数都为前一次参数加一,因此 index 应该始终比当前的参数小,举个例子:
- index = -1、i = 0 调用 dispatch(0) 之后 index = 0
- index = 0,i = 1 调用 dispatch(1) 之后 index = 1
- 重复调用 dispatch(1) ,index = 1,i = 1,i <= index,此时会抛出重复调用错误
这样就确保了程序中 next 一定不会被调用多次。
compose 中返回的是 promise,因此中间件可以直接使用异步函数,大部分时候我们都会使用 async 函数作为中间件,因为异步函数具有很强的传染性,如果把上例改为同步函数就会变成这样:
function a(ctx, next) {
// before ...
next();
// after ...
}
此时如果 next 是异步函数,实际执行顺序应该是 before、after、next,因此只有所有的中间件都是同步时它才可以按照预期执行,但是在 http 中这种使用场景几乎不存在。
最后说回 compose,这个概念在函数式编程中很常见,compose 即函数组合,函数调用 a(b(c(x))) 使用 compose 组合后变为新函数 d =compose(a, b, c),d(x)。koa 的 compose 也是这种思想,不过它执行的是异步函数,而且增加了更强大的控制能力。koa 内部甚至没有内置路由系统,它的一切都可以基于洋葱圈中间件模型来构建。
至此 koa 依赖源码阅读系列文章全部结束了,后面感兴趣可以继续阅读 koa 常用的中间件源码。