berwin/Blog

深入浅出 Koa2 原理

berwin opened this issue · 13 comments

深入浅出 Koa2

说在前面的话:本文针对对koa1非常了解并学习过源码或者阅读过我上篇koa文章的同学阅读~

吸取之前的经验,本章用幽默的风格来分析又臭又硬的原理,我尽量用最通俗易懂的语言来描述复杂的逻辑。

前几天koa发布了2.0版本。这几天找了个不忙的时间,赶紧阅读了2.0的文档和源码

这次改动主要是中间件的部分。其他部分对于使用者来说没什么改动。

阅读过我的上一篇文章的同学应该知道。koa内部主要有两个知识点,context(上下文)和middleware(中间件)两个部分

所以总体来看,改动不算太大,我先把改动分个类

  • 使用
    • 中间件
  • 源码
    • 语法
    • 中间件

使用上的改动

先说使用方面,这次改动让中间件部分可以使用ES2015-2016的语法~

比如async await,在比如箭头函数

正因为中间件支持了async 和await,所以内部的中间件逻辑就不得不做一些改动。

但是koa的作者还是做了兼容的。同时支持3种不同种类的中间件,普通函数async 函数Generator函数。(这个屌)

普通函数的用法

app.use((ctx, next) => {
  const start = new Date();
  return next().then(() => {
    const ms = new Date() - start;
    console.log(`${ctx.method} ${ctx.url} - ${ms}ms`);
  });
});

async函数的用法

app.use(async (ctx, next) => {
  const start = new Date();
  await next();
  const ms = new Date() - start;
  console.log(`${ctx.method} ${ctx.url} - ${ms}ms`);
});

Generator函数的用法

app.use(co.wrap(function *(ctx, next) {
  const start = new Date();
  yield next();
  const ms = new Date() - start;
  console.log(`${ctx.method} ${ctx.url} - ${ms}ms`);
}));

当然,用v1的语法也可以,像下面这样

app.use(function *(next) {
  const start = new Date();
  yield next;
  const ms = new Date() - start;
  console.log(`${this.method} ${this.url} - ${ms}ms`);
});

不过官方并不建议这样写,因为他们打算在v3中取消对这种写法的兼容。

噢,对了,还有一种写法。

const convert = require('koa-convert');

app.use(convert(function *(next) {
  const start = new Date();
  yield next;
  const ms = new Date() - start;
  console.log(`${this.method} ${this.url} - ${ms}ms`);
}));

让我们自己把自己的中间件做一下兼容(v2内部就是这样做的兼容),然后就可以衣食无忧了。。(Are you sure?)

因为v2用的是ES2015-2016的语法,其中包括class,所以node目前是无法支持的,即便是目前比较先进的的v5.10.x也不行(臣妾做不到啊~)

那么,,,你懂得,需要babel编译之后才可以用

koa当然不会替我们编译,这并不符合koa的**和原则,所以需要我们自己去编译。(koa的**和原则是什么??)

使用方面的就说到这,下面说说源码上的改动。

源码上的改动

语法

看了koa的源码之后,发现主文件 application.jsEmitter 的继承,使用了ES2015的语法,大概是这样的

