Koa 梳理分析【二:异步中间件】

1,148 阅读8分钟

博客原文地址 欢迎交流、star

上一章koa实例创建和处理请求的流程梳理了一遍,其中很多细节没有分析,比如生成洋葱中间件的compose函数,contextrequestresponed对象是怎么构建的等。这一章就来梳理一下这些细节,学习koa的思想和编程技巧。

洋葱模型中间件

在源码中,是引入了koa-compose工具函数来处理中间件的,最终合并成一个。

const fn = compose(this.middleware);

先看一下compose的简单结构:

function compose (middleware) {
  ...
  return function (context, next) {
    ...
  }
}

可以看到compose函数是一个接受middleware中间数组并返回一个入参为contextnext的函数。这里在koa源码中把这个返回的函数称作为fnMiddleware,它的外部调用形式为:

fnMiddleware(ctx).then(handleResponse).catch(onerror);

koa中调用的时候传入一个ctxnext并没有传入。可以看到这个函数的返回值是一个promise。接下来来看看它的内部实现。

function compose (middleware) {
  ...
  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
      let fn = middleware[i]
      if (i === middleware.length) fn = next
      if (!fn) return Promise.resolve()
      try {
        return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
      } catch (err) {
        return Promise.reject(err)
      }
    }
  }
}
  1. 首先声明一个变量index用来记录当前准备执行的中间件。
  2. 声明dispatch函数,接受一个下标,用来执行对象下标的中间件。
  3. 执行第一个中间件。dispatch(0)

函数内部的核心在于dispatch函数,这个函数的主要几个步骤如下:

  1. 根据传入的下标获取对应的中间件函数,这里会对下标的边界做一些兼容和查错。

  2. 使用Promise.resolve决策一个promise,这个promise就是中间件执行后的返回值。例如我们有个中间件如下:

app.use(async function (ctx, next) {
  return null
})

一个async函数已经会返回一个promise,那么在dispatch函数内部为什么还需要使用Promise.resolve去包裹呢?因为我们传入的中间件有可能就是普通的函数,所有这里是做了一个兼容。

  1. 重点放在fn(context, dispatch.bind(null, i + 1)),被执行的中间件,传入了context对象,这里是直接传入,这就是为什么所有的中间件用到的ctx是全局唯一同一个引用。第二个参数就是next函数,这里把dispatch绑定了下一个下标作为参数传入。如果我们在我们的中间件中不执行next函数,也就是没有调用dispatch(i+1),下一个中间件也就不会被执行了。

  2. 使用try/catch来包裹中间件的执行,有错误就直接返回一个Promise.reject

兼容生成器函数

在看koa的源码的时候,可以看到在使用use添加中间件的时候,会先对函数进行判断,目前2.*的版本下,会将生成器函数转成async/await函数。

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

首先简单的了解一下generator函数。

特点:一是,function关键字与函数名之间有一个星号;二是,函数体内部使用yield表达式,定义不同的内部状态(yield在英语里的意思就是“产出”)。(引用自《ECMAScript 6 入门》) 当执行生产器的时候,会获取一个遍历对象,通过调用对象的next()方法就会返回一个有着 value 和done两个属性的对象{ value: x, done: true/false }value 属性表示当前的内部状态的值,是 yield 表达式后面那个表达式的值;done 属性是一个布尔值,表示是否遍历结束。

简单的介绍之后,可以看到generator的执行,需要通过不停的调用next()函数,但是async/await是可以自动执行的。所以想要将generator转成类async/await就需要有个辅助方法来自动执行generatornext函数。

这里举这样的例子(来自可能是目前最全的koa源码解析指南)。

function* gen() {
  yield new Promise(function (resolve, reject) {
    // 做一些异步操作
    if (true) { // 成功
      resolve()
    } else {
      reject()
    }
  })

  yield new Promise(function (resolve, reject) {
    // 做一些异步操作
    if (true) { // 成功
      resolve()
    } else {
      reject()
    }
  })
}

