详解 koa 的洋葱模型

236 阅读7分钟
koa

我正在参与掘金会员专属活动-源码共读第一期,点击参与

前言

在日常的 Web 开发中,无论是使用 Node.js 进行后端开发, 还是实现 BFF 中间层,或是进行端到端的测试,都可能会出现 koa 的身影。koa 以中间件函数的使用方式而著名,又称洋葱模型,本文就是要将这个洋葱一层层地拨开,探究一下这里面的奥妙之处。

中间件函数的源码地址 — koa-compose

回顾中间件的使用方式

在分析源码之前,我们先来回顾一些中间件的使用方式。

在这里,我引用了 koa 官方文档上的例子

koa

在这个用例中,我们可以清楚地看到 await next() 的作用是调用下一个中间件函数。

其实上述的执行过程,可以类比于算法中的递归回溯思想。

源码解析

如果你对上述的用例感到陌生或者不理解,没关系,我们再来看看 koa-compose 的用例:

const compose = require('koa-compose')

const arr = []
const stack = []

stack.push(async (context, next) => {
  arr.push(1)
  await next()
  arr.push(6)
})

stack.push(async (context, next) => {
  arr.push(2)
  await next()
  arr.push(5)
})

stack.push(async (context, next) => {
  arr.push(3)
  await next()
  arr.push(4)
})

await compose(stack)({})
console.log(arr) // [1, 2, 3, 4, 5, 6]

该用例参考了 koa-compose 中测试文件的第一个测试用例,你可以在任意支持 CommmonJS 模块化的文件中运行上述的代码,整个代码的执行顺序就和 arr.push() 中传入的数字一样。

通过 await compose(stack)({}) 这行代码,我们可以了解到源码中的两个信息:

  1. koa-compose 导出的是一个函数 — compose,向该函数传入一个数组,数组中的每一个元素都是函数。

  2. compose 函数返回的也是一个函数,该函数传入的是一个对象,返回值是 Promise 对象。

基于以上的信息,我们已经可以看懂以下源码的逻辑:

function compose (middleware) {
  // 判断 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!')
  }

  return function (context, next) {
    // ... 省略部分代码
    return dispatch(0)
    function dispatch (i) {
      // ... 省略部分代码
      try {
        return Promise.resolve(/*...省略部分代码*/)
      } catch (err) {
        return Promise.reject(err)
      }
    }
  }
}

middleware 就是 stackcontext 参数的值为 {}next 参数的值为 undefined,因为 await compose(stack)({}),没有传入第二个参数值。

接下来,我们就需要思考一个问题:怎么执行所有的中间件函数呢?也就是如何执行参数 middleware 数组中的所有元素?

源码里使用了函数的 bind 方法的返回值,向中间件函数传递 next 参数值:

function compose (middleware) {
  // 判断 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!')
  }

  return function (context, next) {
    // ... 省略部分代码
    return dispatch(0)
    function dispatch (i) {
      // ... 省略部分代码

      let fn = middleware[i]
      // 执行了最后一个函数后,i 的下一次值为 middleware.length
      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)
      }
    }
  }
}

我们一步步来分析一下这段代码的执行流程,是如何做到执行所有中间件函数的:

  1. return dispatch(0) 表示开始执行第一个中间件函数,也意味着 await compose(stack)({}) 返回的是第一个中间件函数的返回值,而不是最后一个中间件函数的返回值。

  2. 此时 let fn = middleware[i] 取到的就是第一个中间件函数。

  3. 代码执行到 return Promise.resolve(fn(context, dispatch.bind(null, i + 1))),执行第一个中间件函数,还记得每个中间件函数中都有 contextnext 参数吧?next 参数值就是 dispatch.bind(null, i + 1),表示将下一个(第二个)中间件函数的执行传递给 next 参数,最后将第一个中间件函数的返回值传入给 Promise.resolve() 方法。所以说对于第一个中间件函数而言,内部的 await next() 语句就等于 await dispatch(1)

