Zijue/blog

13.手写koa核心原理

Opened this issue · 0 comments

Zijue commented

Koa介绍

Koa 是一个新的 web 框架,由 Express 幕后的原班人马打造, 致力于成为 web 应用和 API 开发领域中的一个更小、更富有表现力、更健壮的基石。 通过利用 async 函数,Koa 帮你丢弃回调函数,并有力地增强错误处理。 Koa 并没有捆绑任何中间件, 而是提供了一套优雅的方法,帮助您快速而愉快地编写服务端应用程序。

Koa的使用

const Koa = require('koa');

const app = new Koa(); // const server = http.createServer(function (req, res) { })

app.use(ctx => {
    ctx.body = 'zijue'
});

app.listen(3000, function () {
    console.log('Koa server start at 3000');
});

运行上述代码,便可开启koa服务。在浏览器中访问http://127.0.0.1:3000/,页面便会显示zijue字样。

很明显,app就是一个http.createServer创建的一个实例;同时还可以绑定error事件,说明本身继承了EventEmitter

const Koa = require('koa');

const app = new Koa();

app.use(ctx => {
    throw Error('报错了');
    ctx.body = 'zijue'
});

app.listen(3000, function () {
    console.log('Koa server start at 3000');
});

app.on('error', function(err){
    console.log('err: ', err); // 打印程序执行中的错误
});

接下来看看app.use(ctx =>{ })中的ctx,首先看看下面这段代码:

app.use(ctx => {
    console.log(ctx.req.url); // http原生的
    console.log(ctx.request.req.url); // koa上封装的request上有req属性。这是为了在request对象中可以通过this获取到原生的req
    
    console.log(ctx.request.query); // koa封装的
    console.log(ctx.query); // koa中封装的request对象的属性被代理到了ctx对象上

    ctx.body = 'zijue'; // 最终会执行 res.end(ctx.body)

    console.log(ctx.response.res); // koa上封装的response上有res属性(http原生的)
    console.log(ctx.response.body); // 同样的,koa中封装的response对象的属性被代理到了ctx对象上
});

koa核心原理的实现

为了和koa源码保持一致,首先创建如下的目录结构:

koa
├── lib
│   ├── application.js
│   ├── context.js
│   ├── request.js
│   └── response.js
└── package.json
  • 首先构建Koa类,让代码不具备其它功能先跑起来
// application.js

const EventEmitter = require('events');
const http = require('http');

class Koa extends EventEmitter {
    constructor() {
        super();
    }
    handleRequest(req, res) {
        console.log(req.url);
        res.end('zijue ~'); // 在浏览器中访问127.0.0.1:3000页面正常返回显示该内容
    }
    listen(...args) {
        const server = http.createServer(this.handleRequest.bind(this));
        server.listen(...args);
    }
}
module.exports = Koa;
  • 完成app.use功能

app.use传递的函数我们称之为中间件函数,它会接受Koa传递的上下文参数ctx对象,该对象上拥有requestresponse封装对象作为属性;

// application.js

const EventEmitter = require('events');
const http = require('http');
const context = require('./context');
const request = require('./request');
const response = require('./response');

class Koa extends EventEmitter {
    constructor() {
        super();
        // 通过原型链的方式,保证应用之间的隔离;否则多个应用共享一个上下文,会造成混乱
        this.context = Object.create(context); // this.context.__proto__ = context
        this.request = Object.create(request);
        this.response = Object.create(response);
    }
    use(middleware) {
        this.middleware = middleware;
    }
    createContext(req, res) {
        // 处理应用间上下文需要隔离,一个应用下的多个请求之间也是需要隔离上下文的。保证每次请求对象和响应对象的独立
        const ctx = Object.create(this.context); // ctx.__proto__.__proto__ = context
        const request = Object.create(this.request);
        const response = Object.create(this.response);

        ctx.request = request; // request.xxx 都是封装的
        ctx.req = ctx.request.req = req; // req.xxx 就是原生的
        ctx.response = response;
        ctx.res = ctx.response.res = res;
        return ctx
    }
    handleRequest(req, res) {
        const ctx = this.createContext(req, res);
        this.middleware(ctx);
        if (ctx.body) {
            res.end(ctx.body);
        } else {
            res.end('Not Found');
        }
    }
    listen(...args) {
        const server = http.createServer(this.handleRequest.bind(this));
        server.listen(...args);
    }
}
module.exports = Koa;

引入我们写的Koa代码,测试一下:

const Koa = require('./koa');

const app = new Koa();

app.use(ctx => {
    ctx.body = 'hi, zijue'
    console.log(ctx.req.url);
})

app.listen(3000, function () {
    console.log('Koa server start at 3000');
});

在浏览器中访问,页面正常显示hi, zijue,控制台也打印了req.url

  • 扩展requestresponse对象
// request.js

const url = require('url');

request = {
    get url() { // Object.defineProperty 属性访问器
        return this.req.url
    },
    get path() {
        return url.parse(this.url).pathname;
    },
    get query() {
        return url.parse(this.url, true).query;
    }
}
module.exports = request;
// response.js

