w3ctech

深入浅出 Koa

转载:原文来自 https://github.com/berwin/Blog/issues/8

koa

学习koa需要一些相关知识,有两个关键词

  • generator
  • promise

本文主要针对koa的原理进行讨论,属于深度篇,并不会对koa的使用过多介绍。

如果在阅读过程中,发现有哪些地方我写的不太清楚,不容易理解,希望能提出,我会参考建议并进行修改~~

koa总体流程图

让我们从一张图开始

上图中,详细说明了koa从启动server之前,到接受请求在到响应请求的过程中,经历了哪些步骤。

那我们按照时间线说起~

启动前

图中有三个蓝色的方块,分别代表三个静态类

什么是静态类?这个是我自己给起的名,哈哈

静态类就是程序运行前就存在的方法集合,动态类就是通过代码生成出的方法集合。额,都是我自己起的名,概念也是我自己琢磨的,就是简单归个类。

三个静态类分别是RequestContextResponse

Request

Request中包含了一些操作 Node原生请求对象的非常有用的方法。例如获取query数据,获取请求url等,更多方法去查API

Response

Response中包含了一些用于设置状态码啦,主体数据啦,header啦,等一些用于操作响应请求的方法。更多方法去查API

Context

Context是koa中最重要的概念之一,Context字面意思是上下文,也有环境等意思,koa中的操作都是基于这个context进行的,例如

this.body = 'hello world';

从前面的图中,启动前的三个蓝色方块可以看到,左边的Request和右边的Response各有一个箭头指向Context,表示Request和Response自身的方法会委托到Context中。

Context中有两部分,一部分是自身属性,主要是应用于框架内部使用,一部分是Request和Response委托的操作方法,主要为提供给用户更方便从Request获取想要的参数和更方便的设置Response内容。

下面是Context源码片段。

var delegate = require('delegates');
var proto = module.exports = {}; // 一些自身方法,被我删了

/**
 * Response delegation.
 */

delegate(proto, 'response')
  .method('attachment')
  .method('redirect')
  .method('remove')
  .method('vary')
  .method('set')
  .method('append')
  .access('status')
  .access('message')
  .access('body')
  .access('length')
  .access('type')
  .access('lastModified')
  .access('etag')
  .getter('headerSent')
  .getter('writable');

/**
 * Request delegation.
 */

delegate(proto, 'request')
  .method('acceptsLanguages')
  .method('acceptsEncodings')
  .method('acceptsCharsets')
  .method('accepts')
  .method('get')
  .method('is')
  .access('querystring')
  .access('idempotent')
  .access('socket')
  .access('search')
  .access('method')
  .access('query')
  .access('path')
  .access('url')
  .getter('origin')
  .getter('href')
  .getter('subdomains')
  .getter('protocol')
  .getter('host')
  .getter('hostname')
  .getter('header')
  .getter('headers')
  .getter('secure')
  .getter('stale')
  .getter('fresh')
  .getter('ips')
  .getter('ip');

delegates是第三方npm包,功能就是把一个对象上的方法,属性委托到另一个对象上

对了,你猜对了,上面那一排方法,都是Request和Response静态类中的方法,有点看目录的感觉~

method方法是委托方法,getter方法用来委托getter,access方法委托getter+setter

下面是源码片段

function Delegator(proto, target) {
  if (!(this instanceof Delegator)) return new Delegator(proto, target);
  this.proto = proto;
  this.target = target;
  this.methods = [];
  this.getters = [];
  this.setters = [];
  this.fluents = [];
}

Delegator.prototype.method = function(name){
  var proto = this.proto;
  var target = this.target;
  this.methods.push(name);

  proto[name] = function(){
    return this[target][name].apply(this[target], arguments);
  };

  return this;
};

从上面的代码中可以看到,它其实是在proto上新建一个与Request和Response上的方法名一样的函数,然后执行这个函数的时候,这个函数在去Request和Response上去找对应的方法并执行。

简单来个栗子

var proto = {};

var Request = {
  test: function () {
    console.log('test');
  }
};

