前言
koa是一个基于nodejs的web开发框架,与其兄弟express相比具备小而精的优势,看看官网是怎么介绍的:
通过利用 async 函数,Koa 帮你丢弃回调函数,并有力地增强错误处理。 Koa 并没有捆绑任何中间件, 而是提供了一套优雅的方法,帮助您快速而愉快地编写服务端应用程序。
既然吹的这么狠,其背后又是怎么样的实现机制呢? 本文将依样画葫芦地实现一个简易版koa,造一个初级的轮子以加深理解,如果你对koa仍不熟悉,那么十分有必要先花少许时间在官网🔖
源码结构
作为一个31k star的优秀项目,其源码简洁到只有4个文件,加起来不到两千行的代码,简直是沉不下心读源码的人的福音。
其中,application是入口文件,context.js是上下文对象相关,request.js是请求对象相关,response.js是响应对象相关。
第一步,在一个空文件夹下创建上述四个文件,开始拙劣的模仿之路
基础功能
先回顾一下官网koa的用法:
const Koa = require('koa');
const app = new Koa();
app.use((ctx, next) => {
ctx.body = 'Hello World';
});
app.listen(3000);
上述几行代码就完成了三件事情:
- 通过listen方法创建了我们的http服务器,端口是3000
- 通过use方法注册了一个koa中间件
- 封装ctx作为中间件参数,其上可以获取res和req
我们知道koa是基于nodejs开发的,那么在
application.js中要做的就是用nodejs实现同样功能
启动服务
// application.js
class Koa {
// 初始化
constructor() {}
// 注册中间件
use() {}
// 监听
listen(...args) {
http
.createServer(function (req, res) {
// console.log(res, req);
res.end('hello world')
})
.listen(...args);
}
}
本质上还是用node的http模块创建了一个server,因为server的listen方法接受多参数,所以这里直接解构所有参数就可以了。新建test.js作为测试文件,运行之后访问本地3000端口页面显示正常,说明服务已经成功启动。
// test.js
let Koa = require("../lib/application");
let app = new Koa();
app.use((ctx, next) => {
console.log('这是一个中间件');
});
app.listen(3000, () => {
console.log("app listen in port 3000");
});
注册中间件
上文中我们并没有实现use方法,use方法接受一个函数作为参数,可以注册多个中间件,所以应该有一个数组进行管理。函数执行需要我们接收ctx作为参数,这个ctx是koa给我们构造的上下文对象,可以通过ctx拿到req和res对象,改造代码如下
class Koa {
constructor() {
this.middleWares = [];
}
use(cb) {
if (typeof cb !== "function")
throw new TypeError("argument must be a function");
this.middleWares.push(cb);
}
handleRequest(req, res) {
this.middleWares.forEach((cb) => {
cb({ req, res });
});
}
listen(...args) {
http.createServer(this.handleRequest.bind(this)).listen(...args);
}
}
以上代码我们将req,res封装成上下文ctx,作为中间件的执行参数,但是koa中的ctx里面绑定了很多请求和相应相关的数据和方法,例如ctx.request.req、ctx.path、ctx.query、ctx.body等等,极大的为开发提供了便利,所以下一步我们应该对ctx的属性做进一步拓展。
委托模式
Koa Context 将 node 的 request 和 response 对象封装到单个对象中,为编写 Web 应用程序和 API 提供了许多有用的方法。这对于将 ctx 添加到整个应用程序中使用的属性或方法非常有用,而更多地依赖于ctx,这可以被认为是一种反模式
为方便起见许多上下文的访问器和方法直接委托给它们的 ctx.request或 ctx.response,例如 ctx.type 和 ctx.length 委托给 response 对象,ctx.path 和 ctx.method 委托给 request。
我们回顾一下一些常见的用法,拿获取请求url举例:
- ctx.req.url 原生的req
- ctx.request.req.url 原生的req
- ctx.request.url 封装在request的url
- ctx.url 代理在ctx上的url,等同ctx.request.url
由此可见,ctx.request就是我们自定义的request,在其上挂载一些属性如query,path方便取用:
// request.js
let url = require("url");
let request = {
get url() {
// this => ctx.request
return this.req.url;
},
get path() {
return url.parse(this.req.url).pathname;
},
get query() {
return url.parse(this.req.url).query;
},
};
// 等同于以下写法
// Object.defineProperty(request, "url", {
// get() {
// return this.req.url;
// },
// });
module.exports = request;
同样的为了在ctx上代理这些属性,我们改造context.js
let proto = {};
function defineGetter(target, key) {
proto.__defineGetter__(key, function () {
return this[target][key];
});
}
function defineSetter(target, key) {
proto.__defineSetter__(key, function (val) {
this[target][key] = val;
});
}
defineGetter("request", "url");
defineSetter("response", "body"); // 代理,实际上是给ctx.response.body赋值
module.exports = proto;
__defineGetter__这种用法MDN不是很推荐,不知为何源码中的代理要用这个实现,更推荐用Object.defineProperty或者get语法。
let context = require("./context");
let request = require("./request");
let response = require("./response");
class Koa {
constructor() {
this.middleWares = [];
this.context = context;
this.request = request;
this.response = response;
}
// ...
handleRequest(req, res) {
let ctx = this.createContext(req, res);
this.middleWares.forEach((cb) => {
cb(ctx);
});
}
// 构造上下文ctx
createContext(req, res) {
// 使用原型链继承 避免影响内部的属性和方法
let ctx = Object.create(this.context);
//
ctx.request = Object.create(this.request);
ctx.response = Object.create(this.response);
ctx.app = ctx.request.app = ctx.response.app = this // 挂载koa实例
ctx.req = ctx.request.req = req;
ctx.res = ctx.response.res = res;
ctx.request.ctx = ctx.response.ctx = ctx;
ctx.req = req;
ctx.res = res;
return ctx;
}
}
测试文件如下,访问url属性,运行后输出是一致的,符合我们预期。当访问path属性时,因为req对象并没有该属性,所以前两者都将输出undefined。
// test.js
let Koa = require("./lib/application.js");
let app = new Koa();
app.use((ctx, next) => {
// ctx.req.url 等价于 ctx.request.req.url(node原生的req)
// ctx.url 等价于 ctx.request.url(koa封装的request)
console.log(ctx.req.url);
console.log(ctx.request.req.url);
console.log(ctx.request.url);
console.log(ctx.url);
ctx.body = "hello world";
});
app.listen(3000);
此时页面没有返回hello world,我们仍需要改造response.js,当中间件执行完毕后,如果ctx.body已经被赋值了,应该修改res状态码为200并调用res.end()在页面输出这个值,否则应该以404的状态码返回错误。
// response.js
let response = {
set body(val) {
if (typeof val !== "string") return;
this.res.statusCode = 200;
this._body = val;
},
get body() {
return this._body;
},
};
module.exports = response;
handleRequest(req, res) {
res.statusCode = 404;
let ctx = this.createContext(req, res);
this.middleWares.forEach((cb) => {
cb(ctx);
});
let body = ctx.body;
if (typeof body == "undefined") {
res.end("404 Not Found");
} else {
res.end(ctx.body);
}
}
中间件机制(洋葱模型)
当一个中间件调用 next() 则该函数暂停并将控制传递给定义的下一个中间件。当在下游没有更多的中间件执行后,堆栈将展开并且每个中间件恢复执行其上游行为。
在执行时调用next,就可以把函数的执行权交给下一个中间件,待其执行完,在回过头继续执行自身,这样代码形成回形针式的级联。这也就是经典的洋葱模型。 在中间件自身,我们还可以使用async函数,这样可以让异步转同步更方便,用一段代码举个例子:
app.use((crx, next) => {
console.log(1)
next()
console.log(2)
})
app.use((crx, next) => {
console.log(3)
next()
console.log(4)
})
app.use((crx, next) => {
console.log(5)
next()
console.log(6)
})
上面这段代码最终会按照1、3、5、6、4、2的顺序输出,等同于下面:
app.use((crx, next) => {
console.log(1);
(crx, next) => {
console.log(3);
(crx, next) => {
console.log(5);
next();
console.log(6);
};
console.log(4);
};
console.log(2);
});
洋葱模型的出现避免了深层嵌套的这种写法,相比之下更加直观并且优雅。对比前后两段代码,参数next其实等同于下一个中间件,执行next()等同于进入下一个函数的执行栈,层层递进到最后一个,当最里层函数执行完后,又会弹出执行栈。
我们需要用一个函数实现串行执行顺序和支持异步,这个compose函数是koa的核心点
compose(ctx, middwares) {
function dispatch(index) {
if (index == middwares.length) return Promise.resolve();
return Promise.resolve(middwares[index](ctx, () => dispatch(index + 1)));
}
return dispatch(0);
}
分析一下代码:
-
compose函数接收中间件数组、ctx对象作为参数,利用递归函数将各中间件串联起来依次调用,所以我们回调的参数next其实就是
()=>dispatch(index + 1)这个箭头函数,dispatch(0)开始进入第一个中间件,next执行的时候调用dispatch(index + 1),进入下一个中间件。 -
当回调是async函数时返回的是Promise对象,Promise.resolve参数是一个promise的时候会由promise的执行结果决定状态,把每个回调包装成Promise以实现异步,利用这些特性我们可以用同步的逻辑处理异步回调嵌套。
把handleRequest函数改造一下,我们应该在compose执行完所有中间件后,根据ctx.body的类型做对应的返回处理:
handleRequest(req,res){
res.statusCode = 404
let ctx = this.createContext(req, res)
let promise = this.compose(this.middlewares, ctx)
promise.then(() => {
if (typeof ctx.body == 'object') {
res.setHeader('Content-Type', 'application/json;charset=utf8')
res.end(JSON.stringify(ctx.body))
} else if (ctx.body instanceof Stream) {
ctx.body.pipe(res)
} else if (typeof ctx.body === 'string') {
res.setHeader('Content-Type', 'text/html;charset=utf8')
res.end(ctx.body)
} else {
res.end('Not found')
}
})
}
错误处理
Koa有一套错误处理机制,需要监听实例的error事件。主要是处理发生error时及时把err抛给app进行处理, 为了传递错误信息,我们让koa继承events模块的EventEmitter,这样可以在程序内部使用emit on分发错误事件
let EventEmitter = require('events').EventEmitter
class Koa extends EventEmitter {
...
}
完善错误处理机制,产生错误后对ctx属性进行修改与相应的处理
handleRequest(req,res){
res.statusCode = 404
let ctx = this.createContext(req, res)
this.on('error', this.onerror); // 错误事件处理
let promise = this.compose(this.middlewares, ctx)
promise.then(() => {
...
}).catch(err => {
this.emit('error', err) // 错误上抛
res.statusCode = 500
res.end('server error')
})
}
// 程序对错误的统一处理
// 打印错误栈
// header设置成err.headers、msg设置为如err.message等等
onerror(err) {
...
console.error(err);
}
总结
至此,我们的简易轮子已经实现了koa的几大优势,可以看到koa的关键点集中在ctx对象和中间件的运用上。 通过委托模式将原生res和req的方法属性代理至ctx上,再挂载koa内置的request和reponse提供一些特有的属性和方便操作的方法,中间件则是使用compose将函数串联起来执行,并且用async await的语法糖使逻辑更加简洁清晰。
如有疑问或者笔者理解不对的地方欢迎各位批评指正,共同进步🔥🔥