2

浅析koa2源码与实现

源码解析

koa2源码地址:https://github.com/koajs/koa

目录

本人从公司内部系统的node modules中找到了koa的源码,目录如下:
image.png
在package.json中依赖库查找到的版本号如下:
image.png

不难看出,koa源码由application.js、context.js、request.js以及response.js组成。

application.js

application.js是koa的入口文件,它继承events创建了一个class实例,并将该实例export出去,这样就会赋予框架事件监听和事件触发的能力。application还暴露了一些常用的api,比如listen、toJSON、inspect、use等等。

listen的实现原理其实就是对http.createServer进行了一个封装,重点是这个函数中传入的callback,它里面包含了中间件的合并,上下文的处理,对res的特殊处理。

use是收集中间件,将多个中间件放入一个缓存队列中,然后通过koa-compose这个插件进行递归组合调用这一些列的中间件。

context.js

这部分就是koa的应用上下文ctx,其实就一个简单的对象暴露,里面的重点在delegate,这个就是代理,这个就是为了开发者方便而设计的,比如我们要访问ctx.repsponse.status但是我们通过delegate,可以直接访问ctx.status访问到它。

request.js、response.js

这两部分就是对原生的res、req的一些操作了,使用es6的get和set的一些语法,去读取headers、body、url等等。

简易版koa2的实现

本文实现的简易版koa2源码地址:https://github.com/TheWalkingFat/simple_koa2

通过查阅网上资料得知,实现一个简易版的Koa2框架,需要理解和实现四个大模块,分别是:

  • 封装node http server、创建Koa类构造函数
  • 构造request、response、context对象
  • 中间件机制和剥洋葱模型的实现
  • 错误捕获和错误处理

封装node http server、创建Koa类构造函数

从koa2源码里面可看出,实现koa的服务器应用和端口监听,其实就是基于node的原生代码进行了封装

let http = require('http');
let server = http.createServer((req, res) => {
    res.writeHead(200);
    res.end('success');
});
server.listen(3001, () => {    
    console.log('listenning on 3001');
});

那么我们需要将上面node.js原生代码封装成koa的模式:

const http = require('http');
const Koa = require('koa');
const app = new Koa();
app.listen(3001);

实现koa的第一步就是对以上的这个过程进行封装,为此我们需要创建application.js实现一个Application类的构造函数

let http = require('http');
class Application {    
    constructor() {        
        this.callbackFunc;
    }
    listen(...args) {        
        let server = http.createServer(this.callback());
        server.listen(...args);
    }
    use(fn) {
        this.callbackFunc = fn;
    }
    callback() {
        return (req, res) => {
            this.callbackFunc(req, res);
        };
    }
}
module.exports = Application;

然后创建app.js,引入application.js,运行服务器实例启动监听代码:

let Koa = require('./application');
let app = new Koa();
app.use((req, res) => {
    res.writeHead(200);
    res.end('success');
});
app.listen(3001, () => {
    console.log('listening on 3001');
});

现在在命令行中输入node app.js启动服务,然后再浏览器中访问localhost:3001即可看到浏览器输出success
现在第一步我们已经完成了,对http server进行了简单的封装和创建了一个可以生成koa实例的类class,这个类里还实现了app.use用来注册中间件和注册回调函数,app.listen用来开启服务器实例并传入callback回调函数。

构造request、response、context对象

看源码发现,其中request.js、response.js、context.js三个文件分别是request、response、context三个模块的代码文件

  • context: context就是我们平时写koa代码时的ctx,它相当于一个全局的koa实例上下文this,它连接了request、response两个功能模块,并且暴露给koa的实例和中间件等回调函数的参数中,起到承上启下的作用
  • request、response: 分别对node的原生request、response进行了一个功能的封装,使用了getter和setter属性,基于node的对象req/res对象封装koa的request/response对象

简单实现request.js:

module.exports = {
    get header() {
      return this.req.headers;
    },
  
    set header(val) {
      this.req.headers = val;
    },
  
    get headers() {
      return this.req.headers;
    },
  
    set headers(val) {
      this.req.headers = val;
    }
}

基于getter和setter,在request.js里还封装了header、url、origin、path等方法,都是对原生的request上用getter和setter进行了封装,在这便不进行一一实现了,有兴趣的同学可以自行查阅源码。

response.js:
它和request原理一样,也是基于getter和setter对原生response进行了封装,那我们接下来通过对常用的ctx.body和ctx.status这个两个语句当做例子简述一下

module.exports = {
    get body() {
        return this._body;
    },
    set body(data) {
        this._body = data;
    },
    get status() {
        return this.res.statusCode;
    },
    set status(statusCode) {
        this.res.statusCode = statusCode;
    }
}

这里有一点要注意的是,对于statusCode的读写操作,我们是直接基于原生response对象的statusCode进行操作,而对于body,我们则是存放了一个私有变量,而不是直接对原对象的属性进行操作,是因为在我们编写koa代码的时候,会对body进行多次的读取和修改,所以真正返回浏览器信息的操作是在application.js里进行封装和操作。

context.js:
现在我们已经实现了request.js、response.js,获取到了request、response对象和他们的封装的方法,然后我们开始实现context.js,context的作用就是将request、response对象挂载到ctx的上面,让koa实例和代码能方便的使用到request、response对象中的方法。

