从头实现一个koa框架
koajs是最流行的nodejs后端框架之一,有很多网站都使用koa进行开发,同时社区也涌现出了一大批基于koa封装的企业级框架。然而,在这些亮眼的成绩背后,作为核心引擎的koa代码库本身,却非常的精简,不得不让人惊叹于其巧妙的设计。
在平时的工作开发中,笔者是koa的重度用户,因此对其背后的原理自然也是非常感兴趣,因此在闲暇之余进行了研究。不过本篇文章,并不是源码分析,而是从相反的角度,向大家展示如何从头开发实现一个koa框架,在这个过程中,koa中最重要的几个概念和原理都会得到展现。相信大家在看完本文之后,会对koa有一个更深入的理解,同时在阅读本文之后再去阅读koa源码,思路也将非常的顺畅。
首先放出笔者实现的这个koa框架代码库地址:simpleKoa
需要说明的是,本文实现的koa是koa 2版本,也就是基于async/await的,因此需要node版本在7.6以上。如果读者的node版本较低,建议升级,或者安装babel-cli,利用其中的babel-node来运行例子。
四条主线
笔者认为,理解koa,主要需要搞懂四条主线,其实也是实现koa的四个步骤,分别是
- 封装node http Server
- 构造resquest, response, context对象
- 中间件机制
- 错误处理
下面就一一进行分析。
主线一:封装node http Server: 从hello world说起
首先,不考虑框架,如果使用原生http模块来实现一个返回hello world的后端app,代码如下:
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');
});
实现koa的第一步,就是对这个原生的过程进行封装,为此,我们首先创建application.js实现一个Application对象:
// application.js
let http = require('http');
class Application {
/**
* 构造函数
*/
constructor() {
this.callbackFunc;
}
/**
* 开启http server并传入callback
*/
listen(...args) {
let server = http.createServer(this.callback());
server.listen(...args);
}
/**
* 挂载回调函数
* @param {Function} fn 回调处理函数
*/
use(fn) {
this.callbackFunc = fn;
}
/**
* 获取http server所需的callback函数
* @return {Function} fn
*/
callback() {
return (req, res) => {
this.callbackFunc(req, res);
};
}
}
module.exports = Application;
然后创建example.js:
let simpleKoa = require('./application');
let app = new simpleKoa();
app.use((req, res) => {
res.writeHead(200);
res.end('hello world');
});
app.listen(3000, () => {
console.log('listening on 3000');
});
可以看到,我们已经初步完成了对于http server的封装,主要实现了app.use
注册回调函数,app.listen
语法糖开启server并传入回调函数了,典型的koa风格。
但是美中不足的是,我们传入的回调函数,参数依然使用的是req
和res
,也就是node原生的request和response对象,这些原生对象和api提供的方法不够便捷,不符合一个框架需要提供的易用性。因此,我们需要进入第二条主线了。
主线二:构造request, response, context对象
如果阅读koa文档,会发现koa有三个重要的对象,分别是request, response, context。其中request是对node原生的request
的封装,response是对node原生response
对象的封装,context
对象则是回调函数上下文对象,挂载了koa request和response对象。下面我们一一来说明。
首先要明确的是,对于koa的request和response对象,只是提供了对node原生request和response对象的一些方法的封装,明确了这一点,我们的思路是,使用js的getter和setter属性,基于node的对象req/res对象封装koa的request/response对象。
规划一下我们要封装哪些易用的方法。这里在文章中为了易懂,姑且只实现以下方法:
对于simpleKoa request对象,实现query
读取方法,能够读取到url中的参数,返回一个对象。
对于simpleKoa response对象,实现status
读写方法,分别是读取和设置http response的状态码,以及body
方法,用于构造返回信息。
而simpleKoa context对象,则挂载了request和response对象,并对一些常用方法进行了代理。
首先创建request.js:
// request.js
let url = require('url');
module.exports = {
get query() {
return url.parse(this.req.url, true).query;
}
};
很简单,就是导出了一个对象,其中包含了一个query的读取方法,通过url.parse
方法解析url中的参数,并以对象的形式返回。需要注意的是,代码中的this.req
代表的是node的原生request对象,this.req.url
就是node原生request中获取url的方法。稍后我们修改application.js的时候,会为koa的request对象挂载这个req。
然后创建response.js:
// response.js
module.exports = {
get body() {
return this._body;
},
/**
* 设置返回给客户端的body内容
*
* @param {mixed} data body内容
*/
set body(data) {
this._body = data;
},
get status() {
return this.res.statusCode;
},
/**
* 设置返回给客户端的stausCode
*
* @param {number} statusCode 状态码
*/
set status(statusCode) {
if (typeof statusCode !== 'number') {
throw new Error('statusCode must be a number!');
}
this.res.statusCode = statusCode;
}
};
也很简单。status
读写方法分别设置或读取this.res.statusCode
。同样的,这个this.res
是挂载的node原生response对象。而body
读写方法分别设置、读取一个名为this._body
的属性。这里设置body的时候并没有直接调用this.res.end
来返回信息,这是考虑到koa当中我们可能会多次调用response的body方法覆盖性设置数据。真正的返回消息操作会在application.js中存在。
然后我们创建context.js文件,构造context对象的原型:
// context.js
module.exports = {
get query() {
return this.request.query;
},
get body() {
return this.response.body;
},
set body(data) {
this.response.body = data;
},
get status() {
return this.response.status;
},
set status(statusCode) {
this.response.status = statusCode;
}
};
可以看到主要是做一些常用方法的代理,通过context.query
直接代理了context.request.query
,context.body
和context.status
代理了context.response.body
与context.response.status
。而context.request
,context.response
则会在application.js中挂载。
由于context对象定义比较简单并且规范,当实现更多代理方法时候,这样一个一个通过声明的方式显然有点笨,js中,设置setter/getter,可以通过对象的__defineSetter__
和__defineSetter__
来实现。为此,我们精简了上面的context.js实现方法,精简版本如下:
let proto = {};
// 为proto名为property的属性设置setter
function delegateSet(property, name) {
proto.__defineSetter__(name, function (val) {
this[property][name] = val;
});
}
// 为proto名为property的属性设置getter
function delegateGet(property, name) {
proto.__defineGetter__(name, function () {
return this[property][name];
});
}
// 定义request中要代理的setter和getter
let requestSet = [];
let requestGet = ['query'];
// 定义response中要代理的setter和getter
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;
这样,当我们希望代理更多request和response方法的时候,可以直接向requestGet/requestSet/responseGet/responseSet数组中添加method的名称即可(前提是在request和response中实现了)。
最后让我们来修改application.js,基于刚才的3个对象原型来创建request, response, context对象:
// application.js
let http = require('http');
let context = require('./context');
let request = require('./request');
let response = require('./response');
class Application {
/**
* 构造函数
*/
constructor() {
this.callbackFunc;
this.context = context;
this.request = request;
this.response = response;
}
/**
* 开启http server并传入callback
*/
listen(...args) {
let server = http.createServer(this.callback());
server.listen(...args);
}
/**
* 挂载回调函数
* @param {Function} fn 回调处理函数
*/
use(fn) {
this.callbackFunc = fn;
}
/**
* 获取http server所需的callback函数
* @return {Function} fn
*/
callback() {
return (req, res) => {
let ctx = this.createContext(req, res);
let respond = () => this.responseBody(ctx);
this.callbackFunc(ctx).then(respond);
};
}
/**
* 构造ctx
* @param {Object} req node req实例
* @param {Object} res node res实例
* @return {Object} ctx实例
*/
createContext(req, res) {
// 针对每个请求,都要创建ctx对象
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;
}
/**
* 对客户端消息进行回复
* @param {Object} ctx ctx实例
*/
responseBody(ctx) {
let content =