var name = 'test';
proto[name] = function () {
  return Request[name].apply(Request, arguments);
};

我们在来看看getter方法

Delegator.prototype.getter = function(name){
  var proto = this.proto;
  var target = this.target;
  this.getters.push(name);

  proto.__defineGetter__(name, function(){
    return this[target][name];
  });

  return this;
};

可以看到,在proto上绑定个getter函数,当函数被触发的时候去,会去对应的request或response中去读取对应的属性,这样request或response的getter同样会被触发~

我们在来看看access

Delegator.prototype.access = function(name){
  return this.getter(name).setter(name);
};

可以看到,这个方法是getter+setter,getter上面刚说过,setter与getter同理,不多说了,心好累....

应用启动前的内容到现在就说完了,接下来我们看看使用koa来启动一个app的时候,koa内部会发生什么呢?

启动server

我们使用koa来启动server的时候有两个步骤。第一步是init一个app对象,第二步是用app对象监听下端口号,一个server就启动好了。

// 第一步 - 初始化app对象
var koa = require('koa');
var app = koa();

// 第二步 - 监听端口
app.listen(1995);

简单吧?

不了解内部机制的同学,通常会认为server是在koa()这个时候启动的,app.listen只是监听下端口而已~

事实上。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。并不是。

有木有被刷新三观???

我们看下源码片段

module.exports = Application;

function Application() {
  if (!(this instanceof Application)) return new Application;
  this.env = process.env.NODE_ENV || 'development';
  this.subdomainOffset = 2;
  this.middleware = [];
  this.proxy = false;
  this.context = Object.create(context);
  this.request = Object.create(request);
  this.response = Object.create(response);
}

从源码中可以看到,执行koa()的时候初始化了一些很有用的东西,包括初始化一个空的中间件集合,基于Request,Response,Context为原型,生成实例等操作。

Request和Response的属性和方法委托到Context中也是在这一步进行的

并没有启动server

我们看第二步,在看一段源码

app.listen = function(){
  debug('listen');
  var server = http.createServer(this.callback());
  return server.listen.apply(server, arguments);
};

可以看到,在执行app.listen(1995)的时候,启动了一个server,并且监听端口。熟悉nodejs的同学知道http.createServer接收一个函数作为参数,每次服务器接收到请求都会执行这个函数,并传入两个参数(request和response,简称req和res),那么现在重点在this.callback这个方法上。

我们一起看一下this.callback是何方神圣

app.callback = function(){
  if (this.experimental) {
    console.error('Experimental ES7 Async Function support is deprecated. Please look into Koa v2 as the middleware signature has changed.')
  }
  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(req, res){
    res.statusCode = 404;
    var ctx = self.createContext(req, res);
    onFinished(res, ctx.onerror);
    fn.call(ctx).then(function () {
      respond.call(ctx);
    }).catch(ctx.onerror);
  }
};

这个方法其实可以分成两部分,一部分是执行函数的那一瞬间所执行的代码,另一部分是接收请求的时候所执行的代码。

而前一部分就是总体流程图中,启动server这个时间段,黄色椭圆形所执行的那一部分,初始化中间件!!!

第一部分

先说第一部分,很明显,这环节是在初始化中间件,那为什么要初始化中间件呢?处理后的中间件与处理之前的中间件又有什么不同呢????

童鞋,,,不要着急,听我慢慢道来~~

我们添加中间的时候使用app.use方法,其实这个方法只是把中间件push到一个数组,然后就没有然后了。。(⊙﹏⊙)

很明显,所有中间件都在数组中,那么它们之间是没有联系的,如果没有联系,就不可能实现流水线这样的功能。。。。

那么这些中间件处理之后会变成什么样的????

我们先看代码,上面的代码中用this.experimental这个属性做了一个判断。这个属性是什么鸟。

this.experimental 关于这个属性我并没有在官方文档上看到说明,但以我对koa的了解,这个方法是为了判断是否支持es7,默认是不支持的,如果想支持,需要在代码中明确指定this.experimental = true,开启这个属性之后,中间件可以传入async函数。

