前言

Koa 2.x 版本是当下最流行的 Node.js 框架,同时社区涌现出一大批围绕 Koa 2.x 的中间件以及基于 Koa 2.x 封装的企业级框架,如 egg.js,然而 Koa 本身的代码却非常精简,精简到所有文件的代码去掉注释后还不足 2000 行,本篇就围绕着这 2000 行不到的代码抽出核心逻辑进行分析,并压缩成一版只有 200 行不到的简易版 Koa

Koa 分析过程

在下面的内容中,我们将对 Koa 所使用的功能由简入深的分析,首先会给出使用案例,然后根据使用方式,分析实现原理,最后对分析的功能进行封装,封装过程会从零开始并一步一步完善,代码也是从少到多,会完整的看到一个简版 Koa 诞生的过程,在此之前我们打开 Koa 源码地址


Koa 文件目录
Koa 文件目录


通过上面对 Koa 源码目录的截图,发现只有 4 个核心文件,为了方便理解,封装简版 Koa 的文件目录结构也将严格与源码同步。

搭建基本服务

在引入 Koa 时我们需要创建一个 Koa 的实例,而启动服务是通过 listen 监听一个端口号实现的,代码如下。

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

app.listen(3000, () => {
  console.log('server start 3000');
});

通过使用我们可以分析出 Koa 导出的应该是一个类,或者构造函数,鉴于 Koa 诞生的时间以及基于 node v7.6.0 以上版本的情况来分析,正是 ES6 开始流行的时候,所以推测 Koa 导出的应该是一个类,打开源码一看,果然如此,所以我们也通过 class 的方式来实现。

而从启动服务的方式上看,app.listen 的调用方式与原生 http 模块提供的 server.listen 几乎相同,我们分析,listen 方法应该是对原生 http 模块的一个封装,启动服务的本质还是靠 http 模块来实现的。

/* 文件路径:~koa/application.js */
const http = require('http');

class Koa {
  handleRequest(req, res) {
    // 请求回调
  }
  listen(...args) {
    // 创建服务
    const server = http.createServer(this.handleRequest.bind(this));

    // 启动服务
    server.listen(...args);
  }
}

module.exports = Koa;

上面的代码初步实现了我们上面分析出的需求,为了防止代码冗余,我们将创建服务的回调抽取成一个 handleRequest 的实例方法,内部的逻辑在后面完善,现在可以创建这个 Koa 类的实例,通过调用实例的 listen 方法启动一个服务器。

上下文对象 ctx 的封装

基本使用

Koa 还有一个很重要的特性,就是它的 ctx 上下文对象,我们可以调用 ctxrequestresponse 属性获取原 reqres 的属性和方法,也在 ctx 上增加了一些原生没有的属性和方法,总之 ctx 给我们要操作的属性和方法提供了多种调用方式,使用案例如下。

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

app.use((ctx, next) => {
  // 原生的 req 对象的 url 属性
  console.log(ctx.req.url);
  console.log(ctx.request.req.url);
  console.log(ctx.response.req.url);

  // Koa 扩展的 url
  console.log(ctx.url);
  console.log(ctx.request.req.url);

  // 设置状态码和响应内容
  ctx.response.status = 200;
  ctx.body = 'Hello World';
});

app.listen(3000, () => {
  console.log('server start 3000');
});

创建 ctx 的引用关系

从上面我们可以看出,ctxuse 方法的第一个参数,requestresponsectx 新增的,而通过这两个属性又都可以获取原生的 reqres 属性,ctx 本身也可以获取到原生的 reqres,我们可以分析出,ctx 是对这些属性做了一个集成,或者说特殊处理。

源码的文件目录中正好有与 requestresponse 名字相对应的文件,并且还有 context 名字的文件,我们其实可以分析出这三个文件就是用于封装 ctx 上下文对象使用的,而封装 ctx 中也会用到 reqres,所以核心逻辑应该在 handleRequest 中实现。

在使用案例中 ctx 是作为 use 方法中回调函数的参数,所以我们分析应该有一个数组统一管理调用 use 后传入的函数,Koa 应该有一个属性,值为数组,用来存储这些函数,下面是实现代码。

/* 文件路径:~koa/application.js */
const http = require('http');

// ************************** 以下为新增代码 **************************
const context = require('./context');
const request = require('./request');
const response = require('./response');
// ************************** 以上为新增代码 **************************