stack.push(async (context, next) => {
  arr.push(1)
  await next()  // await dispatch(1)
  arr.push(6)
})
  1. 代码执行到 await next() 后,会重复执行第二个和第三个步骤,let fn = middleware[i] 取到的就是第二个中间件函数,return Promise.resolve(fn(context, dispatch.bind(null, i + 1))) 执行第二个中间件函数,其中 dispatch.bind(null, i + 1) 表示将第三个中间函数的执行传递给 next 参数,所以在第二个中间函数中的 await next() 等于 await dispatch(2)

  2. 如此重复执行第二个和第三个步骤,直到执行最后一个(第三个)中间件函数,它的内部仍然有 await next()await dispatch(3))语句,这是为什么呢?按照常规流程来说,这里执行到最后一个函数,应该不需要再往下执行下一个函数了。其实,最后的 await next() 语句是用来执行 compose 函数内部的 next 函数,上文有一处也提到过,它是可以通过 await compose(stack)({}) 来传递的,即:

await compose(stack)({}, async (context, next) => {
  // ...
})
  1. 那怎么去同时兼容 await compose(stack)({}) 没传递 next 函数的情况呢?下面这两行代码就解决了这个问题:
// ... 省略部分代码
if (i === middleware.length) fn = next
if (!fn) return Promise.resolve()
// ... 省略部分代码

代码执行到了 await dispatch(3),那么 let fn = middleware[i] 取到的值肯定是 undefined,同时,i 的值肯定也等于 middleware.length,这时就需要将 next 函数赋值给 fn,如果最后还是为 undefined,那就停止继续执行,返回状态为 fulfilledPromise 对象;否则继续执行,重复第二个和第三个步骤,最后会再次执行到 if (!fn) return Promise.resolve() 语句,停止继续执行。

到此为止,我们已经掌握了 90% 的中间件源码了。剩下 10% 的源码是用来解决在一个中间件函数内多次执行 await next() 语句的问题。比如,在第一个中间件函数内执行两次 await next 语句:

stack.push(async (context, next) => {
  arr.push(1)
  await next()
  await next()
  arr.push(6)
})

这时候,就需要在第二次执行 await next() 语句的时候抛出错误。

源码中的实现方式是:通过一个 index 变量,来记录上一次执行中间件函数的索引,如果 index 变量值大于或等于当前将要执行中间件函数的索引,就说明是多次执行了 await next(),需要抛出错误。

function compose (middleware) {
  // 判断 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!')
  }

  return function (context, next) {
    // 记录上一次执行中间件函数的索引,初始值为 -1。
    let index = -1
   
    return dispatch(0)
    function dispatch (i) {
      // 说明在同一个中间件函数多次执行了 await next()
      if (i <= index) return Promise.reject(new Error('next() called multiple times'))
      index = i

      let fn = middleware[i]
      // 执行了最后一个函数后,i 的下一次值为 middleware.length
      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)
      }
    }
  }
}

为什么可以这么判断呢?我们依然是拿上面三个中间件函数作为例子:

stack.push(async (context, next) => {
  arr.push(1)
  await next()  // await dispatch(1)
  await next()  // await dispatch(1)
  arr.push(6)
})

stack.push(async (context, next) => {
  arr.push(2)
  await next()  // await dispatch(2)
  arr.push(5)
})

stack.push(async (context, next) => {
  arr.push(3)
  await next()  // await dispatch(3)
  arr.push(4)
})

根据前面已经解析过的执行流程,我们可以得出,在执行第一个中间件函数中的第二次 await next() 语句中,dispatch(1) 函数的 index 值为 3,而此时的 i 值为 1,所以符合错误条件,抛出错误。

其实,我们再细想一下,无论第二次执行的 await next() 语句是在哪个中间件函数,都会先执行完每个中间件函数的第一次 awai next() 语句,在这个例子的执行流程就是:

  1. 第三行的 await next()
  2. 第十行的 await next()
  3. 第十六行的 await next()
  4. 第四行的 await next()

也就是说,在执行第二次 await next() 语句的时候,index 值一定是 middleware.length,而每个 await next() 语句中的 i 值都是小于 middleware.length,所以可以通过 index 值大于或等于 i 值来判断是多次执行了 await next()index 等于 i 的情况是在最后一个中间件函数多次执行 await next()

好了,以上就是中间件源码的全部,虽然整个源码不到 30 行,但它的逻辑性比较强,思维缜密,还是值得我们反复斟酌学习的。

总结

  1. 中间件函数中的 await next() 的作用是调用下一个中间件函数。
  2. 每个中间件函数都需要执行 await next() 语句,最后一个中间件函数的 await next() 语句是用来执行,外部传递给 compose 函数的中间件函数。
  3. 通过记录上一次执行中间件函数的索引,来与当前执行中间件函数的索引做比较,如果是大于或等于,那就说明在某个中间件函数内多次执行了 await next() 语句,需要抛出错误。