module.exports = class Application extends Emitter {

  /**
   * Initialize a new `Application`.
   *
   * @api public
   */

  constructor() {
    super();

    this.proxy = false;
    this.middleware = [];
    this.subdomainOffset = 2;
    this.env = process.env.NODE_ENV || 'development';
    this.context = Object.create(context);
    this.request = Object.create(request);
    this.response = Object.create(response);
  }

  ...

我们回顾下v1

/**
 * Application prototype.
 */

var app = Application.prototype;

/**
 * Expose `Application`.
 */

module.exports = Application;

/**
 * Initialize a new `Application`.
 *
 * @api public
 */

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

/**
 * Inherit from `Emitter.prototype`.
 */

Object.setPrototypeOf(Application.prototype, Emitter.prototype);

...

中间件

中间件这块改动比较大,咱们从头开始。。

先从注册中间件开始,也就是 app.use 这个方法开始,先贴一段源码(为了方便观察,我把不重要的代码删了)

use(fn) {
  if (typeof fn !== 'function') throw new TypeError('middleware must be a function!');
  if (isGeneratorFunction(fn)) {
    fn = convert(fn);
  }
  this.middleware.push(fn);
  return this;
}

我们对比下v1是什么样的

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

我们看到 v2 多了一个判断,如果是Generator函数,那就用 convert 把函数包起来,然后在push到 this.middleware 这就是针对v1的写法做的兼容。(官方说v3发布的时候就不兼容v1的写法,应该就是把这个判断删了,,我邪恶的猜测着)

没关系,官方给咱们支了一招,他们不帮咱们做兼容咱们可以来(自己动手风衣主食啊),自己把自己的中间件用 convert 包起来在use。。

convert 是干啥用的?我们看下源码

function convert (mw) {
  if (typeof mw !== 'function') {
    throw new TypeError('middleware must be a function')
  }
  if (mw.constructor.name !== 'GeneratorFunction') {
    // assume it's Promise-based middleware
    return mw
  }
  const converted = function (ctx, next) {
    return co.call(ctx, mw.call(ctx, createGenerator(next)))
  }
  converted._name = mw._name || mw.name
  return converted
}

其实核心就一句(为了方便理解,做一个小改动)

return function (ctx, next) {
  return co.call(ctx, mw.call(ctx, createGenerator(next)))
}

大概就是,把一个普通函数push到中间件里,执行这个中间件,返回promise,,不要问我为啥返回promise,快去上一篇文章好好学习。

接下来我们在看看中间件是怎样运行的,下面这个熟悉的函数,不要问我它是干啥的,哔哔哔

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

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

  return (req, res) => {
    res.statusCode = 404;
    const ctx = this.createContext(req, res);
    onFinished(res, ctx.onerror);
    fn(ctx).then(() => respond(ctx)).catch(ctx.onerror);
  };
}

这块我分两部分讲

  • 启动server
  • 接收请求

就是上面代码中return前和return后,return前是启动server阶段,return后是接受请求阶段(虽然启动server阶段就一行代码)

启动server

看第一行代码

const fn = compose(this.middleware);

童鞋们知道 this.middleware 里面现在是一些什么东西嘛?

不要告诉我中间件,,,,,,

现在 this.middleware 中存了一些函数,不管他是什么函数,反正只要执行它,它就返回promise。这个函数有可能是 async 函数 有可能是被 convert 包装后的Generator函数,或者是被 co.wrap 包装后的Generator函数,也有可能是普通函数的中间件(哈哈哈哈,不要忘了v2支持普通函数的中间件哦~),反正这些函数都有一个特性,那就是执行它们,会返回promise。

现在这群函数被传到 compose 中进行处理,处理之后变成啥样了???

我先说另一个事,这里先暂停,我们先知道中间件被 compose 处理成怪物了,我们先看看这些怪我的作用

接收请求

我们看接收请求时要执行的代码(fn就是那个怪物):

return (req, res) => {
  res.statusCode = 404;
  const ctx = this.createContext(req, res);
  onFinished(res, ctx.onerror);
  fn(ctx).then(() => respond(ctx)).catch(ctx.onerror);
};

我们对比下v1

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

一模一样,没有任何区别。。。。(语法除外)

额,既然一模一样,我就不多说了。不懂的童鞋去读上一篇文章,,那里有非常详细的介绍(我是不是太会偷懒了。。)

好了,那么现在最重要的地方来了,compose 是怎么把中间件变成怪物的,又是怎么把三个种类的中间件变成可以实现中间件逻辑的函数呢,这一次的回逆是怎样实现的?

先留个悬念,不然不知道这里是重点。

上篇文章说过 compose 这个模块,有意思的是,这个模块也升级了。koa v1 对应着compose v2koa v2 对应着 compose v3,我们看看 compose v3 中的代码

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) {
    // last called middleware #
    let index = -1
    return dispatch(0)
    function dispatch (i) {
      if (i <= index) return Promise.reject(new Error('next() called multiple times'))
      index = i
      const fn = middleware[i] || next
      if (!fn) return Promise.resolve()
      try {
        return Promise.resolve(fn(context, function next () {
          return dispatch(i + 1)
        }))
      } catch (err) {
        return Promise.reject(err)
      }
    }
  }
}

代码看起来有点吓人,我们主要看return 后面的那个函数里的逻辑,因为每次server接收到请求的时候都会执行这个函数,,不要问我为什么,去看上一篇文章~