class Koa {
// ************************** 以下为新增代码 **************************
  contructor() {
    // 存储中间件
    this.middlewares = [];

    // 为了防止通过 this 修改属性而导致影响原引入文件的导出对象,做一个继承
    this.context = Object.create(context);
    this.request = Object.create(request);
    this.response = Object.create(response);
  }
  use(fn) {
    // 将传给 use 的函数存入数组中
    this.middlewares.push(fn);
  }
  createContext(req, res) {
    // 或取定义的上下文
    const ctx = this.context;

    // 增加 request 和 response
    ctx.request = this.request;
    ctx.response = this.response;

    // 让 ctx、request、response 都具有原生的 req 和 res
    ctx.req = ctx.request.req = ctx.response.req = req;
    ctx.res = ctx.response.res = ctx.request.res = res;

    // 返回上下文对象
    return ctx;
  }
// ************************** 以上为新增代码 **************************
  handleRequest(req, res) {
    // 创建 ctx 上下文对象
    const ctx = this.createContext(req, res);
  }
  listen(...args) {
    // 创建服务
    const server = http.createServer(this.handleRequest.bind(this));

    // 启动服务
    server.listen(...args);
  }
}

module.exports = Koa;

首先,给实例创建了三个属性 contextrequestresponse 分别继承了 context.jsrequest.jsresponse.js 导出的对象,之所以这么做而不是直接赋值是防止操作实例属性时 “污染” 原对象,而获取原模块导出对象的属性可以通过原型链进行查找,并不影响取值。

其次,给实例挂载了 middlewares 属性,值为数组,为了存储 use 方法调用时传入的函数,在 handleRequest 把创建 ctx 属性及引用的过程单独抽取成了 createContext 方法,并在 handleRequest 中调用,返回值为创建好的 ctx 对象,而在 createContext 中我们根据案例中的规则构建了 ctx 的属性相关的各种引用关系。

实现 request 取值

上面构建的属性中,所有通过访问原生 reqres 的属性都能获取到,反之则是 undefined,这就需要我们去构建 request.js

/* 文件路径:~koa/request.js */
const url = require('url');

// 给 url 和 path 添加 getter
const request = {
  get url() {
    return this.req.url;
  },
  get path() {
    return url.parse(this.req.url).pathname;
  }
};

module.exports = request;

上面我们只构造了两个属性 urlpath,我们知道 url 是原生所自带的属性,我们在使用 ctx.request.url 获取是通过 request 对象设置的 getter,将 ctx.request.req.url 的值返回了。

path 是原生 req 所没有的属性,但却是通过原生 requrl 属性和 url 模块共同构建出来的,所以我们同样用了给 request 对象设置 getter 的方式获取 requrl 属性,并使用 url 模块将转换对象中的 pathname 返回,此时就可以通过 ctx.request.path 来获取访问路径,至于源码中我们没有处理的 req 属性都是通过这样的方式建立的引用关系。

实现 response 的取值和赋值

Koaresponse 对象的真正作用是给客户端进行响应,使用时是通过访问属性获取,并通过重新赋值实现响应,但是现在 response 获取的属性都是 undefined,我们这里先不管响应给浏览器的问题,首先要让 response 下的某个属性有值才行,下面我们来实现 response.js

/* 文件路径:~koa/response.js */
// 给 body 和 status 添加 getter 和 setter
const response = {
  get body() {
    return this._body;
  },
  set body(val) {
    // 只要给 body 赋值就代表响应成功
    this.status = 200;
    this._body = val;
  },
  get status() {
    return this.res.statusCode;
  },
  set status(val) {
    this.res.statusCode = val;
  }
};

module.exports = response;

这里选择了 Koa 在使用时,response 对象上比较重要的两个属性进行处理,因为这两个属性是服务器响应客户端所必须的,并模仿了 request.js 的方式给 bodystatus 设置了 getter,不同的是响应浏览器所做的其实是赋值操作,所以又给这两个属性添加了 setter,对于 status 来说,直接操作原生 res 对象的 statusCode 属性即可,因为同为赋值操作。

还有一点,响应是通过给 body 赋值实现,我们认为只要触发了 bodysetter 就成功响应,所以在 bodygetter 中将响应状态码设置为 200,至于 body 赋值是如何实现响应的,放在后面再说。

ctx 代理 request、response 的属性

