Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

# 手摸手撸一个mini-koa #20

Open
YIngChenIt opened this issue Jun 30, 2020 · 0 comments
Open

# 手摸手撸一个mini-koa #20

YIngChenIt opened this issue Jun 30, 2020 · 0 comments

Comments

@YIngChenIt
Copy link
Owner

手摸手撸一个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源码其实不难,但是源码的逻辑很简洁但是很绕,需要花很多时间消化.

源码

点击查看源码

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant