Koa 源码分析

219 阅读10分钟

这篇文章来自我们团队穆召同学的分享,介绍了Koa源码的结构。现在流行的如Egg.js等Node.js后端框架,就是基于Koa二次封装的。对服务端开发感兴趣的同学不妨来了解一下。

阅读文章前,建议提前准备好Koa源码,以便随时对照查看。

概览

Koa的源码结构非常简单,所有源码都在lib文件夹下,总共只包含4个文件:

- lib
  - application.js // 入口文件,定义了Application类
  - context.js // 请求上下文 ctx
  - request.js // ctx.request 对象
  - response.js // ctx.response 对象

下面通过一个简单的koa应用详细介绍各个文件的内容

一个简单的Koa应用

// npm i koa

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

app.use(async (ctx, next) => {
  ctx.body = 'Hello Koa';
});

app.listen(7000);

node app.js运行上述代码,此时一个简单的web服务就启动成功了。然后在浏览器导航栏输入http://localhost:7000就可以看出到 ‘Hello, Koa’

首先,我们通过require('koa')引入Koa包,然后new Koa()创建了Koa的实例。检查Koa文件夹中的package.json文件,可以看出实际引入的是lib/application文件。

application.js


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

// 继承 Emitter, 拥有处理事件能力
module.exports = class Application extends Emitter {
  /**
   * Initialize a new `Application`.
   *
   * @api public
   */

  /**
    *
    * @param {object} [options] Application options
    * @param {string} [options.env='development'] Environment
    * @param {string[]} [options.keys] Signed cookie keys
    * @param {boolean} [options.proxy] Trust proxy headers
    * @param {number} [options.subdomainOffset] Subdomain offset
    * @param {string} [options.proxyIpHeader] Proxy IP header, defaults to X-Forwarded-For
    * @param {number} [options.maxIpsCount] Max IPs read from proxy IP header, default to 0 (means infinity)
    *
    */
  constructor (options) {
    super()
    options = options || {}
    // 代理服务有关
    // X-Forwarded-Proto XFP记录客户端与代理服务器连接使用的协议(HTTP,HTTPS)
    // X-Forwarded-For <client>, <proxy1>, <proxy2>
    // X-Forwarded-Host 记录了客户端访问最初的host
    this.proxy = options.proxy || false
    this.subdomainOffset = options.subdomainOffset || 2 // 子域偏移
    this.proxyIpHeader = options.proxyIpHeader || 'X-Forwarded-For' // 获取 ctx.request.ips 的 header 字段
    this.maxIpsCount = options.maxIpsCount || 0 // 获取 ctx.request.ips 的最大数量
    this.env = options.env || process.env.NODE_ENV || 'development' // 环境变量
    if (options.keys) this.keys = options.keys // cookie签名密钥数组
    this.middleware = [] // 定义了一个中间件数组,所有中间件都会push到该数组
    // Object.create 实现了继承
    this.context = Object.create(context) // 根据context.js创建context
    this.request = Object.create(request) // 根据request.js创建request  
    this.response = Object.create(response) // 根据response.js创建response
    // util.inspect.custom support for node 6+
    /* istanbul ignore else */
    if (util.inspect.custom) {
      // 定义this[util.inspect.custom] 可以覆盖util.inspect的默认行为
      this[util.inspect.custom] = this.inspect
    }
  }
  ...
}

其中,this.proxy 从注释中可以出为 是否信任代理头部,我们来看看和 this.proxy 相关的源码,打开 request.js 文件, 全局搜索 proxy 关键词,可以看到和proxy相关的几个方法。 当 proxy 设置为 true 时, request.hostrequest.protocalrequest.ips会分别获取请求头的 X-Forwarded-Host, X-Forwarded-For, X-Forwarded-Proto 的值作为返回值,这三个值其实和代理服务有关,有兴趣的同学可以去mozilla官网详细了解。this.proxyIpHeader决定request.ips具体获取请求头的字段。maxIpsCount决定了request.ips返回ips的最大数量。