我想说的是,无论是否开启ES7,原理都是相同的,只是因为语法特性的不同,需要不同的处理,核心思想不会因为不同的语言特性而改变,支持ES7显然处理起来更方便,因为默认不开启this.experimental,所以这里我们针对不开启的情况进行讨论~

这样一来,第一部分的代码就简化成了这样

var fn = co.wrap(compose(this.middleware));

虽然只剩下一行代码,但不要小瞧它哦~~

我们先看compose(this.middleware)这部分,compose的全名叫koa-compose,他的作用是把一个个不相干的中间件串联在一起。。

例如

// 有3个中间件
this.middlewares = [function *m1() {}, function *m2() {}, function *m3() {}];

// 通过compose转换
var middleware = compose(this.middlewares);

// 转换后得到的middleware是这个样子的
function *() {
  yield *m1(m2(m3(noop())))
}

有木有很神奇的感觉??更神奇的是,generator函数的特性是,第一次执行并不会执行函数里的代码,而是生成一个generator对象,这个对象有next,throw等方法。

这就造成了一个现象,每个中间件都会有一个参数,这个参数就是下一个中间件执行后,生成出来的generator对象,没错,这就是大名鼎鼎的 next

compose是如何实现这样的功能的呢??我们看一下代码

/**
 * Expose compositor.
 */

module.exports = 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){
  return function *(next){
    if (!next) next = noop();

    var i = middleware.length;

    while (i--) {
      next = middleware[i].call(this, next);
    }

    return yield *next;
  }
}

/**
 * Noop.
 *
 * @api private
 */

function *noop(){}

这是这个模块的所有代码,很简单,逻辑是这样的

先把中间件从后往前依次执行,并把每一个中间件执行后得到的generator对象赋值给变量next,当下一次执行中间件的时候(也就是执行前一个中间件的时候),把next传给第一个参数。这样就保证前一个中间件的参数是下一个中间件生成的generator对象,第一次执行的时候next为noopnoop是空的generator函数。

koa的中间件必须为generator函数(就是带星号的函数),否则无法顺利的执行中间件逻辑

最后,有一个非常巧妙的地方,就是最后一行return yield *next;

这行代码可以实现把compose执行后return的函数变成第一个中间件,也就是说,执行compose之后会得到一个函数,执行这个函数就与执行第一个中间件的效果是一模一样的,这主要依赖了generator函数的yield *语句的特性。

现在中间件的状态就已经从不可用变成可用了。不可用的中间件是一个数组,可用的中间件是一个generator函数

我们接着说刚才没说完的

var fn = co.wrap(compose(this.middleware));

上面这段代码现在就可以理解成下面这样

var fn = co.wrap(function *() {yield *m1(m2(m3(noop())))});

里面的函数刚刚已经说过是可用状态的中间件,那么co.wrap是干什么用的呢??

co是TJ大神基于Generator开发的一款流程控制模块,白话文就是:就是把异步变成同步的模块。。。(感觉逼格瞬间拉低了。。。)

看下源码

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

从源码中可以看到,它接收一个参数,这个参数就是可用状态下的中间件,返回一个函数createPromise,当执行createPromise这个函数的时候,调用co并传入一个参数,这个参数是中间件函数执行后生成的Generator对象。

这意味着,返回的这个函数是触发执行中间件逻辑的关键,一旦这个函数被执行,那么就会开始执行中间件逻辑

app.callback = function(){
  if (this.experimental) {
    console.error('Experimental ES7 Async Function support is deprecated. Please look into Koa v2 as the middleware signature has changed.')
  }
  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(req, res){
    res.statusCode = 404;
    var ctx = self.createContext(req, res);
    onFinished(res, ctx.onerror);
    fn.call(ctx).then(function () {
      respond.call(ctx);
    }).catch(ctx.onerror);
  }
};

从源码中,可以看到这个函数赋值给fn,fn是在下面那个函数中执行的,下面那个函数是接下来要说的内容~