let g = gen()
let ret = g.next() // 拿到第一个`promise`

怎么让next()继续执行下去呢?看看如下代码。

let p = ret.value // 第一个 promise
p.then(() => { g.next() })

如上,只需要使用一定的方式在每一个promise的决策中再次调用next方法,直到生成器被执行完。 如果想通过上面的方式去实现转换,最终要的一步就是使每一个yield后面返回都应该是一个promise

koa里面的convert函数,最终是调用的是co这库来进行转换的,所以来看看它是怎么处理的。

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);
    }
    ...
    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) + '"'));
    }
  });
}
  1. 整个函数返回一个promise对象,这与async/await一致。
  2. 在返回的promise中,首先判断是否为可执行的生成器函数,然后调用函数,获取到遍历对象
  3. 然后第一次手动执行onFulfilled函数,这个函数就是来调用next()方法的。
  4. 声明onRejected函数,主要用来调用生成器的throw()方法来结束执行和报错的。
  5. 在3、4步骤中,只要调用了g.next()方法,最终都会调用co自己声明的next函数,这个函数的主要工作就是将给promisevalue转成promise,然后再在promise的下一个异步去调用onFulfilledonRejected函数,以此往复。

以上就是如何把generator函数转为类async的逻辑了。

每个请求的独立context

在阅读源码的过程中,在处理请求的回调函数中, 都会调用createContext函数来创建一个独立且整个处理过程中唯一的context对象。

const handleRequest = (req, res) => {
      const ctx = this.createContext(req, res);
      return this.handleRequest(ctx, fn);
}

在中间件处理请求的时候,上一章说过,他们是共享这个context对象的,在处理完之后统一交给response对象去将结果响应给请求方。

// createContext
createContext(req, res) {
    const context = Object.create(this.context);
    const request = context.request = Object.create(this.request);
    const response = context.response = Object.create(this.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.originalUrl = request.originalUrl = req.url;
    context.state = {};
    return context;
}

createContext函数通过Objectet.create方法创建了一个原型为从context.js文件导出的对象的一个新对象,然后在将requestresponse这两个koa自己扩展的对象和http原生的reqres挂载上去了。所以我们在中间件处理响应的时候就可以直接通过ctx访问到原生的reqres对象了,并且能访问到requestresponse上的扩展方法了。

委托模式

在处理请求的的时候,可以直接通过访问ctx.headerctx.urlctx.method来获取一些请求头或者去设置响应头等。能够这样操作,主要是因为ctx.requestctx.response的一些属性和方法被委托到了ctx这个对象上。如果对vue比较熟悉的人,也会感受到vue里面的一些datamethod可以直接在vue的对象中访问到,这些也是委托模式,将其他对象的属性委托在了最上层的对象属性上。

看一下,context.js里面委托的实现,这里截取其中几行代码

delegate(proto, 'request')
  .method('acceptsLanguages')
  .method('get')
  .method('is')
  .access('querystring')
  .access('idempotent')
...

这里通过delegate函数,将proto对象里面的request属性下面的方法委托到proto对象上。这里的delegate函数是引入的一个库const delegate = require('delegates');源码

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

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

通过构造函数初始化委托的对象和被委托的目标对象,这里的methodgetter方法都是很简单的,当访问委托对象上的属性时,就是去调用了target上的属性,只是需要注意this的执行。然后getter的调用也就是直接访问了相应的值。当然这个库还有其他一些方法,有兴趣的可以去了解。

小结

通过分析,对koa源码里面的一些细节更加的清晰了,中间件的合并和co函数的实现思想有了足够的认识。分析源码还是收益不少,委托模式的使用可以应用在一些通用的库上面,让使用者能够最直接的访问到对象的属性值,适当的减少了使用难度。

参考文章:

  1. 可能是目前最全的koa源码解析指南
  2. ECMAScript 6 入门