阅读原文
前言
Koa 2.x
版本是当下最流行的 NodeJS 框架,同时社区涌现出一大批围绕 Koa 2.x
的中间件以及基于 Koa 2.x
封装的企业级框架,如 egg.js
,然而 Koa
本身的代码却非常精简,精简到所有文件的代码去掉注释后还不足 2000
行,本篇就围绕着这 2000
行不到的代码抽出核心逻辑进行分析,并压缩成一版只有 200
行不到的简易版 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) {
// 创建服务
let server = http.createServer(this.handleRequest.bind(this));
// 启动服务
server.listen(...args);
}
}
module.exports = Koa;
上面的代码初步实现了我们上面分析出的需求,为了防止代码冗余,我们将创建服务的回调抽取成一个 handleRequest
的实例方法,内部的逻辑在后面完善,现在可以创建这个 Koa
类的实例,通过调用实例的 listen
方法启动一个服务器。
上下文对象 ctx 的封装
1、基本使用
Koa
还有一个很重要的特性,就是它的 ctx
上下文对象,我们可以调用 ctx
的 request
和 response
属性获取原 req
和 res
的属性和方法,也在 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");
});
2、创建 ctx 的引用关系
从上面我们可以看出,ctx
为 use
方法的第一个参数,request
和 response
是 ctx
新增的,而通过这两个属性又都可以获取原生的 req
和 res
属性,ctx
本身也可以获取到原生的 req
和 res
,我们可以分析出,ctx
是对这些属性做了一个集成,或者说特殊处理。
源码的文件目录中正好有与 request
、response
名字相对应的文件,并且还有 context
名字的文件,我们其实可以分析出这三个文件就是用于封装 ctx
上下文对象使用的,而封装 ctx
中也会用到 req
和 res
,所以核心逻辑应该在 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) {
// 或取定义的上下文
let 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 上下文对象
let ctx = this.createContext(req, res);
}
listen(...args) {
// 创建服务
let server = http.createServer(this.handleRequest.bind(this));
// 启动服务
server.listen(...args);
}
}
module.exports = Koa;
首先,给实例创建了三个属性 context
、request
和 response
分别继承了 context.js
、request.js
和 response.js
导出的对象,之所以这么做而不是直接赋值是防止操作实例属性时 “污染” 原对象,而获取原模块导出对象的属性可以通过原型链进行查找,并不影响取值。
其次,给实例挂载了 middlewares
属性,值为数组,为了存储 use
方法调用时传入的函数,在 handleRequest
把创建 ctx
属性及引用的过程单独抽取成了 createContext
方法,并在 handleRequest
中调用,返回值为创建好的 ctx
对象,而在 createContext
中我们根据案例中的规则构建了 ctx
的属性相关的各种引用关系。
3、实现 request 取值
上面构建的属性中,所有通过访问原生 req
或 res
的属性都能获取到,反之则是 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;
上面我们只构造了两个属性 url
和 path
,我们知道 url
是原生所自带的属性,我们在使用 ctx.request.url
获取是通过 request
对象设置的 getter
,将 ctx.request.req.url
的值返回了。
path
是原生 req
所没有的属性,但却是通过原生 req
的 url
属性和 url
模块共同构建出来的,所以我们同样用了给 request
对象设置 getter
的方式获取 req
的 url
属性,并使用 url
模块将转换对象中的 pathname
返回,此时就可以通过 ctx.request.path
来获取访问路径,至于源码中我们没有处理的 req
属性都是通过这样的方式建立的引用关系。
4、实现 response 的取值和赋值
Koa
中 response
对象的真正作用是给客户端进行响应,使用时是通过访问属性获取,并通过重新赋值实现响应,但是现在 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
的方式给 body
和 status
设置了 getter
,不同的是响应浏览器所做的其实是赋值操作,所以又给这两个属性添加了 setter
,对于 status
来说,直接操作原生 res
对象的 statusCode
属性即可,因为同为赋值操作。
还有一点,响应是通过给 body
赋值实现,我们认为只要触发了 body
的 setter
就成功响应,所以在 body
的 getter
中将响应状态码设置为 200
,至于 body
赋值是如何实现响应的,放在后面再说。
5、ctx 代理 request、response 的属性
上面实现了通过 request
和 response
对属性的操作,Koa
虽然给我们提供了多样的属性操作方式,但由于我们程序猿(媛)们都很 “懒”,几乎没有人会在开发的时候愿意多写代码,大部分情况都是通过 ctx
直接操作 request
和 response
上的属性,这就是我们现在的问题所在,这些属性通过 ctx
访问不到。
我们需要给 ctx
对象做一个代理,让 ctx
可以访问到 request
和 response
上的属性,这个场景何曾相识,不正是 Vue
创建实例时,将传入参数对象 options
的 data
属性代理给实例本身的场景吗,既然如此,我们也通过相似的方式实现,还记得上面引入的 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
中直接编写,这样方便大家理解原理,也可以清楚的知道是如何实现代理的。
我们封装了两个方法 defineGetter
和 defineSetter
分别来实现取值和设置值时,将传入的属性(第二个参数)代理给传入的对象(第一个参数),函数内是通过 Object.prototype.__defineGetter__
和 Object.prototype.__defineSetter__
实现的,点击方法名可查看官方 API。
洋葱模型 —— 实现中间件的串行
现在已经实现了 ctx
上下文对象的创建,但是会发现我们封装 ctx
之前所写的案例 use
回调中的代码并不能执行,也不会报错,根本原因是 use
方法内传入的函数没有调用,在使用 Koa
的过程中会发现,我们往往使用多个 use
,并且传入 use
的回调函数除了 ctx
还有第二个参数 next
,而这个 next
也是一个函数,调用 next
则执行下一个 use
中的回调函数,否则就会 “卡住”,这种执行机制被取名为 “洋葱模型”,而这些被执行的函数被称为 “中间件”,下面我们就来分析这个 “洋葱模型” 并实现中间件的串行。
1、洋葱模型分析
下面来看看表述洋葱模型的一个经典案例,结果似乎让人匪夷所思,一时很难想到原因,不着急先看了再说。
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
执行完毕,即出栈后,继续执行上一个回调函数的代码。
2、支持异步的中间件串行
在实现中间件串行之前需要补充一点,中间件函数内调用 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) {
// 或取定义的上下文
let 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 + 1)));
}
return dispatch(0); // 默认执行一次
}
// ***************************** 以上为新增代码 *****************************
handleRequest(req, res) {
// 创建 ctx 上下文对象
let ctx = this.createContext(req, res);
// ***************************** 以下为新增代码 *****************************
// 执行 compose 将中间件组合在一起
this.compose(ctx, this.middlewares);
// ***************************** 以上为新增代码 *****************************
}
listen(...args) {
// 创建服务
let 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
后面不再有任何逻辑,完全可以使用 return
替代。
实现真正的响应
在对 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) {
// 或取定义的上下文
let 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 + 1)));
}
return dispatch(0); // 默认执行一次
}
handleRequest(req, res) {
// 创建 ctx 上下文对象
let ctx = this.createContext(req, res);
// ***************************** 以下为修改代码 *****************************
// 设置默认状态码(Koa 规定),必须在调用中间件之前
ctx.status = 404;
// 执行 compose 将中间件组合在一起
this.compose(ctx, this.middlewares).then(() => {
// 获取最后 body 的值
let 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) {
// 创建服务
let server = http.createServer(this.handleRequest.bind(this));
// 启动服务
server.listen(...args);
}
}
module.exports = Koa;
处理 response
时,在 body
的 setter
中将状态码设置为了 200
,就是说需要设置 ctx.body
去触发 setter
让响应成功,如果没有给 ctx.body
设置任何值,默认应该是无响应的,在官方文档也有默认状态码为 404
的明确说明,所以在 handleRequest
把状态码设置为了 404
,但必须在 compose
执行之前才叫默认状态码,因为中间件中可能会操作 ctx.body
,重新设置状态码。
在 comose
的 then
中,也就是在所有中间件执行后,我们取出 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
后继续调用的 catch
,catch
内可以捕获到任意一个中间件执行时出现的错误。
// 文件路径:~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) {
// 或取定义的上下文
let 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 + 1)));
}
return dispatch(0); // 默认执行一次
}
handleRequest(req, res) {
// 创建 ctx 上下文对象
let ctx = this.createContext(req, res);
// 设置默认状态码(Koa 规定),必须在调用中间件之前
ctx.status = 404;
// 执行 compose 将中间件组合在一起
this.compose(ctx, this.middlewares).then(() => {
// 获取最后 body 的值
let 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) {
// 创建服务
let server = http.createServer(this.handleRequest.bind(this));
// 启动服务
server.listen(...args);
}
}
module.exports = Koa;
在使用的案例当中,使用 app
(即 Koa
创建的实例)监听了一个 error
事件,当中间件执行错误时会触发该监听的回调,这让我们想起了 NodeJS 中一个重要的核心模块 events
,这个模块帮我们提供了一个事件机制,通过 on
方法添加监听,通过 emit
触发监听,所以我们引入了 events
,并让 Koa
类继承了 events
导入的 EventEmitter
类,此时 Koa
的实例就可以使用 EventEmitter
原型对象上的 on
和 emit
方法。
在 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
中主要的部分有 listen
创建服务器、封装上下文对象 ctx
并代理属性、use
方法添加中间件、compose
串行执行中间、让 Koa
继承 EventEmitter
实现错误监听,而我个人觉得最重要的就是 compose
,它是一个事件串行机制,也是实现 “洋葱模型” 的核心,如今 compose
已经不再只是一个方法名,而是一种编程思想,用于将多个程序串行在一起,或同步,或异步,在 Koa
中自不必多说,因为大家已经见识过了,compose
在 React
中也起着串联中间件的作用,如串联 promise
、redux-thunk
、logger
等,在 Webpack
源码依赖的核心模块 tapable
中也有所应用,在我们的学习过程中,这样优秀的编程思想是应该重点吸收的。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。