到现在,我们的koa已经处于一种待机状态,所有准备都以准备好(中间件和context),万事俱备,只欠东风。。。。。。

东风就是request请求~~

接收请求

前面说了启动前的一些准备工作和启动时的初始化工作,现在最后一步就是接收请求的时候,koa要做的事情了,这部分也是koa中难度最大的一部分。不过认真阅读下去会有收获的。。

上面我们说this.callback这个方法有两个部分,第一个部分是初始化中间件,而另一部分就是接收请求时执行的函数啦。

简单回顾下

// 创建server并监听端口
app.listen = function(){
  debug('listen');
  var server = http.createServer(this.callback());
  return server.listen.apply(server, arguments);
};

// 这个方法返回的函数会被传递到http.createServer中,http.createServer这个方法的作用是每当服务器接收到请求的时候,都会执行第一个参数,并且会传递request和response
app.callback = function(){
  if (this.experimental) {
    console.error('Experimental ES7 Async Function support is deprecated. Please look into Koa v2 as the middleware signature has changed.')
  }
  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(req, res){
    res.statusCode = 404;
    var ctx = self.createContext(req, res);
    onFinished(res, ctx.onerror);
    fn.call(ctx).then(function () {
      respond.call(ctx);
    }).catch(ctx.onerror);
  }
};

所以第二部分的重点就是下面段代码啦~

return function(req, res){
  res.statusCode = 404;
  var ctx = self.createContext(req, res);
  onFinished(res, ctx.onerror);
  fn.call(ctx).then(function () {
    respond.call(ctx);
  }).catch(ctx.onerror);
}

我们先看这段代码

var ctx = self.createContext(req, res);

不知道各位童鞋还记不记得文章一开始的时候那个总体流程图下面的那个类似于八卦一样的东西???

这行代码就是创建一个最终可用版的context。

从上图中,可以看到分别有五个箭头指向ctx,表示ctx上包含5个属性,分别是request,response,req,res,app。request和response也分别有5个箭头指向它们,所以也是同样的逻辑。

这里需要说明下

  • request - request继承于Request静态类,包含操作request的一些常用方法
  • response - response继承于Response静态类,包含操作response的一些常用方法
  • req - nodejs原生的request对象
  • res - nodejs原生的response对象
  • app - koa的原型对象

不多说,咱们观摩下代码

app.createContext = function(req, res){

  // 继承
  var context = Object.create(this.context);
  var request = context.request = Object.create(this.request);
  var response = context.response = Object.create(this.response);

  // 往context,request,response身上挂载属性
  context.app = request.app = response.app = this;
  context.req = request.req = response.req = req;
  context.res = request.res = response.res = res;
  request.ctx = response.ctx = context;
  request.response = response;
  response.request = request;
  context.onerror = context.onerror.bind(context);
  context.originalUrl = request.originalUrl = req.url;
  context.cookies = new Cookies(req, res, {
    keys: this.keys,
    secure: request.secure
  });
  context.accept = request.accept = accepts(req);
  context.state = {};

  // 最后返回完整版context
  return context;
};

讲到这里其实我可以很明确的告诉大家,,,koa中的this其实就是app.createContext方法返回的完整版context

又由于这段代码的执行时间是接受请求的时候,所以表明每一次接受到请求,都会为该请求生成一个新的上下文

上下文到这里我们就说完啦。我们接着往下说,看下一行代码

onFinished(res, ctx.onerror);

这行代码其实很简单,就是监听response,如果response有错误,会执行ctx.onerror中的逻辑,设置response类型,状态码和错误信息等。

源码如下:

