Koa 源码剖析

90 阅读10分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第29天,点击查看活动详情

Koa 源码剖析

本文主要从源码的角度来讲述 Koa,尤其是其中间件系统是如何实现的。

跟 Express 相比,Koa 的源码异常简洁,Express 因为把路由相关的代码嵌入到了主要逻辑中,因此读 Express 的源码可能长时间不得要领,而直接读 Koa 的源码几乎没有什么障碍。

Koa 的主要代码位于根目录下的 lib 文件夹中,只有 4 个文件,去掉注释后的源码不到 1000 行,下面列出了这 4 个文件的主要功能。

  • request.js:对 http request 对象的封装。
  • response.js:对 http response 对象的封装。
  • context.js:将上面两个文件的封装整合到 context 对象中
  • application.js:项目的启动及中间件的加载。

1. Koa 的启动过程

首先回忆一下一个 Koa 应用的结构是什么样子的。

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

//加载一些中间件
app.use(...);
app.use(....);
app.use(.....);

app.listen(3000);

Koa 的启动过程大致分为以下三个步骤:

  • 引入 Koa 模块,调用构造方法新建一个 app 对象。
  • 加载中间件。
  • 调用 listen 方法监听端口。

我们逐步来看上面三个步骤在源码中的实现。

首先是类和构造函数的定义,这部分代码位于 application.js 中。

// application.js
const response = require('./response')
const context = require('./context')
const request = require('./request')
const Emitter = require('events')
const util = require('util')
// ...... 其他模块

module.exports = class Application extends Emitter {
  constructor (options) {
    super()
    options = options || {}
    this.proxy = options.proxy || false
    this.subdomainOffset = options.subdomainOffset || 2
    this.proxyIpHeader = options.proxyIpHeader || 'X-Forwarded-For'
    this.maxIpsCount = options.maxIpsCount || 0
    this.env = options.env || process.env.NODE_ENV || 'development'
    if (options.keys) this.keys = options.keys
    this.middleware = []
    // 下面的 context,request,response 分别是从其他三个文件夹中引入的
    this.context = Object.create(context)
    this.request = Object.create(request)
    this.response = Object.create(response)
    // util.inspect.custom support for node 6+
    /* istanbul ignore else */
    if (util.inspect.custom) {
      this[util.inspect.custom] = this.inspect
    }
  }
  // ...... 其他类方法
}

首先我们注意到该类继承于 Events 模块,然后当我们调用 Koa 的构造函数时,会初始化一些属性和方法,例如以context/response/request为原型创建的新的对象,还有管理中间件的 middleware 数组等。

2. 中间件的加载

中间件的本质是一个函数。在 Koa 中,该函数通常具有 ctxnext 两个参数,分别表示封装好的 res/req 对象以及下一个要执行的中间件,当有多个中间件的时候,本质上是一种嵌套调用,就像洋葱图一样。

Koa 和 Express 在调用上都是通过调用 app.use() 的方式来加载一个中间件,但内部的实现却大不相同,我们先来看application.js 中相关方法的定义。

/**
 * Use the given middleware `fn`.
 *
 * Old-style middleware will be converted.
 *
 * @param {Function} fn
 * @return {Application} self
 * @api public
 */

use(fn) {
  if (typeof fn !== 'function') throw new TypeError('middleware must be a function!')
  debug('use %s', fn._name || fn.name || '-')
  this.middleware.push(fn)
  return this
}

Koa 在 application.js 中维持了一个 middleware 的数组,如果有新的中间件被加载,就 push 到这个数组中,除此之外没有任何多余的操作,相比之下,Express 的 use 方法就麻烦得多,读者可以自行参阅其源码。

此外,之前版本中该方法中还增加了 isGeneratorFunction 判断,这是为了兼容 Koa1.x 的中间件而加上去的,在 Koa1.x 中,中间件都是 Generator 函数,Koa2 使用的 async 函数是无法兼容之前的代码的,因此 Koa2 提供了 convert 函数来进行转换,关于这个函数我们不再介绍。

