koa2中间件原理剖析

  koa 中间件是以级联代码(Cascading) 的方式来执行的,可参照下面这张图:


  在前面一文koa2、koa1、express比较讲到koa中间件可以简单的由递归操作实现,但递归操作是低效的、且存在栈溢出等问题,koa2采用的不是这种方式,而是用到了Promise、尾调用、闭包等技术,实现了洋葱模型的数据流入流出能力,是一种优化后的递归调用,接下来具体剖析一下,koa2是怎么做到的。
  在 koa中,要应用一个中间件,我们使用 app.use(),源码在node_modules/koa/lib/application.js,这个函数的作用将调用 use(fn) 方法中的参数(不管是普通的函数或者是中间件)都添加到this.middlware 这个数组中。在 koa2 中,还对 Generator 语法的中间件做了兼容,使用isGeneratorFunction(fn)这个方法来判断是否为 Generator语法,并通过 convert(fn)这个方法进行了转换,转换成async/await 语法,最后通过 callback() 这个方法执行。

 /**
   * Use the given middleware `fn`.
   *
   * Old-style middleware will be converted.
   *
   * @param {Function} fn
   * @return {Application} self
   * @api public
   */

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;
  }

callback的源码如下:

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

  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;
  }

  源码中,通过 compose() 这个方法,就能将我们传入的中间件数组转换并级联执行,最后 callback() 返回this.handleRequest()的执行结果。返回的是什么内容我们暂且不关心,我们先来看看 compose() 这个方法做了什么事情,能使得传入的中间件能够级联执行,并返回 Promise
  compose() 是 koa2 实现中间件级联调用的一个库,叫做 koa-compose在node_modules/koa/koa-compose/index.js,源码很简单,只有一个函数,如下:

/**
 * 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()先对中间件的每一项进行类型检查,middleware必须是数组,数组内的每一项必须是函数。
   compose()返回一个匿名函数的结果,该匿名函数自执行了 dispatch() 这个函数,并传入了0作为参数。

来看看 dispatch(i) 这个函数都做了什么事?
  i 作为该函数的参数,用于获取到当前下标的中间件,在上面的 dispatch(0) 传入了0,用于获取 middleware[0] 中间件。
  首先判断 i<==index,如果 true 的话,则说明 next() 方法调用多次。为什么可以这么判断呢?等我们解释了所有的逻辑后再来回答这个问题。
  接下来将当前的 i 赋值给 index,记录当前执行中间件的下标,并对 fn 进行赋值,获得中间件,然后是使用中间件。

try {
  return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
 } catch (err) {
  return Promise.reject(err)
 }

  上面的代码执行了中间件 fn(context, next),并传递了 context 和 next 函数两个参数。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 中间件机制

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,591评论 6 501
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,448评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,823评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,204评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,228评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,190评论 1 299
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,078评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,923评论 0 274
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,334评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,550评论 2 333
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,727评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,428评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,022评论 3 326
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,672评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,826评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,734评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,619评论 2 354

推荐阅读更多精彩内容

  • 我们知道,Koa 中间件是以级联代码(Cascading) 的方式来执行的。类似于回形针的方式,可参照下面这张图:...
    中间件阅读 1,555评论 0 4
  • 本节将结合例子和源码对koa2的中间件机制做一介绍。 什么是中间件? 中间件的本质就是一种在特定场景下使用的函数,...
    空无一码阅读 1,445评论 0 2
  • 官方描述: Contrasting Connect's implementation which simply p...
    编程go阅读 1,604评论 0 1
  • 源码阅读 上次一篇说到callback中使用了compose,现在看看里面都做了什么。 中间件机制(洋葱模型):它...
    ceido阅读 664评论 0 2
  • 一捧小爱随君旁, 洋意小情是大方。 山野大都总相宜, 阴霾狂暴尽灿烂。 虽无芳香也雅气, 简单绿意自坚强。
    丁开颜阅读 114评论 0 0