这个函数里有一个重要的函数,dispatch (就这么一个函数能不重要嘛)

前方高能预警!!

首先在执行到匿名函数的时候(就是return返回的那个函数),会执行 dispatch,并传一个参数 0,其次就是在 dispatch 执行的过程中会自己调用自己,递归调用。

我们再来看两个变量 indexi

index 一开始默认是 -1

i 一开始默认是 0(因为第一次传递的参数是 0)

dispatch 的第一行有一个判断,如果 i <= index 抛出错误。

判断的下面是一个赋值,index = i

然后下面是一个递归调用,参数是 i + 1

也就是说,如果没有意外,index 是永远小于 i 的,那什么情况下 index 会大于或等于 i

同一个中间件中多次调用 next() (执行next就是执行dispatch)的时候,index 会大于 i,从代码上看,每个中间件都有一个自己的作用域,也就是说同一个中间件,i 是不变的,在 i 不变的情况下,多次调用 next的情况下,第一次调用,index 小于 i,第二次调用,index就 等于 i了。。。

额,,啰嗦了,上面那个理解了最好,没理解也没关系,就是先给大家热热身,从现在开始,把我们的大脑要高速运转。

其实..... 我们只需要知道 dispatch 每次执行都会有一个变量 i,这个 i 是干啥的?

i 其实是用来在 this.middleware 中获取中间件的下标,dispatch 函数第一次执行 i0,第二次是 1,以此类推。。。。

看这行代码,就行用来获取中间件用的

const fn = middleware[i] || next

好啦,现在我们取到中间件了,但是怎么使用呢??

先来一段代码

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

执行中间件,并传递两个参数,contextnext函数,context是koa中的上下文没什么可说的,说说 next 函数,next 也蛮简单的,就是return一个 dispatch 的执行结果,注意那个参数 i+1,这个参数很有学问,传递一个 i+1,就相当于一旦执行next函数,就等同于执行下一个中间件。

PS:一个中间件只能执行一次next,否则逻辑上会出现问题,为了避免这个问题,在 dispatch 中一开始就做了判断,就是一开始咱们说的 indexi 的问题。

这个地方其实就跟koa1有点不同了。koa1是可以在一个中间件中多次调用next的,并且不会出现问题,因为一个yield只能执行一次,即便调用再多的next在generator函数中被执行过的代码也不会重复执行,所以多次调用时不会报错,不会出现问题的,只是执行了跟没执行一样,没效果。所以即便是koa1也是不建议多次调用next,因为每调用一次,就会创建个promise,然后在里面执行一次getn.next然后发现返回值是{value: undefined, done: true},然后在resolve()跳回来。这样有点浪费性能。

在中间件中,我们通常会这样使用

await next();

async 的语法是,await后面会跟一个promise,await会等待promise,等promise执行完了,在往下执行

而我们的这些中间件,都有一个特点,执行完会返回promise,所以正好被await监听。

我们中间件本身返回的就是promise,为什么会被Promise.resolve包起来?这里是一个兼容写法,如果只支持async函数当然没问题,但我们的中间件除了支持async函数外,还支持普通函数呦~~~

所以,如果中间件使用async函数写的,流程大概是这样的

  1. 先执行第一个中间件(因为默认会先执行一次dispatch(0)),这个中间件会返回promise,koa会监听这个promise,一旦成功或者失败,都会做出不同的处理,并结束这次响应
  2. 在执行中间件逻辑的时候,我们会执行这样一段代码 await next();,在这里手动触发第二个中间件执行,第二个中间件和第一个中间件一样,也会返回promise(废话,一奶同胞的兄弟能不一样么)await会监听这个promise,什么时候执行完了,什么时候继续执行第一个中间件后续的代码。(中间件的回逆就是这样实现的)
  3. 在第二个中间件触发的时候,也会执行 await next(); 这样一段代码来触发第三个中间件并等待第三个中间件执行完了在执行后续代码,否则就一直等,以此类推

所以就造成了这样一个现象,第一个中间件代码执行一半停在这了,触发了第二个中间件的执行,第二个中间件执行了一半停在这了,触发了第三个中间件的执行,然后,,,,,,第一个中间件等第二个中间件,第二个中间件等第三个中间件,,,,,,第三个中间件全部执行完毕,第二个中间件继续执行后续代码,第二个中间件代码全部执行完毕,执行第一个中间件后续代码,然后结束(不得不说,,TJ大神真想法)