if (isGeneratorFunction(fn)) {
  // ......
  fn = convert(fn)
}

接下来我们来看看对中间件的调用。

/**
 * Return a request handler callback
 * for node's native http server.
 *
 * @return {Function}
 * @api public
 */

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

  if (!this.listenerCount('error')) this.on('error', this.onerror)

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

  return handleRequest
}

可以看出关于中间件的核心逻辑应该位于 compose 方法中,该方法是一个名为 Koa-compose 的第三方模块github.com/Koajs/compo…,我们可以看看其内部是如何实现的。

该模块只有一个方法 compose,调用方式为 compose([a, b, c, ...]),该方法接受一个中间件的数组作为参数,返回的仍然是一个中间件(函数),可以将这个函数看作是之前加载的全部中间件的功能集合。

/**
 * Compose `middleware` returning
 * a fully valid middleware comprised
 * of all those which are passed.
 *
 * @param {Array} middleware
 * @return {Function}
 * @api public
 */

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

该方法的核心是一个递归调用的 dispatch 函数,为了更好地说明这个函数的工作原理,这里使用一个简单的自定义中间件作为例子来配合说明。

function myMiddleware(context, next) {
  process.nextTick(function () {
    console.log('I am a middleware');
  })
  next();
}

可以看出这个中间件除了打印一条消息,然后调用 next 方法之外,没有进行任何操作,我们以该中间件为例,在 Koa 的 app.js 中使用 app.use 方法加载该中间件两次。

const Koa = require('Koa');
const myMiddleware = require("./myMiddleware");
app.use(md1);
app.use(dm2);
app.listen(3000);

app 真正实例化是在调用 listen 方法之后,那么中间件的加载同样位于 listen 方法之后。

那么 compose 方法的实际调用为 compose[myMiddleware,myMiddleware],在执行 dispatch(0) 时,该方法实际可以简化为:

function compose(middleware) {
  return function (context, next) {
    try {
      return Promise.resolve(md1(context, function next() {
        return Promise.resolve(md2(context, function next() {

        }))
      }))
    } catch (err) {
      return Promise.reject(err)
    }
  }
}

可以看出 compose 的本质仍是嵌套的中间件。

3. listen() 方法

这是 app 启动过程中的最后一步,读者会疑惑:为什么这么一行也要算作单独的步骤,事实上,上面的两步都是为了 app 的启动做准备,整个 Koa 应用的启动是通过 listen 方法来完成的。下面是 application.js 中 listen 方法的定义。

/**
 * Shorthand for:
 *
 *    http.createServer(app.callback()).listen(...)
 *
 * @param {Mixed} ...
 * @return {Server}
 * @api public
 */
listen(...args) {
  debug('listen')
  const server = http.createServer(this.callback())
  return server.listen(...args)
}

上面的代码就是 listen 方法的内容,可以看出第 3 行才真正调用了 http.createServer 方法建立了 http 服务器,参数为上节 callback 方法返回的 handleRequest 方法,源码如下所示,该方法做了两件事:

  • 封装 requestresponse 对象。
  • 调用中间件对 ctx 对象进行处理。
/**
 * Handle request in callback.
 *
 * @api private
 */

handleRequest (ctx, fnMiddleware) {
  const res = ctx.res
  res.statusCode = 404
  const onerror = err => ctx.onerror(err)
  const handleResponse = () => respond(ctx)
  onFinished(res, onerror)
  return fnMiddleware(ctx).then(handleResponse).catch(onerror)
}

4. next() 与 return next()

我们前面也提到过,Koa 对中间件调用的实现本质上是嵌套的 promise.resolve 方法,我们可以写一个简单的例子。

