Koa 洋葱模型的秘密 - koa-compose

1,099 阅读4分钟

我认识的 Koa

如果你接触过 node.js 的开发,肯定听说过 Express/Koa,其中 Koa 更是因为其轻量、优雅的中间件设计而成为经典的研发面试题目。

models-154a136a.png

相信上面这张经典的图例,很多人都见到过,那么 Koa 中间件的设计(也即洋葱模型)是怎么实现的呢?带着疑问,开启这次源码共读之旅。

思考一下,下面代码执行结果

const app = new Koa();

app.use(async (ctx, next) => {
	console.log(1);
	await next();
	console.log(6);
});

app.use(async (ctx, next) => {
	console.log(2);
	await next();
	console.log(5);
});

app.use(async (ctx, next) => {
	console.log(3);
	await next();
	console.log(4);
});

由上文洋葱模型的图例,我们可以得知,输出结果明显是:

1
2
3
4
5
6

这个是怎么实现的呢,能够让注册中间件从外到内执行,然后等待 next 执行后,能够逆序往外再执行剩下逻辑。

koa-compose

使以上洋葱模型生效的设计,就是 koa-compose 库;我们来看下它的源码实现

function compose (middleware) {
  if (!Array.isArray(middleware)) throw new TypeError('Middleware stack must be an array!')
  for (const fn of middleware) {
    if (typeof fn !== 'function') throw new TypeError('Middleware must be composed of functions!')
  }

  /**
   * @param {Object} context
   * @return {Promise}
   * @api public
   */
  return function (context, next) {
    // last called middleware #
    let index = -1
    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)
      }
    }
    return dispatch(0)
  }
}

🥲 啊这,全部源代码就这点?是的,除了导出那行代码以外,这就是全部源代码!!!

从以上代码,可以看到 compose 函数接收的一个 middleware 的数组(实际上就是上文使用 app.use 注册的那些函数),数据的每一项都需要是函数类型。

那么我们就可以得知,洋葱模型的中间件实现秘密就是后面那段 return function (context, next) {...} 里面了。

// 省略前面代码
return function (context, next) {
	// last called middleware #
	let index = -1
	function dispatch (i) {
	  if (i <= index) return Promise.reject(new Error('next() called multiple times'))
	  index = i
	  // 取出 middleware 数据里面的某一项
	  let fn = middleware[i]
	  // 最后到达末尾,next 值为 undefined
	  if (i === middleware.length) fn = next
	  if (!fn) return Promise.resolve()
	  try {
		// ??? 这段是怎么回事,递归 dispatch,bind ...
		return Promise.resolve(fn(context, dispatch.bind(null, i + 1)))
	  } catch (err) {
		return Promise.reject(err)
	  }
	}
	return dispatch(0)
}

dispatch 函数

上面那段难以理解的递归 dispatch.bind() 操作就是关键,口述比较难以解释,那么启用调试打发看看:

Pasted image 20230729235630.png

Pasted image 20230729235835.png

在上两图的代码处打上断点,然后访问,调试执行

Pasted image 20230730000236.png

我们选择 Step into,进入 fn 执行内部看看

Pasted image 20230730000532.png

没错,就是第一个中间件注册的地方,然后 await next() 执行后呢?

Pasted image 20230730000819.png

等等,执行回到原点?不过 i 变成了 1,断点到这里不得不感叹这短小精妙的代码竟是如此的优雅。这里的关键就是 dispatch.bind(null, i + 1) 这个高阶函数传参;bind 方法可以返回一个新的函数,且还可以把原函数接收的参数,提前传递到新返回的函数内!(这是 bind 方法后面参数在起作用);至此,可以解释得通了,取出 middleware 当前项的函数执行后,传递的 next 方法实际上是下一个 middlware 项的函数再包装(使用 dispatch.bind),并且 dispatch 方法总是返回一个 Promise,因此 next 方法执行可以被 await 等待进入异步任务队列,且这里是调用了下一个中间件的函数(栈结构)。栈结构的先进后出特性,实现从外到内执行,然后等待 next 执行后,能够逆序往外再执行剩下逻辑, 以上就是经典的“洋葱模型”个中细节和原理。

简化代码,加强理解

// 剔除其余代码,简化后,这样更好理解一些
const [fn1, fn2, fn3] = middleware;
const fnMiddleware = function(context){
    return Promise.resolve(
      fn1(context, function next(){
        return Promise.resolve(
          fn2(context, function next(){
              return Promise.resolve(
                  fn3(context, function next(){
                    return Promise.resolve();
                  })
              )
          })
        )
    })
  );
};

当 middleware 到达最后一项是,返回一个 Promise.resolve() 纯净的 Promise 来作为 next,执行完之后就会逆序执行中间件 await next() 之后的代码逻辑了。

总结

koa-compose 源代码真的很精妙,有种大巧不工,浑然天成的感觉,真的666,但第一次看还是容易迷惑,使用断点调试大法理解起来会更加清楚、深刻,里面高阶函数、闭包、Promisebind等知识要灵活组合运用到一起,大大增加理解难度,学到了 😎。