深入理解 Koa2 中间件机制

7,151 阅读6分钟

我们知道,Koa 中间件是以级联代码(Cascading) 的方式来执行的。类似于回形针的方式,可参照下面这张图:

今天这篇文章就来分析 Koa 的中间件是如何实现级联执行的。 在 koa 中,要应用一个中间件,我们使用 app.use():

app
  .use(logger())
  .use(bodyParser())
  .use(helmet())

先来看看use() 是什么,它的源码如下:

  use(fn) {
    if (typeof fn !== 'function') throw new TypeError('middleware must be a function!');
    if (isGeneratorFunction(fn)) {
      deprecate('Support for generators will be removed in v3. ' +
                'See the documentation for examples of how to convert old middleware ' +
                'https://github.com/koajs/koa/blob/master/docs/migration.md');
      fn = convert(fn);
    }
    debug('use %s', fn._name || fn.name || '-');
    this.middleware.push(fn);
    return this;
  }

这个函数的作用在于将调用 use(fn) 方法中的参数(不管是普通的函数或者是中间件)都添加到 this.middlware 这个数组中。

Koa2 中,还对 Generator 语法的中间件做了兼容,使用 isGeneratorFunction(fn) 这个方法来判断是否为 Generator 语法,并通过 convert(fn) 这个方法进行了转换,转换成 async/await 语法。然后把所有的中间件都添加到了 this.middleware ,最后通过 callback() 这个方法执行。callback() 源码如下:

  /**
   * Return a request handler callback
   * for node's native http server.
   *
   * @return {Function}
   * @api public
   */

  callback() {
    const fn = compose(this.middleware);

    if (!this.listeners('error').length) this.on('error', this.onerror);

    const handleRequest = (req, res) => {
      const ctx = this.createContext(req, res);
      return this.handleRequest(ctx, fn);
    };

    return handleRequest;
  }

源码中,通过 compose() 这个方法,就能将我们传入的中间件数组转换并级联执行,最后 callback() 返回this.handleRequest()的执行结果。返回的是什么内容我们暂且不关心,我们先来看看 compose() 这个方法做了什么事情,能使得传入的中间件能够级联执行,并返回 Promise

compose() 是 koa2 实现中间件级联调用的一个库,叫做 koa-compose。源码很简单,只有一个函数,如下:

/**
 * Compose `middleware` returning
 * a fully valid middleware comprised
 * of all those which are passed.
 *
 * @param {Array} middleware
 * @return {Function}
 * @api public
 */

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) {
    // 记录上一次执行中间件的位置 #
    let index = -1
    return dispatch(0)
    function dispatch (i) {
      // 理论上 i 会大于 index,因为每次执行一次都会把 i递增,
      // 如果相等或者小于,则说明next()执行了多次
      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, function next () {
          return dispatch(i + 1)
        }))
      } catch (err) {
        return Promise.reject(err)
      }
    }
  }
}

可以看到 compose() 返回一个匿名函数的结果,该匿名函数自执行了 dispatch() 这个函数,并传入了0作为参数。

来看看 dispatch(i) 这个函数都做了什么事? i 作为该函数的参数,用于获取到当前下标的中间件。在上面的 dispatch(0) 传入了0,用于获取 middleware[0] 中间件。

首先显示判断 i<==index,如果 true 的话,则说明 next() 方法调用多次。为什么可以这么判断呢?等我们解释了所有的逻辑后再来回答这个问题。

接下来将当前的 i 赋值给 index,记录当前执行中间件的下标,并对 fn 进行赋值,获得中间件。

index = i;
let fn = middleware[i]

获得中间件后,怎么使用?

    try {
        return Promise.resolve(fn(context, function next () {
          return dispatch(i + 1)
        }))
      } catch (err) {
        return Promise.reject(err)
      }

上面的代码执行了中间件 fn(context, next),并传递了 contextnext 函数两个参数。context 就是 koa 中的上下文对象 context。至于 next 函数则是返回一个 dispatch(i+1) 的执行结果。值得一提的是 i+1 这个参数,传递这个参数就相当于执行了下一个中间件,从而形成递归调用。 这也就是为什么我们在自己写中间件的时候,需要手动执行

await next()

只有执行了 next 函数,才能正确得执行下一个中间件。

因此每个中间件只能执行一次 next,如果在一个中间件内多次执行 next,就会出现问题。回到前面说的那个问题,为什么说通过 i<=index 就可以判断 next 执行多次?

因为正常情况下 index 必定会小于等于 i。如果在一个中间件中调用多次 next,会导致多次执行 dispatch(i+1)。从代码上来看,每个中间件都有属于自己的一个闭包作用域,同一个中间件的 i 是不变的,而 index 是在闭包作用域外面的。

当第一个中间件即 dispatch(0)next() 调用时,此时应该是执行 dispatch(1),在执行到下面这个判断的时候,

if (i <= index) return Promise.reject(new Error('next() called multiple times'))

此时的 index的值是0,而 i 的值是1,不满足 i<=index 这个条件,继续执行下面的 index=i 的赋值,此时 index 的值为1。但是如果第一个中间件内部又多执行了一次 next()的话,此时又会执行 dispatch(2)。上面说到,同一个中间件内的 i 的值是不变的,所以此时 i 的值依然是1,所以导致了 i <= index 的情况。

可能会有人有疑问?既然 async 本身返回的就是 Promise,为什么还要在使用 Promise.resolve() 包一层呢。这是为了兼容普通函数,使得普通函数也能正常使用。

再回到中间件的执行机制,来看看具体是怎么回事。 我们知道 async 的执行机制是:只有当所有的 await 异步都执行完之后才能返回一个 Promise。所以当我们用 async 的语法写中间件的时候,执行流程大致如下:

  1. 先执行第一个中间件(因为compose 会默认执行 dispatch(0)),该中间件返回 Promise,然后被 Koa 监听,执行对应的逻辑(成功或失败)
  2. 在执行第一个中间件的逻辑时,遇到 await next()时,会继续执行 dispatch(i+1),也就是执行 dispatch(1),会手动触发执行第二个中间件。这时候,第一个中间件 await next() 后面的代码就会被 pending,等待 await next() 返回 Promise,才会继续执行第一个中间件 await next() 后面的代码。
  3. 同样的在执行第二个中间件的时候,遇到 await next() 的时候,会手动执行第三个中间件,await next() 后面的代码依然被 pending,等待 await 下一个中间件的 Promise.resolve。只有在接收到第三个中间件的 resolve 后才会执行后面的代码,然后第二个中间会返回 Promise,被第一个中间件的 await 捕获,这时候才会执行第一个中间件的后续代码,然后再返回 Promise
  4. 以此类推,如果有多个中间件的时候,会依照上面的逻辑不断执行,先执行第一个中间件,在 await next() 出 pending,继续执行第二个中间件,继续在 await next() 出 pending,继续执行第三个中间,直到最后一个中间件执行完,然后返回 Promise,然后倒数第二个中间件才执行后续的代码并返回Promise,然后是倒数第三个中间件,接着一直以这种方式执行直到第一个中间件执行完,并返回 Promise,从而实现文章开头那张图的执行顺序。

通过上面的分析之后,如果你要写一个 koa2 的中间件,那么基本格式应该就长下面这样:

async function koaMiddleware(ctx, next){
    try{
        // do something
        await next()
        // do something
    }
    .catch(err){
        // handle err
    }    
}

最近正在使用 koa2 + React 写一个博客,有兴趣的同学可以前往 GitHub 地址查看:koa-blog-api