blog icon indicating copy to clipboard operation
blog copied to clipboard

品味Koa v1.x & Co

Open SamHwang1990 opened this issue 9 years ago • 0 comments

最近Node.js 正式发布V7.0.0,在harmony 模式下支持async、wait 特性,异步操作真正实现同步写法,Koa v2.x 已预先支持async、wait 特性。

在跟上步伐之前,认真阅读了一下Koa v1.x 和Co 的源码,看完虽不能说获益匪浅,但真的够回味好久,个中思路叹为观止。所以,很有必要把精彩的地方纪录下来。

概述

Koa 是node.js 平台上的一个服务端框架,与express.js 类似,但是实现思路不一样,个人感觉,区别在于中间件的运行逻辑、异步逻辑写法、错误处理上。

官网:http://koajs.com/

Github: https://github.com/koajs/koa

Koa 中间件

中间件,意思是每次访问都可能运行的逻辑,整个访问过程的处理由一个个中间件组成。Koa 中间件是一个Generator 函数,并通过app.use() 注册到应用的中间件队列中。

写法如下:

app.use(function* (next) {		// A: Generator 接受一个参数,通常命名为next
  var start = new Date();
  yield next;					// B: 暂停处理当前中间件,先处理next 的逻辑,完成后再继续下面的
  var end = new Data();			// C: 已经成功从next 的执行中返回来了
  console.log(`time spend: ${end.getTimes() - start.getTimes()}`);
});

上面简单的完成了一个纪录请求处理时常的中间件,类似于X-Response-Time。

参数next

参数next 其实是下一个中间件Generator 函数调用生成的generator 对象,也就是,其实我可以手动启动下一个中间件的,比如:next.next()

当然,实际使用中是不会这样做的,而是在合适的时机将next 参数yield 出去,交由koa 来完成下一个及往后的中间件调用与返回,例如上面的B 行代码。

熟悉Generator API 的朋友会反映过来,既然next 已经是一个generator 对象,那我也是否可以用这种方式来启动下一个中间件呢:yield* next;?答案是,能,只是在现有Koa 的逻辑下,行为可能会与预期的不同:

如果以yield*方式输出next,会立即启动下一个以及再下一个中间件的逻辑,同时,第三个启动的中间件的返回结果和错误信息本应先经由第二个中间件做处理,但实际上,却是跳过了第二个中间件,直接抛给第一个启动的中间件了。

当然了,不能武断的说yield*是错误的使用方法,而是要看情景,比如,如果你真的想跳过下一个中间件的错误处理,直接接收更后的中间件的结果,就完全可以使用yield* next;,不过,较常使用的还是yield next;

两方面的原因导致yield* nextyield next会出现这样细微的差别,yield*的机制以及co的机制,这个下文解读部分会解释。

洋葱式的中间件调用顺序

引用一张经典的图来说下Koa 中间件的运行逻辑:

洋葱中,每个环(层)都表示一个中间件,从Request 到Response 的方向看,可以看到,最外层的中间件最先启动,然后里层的中间件逐级调用,直到最里层,也就是说,此时已经没有中间件了,于是开始走回头路,逐级将外层中间件中未走完的逻辑都执行完毕,伪代码如下:

var koa = require('koa');
var app = koa();

app.use(function* (next) {
  console.log('1');
  yield next;
  console.log('1 continue');
});

app.use(function* (next) {
  console.log('2');
  yield next;
  console.log('2 continue');
});

app.use(function* (next) {
  console.log('3');
  yield next;
  console.log('3 continue');
});

app.use(function* (next) {
  console.log('4');
  yield next;
  console.log('4 continue');
});

app.listen(3000);

访问上面代码会得到这样的控制台输出:

1
2
3
4
4 continue
3 continue
2 continue
1 continue

Koa & Co

Koa 运行时,先使用koa-compose会将所有中间件封装到一个Generator 函数,交由co.wrap将Generator 函数封装成一个Promise 工厂函数,运行该工厂函数,会返回由co返回的基于中间件封装的Generator 函数的Promise 实例,而Koa 实例对该Promise 实例注册了回调,以便在每个请求的结尾做些处理,无论是成功还是出错。下面先简单展示下代码:

// koa-compose.js
function compose(middleware){	// koa 实例注册的中间件列表
  return function *(next){		// 返回一个Generator 函数
    if (!next) next = noop();

    var i = middleware.length;

    // 中间件列表从后往前执行,保证Koa 先启动第一个中间件
    // 中间件Generator 函数的执行会生成一个Generator 对象,
    // 并作为参数next 传入前一个中间件的Generator 函数
    // 达到的效果是,中间件里面调用yield* next,实际上是交由Koa 去启动下一个中间件
    while (i--) {
      next = middleware[i].call(this, next);
    }

    return yield *next;
  }
}
// co.js