上面实现了通过 requestresponse 对属性的操作,Koa 虽然给我们提供了多样的属性操作方式,但由于我们程序员们都很 “懒”,几乎没有人会在开发的时候愿意增加获取某个属性值的路径长度,大部分情况都是通过 ctx 直接操作 requestresponse 上的属性,这就是我们现在的问题所在,这些属性通过 ctx 访问不到。

我们需要给 ctx 对象做一个代理,让 ctx 可以访问到 requestresponse 上的属性,这个场景何曾相识,不正是 Vue2 创建实例时,将传入参数对象 optionsdata 属性代理给实例本身的场景吗,既然如此,我们也通过相似的方式实现,还记得上面引入的 context 模块作为实例的 context 属性所继承的对象,而剩下的最后一个核心文件 context.js 正是用来做这件事的,代码如下。

/* 文件路径:~koa/context.js */
const proto = {};

// 将传入对象属性代理给 ctx
function defineGetter(property, key) {
  proto.__defineGetter__(key, function () {
    return this[property][key];
  });
}

// 设置 ctx 值时直接操作传入对象的属性
function defineSetter(property, key) {
  proto.__defineSetter__(key, function (val) {
    this[property][key] = val;
  });
}

// 将 request 的 url 和 path 代理给 ctx
defineGetter('request', 'url');
defineGetter('request', 'path');

// 将 response 的 body 和 status 代理给 ctx
defineGetter('response', 'body');
defineSetter('response', 'body');
defineGetter('response', 'status');
defineSetter('response', 'status');

module.exports = proto;

Vue 中是使用 Object.defineProperty 来时实现的代理,而在 Koa 源码中借助了 delegate 第三方模块来实现的,并在添加代理时链式调用了 delegate 封装的方法,我们并没有直接使用 delegate 模块,而是将 delegate 内部的核心逻辑抽取出来在 context.js 中直接编写,这样方便大家理解原理,也可以清楚的知道是如何实现代理的。

我们封装了两个方法 defineGetterdefineSetter 分别来实现取值和设置值时,将传入的属性(第二个参数)代理给传入的对象(第一个参数),函数内是通过 Object.prototype.__defineGetter__Object.prototype.__defineSetter__ 实现的。

洋葱模型 —— 实现中间件的串行

现在已经实现了 ctx 上下文对象的创建,但是会发现我们封装 ctx 之前所写的案例 use 回调中的代码并不能执行,也不会报错,根本原因是 use 方法内传入的函数没有调用,在使用 Koa 的过程中会发现,我们往往使用多个 use,并且传入 use 的回调函数除了 ctx 还有第二个参数 next,而这个 next 也是一个函数,调用 next 则执行下一个 use 中的回调函数,否则就会 “卡住”,这种执行机制被称为 “洋葱模型”,而这些被执行的函数被称为 “中间件”,下面我们就来分析这个 “洋葱模型” 并实现中间件的串行。


洋葱模型执行过程
洋葱模型执行过程


洋葱模型分析

下面来看看表述洋葱模型的一个经典案例,结果似乎让人匪夷所思,一时很难想到原因,不着急先看了再说。

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

app.use((ctx, next) => {
  console.log(1);
  next();
  console.log(2);
});

app.use((ctx, next) => {
  console.log(3);
  next();
  console.log(4);
});

app.use((ctx, next) => {
  console.log(5);
  next();
  console.log(6);
});

app.listen(3000, () => {
  console.log('server start 3000');
});

// 1
// 3
// 5
// 6
// 4
// 2

根据上面的执行特性我们不妨来分析以下,我们知道 use 方法执行时其实是把传入的回调函数放入了实例的 middlewares 数组中,而执行结果打印了 1 说明第一个回调函数被执行了,接着又打印了 2 说明第二个回调函数被执行了,根据上面的代码我们可以大胆的猜想,第一个回调函数调用的 next 肯定是一个函数,可能就是下一个回调函数,或者是 next 函数中执行了下一个回调函数,这样根据函数调用栈先进后出的原则,会在 next 执行完毕,即出栈后,继续执行上一个回调函数的代码。

支持异步的中间件串行

在实现中间件串行之前需要补充一点,中间件函数内调用 next 时,前面的代码出现异步,则会继续向下执行,等到异步执行结束后要执行的代码插入到同步代码中,这会导致执行顺序错乱,所以在官方推荐中告诉我们任何遇到异步的操作前都需要使用 await 进行等待(包括 next,因为下一个中间件中可能包含异步操作),这也间接的说明了传入 use 的回调函数只要有异步代码需要 await,所以应该是 async 函数,而了解 ES7 特性 async/await 的我们来说,一定能分析出 next 返回的应该是一个 Promise 实例,下面是我们在之前 application.js 基础上的实现。