其实koa1也是这个逻辑,koa2对于koa1**上是没有变化的,变的只是语法、

中间件的实现逻辑 - 普通函数

有的同学说了,如果我用普通函数写中间件,是怎样实现与async函数同样逻辑的呢?

其实并不难,async函数有几个特点能帮助它完成中间件的逻辑

  1. 执行后返回promise
  2. 函数内部可以通过await暂停函数,并等待下一个中间件执行完成后,继续执行

首先我们看下普通函数的用法

app.use((ctx, next) => {
  const start = new Date();
  return next().then(() => {
    const ms = new Date() - start;
    console.log(`${ctx.method} ${ctx.url} - ${ms}ms`);
  });
});

先说第一个条件,「执行后返回promise」

上面我们说过,我们的中间件在 dispatch 中会被 Promise.resolve 包住并返回,所以第一个条件满足

我们在说第二个条件,「中间件内部可以监听promise并等待promise接收后执行后续代码」

很明显,第二个条件也满足,因为普通函数的写法是异步的,后续代码在then里面。(async也不过是看起来同步而已,其实是同样的逻辑,普通函数的写法更露骨)

中间件的实现逻辑 - Generator函数

先看看用法

app.use(co.wrap(function *(ctx, next) {
  const start = new Date();
  yield next();
  const ms = new Date() - start;
  console.log(`${ctx.method} ${ctx.url} - ${ms}ms`);
}));

首先第一个条件「执行后返回promise」

可以看到中间件是用 co.warp 包起来的,co.warp会返回promise,上一章我详细的讲解过,第一个条件满足

我们在说第二个条件,「中间件内部可以监听promise并等待promise接收后执行后续代码」

co 与async一样,yield后面可以跟一个promise,co会监听这个promise,什么时候这个promise执行完了。什么时候执行后续的代码,这点跟async是一模一样的,只是写法略有不同,第二个条件满足

关于 co 的内部原理,上一篇文章中有详细的分析与介绍~

转载请注明出处

。。。。编译之后执行,听起来都蛋疼,还不如用1.2.。。

@nunnly 嗯,目前2.0还比较超前,等过一段日子随着node支持的语法越来越高,到时候就不需要编译了。

什么时候node会支持这种ES7的写法呢? 现在已经nodev6了。

@tosone 现在node的稳定版是4.x。。。

function getBody(str) {
    return new Promise(function (resolve, reject) {
        resolve(str);
    });
}

app.use(function (ctx, next) {
    console.log(1);
    next().then(data=>console.log(2));
});

app.use(co.wrap(function \* (ctx, next) {
    ctx.body = yield getBody('Hello1');
    console.log(3, ctx.body);
    yield next();
    console.log(4, ctx.body);
}));

app.use(co.wrap(function \* (ctx, next) {
    let tmp = yield getBody('Hello2');
    ctx.body = ctx.body + '\n' + 'Hello2';
    console.log(5, ctx.body);
    yield next();
    console.log(6, ctx.body);
}));

app.listen(3000, ()=>console.log('koa start at 3000...'))

我发现2.0里面普通函数和generator函数混用,中间件加载是有问题的,上面是测试样例

@hyj1991 你的普通函数沒有 return
將 next().then(data=>console.log(2)); 改為 return next().then(data => console.log(2)) 應該就無問題了

@aaawhz 额,,好的,以后我写文章的时候注意一下这个问题~~

写的好!

不会啊 写的很好 楼上是因为没有看第一篇文章才觉得牵强吧

好文,多谢博主!但感觉这种深入的技术文配上一些比较魔性的图之后,比如那个哈哈哈,容易打乱读者节奏诶。感觉一下脑子会短路,可能因人而异吧。

@camiler 嗯嗯嗯,已经把这些不太合适的比较有魔性的表情图删除了,好几个人反馈这个问题了。哈哈哈

请问koa里面如何关闭server呢?

请问koa里面如何关闭server呢?

listen(port?: number, hostname?: string, backlog?: number, listeningListener?: () => void): Server;

根据index.d.ts定义
app.listen 返回的是 http server
那么举例

let server = app.listen(3000)
server.close()