/**
   * Parse the "Host" header field host
   * and support X-Forwarded-Host when a
   * proxy is enabled.
   *
   * @return {String} hostname:port
   * @api public
   */

  get host () {
    const proxy = this.app.proxy
    let host = proxy && this.get('X-Forwarded-Host')
    if (!host) {
      if (this.req.httpVersionMajor >= 2) host = this.get(':authority')
      if (!host) host = this.get('Host')
    }
    if (!host) return ''
    return host.split(/\s*,\s*/, 1)[0]
  },
  /**
   * Return the protocol string "http" or "https"
   * when requested with TLS. When the proxy setting
   * is enabled the "X-Forwarded-Proto" header
   * field will be trusted. If you're running behind
   * a reverse proxy that supplies https for you this
   * may be enabled.
   *
   * @return {String}
   * @api public
   */

  get protocol () {
    if (this.socket.encrypted) return 'https'
    if (!this.app.proxy) return 'http'
    const proto = this.get('X-Forwarded-Proto')
    return proto ? proto.split(/\s*,\s*/, 1)[0] : 'http'
  },
  /**
   * When `app.proxy` is `true`, parse
   * the "X-Forwarded-For" ip address list.
   *
   * For example if the value was "client, proxy1, proxy2"
   * you would receive the array `["client", "proxy1", "proxy2"]`
   * where "proxy2" is the furthest down-stream.
   *
   * @return {Array}
   * @api public
   */

  get ips () {
    const proxy = this.app.proxy
    const val = this.get(this.app.proxyIpHeader)
    let ips = proxy && val
      ? val.split(/\s*,\s*/)
      : []
    if (this.app.maxIpsCount > 0) {
      ips = ips.slice(-this.app.maxIpsCount)
    }
    return ips
  },
  

this.subdomainOffset 表示字域偏移量,可以看以下 request.js 中的源码, 举个例子: 如果请求域名为 tobi.ferrets.example.com , 如果 app.subdomainOffset 没设置,this.subdomains 返回 ["ferrets", "tobi"], 如果 app.subdomainOffset 设置为3,则this.subdomains返回["tobi"]

/**
   * Return subdomains as an array.
   *
   * Subdomains are the dot-separated parts of the host before the main domain
   * of the app. By default, the domain of the app is assumed to be the last two
   * parts of the host. This can be changed by setting `app.subdomainOffset`.
   *
   * For example, if the domain is "tobi.ferrets.example.com":
   * If `app.subdomainOffset` is not set, this.subdomains is
   * `["ferrets", "tobi"]`.
   * If `app.subdomainOffset` is 3, this.subdomains is `["tobi"]`.
   *
   * @return {Array}
   * @api public
   */

  get subdomains () {
    const offset = this.app.subdomainOffset
    const hostname = this.hostname
    if (net.isIP(hostname)) return []
    return hostname
      .split('.')
      .reverse()
      .slice(offset)
  },

this.env 表示运行的环境变量,默认为developmentthis.keys 定义 cookie 签名密钥数组。util.inspect为返回对象的字符串表示,而this[util.inspect.custom] = function(){return 'example'}可以覆盖util.inspect的默认行为并打印出example

this.middleware = [] // 定义了一个中间件数组,所有中间件都会push到该数组
// Object.create 实现了继承
this.context = Object.create(context) // 根据context.js创建context
this.request = Object.create(request) // 根据request.js创建request  
this.response = Object.create(response) // 根据response.js创建response

this.middleware 定义了中间件数组,所有使用app.use()注册的中间件都会push到这个数组里面。

使用 Object.create() 分别实现 this.contextthis.requestthis.response 对contextrequestresponse三个文件的继承。

context.js

const proto = module.exports = {
  inspect () {},
  toJSON () {},
  assert: httpAssert,
  throw (...args) {},

  /**
   * Default error handling.
   *
   * @param {Error} err
   * @api private
   */

  onerror (err) {},

  get cookies () {},

  set cookies (_cookies) {}
}