/* 文件路径:~koa/application.js */
const http = require('http');
const context = require('./context');
const request = require('./request');
const response = require('./response');

class Koa {
  contructor() {
    // 存储中间件
    this.middlewares = [];

    // 为了防止通过 this 修改属性而导致影响原引入文件的导出对象,做一个继承
    this.context = Object.create(context);
    this.request = Object.create(request);
    this.response = Object.create(response);
  }
  use(fn) {
    // 将传给 use 的函数存入数组中
    this.middlewares.push(fn);
  }
  createContext(req, res) {
    // 或取定义的上下文
    const ctx = this.context;

    // 增加 request 和 response
    ctx.request = this.request;
    ctx.response = this.response;

    // 让 ctx、request、response 都具有原生的 req 和 res
    ctx.req = ctx.request.req = ctx.response.req = req;
    ctx.res = ctx.response.res = ctx.request.res = res;

    // 返回上下文对象
    return ctx;
  }
// ************************** 以下为新增代码 **************************
  compose(ctx, middles) {
    // 创建一个递归函数,参数为存储中间件的索引,从 0 开始
    function dispatch(index) {
      // 在所有中间件执行之后给 compose 返回一个 Promise(兼容一个中间件都没写的情况)
      if (index === middles.length) return Promise.resolve();

      // 取出第 index 个中间件函数
      const route = middles[index];

      // 为了兼容中间件传入的函数不是 async,一定要包装成一个 Promise
      return Promise.resolve(route(ctx, () => dispatch(++index)));
    }
    return dispatch(0); // 默认执行一次
  }
// ************************** 以上为新增代码 **************************
  handleRequest(req, res) {
    // 创建 ctx 上下文对象
    const ctx = this.createContext(req, res);

// ************************** 以下为新增代码 **************************
    // 执行 compose 将中间件组合在一起
    this.compose(ctx, this.middlewares);
// ************************** 以上为新增代码 **************************
  }
  listen(...args) {
    // 创建服务
    const server = http.createServer(this.handleRequest.bind(this));

    // 启动服务
    server.listen(...args);
  }
}

module.exports = Koa;

仔细想想我们其实在利用循环执行每一个 middlewares 中的函数,而且需要把下一个中间件函数的执行作为函数体的代码包装一层成为新的函数,并作为参数 next 传入,那么在上一个中间件函数内部调用 next 就相当于先执行了下一个中间件函数,而下一个中间件函数内部调用 next,又先执行了下一个的下一个中间件函数,依次类推。

直到执行到最后一个中间件函数,调用了 next,但是 middlewares 中已经没有下一个中间件函数了,这也是为什么我们要给下一个中间件函数外包了一层函数而不是直接将中间件函数传入的原因之一(另一个原因是解决传参问题,因为在执行时还要传入下一个中间件函数),但是防止递归 “死循环”,要配合一个终止条件,即指向 middlewares 索引的变量等于了 middlewares 的长度,最后只是相当于执行了一个只有一条判断语句的函数就 return 的函数,而并没有报错。

在这整个过程中如果有任意一个 next 没有被调用,就不会向下执行其他的中间件函数,这样就 “卡住了”,完全符合 Koa 中间件的执行规则,而 await 过后也就是下一个中间件优先执行完成,则会继续执行当前中间件 next 调用下面的代码,这也就是 1、3、5、6、4、2 的由来。

为了实现所描述的执行过程,将所有中间件串行的逻辑抽出了一个 compose 方法,但是我们没有使用普通的循环,而是使用递归实现的,首先在 compose 创建 dispatch 递归函数,参数为当前数组函数的索引,初始值为 0,函数逻辑是先取出第一个函数执行,并传入一个回调函数参数,回调函数参数中递归 dispatch,参数 +1,这样就会将整个中间件串行起来了。

但是上面的串行也只是同步串行,如果某个中间件内部需要等待异步,则调用的 next 函数必须返回一个 Promise 实例,有些中间件没有执行异步,则不需要 async 函数,也不会返回 Promise,而 Koa 规定只要遇到 next 就需要等待,则将取出每一个中间件函数执行后的结果使用 Promise.resolve 强行包装成一个成功态的 Promise,就对异步进行了兼容。