// fn 为上面koa-compose.js 中compose 方法调用的结果,是一个Generator 函数
// 类似于:co.wrap(compose([middles]));
co.wrap = function (fn) {
  createPromise.__generatorFunction__ = fn;
  
  // 返回一个Promise 工厂函数
  return createPromise;		
  function createPromise() {
    // 调用该工程函数,会先调用fn 声称Generator 对象,传给co 去生成一个Promise 实例
    return co.call(this, fn.apply(this, arguments));
  }
};

经过上面两个方法,Koa 已经将所有中间件都处理好了,此时只要交给Co 去完成中间件队列洋葱式的执行逻辑即可。

下面写一段伪代码来解释上面koa-compose 的运行结果:

// app.js
app.use(function* (next) {
  console.log('1');
  yield next;
  console.log('1 continue');
});

app.use(function* (next) {
  console.log('2');
  yield next;
  console.log('2 continue');
});

app.use(function* (next) {
  console.log('3');
  yield next;
  console.log('3 continue');
});
// 运行 koa-compose 后,中间件的处理结果
return function*() {
  // 中间件的处理结果
  return yield* (function* (next) {
  	console.log('1');
    yield next;
    console.log('1 continue');
  }(function* (next) {
    console.log('2');
    yield next;
    console.log('2 continue');
  }(function* (next) {
    console.log('3');
    yield next;
    console.log('3 continue');
  }(function(){}))));
}

// 简化一点变成:
return function* () {
  return yield* Generator1(Generator2(Generator3(noop())));
}

看完Koa 对中间件的处理方式以及返回结果后,再来看看co.js 中核心的方法,看它是怎样洋葱式的启动所有的中间件的。

在co.js 中,很核心的概念,除了Generator 外,还有一个:Promise,几乎万物皆Promise 的感觉。

// co.js

// 参数gen 为上文koa-compose 返回的Generator 函数调用生成的Generator 对象
function co(gen) {
  var ctx = this;
  var args = slice.call(arguments, 1);

  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();

    // 该方法除了是中间件队列的启动入口,还可以作为下一个中间件执行完毕后的回调
    function onFulfilled(res) {
      var ret;
      try {
        ret = gen.next(res);
      } catch (e) {
        return reject(e);
      }
      next(ret);
      return null;
    }

    // 多用于捕获下一个中间件的错误返回
    function onRejected(err) {
      var ret;
      try {
        ret = gen.throw(err);
      } catch (e) {
        return reject(e);
      }
      next(ret);
    }

    // 该方法是最核心的逻辑
    // next 会尝试将Generator 对象的yield 返回封装成Promise
    // next 方法首先判断当前Generator 对象是否已执行完毕,是的话可以resolve 了
    // 如果未执行完,则将yeild 返回结果封装成Promise,
    // 并将当前中间件Promise 上下文中的onFulfilled 和onRejected 作为成功和失败的回调
    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) + '"'));
    }
  });
}

上面就是co.js 的核心运行代码了,有几个很重要的思想,让这段逻辑充满奥妙:

  • 每个中间件的Generator 对象都会被co 包装成promise,无论是直接调用co()还是使用toPromise(),创建的promise 已被调用者添加了resolve、reject 回调来捕捉promise 的成功或错误返回;
  • 而每个promise 的逻辑里面,会创建两个方法:onFulfilledonRejected,个人感觉,最关键的理解点是作为下一个中间件Generator 对象所包装成的promise 的resolve、reject 回调(这里对应上面第一点),也就是,这两个函数分别可以获取下一个中间件的返回结果以及error throw;如果Generator yield 出来的不适另一个Generator 对象,比如一个普通的对象,co 也会将对象包装成promise,并立即出发起resolve 返回给onFulfilled方法,这样,Generator 对象的行为都在onFulfillednext方法中得以保留;
  • 让上面的一切都运作得如此顺利,next方法功不可没,ret参数为当前上下文Generator 对象的yield 结果,如果运行结束,调用resolve 方法,将结果返回给父中间件的onFulfilled方法,表示当前中间件已完结;如果未运行结束,则将value 包装成下一个promise,如此类推。

Koa 的错误处理

由于有co.js 的支撑,Koa 在中间件错误处理上非常灵活,从Koa 的错误处理逻辑上,可以再一次体会co.js 的“黑魔法”到底有多奥妙。

鉴于Koa 的中间件都是Generator 函数,所以,参照API,中间件的出错方式大致分为两种:

  1. 中间件自身逻辑运行时出错,这时需要中间件自己try/catch 并抛出错误;
  2. 由外部调用者触发generator.throw,此时,中间件可以捕抓调用者触发的错误,然后抛出,或者不捕抓直接抛出了;

以第一点的场景来说,中间件自身抛出了错误,此时,理论上会被中间件promise 上下文中的onFulfilled方法中的try/catch 块所捕抓,并reject promise,将错误抛出给父中间件的promise。

