# 手摸手撸一个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 上挂载的 requestresponserequest.jsresponse.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

到目前为止,我们对 ctxreq的集成的逻辑基本走完了,其他的思路也是大同小异,对于 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源码其实不难,但是源码的逻辑很简洁但是很绕,需要花很多时间消化.

# 源码

点击查看源码