let ctx = 1;
const md1 = function (ctx, next) {
  next();
}
const md2 = function (ctx, next) {
  return ++ctx;
}
const p = Promise.resolve(
  mdl(ctx, function next() {
    return Promise.resolve(
      md2(ctx, function next() {
        //更多的中间件...
      })
    )
  })
)
p.then(function (ctx) {
  console.log(ctx);
})

代码在第一行定义的变量 ctx,我们可以将其看作 Koa 中的 ctx 对象,经过中间件的处理后,ctx 的值会发生相应的变化。

我们定义了 md1md2 两个中间件,md1 没有做任何操作,只调用了 next 方法,md2 则是对 ctx 执行加一的操作,那么在最后的 then 方法中,我们期望 ctx 的值为 2。

我们可以尝试运行上面的代码,最后的结果却是 undefined,在 md1next 方法前加上 return 关键字后,就能得到正常的结果了。

在 Koa 的源码 application.js 中,callback 方法的最后一行:

/**
 * Return a request handler callback
 * for node's native http server.
 *
 * @return {Function}
 * @api public
 */

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

  if (!this.listenerCount('error')) this.on('error', this.onerror)

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

  return handleRequest
}

/**
 * Handle request in callback.
 *
 * @api private
 */

handleRequest (ctx, fnMiddleware) {
  const res = ctx.res
  res.statusCode = 404
  const onerror = err => ctx.onerror(err)
  const handleResponse = () => respond(ctx)
  onFinished(res, onerror)
  return fnMiddleware(ctx).then(handleResponse).catch(onerror)
}

中的 fnMiddleware(ctx) 相当于之前代码第 8 行声明的 Promise 对象 p,被中间件方法修改后的 ctx 对象被 then 方法传给 handleResponse 方法返回给客户端。

每个中间件方法都会返回一个 Promise 对象,里面包含的是对 ctx 的修改,通过调用 next 方法来调用下一个中间件。

fn(context, function next () {
  return dispatch(i + 1);
})

再通过 return 关键字将修改后的 ctx 对象作为 resolve 的参数返回。

如果多个中间件同时操作了 ctx 对象,那么就有必要使用 return 关键字将操作的结果返回到上一级调用的中间件里。

事实上,如果读者去读 Koa-router 或者 Koa-static 的源码,也会发现它们都是使用 return next 方法。

5. 关于 Can't set headers after they are sent.

这是使用 Express 或者 Koa 常见的错误之一,其原因如字面意思,对于同一个 HTTP 请求重复发送了 HTTP HEADER 。服务器在处理HTTP 请求时会先发送一个响应头(使用 writeHeadsetHeader 方法),然后发送主体内容(通过 send 或者 end 方法),如果对一个 HTTP 请求调用了两次 writeHead 方法,就会出现 Can't set headers after they are sent 的错误提示,例如下面的例子:

const http = require("http");

http.createServer(function (req, res) {
  res.setHeader('Content-Type', 'text/html');
  res.end('ok');

  resend(req, res); // 在响应结束后再次发送响应信息
  
}).listen(5000);

function resend(req, res) {
  res.setHeader('Content-Type', 'text/html');
  res.end('error');
}

试着访问 localhost:5000 就会得到错误信息,这个例子太过直白了。下面是一个 Express 中的例子,由于中间件可能包含异步操作,因此有时错误的原因比较隐蔽。

const express = require('express');
const app = express();

app.use(function (req, res, next) {
  setTimeout(function () {
    res.redirect("/bar");
  }, 1000);
  next();
});

app.get("/foo", function (req, res) {
  res.end("foo");
});

app.get("/bar", function (req, res) {
  res.end("bar");
});

app.listen(3000);

运行上面的代码,访问 http://localhost:3000/foo 会产生同样的错误,原因也很简单,在请求返回之后,setTimeout 内部的 redirect 会对一个已经发送出去的 response 进行修改,就会出现错误,在实际项目中不会像 setTimeout 这么明显,可能是一个数据库操作或者其他的异步操作,需要特别注意。

