# 手摸手撸一个mini-koa
# 前言
在之前的源码分析中,有些细节的话还是很难理解,如中间件洋葱模型机制是如何实现的、异步错误是怎么统一监听的、context上下文超集内部原理等,那么我们现在通过自己手写实现一款mini-koa框架来加深对koa源码的理解,核心思想都是和源码一致的。现在我们准备的目录结构如下:
── koa
├── application.js
├── context.js
├── request.js
└── response.js
── server.js
这是一个最典型的koa的目录结构了,那么他们对应的文件初始化如下:
// context.js
let context = {}
module.exports = context
// request.js
let request = {}
module.exports = request
// response.js
let response = {}
module.exports = response
// application.js
module.exports = class {}
// server.js
const Koa = require('./koa/application')
const app = new Koa()
app.use(async(ctx, next) => {
ctx.body = 'hello world'
next()
})
app.listen(5050, () => {
console.log('sever start')
})
我们可以发现 server.js
默认引入我们自己的koa, 其他用法和koa一样。response.js
request.js
context.js
分别导出一个对象,application.js
导出一个koa的类, 那我们现在按照跑通测试用例的方法一步一步实现一个mini版的koa。
# 初始化
TIP
我们先实现项目初始化,即让我们的koa能够起一个服务
测试用例代码:
const Koa = require('./koa/application')
const app = new Koa()
app.listen(5050, () => {
console.log('sever start')
})
根据测试用例我们可以知道,application
默认导出一个类,类的实例可以调用 listen()
方法起一个服务,那么我们来实现一下这个功能
// application.js
const http = require('http')
module.exports = class {
callbacks() {
// 返回形如 (req, res) => {} 的请求处理函数
}
listen() {
let server = http.createServer(this.callbacks.bind(this))
server.listen(...arguments)
}
}
代码中通过node原生的http模块起一个服务,并且将请求处理函数抽离出去,便于后序扩展。那么我们现在已经完成了初始化的流程了。
# context
TIP
context 是koa中的核心之一,它是基于node原生req和res为request和response对象赋能,还提供了很多便捷的方法
测试用例代码:
const Koa = require('./koa/application')
const app = new Koa();
app.use(ctx=>{
// 实质上是node的原生req、res
console.log(ctx.req.url);
console.log(ctx.request.req.url);
// 是koa进行处理过的request、response
console.log(ctx.request.url);
console.log(ctx.url);
console.log(ctx.request.path);
console.log(ctx.path);
ctx.response.body = 'hello';
ctx.body = '111';
console.log(ctx.body)
})
app.listen(5050, () => {
console.log('sever start')
})
我们发现koa的基本用法中,有一个use
方法, use
方法的参数就是我们所说的中间件处理函数,不过这里不一样的是,中间层处理函数的参数是ctx
, 也就是我们说的超集,它可以调用原生req\res 和koa封装过的 request\response 的方法。并且,中间件函数的代码是可以执行的。对上述代码进行分析之后,我们先来一步一步跑通这个测试用例吧。
再类上添加 use
方法,将传入的中间件函数保存,并且在起服务的时候执行中间件函数
const http = require('http')
module.exports = class {
constructor() {
this.fn
}
callbacks() {
this.fn()
}
listen() {
let server = http.createServer(this.callbacks)
server.listen(...arguments)
}
use(middleware) {
this.fn = middleware
}
}
以上代码是不是简单明了,那我们接下来尝试写一个这个 context
通过 createContext
方法创建 context, 根据之前源码的分析,我们分别引入 context.js
request.js
response.js
导出的对象, 为了后序能取到这3个对象,我们将其挂载到实例上。
const http = require('http')
const context = require('./context')
const request = require('./request')
const response = require('./response')
module.exports = class {
constructor() {
this.fn
this.context = Object.create(context)
this.request = Object.create(request)
this.response = Object.create(response)
}
listen() {
let server = http.createServer(this.callbacks.bind(this))
server.listen(...arguments)
}
use(middleware) {
this.fn = middleware
}
callbacks(req, res) {
let ctx = this.createContext(req, res)
this.fn(ctx)
}
createContext(req, res) {
}
}
接下来我们根据测试用例一条一条的过来补充我们的 createContext
方法
TIP
console.log(ctx.req.url)
在源码分析中我们知道,ctx
就是我们context.js
中导出的对象,并且如果要有 req.url
, 我们可以进行如下操作
createContext(req, res) {
let ctx = this.context
ctx.req = req
return ctx
}
其实无异于就是将原生的 req
挂载到 ctx.req
上,就可以实现效果了.
TIP
console.log(ctx.request.req.path)
根据之前的源码分析我们知道, koa中 ctx 上挂载的 request
和 response
是request.js
和 response.js
中导出的对象,那么接下来就好办了,和上面同理
createContext(req, res) {
let ctx = this.context
ctx.request = this.request
ctx.response = this.response
ctx.request.req = ctx.req = req
ctx.response.res = ctx.res = res
return ctx
}
其实逻辑很简单,一个简单的赋值就可以完成了, 那我们继续往下
TIP
console.log(ctx.request.url)
这个代码需要直接取到 ctx.request
上的 url
属性,但是我们目前来说并没有这个属性,那我们可以通过 getter
来实现这个逻辑,先贴代码我们分析一下这个巧妙的方法
// request.js
let request = {
get url() {
return this.req.url
}
}
module.exports = request
通过属性选择器我们可以对 ctx.request.url
中返回的值进行修改, 其实逻辑不难,get
里面的 this
指向 ctx.request
, 而且我们之前在 ctx.request
上挂载了一个原生的 req
属性,所以就能取到它上面的 url
属性了
TIP
console.log(ctx.request.path)
这个测试用例其实和上面的一个用例一致,不一样的地方在于原生的 req
属性中没有 path
这个值,所以需要我们手动来提取
// request.js
const url = require('url')
let request = {
get url() {
return this.req.url
},
get path() {
return url.parse(this.req.url).pathname
}
}
module.exports = request
靠谱是吧,那我们继续
TIP
console.log(ctx.url);
这个的话就比较麻烦一点点,需要直接取 ctx
上的url
属性,但是ctx
上目前是没有的,但是我们不妨换一个思路,我们取 ctx
上的url
属性的时候是不是可以做个代理,让实际上取得是ctx.request.url
的值,靠谱。那我们可以实现对象代理的方法有很多 proxy
Object.defineProperty
Object.__defineGetter__
, 那我们就和源码一样,使用 Object.__defineGetter__
来实现这个代理
// context.js
let context = {}
context.__defineGetter__('url', function() {
return this.request.url
})
module.exports = context
以上代码的意思是,当我们取 context
对象的url
的时候,默认回去 context.request.url
中取, 但是我们要取的属性有很多如 url
path
等,所以我们可以对代码进行优化,抽离函数逻辑
// context.js
let context = {}
function defineGetter(property, key) {
context.__defineGetter__(key, function() { // getter
return this[property][key];
});
}
defineGetter("request", "path");
defineGetter("request", "url");
module.exports = context
到目前为止,我们对 ctx
对 req
的集成的逻辑基本走完了,其他的思路也是大同小异,对于 res
的集成逻辑也差不多,这里就直接贴代码啦
TIP
ctx.response.body = 'hello'; ctx.body = '111'; console.log(ctx.body)
// response.js
let response = {
_body:'', // _ 意味着不希望别人访问到私有属性
get body(){
return this._body
},
set body(value){
this.res.statusCode = 200; // 如果你调用了ctx.body = 'xxx'
this._body = value;
}
}
module.exports = response;
-------------------------
// context.js
let context = {};
function defineGetter(property, key) {
context.__defineGetter__(key, function() { // getter
return this[property][key];
});
}
function defineSetter(property,key){
context.__defineSetter__(key,function(value){ // setter
this[property][key] =value;
})
}
defineGetter("request", "path");
defineGetter("request", "url");
defineGetter("response", "body");
defineSetter('response',"body");
module.exports = context;
需要注意的是,对 req.body
这个属性需要赋值也需要取值,所以我们需要取一个第三方变量来实现。
那么到目前为止,一个mini版的context已经给我们完成了。
# 中间件洋葱模型机制
# 最基本的洋葱模型的实现
我们先来感受下中间件洋葱模型的机制
const Koa = require('./koa/application')
const app = new Koa();
app.use(async (ctx,next)=>{
console.log(1)
await next()
console.log(2)
})
app.use(async (ctx,next)=>{
console.log(3);
await next();
console.log(4);
})
app.use((ctx,next)=>{
console.log(5);
next();
console.log(6);
});
app.listen(5050, () => {
console.log('sever start')
})
以上代码会输出 1 3 5 6 4 2, 这就是一个典型的洋葱模型,如果你对他的运行流程还不了解的话,可以尝试下把代码转化成下面的结构:
app.use(async (ctx,next)=>{
console.log(1)
app.use(async (ctx,next)=>{
console.log(3);
app.use((ctx,next)=>{
console.log(5);
next();
console.log(6);
});
console.log(4);
})
console.log(2)
})
这样的话是不是就对洋葱模型有一个初步的认识了.
此前我们对 use
中的中间件函数是直接执行的,那么现在这种情况很明显是不可以的,那我们修改一下原来代码,将use
挂载的中间件函数放进一个数组,然后递归执行他们
constructor() {
this.fn;
this.context = Object.create(context)
this.request = Object.create(request)
this.response = Object.create(response)
this.middlewares = [] // 新
}
listen() {
let server = http.createServer(this.callbacks.bind(this))
server.listen(...arguments)
}
use(middleware) {
this.middlewares.push(middleware) // 新增
}
但是要实现这个洋葱模型还不够,因为koa中间件洋葱模型机制的核心是将所有中间件函数通过 compose
组合成一个大的函数
我们先来实现 compose
函数,再来分析这样容易理解一点
// application.js
compose(ctx) {
let index = 0
const dispatch = () => {
// 如果中间件函数都执行完了,那就返回成功的promise
if(index === this.middlewares.length) return Promise.resolve()
// 递归取出middlewares中的每个中间件函数
let middleware = this.middlewares[index++]
// 如果这个中间件不是promise 那我就把他包装成一个promise
return Promise.resolve(middleware(ctx, () => dispatch()))
}
return dispatch()
}
callbacks(req, res) {
let ctx = this.createContext(req, res)
this.compose(ctx).then(() => {
})
}
上面的逻辑有点绕,但是和我们之前熟悉的异步逻辑 next()
函数没什么区别,主要是通过一个索引 index
分别从中间件函数数组中取出函数来执行,将 ctx
作为第一个参数,将下一个 next
函数作为第二个参数 ,也就是 app.use((ctx, next) => {})
中的 next
其次,不一定是每一个中间件函数都返回的是 promise
,所以我们需要手动的返回一个 promise
这就是上面代码的大致逻辑了,用了递归,有点饶.
# next 多次调用处理
app.use((ctx, next) => {
next()
next()
})
我们再koa中运行上面代码的时候发现会抛出错误 multiple call next()
表示 next
重复调用了,那我们也来实现一下这个功能吧
// application.js
compose(ctx) {
let index = 0
let i = -1 //新
const dispatch = () => {
if(index <= i ) return Promise.reject('multiple call next()') // 新
i = index// 为了防止多次调用 多次调用index值不会发生变化,但是i第一次已经和index相等了,所以第二次在调用 i 和 index相等 就抛出错误
if(index === this.middlewares.length) return Promise.resolve()
let middleware = this.middlewares[index++]
return Promise.resolve(middleware(ctx, () => dispatch()))
}
return dispatch()
}
我们只需要添加一个索引 i
来判断就可以实现上述功能了
# 统一的错误监控
我们知道,koa的错误监控和 express 这些框架不一样,他的错误监控比较简单和统一,因为通过 compose
包装过的大的中间件函数是一个 promise
, 我们可以通过 promise
的特性 和 events
的异步观察者模式处理方法来实现统一的错误监控
// application.js
const EventEmitter = require('events')
// 继承 EventEmitter
module.exports = class extends EventEmitter {}
callbacks(req, res) {
let ctx = this.createContext(req, res)
this.compose(ctx).then(() => {
}).catch(err => {
// 通过promise 和 EventEmitter实现错误监控
this.emit('error', err)
})
}
那我们就可以在server.js
中通过 app.on('error', (err) => {})
来监听错误了
# ctx.body 多类型
现在就差最后一步我们就能实现一个 mini版的 koa了,对于 ctx.body
的类型有很多,如字符串、数字、buffer、stream等,那么我们需要对这些不同的类型分别进行处理,就直接贴代码啦
callbacks(req,res){ // 处理请求的方法
let ctx = this.createContext(req,res);
this.compose(ctx).then(()=>{
let _body = ctx.body // 新
if(typeof _body=== 'string' || Buffer.isBuffer(_body)){
return res.end(_body);
}else if(typeof _body ==='number'){
return res.end(_body+'');
}else if( _body instanceof Stream){
// 下载header
// res.setHeader('Content-type', 'application/octet-stream');
// res.setHeader('Content-Disposition', 'attachment;filename='+encodeURIComponent('下载'));
_body.pipe(res);
return
}else if(typeof _body === 'object'){
return res.end(JSON.stringify(_body));
}
res.end('Not Found')
}).catch(err=>{
this.emit('error',err)
})
}
# 总结
通过撸一个mini版的koa之后,我们会发现koa源码其实不难,但是源码的逻辑很简洁但是很绕,需要花很多时间消化.