了解 KOA 的工作原理

作者:frank 发表日期:2018-01-14 14:19:43 更新日期:2018-01-14 14:19:43 分类:猿文色

摘要

从 KOA 中令人费解的 yield 看到的.

正文

EGGJS 1.x 版本使用的是 KOA 1.x 版本, KOA 1.x 版本中的 yield 令人费解, 在不熟悉 KOA 的工作原理的情况下, 经常会遇到 "can't set headers after the body has sent..." 等类似的错误. 由于工作时间非常紧, 每天都在测试, 解 bug, 或者新需求, 或者协调资源, 或者解决一些疑难杂症, 没有太多时间研究 KOA 的工作原理, 我以为 KOA 的源码应该非常复杂难懂, 没想到只有 4 个文件, 一天晚上花了一个多小时彻底搞懂了 KOA 的工作原理, 对于中间件或者 controller 中的 yield 也有了比较深刻的理解.

KOA 的源码中包含 4 个文件:

  1. application.js

  2. context.js

  3. request.js

  4. response.js

主要的逻辑在 application.js中, 其他 3 个文件帮我们初始化 context 上下文, 封装 request 和 response 中的属性和方法. 所以我们主要关注 application.js 中的代码. 

application.js 定义了一个类 Application, 暴露了一些方法, 我们平时最常用的写法是:

const Koa = require('koa');
const app = new Koa();

app.use((ctx, next) => {
  next();
});

app.listen(3000);

所以我们主要关注 listen 和 use 方法:

// listen: 创建一个 HTTP 服务
listen(...args) {
  const server = http.createServer(this.callback());
  return server.listen(...args);
}

// use: 向中间件中 push 回调函数
use(fn) {
  if (isGeneratorFunction(fn)) {
    ...
    fn = convert(fn);
  }
  this.middleware.push(fn);
  return this;
}

我们观察到 listen 中调用了 this.callback(), 看看 callback 做了什么:

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

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

  return handleRequest;
}

callback 返回一个函数, 用于处理 HTTP 请求. 这个函数调用了所有的 middlewares. 而且 callback 中调用了 compose, 这个 compose 对中间件做了什么呢, 要理解这个东西, 我们先看看 use 方法, use 中有一个判断, 如果传入的回调函数 fn 是一个 Generator, 则 convert 一下 fn, 那 convert 做了什么:

// convert: 返回一个 converted 函数, 在 co 中调用了 mw (middleware),
function convert (mw) {
  ...
  const converted = function (ctx, next) {
    return co.call(ctx, mw.call(ctx, createGenerator(next)))
  }
  converted._name = mw._name || mw.name
  return converted
}

co 就是关键了, 我们再看看 co 干了什么:

// co 用来执行一个 Generator 的中间件函数
// 封装所有的 next 操作以及对 done 的判断
// 返回一个 promsie
function co(gen) {
  var ctx = this;
  var args = slice.call(arguments, 1)

  return new Promise(function(resolve, reject) {
    ...

    onFulfilled();

    function onFulfilled(res) {
      var ret;
      try {
        ret = gen.next(res);
      } catch (e) {
        return reject(e);
      }
      next(ret);
    }
    
    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(...);
    }
  });
}

ok, 现在我们知道了 convert 调用了 co, 将 Generator 中间件的执行封装了起来, 最后返回一个 promise. 如果一个中间件不是 Generator, 则会被直接 push 到 middlewares 的数组中. 回到 callback 中, compose 到底做了什么:

// compose 返回一个函数, 在这个函数中会依次执行所有的 middleware,
// 每一个 middleware 的 next 函数中都会调用下一个 middleware,
// 直到所有的 middleware 都执行完成.
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, function next () {
          return dispatch(i + 1)
        }))
      } catch (err) {
        return Promise.reject(err)
      }
    }
  }
}

等到所有的 middleware 都执行完成后, 就会调用 callback 中的 handleResponse 方法, 做最后的 respond 操作. 到这里一个 HTTP 请求就执行完成了.

总结一下, 整个流程是这样的:

首先, KOA 会处理所有的 middleware, 如果这个 middleware 是生成器(Generator), 则会通过 co 封装一次; 

其次, 处理完所有的 middleware 后会调用 compose 依次调用所有的 middleware; 

最后, 返回处理结果.