Apr 04, 2019

代码的艺术:koa 源码精读

Node.js 界大名鼎鼎的 koa,不需要多废话了,用了无数次,今天来拜读一下它的源码。

Koa 并不是 Node.js 的拓展,它只是在 Node.js 的基础上实现了以下内容:

  • 中间件式的 HTTP 服务框架 (与 Express 一致)
  • 洋葱模型 (与 Express 不同)

一统天下级别的框架,只包含了约 500 行源代码。极致强大,极致简单。大概这就是码农与码神的区别,真正的代码的艺术吧。

源码结构如下:

lib
├── application.js
├── context.js
├── request.js
└── response.js

一共就这四个文件(当然,还包含了发布在 npm 上面的其它 package,后面会说到),一目了然。

  1. application.js 是应用的入口,也就是 require('koa') 所得到的东西。它是一个继承自 events 的 Class
  2. context.js 就是对应每一个 req / res 的 ctx
  3. request.js / response.js 就不用说了

下面从最基础的看起。

request.js

request.js 大概的样子如下:

// request.js
module.exports = {
  get header() {
    return this.req.headers;
  },
  set header(val) {
    this.req.headers = val;
  },
  get headers() {
    return this.req.headers;
  },
  set headers(val) {
    this.req.headers = val;
  },
  // 其它 getter & setter......
}

这里的 this.req 实际上是 http.IncomingMessage,创建的时候传入的,后面会提到。

这个文件绝大多数是 helper 方法,把本来已经存在在 http.IncomingMessage 中的属性通过更方便的方式(getter / setter)来存取,以达到通过同一个属性来读写的目的。比如想要获取一个 request 的 header 时,通过 ctx.request.heder,而想写入 header 时,可以通过 ctx.request.heder = xxx 来实现。这也是 koa 的友好特性之一。

其中有一个特殊的是 ip

// request.js
const IP = Symbol('context#ip');

module.exports = {
  // ...
  get ip() {
    if (!this[IP]) {
      this[IP] = this.ips[0] || this.socket.remoteAddress || '';
    }
    return this[IP];
  },
  set ip(_ip) {
    this[IP] = _ip;
  },
  // ...
}

Symbol('context#ip')request 对象唯一一个来自自身的 key,我猜测它的目的是:

  1. 允许开发者对真实请求 ip 进行改写
  2. 同时利用 Symbol 不等于任何值的特性,使它成为私有属性,对外不可见,只可通过 getter 获取

response.js

response.jsrequest.js 类似,不同之处在于,response.js 重点更多在 setter 上面,很好理解,因为 response 的重点是一个服务器向用户返回内容的过程。

koa 的一大特性是在于,只需要向 ctx.response.body 赋值就能完成一次请求响应。代码:

module.exports = {
  // ...
  get body() {
    return this._body;
  },
  set body(val) {
    const original = this._body;
    this._body = val;

    // no content
    if (null == val) {
      if (!statuses.empty[this.status]) this.status = 204;
      this.remove('Content-Type');
      this.remove('Content-Length');
      this.remove('Transfer-Encoding');
      return;
    }

    // set the status
    if (!this._explicitStatus) this.status = 200;

    // set the content-type only if not yet set
    const setType = !this.header['content-type'];

    // string
    if ('string' == typeof val) {
      if (setType) this.type = /^\s*</.test(val) ? 'html' : 'text';
      this.length = Buffer.byteLength(val);
      return;
    }

    // buffer
    if (Buffer.isBuffer(val)) {
      if (setType) this.type = 'bin';
      this.length = val.length;
      return;
    }

    // stream
    if ('function' == typeof val.pipe) {
      onFinish(this.res, destroy.bind(null, val));
      ensureErrorHandler(val, err => this.ctx.onerror(err));

      // overwriting
      if (null != original && original != val) this.remove('Content-Length');

      if (setType) this.type = 'bin';
      return;
    }

    // json
    this.remove('Content-Length');
    this.type = 'json';
  },
  // ...
}

可以看到,在 body 的 setter 里面,分别对传入的值为 null / string / buffer / stream / json 的情况进行了处理,并完成了向客户端返回的其它逻辑(设置各种响应头以及状态码),以达到上述目的。

为了达到「至简」目的,koa 对外暴露的 API 基本都是通过 getter / setter 的方式实现的,值得借鉴。

context.js

Context 「上下文」(通常简写为 ctx)是 koa 的核心之一,它代表了一次用户请求,每个请求都对应着一个独立的 context,实际上它就是 requestresponse 的结合体,通过「委托模式」实现。它的作用是,开发者对于每一个请求,只需要拿到它的 ctx,就能获取到所有请求的相关信息,亦能做出任何形式的响应。

它的核心代码如下:

'use strict';
const delegate = require('delegates');
const Cookies = require('cookies');

const COOKIES = Symbol('context#cookies');