6. Context 对象的实现

关于 ctx 对象是如何得到 request/response 对象中的属性和方法的,可以阅读 context.js 的源码,其核心代码如下所示。此外,delegate 模块还广泛运用在了 Koa 的各种中间件中。

const delegate = require('delegates')

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

delegate 是一个 Node 第三方模块,作用是把一个对象中的属性和方法委托到另一个对象上。

读者可以访问该模块的项目地址 https://github.com/tj/node-delegates,然后就会发现该模块的主要贡献者还是TJ Holowaychuk。

这个模块的代码同样非常简单,源代码只有 100 多行,我们这里详细介绍一下。

在上面的代码中,我们使用了如下三个方法:

  • method:用于委托方法到目标对象上。
  • access:综合 gettersetter,可以对目标进行读写。
  • getter:为目标属性生成一个访问器,可以理解成复制了一个只读属性到目标对象上。

gettersetter 这两个方法是用来控制对象的读写属性的,下面是 method 方法与 access 方法的实现。

/**
 * Delegate method `name`.
 *
 * @param {String} name
 * @return {Delegator} self
 * @api public
 */

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

method 方法中使用 apply 方法将原目标的方法绑定到目标对象上。

下面是 access 方法的定义,综合了 getter 方法和 setter 方法。

/**
 * Delegator accessor `name`.
 *
 * @param {String} name
 * @return {Delegator} self
 * @api public
 */

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

/**
 * Delegator getter `name`.
 *
 * @param {String} name
 * @return {Delegator} self
 * @api public
 */

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

/**
 * Delegator setter `name`.
 *
 * @param {String} name
 * @return {Delegator} self
 * @api public
 */

Delegator.prototype.setter = function(name){
  var proto = this.proto;
  var target = this.target;
  this.setters.push(name);

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

  return this;
};

最后是 delegate 的构造函数,该函数接收两个参数,分别是源对象和目标对象。

/**
 * Initialize a delegator.
 *
 * @param {Object} proto
 * @param {String} target
 * @api public
 */

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 = [];
} 

可以看出 deletgate 对象在内部维持了一些数组,分别表示委托得到的目标对象和方法。

关于动态加载中间件

在某些应用场景中,开发者可能希望能够动态加载中间件,例如当路由接收到某个请求后再去加载对应的中间件,但在 Koa 中这是无法做到的。原因其实已经包含在前面的内容了,Koa 应用唯一一次加载所有中间件是在调用 listen 方法的时候,即使后面再调用 app.use 方法,也不会生效了。

7. Koa 的优缺点

通过上面的内容,相信读者已经对 Koa 有了大概的认识,和 Express 相比,Koa 的优势在于精简,它剥离了所有的中间件,并且对中间件的执行做了很大的优化。

一个经验丰富的 Express 开发者想要转到 Koa 上并不需要很大的成本,唯一需要注意的就是中间件执行的策略会有差异,这可能会带来一段时间的不适应。

现在我们来说说 Koa 的缺点,剥离中间件虽然是个优点,但也让不同中间件的组合变得麻烦起来,Express 经过数年的沉淀,各种用途的中间件已经很成熟;而 Koa 不同,Koa2.0 推出的时间还很短,适配的中间件也不完善,有时单独使用各种中间件还好,但一旦组合起来,可能出现不能正常工作的情况。

举个例子,如果想同时使用 routerviews 两个中间件,就要在 render 方法前加上 return 关键字(和 return next()一个道理),对于刚接触 Koa 的开发者可能要花很长时间才能定位问题所在。再例如前面的 koa-sessionKoa-router,我初次接触这两个中间件时也着实花了一些功夫来将他们正确地组合在一块。虽然中间件概念的引入让Node开发变得像搭积木一样,但积木之间如果不能很顺利地拼接在一块的话,也会增加开发成本。