你知道 koa 中间件执行原理吗?

3,417 阅读9分钟

前言

原文地址

最近几天花了比较长的时间在koa(1)的源码分析上面,初次看的时候,被中间件执行那段整的晕乎乎的,完全不知道所以,再次看,好像明白了些什么,再反复看,我去,简直神了,简直泪流满面,简直丧心病狂啊!!!

koa

用在前面

下面的例子会在控制台中打印出一些信息(具体打印出什么?可以猜猜😀),然后返回hello world

let koa = require('koa')
let app = koa()

app.use(function * (next) {
  console.log('generate1----start')
  yield next
  console.log('generate1----end')
})

app.use(function * (next) {
  console.log('generate2----start')
  yield next
  console.log('generate2----end')
  this.body = 'hello world'
})

app.listen(3000)

用过koa的同学都知道添加中间件的方式是使用koa实例的use方法,并传入一个generator函数,这个generator函数可以接受一个next(这个next到底是啥?这里先不阐明,在后面会仔细说明)。

执行use干了嘛

这是koa的构造函数,为了没有其他信息的干扰,我去除了一些暂时用不到的代码,这里我们把目光聚焦在middleware这个数组即可。

function Application() {
  // xxx
  this.middleware = []; // 这个数组就是用来装一个个中间件的
  // xxx
}

接下来我们要看use方法了

同样去除了一些暂时不用的代码,可以看到每次执行use方法,就把外面传进来的generator函数push到middleware数组中


app.use = function(fn){
  // xxx
  this.middleware.push(fn);
  // xxx
};

好啦!你已经知道koa中是预先通过use方法,将请求可能会经过的中间件装在了一个数组中。

接下来我们要开始本文的重点了,当一个请求到来的时候,是怎样经过中间件,怎么跑起来的

首先我们只要知道下面这段callback函数就是请求到来的时候执行的回调即可(同样尽量去除了我们不用的代码)


app.callback = function(){
  // xxx

  var fn = this.experimental
    ? compose_es7(this.middleware)
    : co.wrap(compose(this.middleware));

  // xxx

  return function(req, res){
    // xxx

    fn.call(ctx).then(function () {
      respond.call(ctx);
    }).catch(ctx.onerror);

    // xxx
  }
};

这段代码可以分成两个部分

  1. 请求前的中间件初始化处理部分
  2. 请求到来时的中间件运行部分

我们分部分来说一下


var fn = this.experimental
    ? compose_es7(this.middleware)
    : co.wrap(compose(this.middleware));

这段代码对experimental做了下判断,如果设置为了true那么koa中将可以支持传入async函数,否则就执行co.wrap(compose(this.middleware))。

只有一行初始化中间件就做完啦?

我知道koa很屌,但也别这么屌好不好,所以说评价一个好的程序员不是由代码量决定的

我们来看下这段代码到底有什么神奇的地方

compose(this.middleware)

把装着中间件middleware的数组作为参数传进了compose这个方法,那么compose做了什么事呢?其实就是把原本毫无关系的一个个中间件给首尾串起来了,于是他们之间就有了千丝万缕的联系。

function compose(middleware){
  return function *(next){
    // 第一次得到next是由于*noop生成的generator对象
    if (!next) next = noop(); 

    var i = middleware.length;
    // 从后往前开始执行middleware中的generator函数
    while (i--) {
      // 把后一个中间件得到的generator对象传给前一个作为第一个参数存在
      next = middleware[i].call(this, next);
    }

    return yield *next;
  }
}

function *noop(){}

文字解释一下就是,compose将中间件从最后一个开始处理,并一直往前直到第一个中间件。其中非常关键的就是将后一个中间件得到generator对象作为参数(这个参数就是文章开头说到的next啦,也就是说next其实是一个generator对象)传给前一个中间件。当然最后一个中间件的参数next是一个空的generator函数生成的对象。

我们自己来写一个简单的例子说明compose是如何将多个generator函数串联起来的

function * gen1 (next) {
  yield 'gen1'
  yield * next // 开始执行下一个中间件
  yield 'gen1-end' // 下一个中间件执行完成再继续执行gen1中间件的逻辑
}