我们最后也希望 compose 返回一个 Promise 方便执行一些只有在中间件都执行后才会执行的逻辑,每次串行最后执行的都是一个只有一条判断逻辑就 return 了的函数(包含一个中间件也没有的情况),此时 compose 返回了 undefined,无法调用 then 方法,为了兼容这种情况也强行的使用相同的 “招数”,在判断条件的 return 关键字后面加上了 Promise.resolve(),直接返回了一个成功态的 Promise

注意:官方只是推荐我们在调用 next 的时候使用 await 等待,即使执行的 next 真的存在异步,也不是非 await 不可,我们完全可以使用 return 来代替 await,唯一的区别就是 next 调用后,下面的代码不会再执行了,类比 “洋葱模型”,形象地说就是 “下去了就上不来了”,这个完全可以根据我们的使用需要而定,同一个中间件中,如果 next 后面不再有任何逻辑需要执行,就可以这样去做。

实现真正的响应

在对 ctx 实现属性代理后,我们通过 ctx.body 重新赋值其实只是改变了 response.js 导出对象的 _body 属性,而并没有实现真正的响应,看下面这个 Koa 的例子。

const Koa = require('koa');
const fs = require('fs');

const app = new Koa();

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

app.use(async (ctx, next) => {
  ctx.body = fs.createReadStream('1.txt');

  ctx.body = await new Promise((resolve, reject) => {
    setTimeout(() => resolve('panda'), 3000);
  });
});

app.listen(3000, () => {
  console.log('server start 3000');
});

其实最后响应给客户端的值是 panda,正常在最后一个中间件执行后,由于异步定时器的代码没有执行完,ctx.body 最后的值应该是 1.txt 的可读流,这与客户端接收到的值相违背,通过这个猜想上的差异我们应该知道,compose 在串行执行中间件后为什么要返回一个 Promise 了,因为最后执行的只有判断语句的函数会等待我们例子中最后一个 use 传入的中间件函数执行完毕调用,也就是说在执行 compose 返回值的 then 时,ctx.body 的值已经是 panda 了。

/* 文件路径:~koa/application.js */
const http = require('http');

// ************************** 以下为新增代码 **************************
const Stream = require('stream');
// ************************** 以上为新增代码 **************************

const context = require('./context');
const request = require('./request');
const response = require('./response');

class Koa {
  contructor() {
    // 存储中间件
    this.middlewares = [];

    // 为了防止通过 this 修改属性而导致影响原引入文件的导出对象,做一个继承
    this.context = Object.create(context);
    this.request = Object.create(request);
    this.response = Object.create(response);
  }
  use(fn) {
    // 将传给 use 的函数存入数组中
    this.middlewares.push(fn);
  }
  createContext(req, res) {
    // 或取定义的上下文
    const ctx = this.context;

    // 增加 request 和 response
    ctx.request = this.request;
    ctx.response = this.response;

    // 让 ctx、request、response 都具有原生的 req 和 res
    ctx.req = ctx.request.req = ctx.response.req = req;
    ctx.res = ctx.response.res = ctx.request.res = res;

    // 返回上下文对象
    return ctx;
  }
  compose(ctx, middles) {
    // 创建一个递归函数,参数为存储中间件的索引,从 0 开始
    function dispatch(index) {
      // 在所有中间件执行之后给 compose 返回一个 Promise(兼容一个中间件都没写的情况)
      if (index === middles.length) return Promise.resolve();

      // 取出第 index 个中间件函数
      const route = middles[index];

      // 为了兼容中间件传入的函数不是 async,一定要包装成一个 Promise
      return Promise.resolve(route(ctx, () => dispatch(++index)));
    }
    return dispatch(0); // 默认执行一次
  }
  handleRequest(req, res) {
    // 创建 ctx 上下文对象
    const ctx = this.createContext(req, res);

// ************************** 以下为修改代码 **************************
    // 设置默认状态码(Koa 规定默认状态码为 404),必须在调用中间件之前
    ctx.status = 404;

    // 执行 compose 将中间件组合在一起
    this.compose(ctx, this.middlewares).then(() => {
      // 获取最后 body 的值
      const body = ctx.body;

      // 检测 ctx.body 的类型,并使用对应的方式将值响应给浏览器
      if (Buffer.isBuffer(body) || typeof body === 'string') {
        // 处理 Buffer 类型的数据
        res.setHeader('Content-Type', 'text/plain;charset=utf8');
        res.end(body);
      } else if (typeof body === 'object') {
        // 处理对象类型
        res.setHeader('Content-Type', 'application/json;charset=utf8');
        res.end(JSON.stringify(body));
      } else if (body instanceof Stream) {
        // 处理流类型的数据
        body.pipe(res);
      } else {
        res.end('Not Found');
      }
    });
// ************************** 以上为修改代码 **************************
  }
  listen(...args) {
    // 创建服务
    const server = http.createServer(this.handleRequest.bind(this));

    // 启动服务
    server.listen(...args);
  }
}