/* istanbul ignore else */
if (util.inspect.custom) {
  module.exports[util.inspect.custom] = module.exports.inspect
}

/**
 * Response delegation.
 */

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

/**
 * Request delegation.
 */

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

可以看出context.js定义了一些方法和属性, 可以自行了解一下,都比较容易理解。文件末尾调用delegate方法,将proto的方法委托给responserequest,所以此时调用proto的方法实际是调用了responserequest的方法。

委托模式,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.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;
};

Delegator.prototype.fluent = function (name) {
  var proto = this.proto;
  var target = this.target;
  this.fluents.push(name);

  proto[name] = function(val){
    if ('undefined' != typeof val) {
      this[target][name] = val;
      return this;
    } else {
      return this[target][name];
    }
  };

  return this;
};

method方法将proto的方法委托给targetgetter,setterproto的属性委托给targetaccess方法同时定义了gettersetter。而fluent方法则是access方法的另一种写法。可以结合context代码看出,context自己并没有额外声明方法的实现,只是将方法和属性分别委托给了requestresponse。所以我们在koa程序中调用 ctx.body 等方法实际上是调用的对应的responserequest上的方法。

request.js

module.exports = {
  get header () {
    return this.req.headers
  },
  set header (val) {
    this.req.headers = val
  },
  ...
}

response.js

module.exports = {
  get socket () {
    return this.res.socket
  },
  get status () {
    return this.res.statusCode
  },
  ...
}

request.jsresponse.js 是基于node原生的reqres对象封装了一些更为简单方便的方法和属性,使处理请求时能方便的调用。

Koa 中间件机制

继续看一看是那个简单的demoKoa实例创建完成后,调用 app.use()给应用添加中间件

// application.js 源码
module.exports = class Application extends Emitter {
  ...
  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
  }
  ...
}

use方法十分简单,仅仅时判断了参数fn是否为function类型,不是的话报错,否则将fn推出this.middleware数组,所以不难理解所有的koa中间件都会按顺序储存在this.middleware

然后调用app.listen启动服务

// application.js 源码
module.exports = class Application extends Emitter {
  ...
  listen (...args) {
    debug('listen')
    const server = http.createServer(this.callback())
    return server.listen(...args)
  }
  ...
}

listen方法其实就是调用了原生http模块的createServer方法创建http服务,然后调用server.listen监听端口启动服务。熟悉node原生http的应该会知道,实际上http.createServer(callback)的参数是一个回调函数,而每个http请求都会执行这个callback,也可以说会经过这个callback, 而所有的node框架中间件机制都是围绕这个callback进行实现的。而Koa这里调用了this.callback()Koa的中间件机制就是在this.callback函数中完成。

// application.js 源码
module.exports = class Application extends Emitter {
  ...
  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
  }
  ...
}

可以看出callback中调用了componse函数返回了一个fncomponse实际上是引入koa-componse包,这个包实现了koa中间件的核心逻辑,后面详细介绍。随后监听了'error'事件,处理错误。接下来返回了返回了handleRequest函数作为原生http.createServer的回调函数,而koa中间件机制,全部是在handleReqeust这个函数中实现。随后listen方法调用了node原生server.listen传入端口号,至此一个整koa服务已经启动成功。此时访问对应的端口号,既可以收到对象的响应。

那么来看看具体的一个请求koa做了什么,结合之前讲的node原生http模块的createServer方法需要传递一个回调函数,而这个回调函数实际上是每个http请求都需要执行的。koa框架通过调用callback方法返回这个回调函数handleRequest,所以当http请求被处理时实际上是调用了handleRequest函数。handleRequest首先会调用this.createContext(req, res)函数,将node原生的reqres传入。