function * gen2 (next) {
  yield 'gen2'
  yield * next // 开始执行下一个中间件
  yield 'gen2-end' // 下一个中间件执行完成再继续执行gen2中间件的逻辑
}

function * gen3 (next) {
  yield 'gen3'
  yield * next // 开始执行下一个中间件
  yield 'gen3-end' // 下一个中间件执行完成再继续执行gen3中间件的逻辑
}

function * noop () {}

var middleware = [gen1, gen2, gen3]
var len = middleware.length
var next = noop() // 提供给最后一个中间件的参数

while(len--) {
  next = middleware[len].call(null, next)
}

function * letGo (next) {
  yield * next
}

var g = letGo(next)

g.next() // {value: "gen1", done: false}
g.next() // {value: "gen2", done: false}
g.next() // {value: "gen3", done: false}
g.next() // {value: "gen3-end", done: false}
g.next() // {value: "gen2-end", done: false}
g.next() // {value: "gen1-end", done: false}
g.next() // {value: undefined, done: true}

看到了吗?中间件被串起来之后执行的顺序是

gen1 -> gen2 -> gen3 -> noop -> gen3 -> gen2 -> gen1

从而首尾相连,进而发生了关系😈。

co.wrap

通过compose处理后返回了一个generator函数。

co.wrap(compose(this.middleware))

所有上述代码可以理解为

co.wrap(function * gen ())

好,我们再看看co.wrap做了什么,慢慢地一步步靠近了哦

co.wrap = function (fn) {
  createPromise.__generatorFunction__ = fn;
  return createPromise;
  function createPromise() {
    return co.call(this, fn.apply(this, arguments));
  }
}

可以看到co.wrap返回了一个普通函数createPromise,这个函数就是文章开头的fn啦。

var fn = this.experimental
    ? compose_es7(this.middleware)
    : co.wrap(compose(this.middleware));

中间件开始跑起来啦

前面已经说完了,中间件是如何初始化的,即如果由不相干到关系密切了,接下来开始说请求到来时,初始化好的中间件是怎么跑的。

fn.call(ctx).then(function () {
  respond.call(ctx);
}).catch(ctx.onerror);

这一段便是请求到来手即将要经过的中间件执行部分,fn执行之后返回的是一个Promise,koa通过注册成功和失败的回调函数来分别处理请求。

让我们回到

co.wrap = function (fn) {
  // xxx

  function createPromise() {
    return co.call(this, fn.apply(this, arguments));
  }
}

createPromise里面的fn就是经过compose处理中间件后返回的一个generator函数,那么执行之后拿到的就是一个generator对象了,并把这个对象传经经典的co里面啦。如果你需要对co的源码了解欢迎查看昨天写的走一步再走一步,揭开co的神秘面纱,好了,接下来就是看co里面如何处理这个被compose处理过的generator对象了

再回顾一下co


function co(gen) {
  var ctx = this;
  var args = slice.call(arguments, 1)

  // we wrap everything in a promise to avoid promise chaining,
  // which leads to memory leak errors.
  // see https://github.com/tj/co/issues/180
  return new Promise(function(resolve, reject) {
    if (typeof gen === 'function') gen = gen.apply(ctx, args);
    if (!gen || typeof gen.next !== 'function') return resolve(gen);

    onFulfilled();

    /**
     * @param {Mixed} res
     * @return {Promise}
     * @api private
     */

    function onFulfilled(res) {
      var ret;
      try {
        ret = gen.next(res);
      } catch (e) {
        return reject(e);
      }
      next(ret);
    }

    /**
     * @param {Error} err
     * @return {Promise}
     * @api private
     */

    function onRejected(err) {
      var ret;
      try {
        ret = gen.throw(err);
      } catch (e) {
        return reject(e);
      }
      next(ret);
    }

    /**
     * Get the next value in the generator,
     * return a promise.
     *
     * @param {Object} ret
     * @return {Promise}
     * @api private
     */

    function next(ret) {
      if (ret.done) return resolve(ret.value);
      var value = toPromise.call(ctx, ret.value);
      if (value && isPromise(value)) return value.then(onFulfilled, onRejected);
      return onRejected(new TypeError('You may only yield a function, promise, generator, array, or object, '
        + 'but the following object was passed: "' + String(ret.value) + '"'));
    }
  });
}