module.exports = Koa;

处理 response 时,在 bodysetter 中将状态码设置为了 200,就是说需要设置 ctx.body 去触发 setter 让响应成功,如果没有给 ctx.body 设置任何值,默认应该是无响应的,在官方文档也有默认状态码为 404 的明确说明,所以在 handleRequest 把状态码设置为了 404,但必须在 compose 执行之前才叫默认状态码,因为中间件中可能会操作 ctx.body,重新设置状态码。

comosethen 中,也就是在所有中间件执行后,我们取出 ctx.body 的值,即为最后生效的响应值,对该值进行了数据类型验证,如 Buffer、字符串、对象和流,并分别用不同的方式处理了响应,但本质都是调用原生 res 对象的 end 方法。

中间件错误处理

在上面的逻辑当中我们实现了很多 Koa 的核心逻辑,但是只考虑了顺利执行的情况,并没有考虑如果中间件中代码执行出现错误的问题,如下面案例。

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

app.use((ctx, next) => {
  // 抛出异常
  throw new Error('Error');
});

// 添加 error 监听
app.on('error', err => {
  console.log(err);
});

app.listen(3000, () => {
  console.log('server start 3000');
});

我们之所以让 compose 方法在执行所有中间件后返回一个 Promise 还有一个更重要的意义,因为在 Promise 链式调用中,只要其中任何一个环节出现代码执行错误或抛出异常,都会直接执行出现错误的 then 方法中错误的回调或者最后的 catch 方法,对于 Koa 中间件的串行而言,最后一个 then 调用 catch 方法就是 compose 的返回值调用 then 后继续调用的 catchcatch 内可以捕获到任意一个中间件执行时出现的错误。

/* 文件路径:~koa/application.js */
const http = require('http');
const Stream = require('stream');

// ************************** 以下为新增代码 **************************
const EventEmitter = require('events');
const httpServer = require('_http_server');
// ************************** 以上为新增代码 **************************

const context = require('./context');
const request = require('./request');
const response = require('./response');

// ************************** 以下为修改代码 **************************
// 继承 EventEmitter 后可以用创建的实例 app 添加 error 监听,可以通过 emit 触发监听
class Koa extends EventEmitter {
  contructor() {
    supper();
// ************************** 以上为修改代码 **************************

    // 存储中间件
    this.middlewares = [];

    // 为了防止通过 this 修改属性而导致影响原引入文件的导出对象,做一个继承
    this.context = Object.create(context);
    this.request = Object.create(request);
    this.response = Object.create(response);
  }
  use(fn) {
    // 将传给 use 的函数存入数组中
    this.middlewares.push(fn);
  }
  createContext(req, res) {
    // 或取定义的上下文
    const ctx = this.context;

    // 增加 request 和 response
    ctx.request = this.request;
    ctx.response = this.response;

    // 让 ctx、request、response 都具有原生的 req 和 res
    ctx.req = ctx.request.req = ctx.response.req = req;
    ctx.res = ctx.response.res = ctx.request.res = res;

    // 返回上下文对象
    return ctx;
  }
  compose(ctx, middles) {
    // 创建一个递归函数,参数为存储中间件的索引,从 0 开始
    function dispatch(index) {
      // 在所有中间件执行之后给 compose 返回一个 Promise(兼容一个中间件都没写的情况)
      if (index === middles.length) return Promise.resolve();

      // 取出第 index 个中间件函数
      const route = middles[index];

      // 为了兼容中间件传入的函数不是 async,一定要包装成一个 Promise
      return Promise.resolve(route(ctx, () => dispatch(++index)));
    }
    return dispatch(0); // 默认执行一次
  }
  handleRequest(req, res) {
    // 创建 ctx 上下文对象
    const ctx = this.createContext(req, res);

    // 设置默认状态码(Koa 规定默认状态码为 404),必须在调用中间件之前
    ctx.status = 404;

    // 执行 compose 将中间件组合在一起
    this.compose(ctx, this.middlewares).then(() => {
      // 获取最后 body 的值
      const body = ctx.body;

      // 检测 ctx.body 的类型,并使用对应的方式将值响应给浏览器
      if (Buffer.isBuffer(body) || typeof body === 'string') {
        // 处理 Buffer 类型的数据
        res.setHeader('Content-Type', 'text/plain;charset=utf8');
        res.end(body);
      } else if (typeof body === 'object') {
        // 处理对象类型
        res.setHeader('Content-Type', 'application/json;charset=utf8');
        res.end(JSON.stringify(body));
      } else if (body instanceof Stream) {
        // 处理流类型的数据
        body.pipe(res);
      } else {
        res.end('Not Found');
      }
// ************************** 以下为修改代码 **************************
    }).catch(err => {
      // 执行 error 事件
      this.emit('error', err);

      // 设置 500 状态码
      ctx.status = 500;

      // 返回状态码对应的信息响应浏览器
      res.end(httpServer.STATUS_CODES[ctx.status]);
    });
// ************************** 以上为修改代码 **************************
  }
  listen(...args) {
    // 创建服务
    const server = http.createServer(this.handleRequest.bind(this));

    // 启动服务
    server.listen(...args);
  }
}