const proto = module.exports = {
  // ...
  get cookies() {
    if (!this[COOKIES]) {
      this[COOKIES] = new Cookies(this.req, this.res, {
        keys: this.app.keys,
        secure: this.request.secure
      });
    }
    return this[COOKIES];
  },
  set cookies(_cookies) {
    this[COOKIES] = _cookies;
  }
};

delegate(proto, 'response')
  .method('attachment')
  .method('redirect')
  .method('remove')
  .method('vary')
  .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(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')
  .access('accept')
  .getter('origin')
  .getter('href')
  .getter('subdomains')
  .getter('protocol')
  .getter('host')
  .getter('hostname')
  .getter('URL')
  .getter('header')
  .getter('headers')
  .getter('secure')
  .getter('stale')
  .getter('fresh')
  .getter('ips')
  .getter('ip');

可以看到里面的主要内容有:

  1. 实现 Cookies 的 getter / setter(因为 koa 把 req 和 res 的 cookies 结合在一起了,所以它须要在 ctx 内实现)
  2. 将 request / response 的逻辑代理到 ctx 上面

关于这个「委托模式」的具体实现,TJ 把它放到了一个独立的 NPM Package delegates 中。它的功能是:将一个类的子类中的方法与属性,暴露到父类中去,而暴露在父类上的方法可以看做真实方法的「代理」。koa 使用了其中的三种模式,分别是:

  1. method 代理方法
  2. access 代理 getter 与 setter
  3. getter 仅代理 getter

其主要源码:

module.exports = Delegator;

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.access = function(name){
  return this.getter(name).setter(name);
};

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

依然非常简洁。method 代理使用 Function.apply 实现,getter / setter 代理使用 object.__defineGetter__object.__defineSetter__ 实现。

application.js

去除兼容、校验、实用方法等逻辑,精简过后,该文件的主要内容如下:

'use strict';
const onFinished = require('on-finished');
const response = require('./response');
const compose = require('koa-compose');
const context = require('./context');
const request = require('./request');
const Emitter = require('events');
const util = require('util');

/**
 * Expose `Application` class.
 * Inherits from `Emitter.prototype`.
 */

module.exports = class Application extends Emitter {
  
  constructor() {
    super();

    this.middleware = [];
    this.context = Object.create(context);
    this.request = Object.create(request);
    this.response = Object.create(response);
  }

  listen(...args) {
    const server = http.createServer(this.callback());
    return server.listen(...args);
  }

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

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

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

    return handleRequest;
  }

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

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

  onerror(err) {
    if (!(err instanceof Error)) throw new TypeError(util.format('non-error thrown: %j', err));

    if (404 == err.status || err.expose) return;
    if (this.silent) return;

    const msg = err.stack || err.toString();
    console.error();
    console.error(msg.replace(/^/gm, '  '));
    console.error();
  }
};

不到一百行,Koa 主要功能已经全在里面了。

现在可以梳理一下当我们创建一个 koa 服务器的时候,实际上都干了些什么吧:

  1. 调用 constructor,初始化 ctx / req / res,以及最重要的 middleware 数组(不过不理解的是,为什么命名没有加 s 呢?)
  2. 对于各种业务场景,调用 app.use,这一步只是一个简单的向 middleware 数组 push 的过程
  3. 调用 app.listen,启动 HTTP 服务器
  4. 对于每一个进来的请求,调用 callback 方法,这个方法做了三件事:
    1. 通过 koa-compose 将中间件数组组合为一个「洋葱」模型
    2. 调用 createContext 方法,为请求创建 ctx 上下文,同时挂载 req / res
    3. 调用 handleRequest 方法,按洋葱模型的顺序执行中间件,并最终返回或报错

这里面最重要的一步就是「洋葱」模型的构建。实际上这个过程也非常简单,以下是 koa-compose 的源码(为了精简,已去除校验等逻辑):

function compose (middleware) {
  return function (context, next) {
    // last called middleware #
    let index = -1
    return dispatch(0)
    function dispatch (i) {
      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. 首先约定,每一个 middleware 都是一个 async 函数(即 Promise),接受两个参数 ctxnext
  2. 当 middleware 内部调用 next 函数时,实际上是递归调用了 dispatch.bind(null, i + 1) 函数,也就是,将 index + 1 的中间件取出来并执行了。因为中间件都是 Promise,所以能够被 await
  3. 递归执行步骤 2,直到调用到最后一个 middleware 时,最后被调用的 middleware 会最先结束,然后到上一个,再到上上一个,如此往复就形成了「洋葱」模型
  4. 最终所有 middleware 都执行完毕,compose 函数返回 Promise.resolve(),即退出递归

「洋葱」模型构建完毕后,compose 函数返回一个 Promise,所有 middleware 都已经被有序串联,只需要直接执行该 promise 实例即可。

让人不禁感叹:大道至简

end

至此,koa 的最主要的功能实现都已过了一遍了。

总结一下它做了的事情:

  1. 通过 getter / setter 方法简化 Node.js HTTP 的使用方式
  2. 通过 ctx 简化开发者访问 req / res 的方式
  3. 通过「洋葱」模型简化 HTTP 请求的处理流程

大概就这样。