什么是 koa 框架?
koa 是一个基于 node 实现的一个新的 web 框架,它是由 express 框架的原班人马打造的。它的特点是优雅、简洁、表达力强、自由度高。它更 express 相比,它是一个更轻量的 node 框架,因为它所有功能都通过插件实现,这种插拔式的架构设计模式,很符合 unix 哲学。
koa 框架现在更新到了 2.x 版本,本文从零开始,循序渐进,讲解 koa2 的框架源码结构和实现原理,展示和详解 koa2 框架源码中的几个最重要的概念,然后手把手教大家亲自实现一个简易的 koa2 框架,帮助大家学习和更深层次的理解 koa2,看完本文以后,再去对照 koa2 的源码进行查看,相信你的思路将会非常的顺畅。
本文所用的框架是 koa2,它跟 koa1 不同,koa1 使用的是 generator+co.js 的执行方式,而 koa2 中使用了 async/await,因此本文的代码和 demo 需要运行在 node 8 版本及其以上,如果读者的 node 版本较低,建议升级或者安装 babel-cli,用其中的 babel-node 来运行本文涉及到的代码。
本文实现的轻量版 koa 的完整代码 gitlab 地址为:article_koa2
koa 源码结构
上图是 koa2 的源码目录结构的 lib 文件夹,lib 文件夹下放着四个 koa2 的核心文件:application.js、context.js、request.js、response.js。
application.js
application.js 是 koa 的入口文件,它向外导出了创建 class 实例的构造函数,它继承了 events,这样就会赋予框架事件监听和事件触发的能力。application 还暴露了一些常用的 api,比如 toJSON、listen、use 等等。
listen 的实现原理其实就是对 http.createServer 进行了一个封装,重点是这个函数中传入的 callback,它里面包含了中间件的合并,上下文的处理,对 res 的特殊处理。
use 是收集中间件,将多个中间件放入一个缓存队列中,然后通过 koa-compose 这个插件进行递归组合调用这一些列的中间件。
context.js
这部分就是 koa 的应用上下文 ctx, 其实就一个简单的对象暴露,里面的重点在 delegate,这个就是代理,这个就是为了开发者方便而设计的,比如我们要访问 ctx.repsponse.status 但是我们通过 delegate,可以直接访问 ctx.status 访问到它。
request.js、response.js
这两部分就是对原生的 res、req 的一些操作了,大量使用 es6 的 get 和 set 的一些语法,去取 headers 或者设置 headers、还有设置 body 等等,这些就不详细介绍了,有兴趣的读者可以自行看源码。
实现 koa2 的四大模块
上文简述了 koa2 源码的大体框架结构,接下来我们来实现一个 koa2 的框架,笔者认为理解和实现一个 koa 框架需要实现四个大模块,分别是:
封装 node http server、创建 Koa 类构造函数
构造 request、response、context 对象
中间件机制和剥洋葱模型的实现
错误捕获和错误处理
下面我们就逐一分析和实现。
模块一:封装 node http server 和创建 Koa 类构造函数
阅读 koa2 的源码得知,实现 koa 的服务器应用和端口监听,其实就是基于 node 的原生代码进行了封装,如下图的代码就是通过 node 原生代码实现的服务器监听。
let http = require(‘http’);
let server = http.createServer((req, res) => {
res.writeHead(200);
res.end(‘hello world’);
});
server.listen(3000, () => {
console.log(‘listenning on 3000’);
});
我们需要将上面的 node 原生代码封装实现成 koa 的模式:
const http = require(‘http’);
const Koa = require(‘koa’);
const app = new Koa();
app.listen(3000);
实现 koa 的第一步就是对以上的这个过程进行封装,为此我们需要创建 application.js 实现一个 Application 类的构造函数
let http = require(‘http’);
class Application {
constructor() {
this.callbackFunc;
}
listen(port) {
let server = http.createServer(this.callback());
server.listen(port);
}
use(fn) {
this.callbackFunc = fn;
}
callback() {
return (req, res) => {
this.callbackFunc(req, res);
};
}
}
module.exports = Application;
然后创建 example.js,引入 application.js,运行服务器实例启动监听代码:
let Koa = require(‘./application’);
let app = new Koa();
app.use((req, res) => {
res.writeHead(200);
res.end(‘hello world’);
});
app.listen(3000, () => {
console.log(‘listening on 3000’);
});
现在在浏览器输入 localhost:3000 即可看到浏览器里显示“hello world”。现在第一步我们已经完成了,对 http server 进行了简单的封装和创建了一个可以生成 koa 实例的类 class,这个类里还实现了 app.use 用来注册中间件和注册回调函数,app.listen 用来开启服务器实例并传入 callback 回调函数,第一模块主要是实现典型的 koa 风格和搭好了一个 koa 的简单的架子。接下来我们开始编写和讲解第二模块。
模块二:构造 request、response、context 对象
阅读 koa2 的源码得知,其中 context.js、request.js、response.js 三个文件分别是 request、response、context 三个模块的代码文件。context 就是我们平时写 koa 代码时的 ctx,它相当于一个全局的 koa 实例上下文 this,它连接了 request、response 两个功能模块,并且暴露给 koa 的实例和中间件等回调函数的参数中,起到承上启下的作用。
request、response 两个功能模块分别对 node 的原生 request、response 进行了一个功能的封装,使用了 getter 和 setter 属性,基于 node 的对象 req/res 对象封装 koa 的 request/response 对象。我们基于这个原理简单实现一下 request.js、response.js,首先创建 request.js 文件,然后写入以下代码:
let url = require(‘url’);
module.exports = {
get query() {
return url.parse(this.req.url, true).query;
}
};
这样当你在 koa 实例里使用 ctx.query 的时候,就会返回 url.parse(this.req.url, true).query 的值。看源码可知,基于 getter 和 setter,在 request.js 里还封装了 header、url、origin、path 等方法,都是对原生的 request 上用 getter 和 setter 进行了封装,笔者不再这里一一实现。
接下来我们实现 response.js 文件代码模块,它和 request 原理一样,也是基于 getter 和 setter 对原生 response 进行了封装,那我们接下来通过对常用的 ctx.body 和 ctx.status 这个两个语句当做例子简述一下如果实现 koa 的 response 的模块,我们首先创建好 response.js 文件,然后输入下面的代码:
module.exports = {
get body() {
return this._body;
},
set body(data) {
this._body = data;
},
get status() {
return this.res.statusCode;
},
set status(statusCode) {
if (typeof statusCode !== ‘number’) {
throw new Error(‘something wrong!’);
}
this.res.statusCode = statusCode;
}
};
以上代码实现了对 koa 的 status 的读取和设置,读取的时候返回的是基于原生的 response 对象的 statusCode 属性,而 body 的读取则是对 this._body 进行读写和操作。这里对 body 进行操作并没有使用原生的 this.res.end,因为在我们编写 koa 代码的时候,会对 body 进行多次的读取和修改,所以真正返回浏览器信息的操作是在 application.js 里进行封装和操作。
现在我们已经实现了 request.js、response.js,获取到了 request、response 对象和他们的封装的方法,然后我们开始实现 context.js,context 的作用就是将 request、response 对象挂载到 ctx 的上面,让 koa 实例和代码能方便的使用到 request、response 对象中的方法。现在我们创建 context.js 文件,输入如下代码:
let proto = {};
function delegateSet(property, name) {
proto.__defineSetter__(name, function (val) {
this[property][name] = val;
});
}
function delegateGet(property, name) {
proto.__defineGetter__(name, function () {
return this[property][name];
});
}
let requestSet = [];
let requestGet = [‘query’];
let responseSet = [‘body’, ‘status’];
let responseGet = responseSet;
requestSet.forEach(ele => {
delegateSet(‘request’, ele);
});
requestGet.forEach(ele => {
delegateGet(‘request’, ele);
});
responseSet.forEach(ele => {
delegateSet(‘response’, ele);
});
responseGet.forEach(ele => {
delegateGet(‘response’, ele);
});
module.exports = proto;
context.js 文件主要是对常用的 request 和 response 方法进行挂载和代理,通过 context.query 直接代理了 context.request.query,context.body 和 context.status 代理了 context.response.body 与 context.response.status。而 context.request,context.response 则会在 application.js 中挂载。
本来可以用简单的 setter 和 getter 去设置每一个方法,但是由于 context 对象定义方法比较简单和规范,在 koa 源码里可以看到,koa 源码用的是__defineSetter__和__defineSetter__来代替 setter/getter 每一个属性的读取设置,这样做主要是方便拓展和精简了写法,当我们需要代理更多的 res 和 req 的方法的时候,可以向 context.js 文件里面的数组对象里面添加对应的方法名和属性名即可。
目前为止,我们已经得到了 request、response、context 三个模块对象了,接下来就是将 request、response 所有方法挂载到 context 下,让 context 实现它的承上启下的作用,修改 application.js 文件,添加如下代码:
let http = require(‘http’);
let context = require(‘./context’);
let request = require(‘./request’);
let response = require(‘./response’);
createContext(req, res) {
let ctx = Object.create(this.context);
ctx.request = Object.create(this.request);
ctx.response = Object.create(this.response);
ctx.req = ctx.request.req = req;
ctx.res = ctx.response.res = res;
return ctx;
}
可以看到,我们添加了 createContext 这个方法,这个方法是关键,它通过 Object.create 创建了 ctx,并将 request 和 response 挂载到了 ctx 上面,将原生的 req 和 res 挂载到了 ctx 的子属性上,往回看一下 context/request/response.js 文件,就能知道当时使用的 this.res 或者 this.response 之类的是从哪里来的了,原来是在这个 createContext 方法中挂载到了对应的实例上,构建了运行时上下文 ctx 之后,我们的 app.use 回调函数参数就都基于 ctx 了。
模块三:中间件机制和剥洋葱模型的实现
目前为止我们已经成功实现了上下文 context 对象、请求 request 对象和响应 response 对象模块,还差一个最重要的模块,就是 koa 的中间件模块,koa 的中间件机制是一个剥洋葱式的模型,多个中间件通过 use 放进一个数组队列然后从外层开始执行,遇到 next 后进入队列中的下一个中间件,所有中间件执行完后开始回帧,执行队列中之前中间件中未执行的代码部分,这就是剥洋葱模型,koa 的中间件机制。
koa 的剥洋葱模型在 koa1 中使用的是 generator + co.js 去实现的,koa2 则使用了 async/await + Promise 去实现的,接下来我们基于 async/await + Promise 去实现 koa2 中的中间件机制。首先,假设当 koa 的中间件机制已经做好了,那么它是能成功运行下面代码的:
let Koa = require(‘../src/application’);
let app = new Koa();
app.use(async (ctx, next) => {
console.log(1);
await next();
console.log(6);
});
app.use(async (ctx, next) => {
console.log(2);
await next();
console.log(5);
});
app.use(async (ctx, next) => {
console.log(3);
ctx.body = “hello world”;
console.log(4);
});
app.listen(3000, () => {
console.log(‘listenning on 3000’);
});
运行成功后会在终端输出 123456,那就能验证我们的 koa 的剥洋葱模型是正确的。接下来我们开始实现,修改 application.js 文件,添加如下代码:
compose() {
return async ctx => {
function createNext(middleware, oldNext) {
return async () => {
await middleware(ctx, oldNext);
}
}
let len = this.middlewares.length;
let next = async () => {
return Promise.resolve();
};
for (let i = len – 1; i >= 0; i–) {
let currentMiddleware = this.middlewares[i];
next = createNext(currentMiddleware, next);
}
await next();
};
}
callback() {
return (req, res) => {
let ctx = this.createContext(req, res);
let respond = () => this.responseBody(ctx);
let onerror = (err) => this.onerror(err, ctx);
let fn = this.compose();
return fn(ctx);
};
}
koa 通过 use 函数,把所有的中间件 push 到一个内部数组队列 this.middlewares 中,剥洋葱模型能让所有的中间件依次执行,每次执行完一个中间件,遇到 next() 就会将控制权传递到下一个中间件,下一个中间件的 next 参数,剥洋葱模型的最关键代码是 compose 这个函数:
compose() {
return async ctx => {
function createNext(middleware, oldNext) {
return async () => {
await middleware(ctx, oldNext);
}
}
let len = this.middlewares.length;
let next = async () => {
return Promise.resolve();
};
for (let i = len – 1; i >= 0; i–) {
let currentMiddleware = this.middlewares[i];
next = createNext(currentMiddleware, next);
}
await next();
};
}
createNext 函数的作用就是将上一个中间件的 next 当做参数传给下一个中间件,并且将上下文 ctx 绑定当前中间件,当中间件执行完,调用 next() 的时候,其实就是去执行下一个中间件。
for (let i = len – 1; i >= 0; i–) {
let currentMiddleware = this.middlewares[i];
next = createNext(currentMiddleware, next);
}
上面这段代码其实就是一个链式反向递归模型的实现,i 是从最大数开始循环的,将中间件从最后一个开始封装,每一次都是将自己的执行函数封装成 next 当做上一个中间件的 next 参数,这样当循环到第一个中间件的时候,只需要执行一次 next(),就能链式的递归调用所有中间件,这个就是 koa 剥洋葱的核心代码机制。
到这里我们总结一下上面所有剥洋葱模型代码的流程,通过 use 传进来的中间件是一个回调函数,回调函数的参数是 ctx 上下文和 next,next 其实就是控制权的交接棒,next 的作用是停止运行当前中间件,将控制权交给下一个中间件,执行下一个中间件的 next() 之前的代码,当下一个中间件运行的代码遇到了 next(),又会将代码执行权交给下下个中间件,当执行到最后一个中间件的时候,控制权发生反转,开始回头去执行之前所有中间件中剩下未执行的代码,这整个流程有点像一个伪递归,当最终所有中间件全部执行完后,会返回一个 Promise 对象,因为我们的 compose 函数返回的是一个 async 的函数,async 函数执行完后会返回一个 Promise,这样我们就能将所有的中间件异步执行同步化,通过 then 就可以执行响应函数和错误处理函数。
当中间件机制代码写好了以后,运行我们的上面的例子,已经能输出 123456 了,至此,我们的 koa 的基本框架已经基本做好了,不过一个框架不能只实现功能,为了框架和服务器实例的健壮,还需要加上错误处理机制。
模块四:错误捕获和错误处理
要实现一个基础框架,错误处理和捕获必不可少,一个健壮的框架,必须保证在发生错误的时候,能够捕获到错误和抛出的异常,并反馈出来,将错误信息发送到监控系统上进行反馈,目前我们实现的简易 koa 框架还没有能实现这一点,我们接下加上错误处理和捕获的机制。
throw new Error(‘oooops’);
基于现在的框架,如果中间件代码中出现如上错误异常抛出,是捕获不到错误的,这时候我们看一下 application.js 中的 callback 函数的 return 返回代码,如下:
return fn(ctx).then(respond);
可以看到,fn 是中间件的执行函数,每一个中间件代码都是由 async 包裹着的,而且中间件的执行函数 compose 返回的也是一个 async 函数,我们根据 es7 的规范知道,async 返回的是一个 promise 的对象实例,我们如果想要捕获 promise 的错误,只需要使用 promise 的 catch 方法,就可以把所有的中间件的异常全部捕获到,修改后 callback 的返回代码如下:
return fn(ctx).then(respond).catch(onerror);
现在我们已经实现了中间件的错误异常捕获,但是我们还缺少框架层发生错误的捕获机制,我们希望我们的服务器实例能有错误事件的监听机制,通过 on 的监听函数就能订阅和监听框架层面上的错误,实现这个机制不难,使用 nodejs 原生 events 模块即可,events 模块给我们提供了事件监听 on 函数和事件触发 emit 行为函数,一个发射事件,一个负责接收事件,我们只需要将 koa 的构造函数继承 events 模块即可,构造后的伪代码如下:
let EventEmitter = require(‘events’);
class Application extends EventEmitter {}
继承了 events 模块后,当我们创建 koa 实例的时候,加上 on 监听函数,代码如下:
let app = new Koa();
app.on(‘error’, err => {
console.log(‘error happends: ‘, err.stack);
});
这样我们就实现了框架层面上的错误的捕获和监听机制了。总结一下,错误处理和捕获,分中间件的错误处理捕获和框架层的错误处理捕获,中间件的错误处理用 promise 的 catch,框架层面的错误处理用 nodejs 的原生模块 events,这样我们就可以把一个服务器实例上的所有的错误异常全部捕获到了。至此,我们就完整实现了一个轻量版的 koa 框架了。
结尾
目前为止,我们已经实现了一个轻量版的 koa 框架了,我们实现了封装 node http server、创建 Koa 类构造函数、构造 request、response、context 对象、中间件机制和剥洋葱模型的实现、错误捕获和错误处理这四个大模块,理解了这个轻量版 koa 的实现原理,再去看 koa2 的源码,你就会发现一切都豁然开朗,koa2 的源码无非就是在这个轻量版基础上加了很多工具函数和细节的处理,限于篇幅笔者就不再一一介绍了。
本文实现的轻量版 koa 的完整代码 gitlab 地址为:article_koa2
最后,TNFE 团队为前端开发人员整理出了小程序以及 web 前端技术领域的最新优质内容,每周更新✨,欢迎 star,github 地址:https://github.com/Tnfe/TNFE-…
作者:第一名的小蝌蚪