let proto = {};

function delegateSet(property, name) {
    proto.__defineSetter__(name, function (val) {
        this[property][name] = val;
    });
}

function delegateGet(property, name) {
    proto.__defineGetter__(name, function () {
        return this[property][name];
    });
}

let requestSet = [];
let requestGet = ['query'];

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;

context.js文件主要是对常用的request和response方法进行挂载和代理,通过context.query直接代理了context.request.query,context.body和context.status代理了context.response.body与context.response.status。而context.request,context.response则会在application.js中挂载。

至此,我们已经得到了request、response、context三个模块对象了,接下来就是将request、response所有方法挂载到context下,让context实现它的承上启下的作用,修改application.js文件,添加如下代码:

let http = require('http');
let context = require('./context');
let request = require('./request');
let response = require('./response');

createContext(req, res) {       
   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;
}

createContext这个方法是关键,它通过Object.create创建了ctx,并将request和response挂载到了ctx上面,将原生的req和res挂载到了ctx的子属性上,往回看一下context/request/response.js文件,就能知道当时使用的this.res或者this.response之类的是从哪里来的了,原来是在这个createContext方法中挂载到了对应的实例上,构建了运行时上下文ctx之后,我们的app.use回调函数参数就都基于ctx了。

中间件机制和剥洋葱模型的实现

从网上找了两张图来解析koa的洋葱模型:
image.png
image.png
koa的中间件机制是一个剥洋葱式的模型,多个中间件通过use放进一个数组队列然后从外层开始执行,遇到next后进入队列中的下一个中间件,所有中间件执行完后开始回帧,执行队列中之前中间件中未执行的代码部分,这就是剥洋葱模型,koa的中间件机制

koa的剥洋葱模型在koa1中使用的是generator + co.js去实现的,koa2则使用了async/await + Promise去实现

修改application.js文件,添加如下代码:

use(middleware) {
    this.middlewares.push(middleware);
}

compose() {
    // 将middlewares合并为一个函数,该函数接收一个ctx对象
    return async ctx => {

        function createNext(middleware, oldNext) {
            return async () => {
                await middleware(ctx, oldNext);
            }
        }

        let len = this.middlewares.length;
        let next = async () => {
            return Promise.resolve();
        };
        for (let i = len - 1; i >= 0; i--) {
            let currentMiddleware = this.middlewares[i];
            next = createNext(currentMiddleware, next);
        }

        await next();
    };
}

callback() {
    return (req, res) => {
        let ctx = this.createContext(req, res);
        let respond = () => this.responseBody(ctx);
        let onerror = (err) => this.onerror(err, ctx);
        let fn = this.compose();
        return fn(ctx).then(respond).catch(onerror);
    };
}

koa通过use函数,把所有的中间件push到一个内部数组队列this.middlewares中,剥洋葱模型能让所有的中间件依次执行,每次执行完一个中间件,遇到next()就会将控制权传递到下一个中间件,下一个中间件的next参数,剥洋葱模型的最关键代码是compose这个函数。

compose里面的createNext函数的作用就是将上一个中间件的next当做参数传给下一个中间件,并且将上下文ctx绑定当前中间件,当中间件执行完,调用next()的时候,其实就是去执行下一个中间件。

通过一个链式反向递归模型的实现,i是从最大数开始循环的,将中间件从最后一个开始封装,每一次都是将自己的执行函数封装成next当做上一个中间件的next参数,这样当循环到第一个中间件的时候,只需要执行一次next(),就能链式的递归调用所有中间件,这个就是koa剥洋葱的核心代码机制。

错误捕获和错误处理

错误的类型分为两种:中间件执行错误以及框架层面的错误
在application.js中添加一下代码:

callback() {
    return (req, res) => {
        let ctx = this.createContext(req, res);
        let respond = () => this.responseBody(ctx);
        let onerror = (err) => this.onerror(err, ctx);
        let fn = this.compose();
        return fn(ctx).then(respond).catch(onerror);
    };
}

onerror(err, ctx) {
    if (err.code === 'ENOENT') {
        ctx.status = 404;
    }
    else {
        ctx.status = 500;
    }
    let msg = err.message || 'Internal error';
    ctx.res.end(msg);
    this.emit('error', err);
}

第一种,中间件执行错误:我们前面说过,koa2是才用async/await + promise来实现洋葱模型,所以.catch自然可以捕抓中间件执行错误。
在callback函数中我们添加了这一行代码

return fn(ctx).then(respond).catch(onerror);

第二种,框架层面的错误:application里面的类继承了EventEmitter,在触发错误的时候主动调用emit('error'),那么外层的引用只需要监听error即可实现。

onerror(err, ctx) {
    if (err.code === 'ENOENT') {
        ctx.status = 404;
    }
    else {
        ctx.status = 500;
    }
    let msg = err.message || 'Internal error';
    ctx.res.end(msg);
    this.emit('error', err);
}
app.on('error', err => {
    console.log('error: ', err);
});

总结

至此,我们已经实现了一个简易版本的koa2,koa2的原理主要是由四部分组成,在理解这四部分之后再去看koa2的源码,会容易得多。
本文实现的简易版koa2源码地址:https://github.com/TheWalkingFat/simple_koa2


TheWalkingFat
522 声望32 粉丝