onerror: function(err){
  // don't do anything if there is no error.
  // this allows you to pass `this.onerror`
  // to node-style callbacks.
  if (null == err) return;

  if (!(err instanceof Error)) err = new Error('non-error thrown: ' + err);

  // delegate
  this.app.emit('error', err, this);

  // nothing we can do here other
  // than delegate to the app-level
  // handler and log.
  if (this.headerSent || !this.writable) {
    err.headerSent = true;
    return;
  }

  // unset all headers
  this.res._headers = {};

  // force text/plain
  this.type = 'text';

  // ENOENT support
  if ('ENOENT' == err.code) err.status = 404;

  // default to 500
  if ('number' != typeof err.status || !statuses[err.status]) err.status = 500;

  // respond
  var code = statuses[err.status];
  var msg = err.expose ? err.message : code;
  this.status = err.status;
  this.length = Buffer.byteLength(msg);
  this.res.end(msg);
}

我们接着说,还有最后一个知识点,也是本章最复杂的知识点,关于中间件的执行流程,这里会说明为什么koa的中间件可以回逆。

我们先看代码

fn.call(ctx).then(function () {
  respond.call(ctx);
}).catch(ctx.onerror);
  • fn - 我们上面讲的co.wrap返回的那个函数
  • ctx - app.createContext执行后返回的完整版context对象

总体上来说,执行fn.call(ctx)会返回promise,koa会监听执行的成功和失败,成功则执行respond.call(ctx);,失败则执行ctx.onerror,失败的回调函数刚刚已经讲过。这里先说说respond.call(ctx);

我们在写koa的时候,会发现所有的response操作都是 this.body = xxx; this.status = xxxx;这样的语法,但如果对原生nodejs有了解的童鞋知道,nodejs的response只有一个api那就是res.end();,而设置status状态码什么的都有不同的api,那么koa是如何做到通过this.xxx = xxx来设置response的呢?

先看一张图,,我盗的图

从图中看到,request请求是以respond结束的。

是滴,所有的request请求都是以respond这个函数结束的,这个函数会读取this.body中的值根据不同的类型来决定以什么类型响应请求

我们来欣赏一下源码

function respond() {
  // allow bypassing koa
  if (false === this.respond) return;

  var res = this.res;
  if (res.headersSent || !this.writable) return;

  var body = this.body;
  var code = this.status;

  // ignore body
  if (statuses.empty[code]) {
    // strip headers
    this.body = null;
    return res.end();
  }

  if ('HEAD' == this.method) {
    if (isJSON(body)) this.length = Buffer.byteLength(JSON.stringify(body));
    return res.end();
  }

  // status body
  if (null == body) {
    this.type = 'text';
    body = this.message || String(code);
    this.length = Buffer.byteLength(body);
    return res.end(body);
  }

  // responses
  if (Buffer.isBuffer(body)) return res.end(body);
  if ('string' == typeof body) return res.end(body);
  if (body instanceof Stream) return body.pipe(res);

  // body: json
  body = JSON.stringify(body);
  this.length = Buffer.byteLength(body);
  res.end(body);
}

仔细阅读的童鞋会发现,咦,,,,为毛没有设置status和header等信息的代码逻辑?这不科学啊。我分明记得状态码是rs.statusCode = 400这样设置的,为啥代码中没有??

这就要从最开始的上下文说起了。为什么Response静态类中添加req和res属性?就是因为添加了req和res之后,response和request类就可以直接操作req和res啦。。我们看一段源码就明白了

set status(code) {
  assert('number' == typeof code, 'status code must be a number');
  assert(statuses[code], 'invalid status code: ' + code);
  this._explicitStatus = true;
  this.res.statusCode = code;
  this.res.statusMessage = statuses[code];
  if (this.body && statuses.empty[code]) this.body = null;
},

主要是this.res.statusCode = code; this.res.statusMessage = statuses[code];这两句,statusCodestatusMessage都是nodejs原生api。有兴趣可以自行查看~

接下来我们开始说说koa的中间件为什么可以回逆,为什么koa的中间件必须使用generator,yield next又是个什么鬼?

我们看这段代码

fn.call(ctx)

fn刚刚上面说过,就是co.wrap返回的那个函数,上面也说过,一旦这个函数执行,就会执行中间件逻辑,并且通过.callctx设为上下文,也就是this。

那中间件逻辑是什么样的呢。我们先看一下源码:

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