module.exports = Koa;

在使用的案例当中,使用 app(即 Koa 创建的实例)监听了一个 error 事件,当中间件执行错误时会触发该监听的回调,这让我们想起了 Node.js 中一个重要的核心模块 events,这个模块帮我们提供了一个事件机制,通过 on 方法添加监听,通过 emit 触发监听,所以我们引入了 events,并让 Koa 类继承了 events 导入的 EventEmitter 类,此时 Koa 的实例就可以使用 EventEmitter 原型对象上的 onemit 方法。

compose 执行后调用的 catch 中,通过实例调用了 emit,并传入了事件类型 error 和错误对象,这样就是实现了中间件的错误监听,只要中间件执行出错,就会执行案例中错误监听的回调。

让引入的 Koa 直接指向 application.js

在上面我们实现了 Koa 大部分常用功能的核心逻辑,但还有一点美中不足,就是我们引入自己的简易版 Koa 时,默认会查找 koa 路径下的 index.js,想要执行我们的 Koa 必须要使用路径找到 application.js,代码如下。

/* 现在的引入方式 */
const Koa = require('./koa/application');
/* 希望的引入方式 */
const Koa = require('./koa');

我们更希望像直接引入指定 koa 文件夹,就可以找到 application.js 文件并执行,这就需要我们在 koa 文件夹创建 package.json 文件,并在动一点小小的 “手脚” 如下。

/* 文件路径:~koa/package.js */
{
  // ...
  "main": "./application.js",
  // ...
}

Koa 原理图

在文章最后一节送给大家一张 Koa 执行的原理图,这张图片是准备写这篇文章时在 Google 上发现的,把 Koa 的整个流程表达的非常清楚,所以这里拿来帮助大家理解 Koa 框架的原理和执行过程。


Koa 原理图
Koa 原理图


之所以没有在文章开篇放上这张图是因为觉得在完全没有了解过 Koa 的原理之前,可能有一部分小伙伴看这张图会懵,会打消学习的积极性,因为本篇的目的就是带着大家从零到有的,一步一步实现简易版 Koa,梳理 Koa 的核心逻辑,如果你已经看到了这里,是不是觉得这张图出现的不早不晚,刚刚好。

总结

最后还是在这里做一个总结,在 Koa 中主要的部分有 listen 创建服务器、封装上下文对象 ctx 并代理属性、use 方法添加中间件、compose 串行执行中间、让 Koa 继承 EventEmitter 实现错误监听,而我个人觉得最重要的就是 compose,它是一个事件串行机制,也是实现 “洋葱模型” 的核心,如今 compose 已经不再只是一个方法名,而是函数式编程的一个重要的特性,用于将多个程序串行在一起,或同步,或异步,在 Koa 中自不必多说,因为大家已经见识过了,composeRedux 中也起着串联中间件的作用,如串联redux-saga, promiseredux-thunklogger 等中间件,在 Webpack 源码依赖的核心模块 tapable 中也有所应用,在我们的学习过程中,这样优秀的编程思想是应该重点吸收的。