response = {
    _body: undefined,
    get body() {
        return this._body;
    },
    set body(value) {
        this._body = value;
    }
}
module.exports = response;
  • requestresponse对象上的方法和属性代理到context对象上
// context.js

const context = {}

function defineGetter(target, key) {
    context.__defineGetter__(key, function () {
        return this[target][key]
    })
}

function defineSetter(target, key) {
    context.__defineSetter__(key, function (val) {
        this[target][key] = val;
    })
}

// 此处按照koa源码使用的api编写,也可以使用defineProperty、proxy等方式
defineGetter('request', 'query');
defineGetter('request', 'path');

defineGetter('response', 'body');
defineSetter('response', 'body');

module.exports = context;
  • 多个中间件的组合处理
    先看两段代码的执行过程:
function sleep(time) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            console.log('sleep');
            resolve();
        }, time);
    })
}

app.use(async (ctx, next) => {
    console.log('1');
    next();
    console.log('2');
    ctx.body = 'hi, zijue ~'
});

app.use(async (ctx, next) => {
    console.log('3');
    await sleep(1000);
    next();
    console.log('4');
});

app.use(async (ctx, next) => {
    console.log('5');
    next();
    console.log('6');
});

执行顺序为:1 -> 3 -> 2 -> 页面显示hi, zijue ~ -> (等待1s) sleep -> 5 -> 6 -> 4;

app.use(async (ctx, next) => {
    console.log('1');
    await next();
    console.log('2');
    ctx.body = 'hi, zijue ~'
});

app.use(async (ctx, next) => {
    console.log('3');
    await sleep(1000);
    await next();
    console.log('4');
});

app.use(async (ctx, next) => {
    console.log('5');
    await next();
    console.log('6');
});

执行顺序为:1 -> 3 -> (等待1s) sleep -> 5 -> 6 -> 4 -> 2 -> 页面显示hi, zijue ~

通过结果可以看出当第一个中间件(从上至下的顺序)执行完毕后,请求就被响应了。所以在Koa中的中间件函数中,调用next()时,前面必须加awaitreturn,这样才能保证后面的中间件执行完成。

对于使用await的多个koa中间件,koa会将传入的多个中间件进行组合处理,内部会将这三个函数全部包装成promise,并且将这三个promise串联起来,内部会使用promise连接起来。当第一个use传入的中间件执行完,整个请求就完成了。

新增compose组合函数:

// application.js

const EventEmitter = require('events');
const http = require('http');
const context = require('./context');
const request = require('./request');
const response = require('./response');

class Koa extends EventEmitter {
    constructor() {
        super();
        // 通过原型链的方式,保证应用之间的隔离;否则多个应用共享一个上下文,会造成混乱
        this.context = Object.create(context); // this.context.__proto__ = context
        this.request = Object.create(request);
        this.response = Object.create(response);
        this.middlewares = [];
    }
    use(middleware) {
        this.middlewares.push(middleware);
    }
    compose(ctx) {
        let dispatch = (i) => {
            if (this.middlewares.length == i) return Promise.resolve(); // 当 执行下标 == 中间件长度,表示所有中间件执行完毕
            return Promise.resolve(this.middlewares[i](ctx, () => dispatch(i + 1))); // 否则,执行当前下标的中间件,并将下标后移的next函数传入中间件
        }
        return dispatch(0);
    }
    createContext(req, res) {
        // 处理应用间上下文需要隔离,一个应用下的多个请求之间也是需要隔离上下文的。保证每次请求对象和响应对象的独立
        const ctx = Object.create(this.context); // ctx.__proto__.__proto__ = context
        const request = Object.create(this.request);
        const response = Object.create(this.response);

        ctx.request = request; // request.xxx 都是封装的
        ctx.req = ctx.request.req = req; // req.xxx 就是原生的
        ctx.response = response;
        ctx.res = ctx.response.res = res;
        return ctx
    }
    handleRequest(req, res) {
        const ctx = this.createContext(req, res);
        res.statusCode = 404;
        this.compose(ctx).then(() => {
            if (ctx.body) {
                res.end(ctx.body);
            } else {
                res.end('Not Found');
            }
        }).catch(err => {
            this.emit('error', err);
        })
    }
    listen(...args) {
        const server = http.createServer(this.handleRequest.bind(this));
        server.listen(...args);
    }
}
module.exports = Koa;

至此,Koa最核心的功能就写完了。但是还有个问题需要解决,就是当用户在中间件中调用两次next()时就会出问题,为此我们需要添加执行标识并限制,代码如下:

    compose(ctx) {
        // 将middlewares中的所有方法拿出来,先调用第一个,第一个完毕后,会调用next,再去调用执行第二个
        let index = -1; // 执行标识
        let dispatch = (i) => {
            if (i <= index) return Promise.reject('next() called multiple times.');
            index = i;
            if (this.middlewares.length == i) return Promise.resolve(); // 当 执行下标 == 中间件长度,表示所有中间件执行完毕
            return Promise.resolve(this.middlewares[i](ctx, () => dispatch(i + 1))); // 否则,执行当前下标的中间件,并将下标后移的next函数传入中间件
        }
        return dispatch(0);
    }