// application.js 源码
module.exports = class Application extends Emitter {
  ...
  createContext (req, res) {
    // 创建独立的ctx
    const context = Object.create(this.context)
    // 创建独立的request赋值给context.request
    const request = context.request = Object.create(this.request)
    // 创建独立的response赋值给context.response
    const response = context.response = Object.create(this.response)
    // 将实例赋值给context.app, request.app, response.app
    context.app = request.app = response.app = this
    // 将原生req, res赋值给context, request, response
    context.req = request.req = response.req = req
    context.res = request.res = response.res = res
    // 将ctx赋值给 request 和 response
    request.ctx = response.ctx = context
    //request, response 相互赋值
    request.response = response
    response.request = request
    // 原生req.url赋值给context, request
    context.originalUrl = request.originalUrl = req.url
    // 创建state对象用作用户自定存储
    context.state = {}
    return context
  }
  ...
}

createContext其实就是保证了每个请求会有自己独立的contextrequestresponse,并且做了一系列赋值操作,使delegate委托生效。

// application.js 源码
module.exports = class Application extends Emitter {
  ...
  handleRequest (ctx, fnMiddleware) {
    const res = ctx.res
    res.statusCode = 404 // 默认了statusCode 为404
    const onerror = err => ctx.onerror(err)
    const handleResponse = () => respond(ctx)
    onFinished(res, onerror) // 是监听了res相关错误事件,onerror去捕获错误
    return fnMiddleware(ctx).then(handleResponse).catch(onerror)
  }
  ...
}

fnMiddleware执行完成后对返回结果进行包装处理,然后catch错误,这里需要了解一下,这里onError调用了ctx.onerror,而ctx.onerror实际上调用了this.app.emit('error')触发了error事件,这块就是koa错误处理,也可以手动监听'error'事件错误处理。

洋葱模型,koa-compose 到底做了什么

koa-compose源码实际上非常简单,整个文件包含注释也就不到50行代码,不过,这50行代码却是koa框架的精华所在。 这里基于一个实例分析koa-compose做了什么

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

app.use(async (ctx, next) => {
  console.log('first enter');
  await next();
  console.log('first out');
} );

app.use(async (ctx, next) => {
  console.log('second enter');
  await next();
  console.log('second out');
})

app.use(async (ctx, next) => {
  console.log('last');
  ctx.body = 'Hello Koa';
});

app.listen(7000);
// first enter
// second enter
// last
// second out
// first out

假设我们启动了上述koa服务,当http处理时,进入了中间件处理环节

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

compose函数返回了一个function,这个function 声明了变量index = -1,定义了一个dispatch函数,直接又返回一个dispatch(0),逐行分析dispatch函数

  1. 判断i是否小于等于index, 小于等于index直接返回Promise.reject。这里0 > -1
  2. i赋值给index。此时index = 0;
  3. 将声明变量fnmiddleware[0]赋值给fn。此时fn就等于第一个use的中间件
  4. i是否登录中间件middleware数组的长度,等于的话直接将next赋值给fn。此时middleware.length === 3
  5. 如果fn不存在,直接返回Promise.resolve()。此时fn存在。
  6. 返回promise,prmise结果取决于fn(context, dispatch.bind(null, i + 1)))的执行结果。此时开始执行第一个中间件。而中间件参数next又传入了dispatch(1);

接下来在示例中可以看到第一个中间件中调用了await next(), 实际上第一个中间件阻塞到了await next()处, 直接又调用了dispatch(1),继续重复上述6步,逐行分析,可以得出结果,第二个中间件也阻塞到了await next(), 并且调用dispatch(2), 开始执行第三个中间件,第三个中间件没有执行next,所以等第三个中间件执行完成之后,第二个中间件await next()后续代码开始执行,等第二个中间件代码执行完成,第一个中间件后续代码开始执行,至此示例代码中的三个中间件全部执行完成,这就是koa的洋葱模型。总结一下,简单来说koa是利用async函数通过await递归的调用中间件实现了洋葱模型。