上级中间件promise 的onRejected方法接收到下级中间件抛出的错误,并用一个try/catch 块包裹,然后将错误以generator.throw传入当前中间件中(这就是第二个场景),如果中间件能捕获并消化掉错误的话,那错误的传递就到此为止。但如果中间件不捕获这个错误就又会把错误抛出,或者中间件消化不了,抛出新的错误,此时,错误都会被onRejected中的catch 块捕获,并调用了reject 当前中间件promise,于是错误又继续往上传递,依次类推,最终,如果没有任何中间件能消化掉错误,则Koa application 实例则会对错误进行处理并返回。

文字描述总是略显无力感,所以,还是需要一段伪代码来展示下这段黑魔法的奥妙:

// demo
var middlewares = [
  // 中间件1
  function* (next) {
    console.log('1');
    yield next;
    console.log('1 continue');
  },
  // 中间件2
  function* (next) {
    console.log('2');
    try {
      yield next;
    } catch(err) {
      console.log(err);
    }
    console.log('2 continue');
  },
  // 中间件3
  function* (next) {
    console.log('3');
    throw new Error('error occur at 3');
  }
];

co(require('koa-compose')(middlewares).then(function(value) {
  console.log(value);
}, function(err) {
  console.log(err);
});

上面的代码先把中间件以数组保存,这模拟koa application 的实现方式,然后调用koa-compose 封装中间件到一个Generator 函数并传给co 运行。

下面我们关注中间件的运行逻辑:

  1. co 将中间件1 封装成Promise1,通过调用onFulfilled方法,在try/catch 中触发第一次generator.next,将yield 的结果以参数形式传给next方法;
  2. next方法将中间件2 的Generator 对象封装成Promise2,并将resolve、reject 回调分别设置为Promise1 上下文的onFulfilledonRejected方法,并启动中间件2,依次类推启动中间件3,所有中间件的启动步骤都跟第一点是一样的,从onFulfilled的try/catch 块开始;
  3. 上面第二点导致,其实每个中间件的Generator 对象都是在Promise 中运行的;
  4. 在中间件3 的运行过程中,抛出了一个错误:new Error('error occur at 3'),这个错误由于没有被自身所捕获,然后被Promise3 上下文的onFulfilled的try/catch 所捕获,然后触发Promise3 reject 了,并将错误传给了Promise2 上下文的onRejected方法;
  5. Promise2 上下文的onRejected方法捕获了异常,并将异常以generator.throw传给了中间件2,中间件2 中有try/catch 块可以捕获这次的错误,并成功消化了这次错误,于是就继续把中间件2 中剩余的逻辑一并执行完,然后Promise2 的onRejected因为没有抛出错误,于是继续执行next方法来完成中间件2 的调用,并在下一次的onFulfilled 中达到done=true的状态;
  6. 当中间件2 成功执行完所有逻辑后,resolve Promise2,并将结果传给Promise1 上下文的onFulfilled方法继续中间件1 的执行,中间件1 中也能顺利完成执行,于是顺利结束本次koa 请求;
  7. koa 请求成功返回的处理逻辑参考koa/lib/application.js的response 方法。

但如果将中间价2 的逻辑改为:

// 中间件2
function* (next) {
  console.log('2');
  try {
    yield next;
  } catch(err) {
    console.log(err);
    throw new Error('but i can not handle error this time');
  }
  console.log('2 continue');
}

也就是,如果中间件2 不能消化掉中间件3 抛出的错误,那流程从第5 点就应该变为:

  1. the same
  2. the same
  3. the same
  4. the same
  5. Promise2 上下文的onRejected方法捕获了异常,并将异常以generator.throw传给了中间件2,中间件2 中有try/catch 块可以捕获这次的错误,但缺不能消化错误,于是抛出了一个新的错误,此时被Promise2 的onRejected的try/catch 块所捕获,并reject Promise2,将错误传给了Promise1 上下文的onRejected方法;
  6. Promise1 上下文的onRejected方法捕获了异常,并将异常以generator.throw传给了中间件1,但中间件1 却没能捕获该错误,于是又将错误抛会给onRejected方法,还好,onRejected方法中有try/catch 能捕获这次错误,然后reject 了Promise1,而此时,由于Promise1 已是最顶层的中间件了,所以,错误只好传给koa 的错误处理函数,也就是交给koa 做统一的错误处理,逻辑参考:koa/lib/context.jsonerror方法。

下面再贴一下koa 中触发上面这一切的入口以及response、onerror 回调:

app.callback = function(){
  // 封装中间件
  var fn = this.experimental
    ? compose_es7(this.middleware)
    : co.wrap(compose(this.middleware));
  var self = this;

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

  return function handleRequest(req, res){
    res.statusCode = 404;
    var ctx = self.createContext(req, res);
    onFinished(res, ctx.onerror);
    
    // 这里开始运行co 封装的第一个中间件的promise,并开始一切
    fn.call(ctx).then(function handleResponse() {
      respond.call(ctx);
    }).catch(ctx.onerror);
  }
};

至此,Koa 中基于Generator API 的中间件运行原理和错误处理原理已经解析得差不多了。是时候看看Koa2 了,基于async/await 做异步,立足于未来呀~~~

参考资料

SamHwang1990 avatar Nov 05 '16 02:11 SamHwang1990