[Koa源码学习] Koa

645 阅读9分钟

系列文章

前言

Koa是一个基于NodejsWeb框架,它没有内置任何中间件,而是提供了一套优雅的方法,来处理请求的执行流程,并且通过使用asyncawait语法,可以很方便的实现中间件的异步处理。

在每次收到来自客户端的请求时,Koa会创建一个上下文对象,代理原生的请求和响应,然后执行一系列的中间件函数,对请求进行相应的处理。那么接下来,我们就来看看Koa内部是如何实现的。

初始化阶段

在初始化阶段,我们可以将其分为以下三个步骤:

  1. 初始化:初始化Koa实例
  2. use:向实例中添加中间件
  3. listen:创建服务器,开启监听

初始化

首先来看看Koa的构造函数,其代码如下所示:

/* koa/lib/application.js */
const Emitter = require('events');
const context = require('./context');
const request = require('./request');
const response = require('./response');

// 继承Emitter,实现事件接口
module.exports = class Application extends Emitter {
  constructor(options) {
    // ...
    // 用来保存中间件列表
    this.middleware = [];
    // Koa实现的上下文对象,用来封装请求和响应
    this.context = Object.create(context);
    // 对原生请求对象的封装
    this.request = Object.create(request);
    // 对原生响应对象的封装
    this.response = Object.create(response);
    // ...
  }
}

可以看到,在创建Koa实例时,主要就是在当前实例上添加middlewarecontextrequestresponse实例属性。

use

在创建Koa实例后,就可以使用use方法,向应用程序中添加中间件,其代码如下所示:

/* koa/lib/application.js */
module.exports = class Application extends Emitter {
  use(fn) {
    if (typeof fn !== 'function') throw new TypeError('middleware must be a function!');
    // ...
    // 将中间件添加到middleware中
    this.middleware.push(fn);
    return this;
  }
}

可以看到,use方法就是接收一个函数,然后将其添加到middleware中。

listen

添加完中间件后,就可以调用listen方法,开启服务器,其代码如下所示:

/* koa/lib/application.js */
module.exports = class Application extends Emitter {
  listen(...args) {
    // 使用原生的http模块,创建服务器,开启监听
    const server = http.createServer(this.callback());
    return server.listen(...args);
  }
}

可以看到,listen就是使用原生的http模块,首先使用http.createServer方法创建一个服务器,然后立即调用listen方法,开启监听。这里最关键的就是通过调用callback方法,生成符合request事件的回调函数,其代码如下所示:

/* koa/lib/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方法中,首先使用compose方法,将middleware构建成一个执行器,然后定义handleRequest方法并返回,这个方法就是添加到request事件上的回调函数,在服务器开启后,每次收到来自客户端的请求时,都会执行该回调函数。那么接下来,就来看看compose是如何构建执行器的。

compose

compose方法是通过koa-compose模块提供的,代码如下所示:

/* koa-compose/index.js */
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
    // 从第一个中间件开始执行,其返回结果是一个Promise
    return dispatch(0)
    function dispatch (i) {
      if (i <= index) return Promise.reject(new Error('next() called multiple times'))
      index = i
      let fn = middleware[i]
      // 执行下一个中间件执行器,koa-router中会用到
      if (i === middleware.length) fn = next
      if (!fn) return Promise.resolve()
      try {
        // 构造next参数,执行中间件
        // 使用Promise.resolve,确保中间件的异步执行
        return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
      } catch (err) {
        return Promise.reject(err)
      }
    }
  }
}

中间件在本质上看就是一组函数列表,而我们的目的,就是提供一个执行模型,在每次收到请求时,都从第一个中间件开始执行,在每次执行中间件之前,首先会构造一个next方法,它用来暂定当前正在执行的中间件,将控制权传递给下一个中间件,这样一来,所有的中间件都会按照模型,按照先入后出的顺序依次执行,同时,为了支持异步中间件,Koa使用Promise.resolve对中间件的执行结果进行包装,在上游的中间件中调用await next()时,必须等到下游中间件执行完毕后,才会执行next之后的代码,从而实现真正的异步中间件系统。

compose

运行时阶段

经过上面的分析,我们已经开启了一个服务器,并且绑定了request事件,还将middleware构建成了一个异步执行器,之前注册的所有中间件,现在可以通过这个执行器,使其按照添加的顺序,依次执行。那么接下来,我们就来看看在Koa收到请求时,其内部是如何处理的,request事件的回调函数如下所示:

/* koa/lib/application.js */
module.exports = class Application extends Emitter {
  callback() {
    // ...
    const handleRequest = (req, res) => {
      // 创建当前请求对应的上下文对象
      const ctx = this.createContext(req, res);
      // 执行中间件
      return this.handleRequest(ctx, fn);
    };
    // ...
  }
}

可以看到,在收到请求时,Koa会先使用createContext方法,创建本次请求对应的上下文对象,其代码如下所示:

/* koa/lib/application.js */
module.exports = class Application extends Emitter {
  createContext(req, res) {
    // 创建context、request、response的实例
    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;
    // 建立与原生req、res之间的联系
    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;
    // Koa推荐存放自定义数据的命名空间
    context.state = {};
    return context;
  }
}

其实就是创建contextrequestresponse的实例,然后建立与原生reqres之间的联系,这样做的好处就是可以通过ctx这个对象,访问所有与本次请求相关的资源,同时,requestresponse对象提供一系列常用的属性、方法,屏蔽了底层http模块的实现细节,方便使用。

在创建ctx后,就调用handleRequest方法,来执行中间件,其代码如下所示:

/* koa/lib/application.js */
module.exports = class Application extends Emitter {
  handleRequest(ctx, fnMiddleware) {
    const res = ctx.res;
    // Koa默认的状态码
    res.statusCode = 404;
    // 处理本次请求相关的错误
    const onerror = err => ctx.onerror(err);
    // 处理最后的响应内容
    const handleResponse = () => respond(ctx);
    // 在res和res.socket上注册错误事件,确保在出错时,程序能正常执行
    onFinished(res, onerror);
    // 执行中间件的执行器
    return fnMiddleware(ctx).then(handleResponse).catch(onerror);
  }
}

可以看到,在handleRequest方法中,主要就是定义onerrorhandleResponse,然后将上下文对象传入执行器,开始执行已添加的中间件。由于执行器的返回结果也是一个Promise,所以在其fulfilled时会调用handleResponse方法,处理响应逻辑,在其rejected时会调用onerror方法,进行错误处理。

ctx.body

在中间件中,我们可以通过给ctx.body赋值,Koa会自动根据数据的类型,进行正确的处理。那么接下来,就来看看Koa内部是如何实现该过程的,其代码如下所示:

/* koa/lib/response.js */
module.exports = {
  set body(val) {
    const original = this._body;
    // 将数据保存在_body中
    this._body = val;

    // 不携带响应体
    if (null == val) {
      if (!statuses.empty[this.status]) this.status = 204;
      if (val === null) this._explicitNullBody = true;
      this.remove('Content-Type');
      this.remove('Content-Length');
      this.remove('Transfer-Encoding');
      return;
    }

    // 覆盖默认的404
    if (!this._explicitStatus) this.status = 200;

    // set the content-type only if not yet set
    const setType = !this.has('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 (val instanceof Stream) {
      onFinish(this.res, destroy.bind(null, val));
      if (original != val) {
        val.once('error', err => this.ctx.onerror(err));
        // overwriting
        if (null != original) this.remove('Content-Length');
      }

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

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

可以看到,在给body赋值时,首先将数据赋值给_body,然后进行如下判断:

  1. 如果值为null:说明响应体为空,首先通过statuses.empty,判断当前状态码是否是204,205,304,如果不是,就设置成默认的204,然后移除Content-TypeContent-LengthTransfer-Encoding响应头。

  2. 如果值的类型是String:在没有设置过Content-Type的情况下,如果内容的第一个非空白字符是<,就将Content-Type设置成text/html,否则,设置成text/plain,然后将Content-Length设置成Buffer.byteLength

  3. 如果值的类型是Buffer:在没有设置过Content-Type的情况下,将其设置成application/octet-stream,将Content-Length设置成buffer.length

  4. 如果值的类型是Stream:就使用onFinish方法,确保在响应流关闭或发生错误时,可以对资源进行清理工作,最后,同样在没有设置过Content-Type时,将其设置成application/octet-stream

  5. 如果以上的条件都不满足,Koa就将内容当作JSON处理,设置Content-Typeapplication/json

以上的逻辑,主要是用来设置Content-TypeContent-Length,但是此时并没有返回具体的响应内容,只是将数据临时存放在res._body中,当所有的中间件执行完毕,或没有继续调用next执行下一个中间件时,本次执行器就执行完毕了,此时就会调用之前的handleResponse方法,在此方法中,Koa会继续调用respond方法,这里才是返回响应的地方,其代码如下所示:

/* koa/lib/application.js */
function respond(ctx) {
  // 绕过Koa的内置response处理,需要自己处理res
  if (false === ctx.respond) return;

  if (!ctx.writable) return;

  const res = ctx.res;
  let body = ctx.body;
  const code = ctx.status;

  // 状态码为204,205,304,不需要响应体,直接结束响应
  if (statuses.empty[code]) {
    // strip headers
    ctx.body = null;
    return res.end();
  }

  // HEAD请求不需要响应体,直接结束响应
  if ('HEAD' === ctx.method) {
    if (!res.headersSent && !ctx.response.has('Content-Length')) {
      const { length } = ctx.response;
      if (Number.isInteger(length)) ctx.length = length;
    }
    return res.end();
  }

  // 状态码不是204,205,304,并且响应内容为null,将原因短语作为响应体返回
  if (null == body) {
    if (ctx.response._explicitNullBody) {
      ctx.response.remove('Content-Type');
      ctx.response.remove('Transfer-Encoding');
      return res.end();
    }
    if (ctx.req.httpVersionMajor >= 2) {
      body = String(code);
    } else {
      body = ctx.message || String(code);
    }
    if (!res.headersSent) {
      ctx.type = 'text';
      ctx.length = Buffer.byteLength(body);
    }
    return res.end(body);
  }

  // 根据数据的类型,返回对应的响应内容
  if (Buffer.isBuffer(body)) return res.end(body);
  if ('string' === typeof body) return res.end(body);
  if (body instanceof Stream) return body.pipe(res);

  // body: json
  body = JSON.stringify(body);
  if (!res.headersSent) {
    ctx.length = Buffer.byteLength(body);
  }
  res.end(body);
}

可以看到,respond方法的判断逻辑和设置body时是很类似的,Koa会根据数据的类型,调用原生的endpipe方法,返回对应的响应内容,默认情况下,Koa会使用JSON.stringify,将数据转换成JSON格式返回。

总结

Koa的整体逻辑是很简单的,它只是提供一个框架,使用时,首先向Koa实例中添加中间件,然后在收到请求时,Koa会按照顺序,对它们依次进行执行,除此之外,Koa内部提供了大量的属性、方法,用来封装底层http的实现细节,在中间件中,可以通过直接给body赋值,来对请求进行响应。