先回顾下,createPromise就是fn,每当执行createPromise的时候,都会执行co,中间件是基于co实现的、所以我们接下来要说的是co的实现逻辑。而执行co所传递的那个参数,我们给它起个名,就叫中间件函数吧,中间件函数也是一个generator函数,因为在执行co的时候执行了这个中间件函数,所以实际上真正传递给co的参数是一个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) + '"'));
    }
  });
}

可以看到,代码并不是很多。

首先执行co会返回一个promise,koa会对这个promise的成功和失败都准备了不同的处理,上面已经说过。

我们在看这段代码

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

这个函数最重要的作用是运行gen.next来执行中间件中的业务逻辑。

通常在开发中间件的时候会这样写

yield next;

所以ret中包含下一个中间件对象(还记得上面我们初始化中间件的时候中间件的参数是什么了吗??)

然后把下一个中间件对象传到了next(ret)这个函数里,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) + '"'));
}

可以看到,逻辑是这样的

如果中间件已经结束(没有yield了),那么调用promise的resolve。

否则的话把ret.value(就是下一个中间件对象),用co在包一层toPromise.call(ctx, ret.value);

if (isGeneratorFunction(obj) || isGenerator(obj)) return co.call(this, obj);

上面是toPromise中的一段代码

既然是用co又执行了一遍,那么co是返回promise的。所以返回的这个value就分别被监听了成功和失败的不同处理。

value.then(onFulfilled, onRejected);

所以我们可以看到,如果第二个中间件里依然有yield next这样的语句,那么第三个中间件依然会被co包裹一层并运行.next方法,依次列推,这是一个递归的操作

所以我们可以肯定的是,每一个中间件都被promise包裹着,直到有一天中间件中的逻辑运行完成了,那么会调用promise的resolve来告诉程序这个中间件执行完了。

那么中间件执行完了之后,会触发onFulfilled,这个函数会执行.next方法。

所以有一个非常重要的一点需要注意,onFulfilled这个函数非常重要,重要在哪里???重要在它执行的时间上。

onFulfilled这个函数只在两种情况下被调用,一种是调用co的时候执行,还有一种是当前promise中的所有逻辑都执行完毕后执行

其实就这一句话就能说明koa的中间件为什么会回逆。

回逆其实是有一个去和一个回的操作

请求的时候经过一次中间件,响应的时候在经过一次中间件。

而onFulfilled的两种被调用的情况正好和这个回逆的过程对应上。

前方高能预警!!!

比如有3个中间件,当系统接收到请求的时候,会执行co,co会立刻执行onFulfilled来调用.next往下执行,将得到的返回结果(第二个中间件的generator对象,上面我们分析过)传到co中在执行一遍。以此类推,一直运行到最后一个yield,这个时候系统会等待中间件的执行结束,一旦最后一个中间件执行完毕,会立刻调用promise的resolve方法表示结束。(这个时候onFulfilled函数的第二个执行时机到了,这样就会出现一个现象,一个generator对象的yield只能被next一次,下次执行.next的时候从上一次停顿的yield处继续执行,所以现在当有一个中间件执行完毕后,在执行.next就会在前一个中间件的yield处继续执行)当最后一个中间件执行完毕后,触发promise的resolve,而别忘了,第二个中间件可是用then监听了成功和失败的不同处理方法,一旦第三个中间件触发成功,第二个中间件会立刻调用onFulfilled来执行.next,继续从第二个中间件上一次yield停顿处开始执行下面的代码,而第二个中间件的逻辑执行完毕后,同样会执行resolve表示成功,而这个时候第一个中间件正好也通过.then方法监听了第二个中间件的promise,也会立刻调用onFulfilled函数来执行.next方法,这样就会继续从第一个中间件上一次yield的停顿处继续执行下面的逻辑,以此类推。

这样就实现了中间件的回逆,通过递归从外到里执行一遍中间件,然后在通过promise+generator从里往外跳。