我们直接看一下onFulfilled,这个时候第一次进co的时候因为已经是generator对象所以会直接执行onFulfilled()

function onFulfilled(res) {
  var ret;
  try {
    ret = gen.next(res);
  } catch (e) {
    return reject(e);
  }
  next(ret);
}

gen.next正是用于去执行中间件的业务逻辑,当遇到yield语句的时候,将紧随其后的结果返回赋值给ret,通常这里的ret,就是我们文中说道的next,也就是当前中间件的下一个中间件。

拿到下一个中间件后把他交给next去处理

function next(ret) {
  if (ret.done) return resolve(ret.value);
  var value = toPromise.call(ctx, ret.value);
  if (value && isPromise(value)) return value.then(onFulfilled, onRejected);
  return onRejected(new TypeError('You may only yield a function, promise, generator, array, or object, '
    + 'but the following object was passed: "' + String(ret.value) + '"'));
}

当中间件执行结束了,就把Promise的状态设置为成功。否则就将ret(也就是下一个中间件)再用co包一次。主要看toPromise的这几行代码即可


function toPromise(obj) {
  // xxx
  if (isGeneratorFunction(obj) || isGenerator(obj)) return co.call(this, obj);
  // xxx
}

注意噢toPromise这个时候的返回值是一个Promise,这个非常关键,是下一个中间件执行完成之后回溯到上一个中间件中断执行处继续执行的关键

function next(ret) {
  // xxx
  var value = toPromise.call(ctx, ret.value);
  // 即通过前面toPromise返回的Promise实现,当后一个中间件执行结束,回退到上一个中间件中断处继续执行
  if (value && isPromise(value)) return value.then(onFulfilled, onRejected); 
  // xxx 
}

看到这里,我们可以总结出,几乎koa的中间件都会被co给包装一次,而每一个中间件又可以通过Promise的then去监测其后一个中间件是否结束,后一个中间件结束后会执行前一个中间件用then监听的操作,这个操作便是执行该中间件yield next后面的那些代码

打个比方:

当koa中接收到一个请求的时候,请求将经过两个中间件,分别是中间件1中间件2

中间件1

// 中间件1在yield 中间件2之前的代码

yield 中间件2

// 中间件2执行完成之后继续执行中间件1的代码

中间件2

// 中间件2在yield noop中间件之前的代码

yield noop中间件

// noop中间件执行完成之后继续执行中间件2的代码

那么处理的过程就是co会立即调用onFulfilled来执行中间件1前半部分代码,遇到yield 中间件2的时候得到中间件2generator对象,紧接着,又把这个对象放到co里面继续执行一遍,以此类推下去知道最后一个中间件(我们这里的指的是那个空的noop中间件)执行结束,继而马上调用promise的resolve方法表示结束,ok,这个时候中间件2监听到noop执行结束了,马上又去执行了onFulfilled来执行yield noop中间件后半部分代码,好啦这个时候中间件2也执行结束了,也会马上调用promise的resolve方法表示结束,ok,这个时候中间件1监听到中间件2执行结束了,马上又去执行了onFulfilled来执行yield 中间件2后半部分代码,最后中间件全部执行完了,就执行respond.call(ctx);

啊 啊 啊好绕,不过慢慢看,仔细想,还是可以想明白的。用代码表示这个过程有点类似

new Promise((resolve, reject) => {
  // 我是中间件1
  yield new Promise((resolve, reject) => {
    // 我是中间件2
    yield new Promise((resolve, reject) => {
      // 我是body
    })
    // 我是中间件2
  })
  // 我是中间件1
});

中间件执行顺序

结尾

罗里吧嗦说了一大堆,也不知道有没有把执行原理说明白。

如果对你理解koa有些许帮助,不介意的话,点击源码地址点颗小星星吧

如果对你理解koa有些许帮助,不介意的话,点击源码地址点颗小星星吧

如果对你理解koa有些许帮助,不介意的话,点击源码地址点颗小星星吧

源码地址