Koa 依赖的库 koa-compose

908 阅读5分钟

这是我参与11月更文挑战的第22天,活动详情查看:2021最后一次更文挑战

本文为 koa 依赖系列文章最后一篇,前面的文章也可以在站内查看:

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],

  1. diapatch(0),fn = a,返回 a(ctx, diapatch(1))
  2. dispatch(1),fn = b,返回 b(ctx, diapatch(2))
  3. diapatch(2),fn = c,返回 c(ctx, diapatch(3))
  4. 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 应该始终比当前的参数小,举个例子:

  1. index = -1、i = 0 调用 dispatch(0) 之后 index = 0
  2. index = 0,i = 1 调用 dispatch(1) 之后 index = 1
  3. 重复调用 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 常用的中间件源码。