所以如果我们在一个中间件中写好多yield,就可以看出关键所在,先通过递归从外往里(从第一个中间件运行到最后一个中间件)每次遇到yield next就会进入到下一个中间件执行,当运行到最后发现没有yield的时候,会跳回上一个中间件继续执行yield后面的,结果发现又有一个yield next,它会再次进入到下一个中间件,进入到下一个中间件后发现什么都没有,因为yield的特性(一个generator对象的yield只能被next一次,下次执行.next的时候从上一次停顿的yield处继续执行),所以便又一次跳入上一个中间件来执行。以此类推。

我们试一下:

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

app.use(function* f1(next) {
  console.log('f1: pre next');
  yield next;
  console.log('f1: post next');
  yield next;
  console.log('f1: fuck');
});

app.use(function* f2(next) {
  console.log('  f2: pre next');
  yield next;
  console.log('  f2: post next');
  yield next;
  console.log('  f2: fuck');
});

app.use(function* f3(next) {
  console.log('  f3: pre next');
  yield next;
  console.log('  f3: post next');
  yield next;
  console.log('  f3: fuck');
});

app.use(function* (next) {
  console.log('hello world')
  this.body = 'hello world';
});


app.listen(3000);

上面的代码打印的log是下面这样的

f1: pre next
  f2: pre next
  f3: pre next
hello world
  f3: post next
  f3: fuck
  f2: post next
  f2: fuck
f1: post next
f1: fuck

如果非要画一个图的话,我脑海中大概长这样

其实刚刚那么一通复杂的逻辑下来,好多同学都会懵逼,那么我用白话文来说一下中间件的逻辑,大概是这样的

第一个中间件代码执行一半停在这了,触发了第二个中间件的执行,第二个中间件执行了一半停在这了,触发了第三个中间件的执行,然后,,,,,,第一个中间件等第二个中间件,第二个中间件等第三个中间件,,,,,,第三个中间件全部执行完毕,第二个中间件继续执行后续代码,第二个中间件代码全部执行完毕,执行第一个中间件后续代码,然后结束

用一张图表示大概是这样的。

middleware

为了方便理解,伪代码大概是下面这样

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

这就是最核心的思想!!!

总结

简单总结一下,其实也很简单,只是第一次接触的同学可能暂时没有理解透彻。

其实就是通过generator来暂停函数的执行逻辑来实现等待中间件的效果,通过监听promise来触发继续执行函数逻辑,所谓的回逆也不过就是同步执行了下一个中间件罢了。

比如有几个中间件,mw1,mw2,mw3,mwn...

站在mw1的角度上看,它是不需要关系mw2里面有没有mw3,它只需要关心mw2何时执行完毕即可,当mw2执行完毕mw1继续执行yield之后的代码逻辑。其实很简单,callback也是这个原理,当mw2执行完毕执行下callback,mw1是不需要关心mw2里面究竟是怎样运行的,只要知道mw2执行完会执行回调就行了。mw2也是同样的道理不需要关心mw3。

到这里,关于koa我们就已经差不多都说完了。当然还有一些细节没有说,比如koa中的错误处理,但其实都是小问题啦,关于generator的错误处理部分弄明白了,自然就明白koa的错误处理是怎样的。这里就不在针对这些讲述了,一次写这么多确实有点累,或许后期会补充进来吧。。

两个重要技术点

最后,如果认真阅读下来的同学能感觉出来,koa中有两个最重要的地方,无论是使用上,还是思想上,这两个点都非常重要,koa也只有这两个概念

  • Middleware - 中间件
  • Context - 上下文

最后说一些自己对koa的感觉,真他妈的是赏心悦目啊,真他妈的是优雅啊!!!每一行代码都浓缩了很多层含义,通过最少的代码实现最复杂的功能,对于我这种追求代码的极致优雅的人,看完koa之后,真的是感触良多,泪流满面啊。。。。

ppt:http://berwin.github.io/ppts/koa/

转载请注明出处:https://github.com/berwin/Blog/issues/8

w3ctech微信

扫码关注w3ctech微信公众号

共收到0条回复