利用js实现一个简易的 koa2 框架

·  阅读 117

概述

koa 是由Express 原班人马打造的,致力于成为一个更小、更富有表现力、更健壮的 Web 框架,koa 不在内核方法中绑定任何中间件, 它仅仅提供了一个轻量优雅的 函数库,使得编写Web 应用变得得心应手。

最近在学习和使用 koa2 框架的过程中,对于它的ctx参数对象以及基于洋葱模型处理中间件的方式很感兴趣,所以查阅了大量的资料和视频,看了一部分源码。差不多弄明白了它的ctx参数和它怎样处理回调函数。

接下来,我们通过js来实现一个简易的koa2框架,注意,我们不会实现一个一摸一样的koa2框架,只是通过这篇博客分享koa2是怎样封装ctx参数对象的以及它基于Promise怎样处理中间件、还有它的Router中间件是怎样实现的。

源码分析

koa2 是基于类的方式来实现其主要功能的,所以通过class的方式来实现其主体。创建一个koa目录,在目录下新建一个application.js文件。创建一个Application类,实现use注册方法和listen监听方法。

class Application extends EventEmitter {
    constructor() {
        super();
        this.context = context;
        this.response = response;
        this.request = request;
        this.middleWares = [];
    }
    
	// 注册方法
    use(fn) {
        // this.fn = fn;
        this.middleWares.push(fn);
    }
    
    // 监听端口号
    listen(...args) {
        const server = http.createServer(this.handleRequest.bind(this));
        server.listen(...args);
    }
}
复制代码

最初使用“this.fn = fn”来绑定从外部传入的回调函数,但是 koa2 可以注册多个方法,所以我们通过数组 this.middleWares 来保存所有方法。

通过listen来开启服务,所以使用 http.createServer 来绑定并使用我们注册的所有方法,this.handleRequest就是一个操作器,逐一启动所有方法,稍后我们来实现它。然后使用listen添加端口号和启动后执行的回调函数。

ctx 参数

新建一个文件使用我们自己的koa2 框架。

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

const app = new Koa();

app.use((ctx) => {
    console.log(ctx.req.url);
    console.log(ctx.request.req.url);
    console.log(ctx.request.url);
    console.log(ctx.url);
    console.log(ctx.method, 'method');
    console.log(ctx.path, 'path');

    ctx.response.body = 'hello';
    console.log(ctx.body);
    ctx.body = '<h1>ctx<h2>';
    console.log({ querystring: ctx.request.querystring }, 'querystring');
})
复制代码

在use方法中注册的方法找中默认会传入 ctx 和 next 两个参数,next参数稍后再说,先说ctx参数。ctx是context的缩写中文一般叫成上下文,这个在所有语言里都有的名词,可以理解为上(request)下(response)沟通的环境,表示一次对话的上下文(包括 HTTP 请求和 HTTP 回复)。通过加工这个对象,就可以控制返回给用户的内容。ctx对象中包含了request和response对象,还有 http.createServer 原生产生的 req 和 res 对象。

我们新建三个文件,context.js、request.js 和 response.js,它们分别表示request和response对象。并将其挂载在Application上。 并且ctx对象上有req和res对象,在request对象上有原生req对象,在response对象上有原生res对象。所以在Application 类上,创建一个 createContext 方法用于实现上述流程。并通过handleRequest 方法执行 createContext 。在listen方法中的http.createServer执行时传入原生req和res对象。

Application操作context

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

class Application extends EventEmitter {
    constructor() {
        super();
        this.context = context;
        this.response = response;
        this.request = request;
        this.middleWares = [];
    }
    
    createContext(req, res) {
        const context = Object.create(this.context);
        context.request = Object.create(this.request);
        context.response = Object.create(this.response);

        context.req = context.request.req = req;
        context.res = context.response.res = res;
        return context;
    }
    
    handleRequest(req, res) {
        // 创建上下文
        let ctx = this.createContext(req, res);
        // fn(ctx); 使用伪代码fn(ctx)来表示执行use中注册的方法时,传入ctx参数,实际的代码肯定不会这么简单,我们以后再说。
    }
    
    listen(...args) {
        const server = http.createServer(this.handleRequest.bind(this));
        server.listen(...args);
    }
}
复制代码

createContext 方法中,通过 Object.create 的方式新创建创建三个对象,这样可以避免操作原对象,并通过

context.req = context.request.req = req;
context.res = context.response.res = res;
复制代码

实现对原生req和res的引用。

在实际中我们发现对于一个参数的获取,比如res中的url参数,有很多种方式。

app.use((ctx) => {
    console.log(ctx.req.url);
    console.log(ctx.request.req.url);
    console.log(ctx.request.url);
    console.log(ctx.url);
})
复制代码

可以通过 ctx.req.url、ctx.request.req.url和ctx.request.url 三种方式获取,也可以直接通过 ctx.url 获取。 对于ctx.req.url、ctx.request.req.url,我们是理解的,因为ctx和ctx.request对象上有res对象。但是为什么可以通过 ctx.request.url 和 ctx.url 获取呢?

request.js和context.js文件

我们在request.js和context.js文件里面做一些操作来达到这一目的。 request.js 代码:

const url = require('url');

let request = {
    get url() {
        return this.req.url;
    },

    get method() {
        return this.req.method;
    },

    get path() {
        return url.parse(this.req.url).pathname;
    },
    get query() {
        let query = url.parse(this.req.url).query;
        const reg = /([^?&#]+)=([^?&#]+)/g;
        const obj = {};
        query ? query.replace(reg, function () {
            obj[arguments[1]] = arguments[2];
        }) : obj
        return obj;
    },
    get querystring() {
        let query = url.parse(this.req.url).query;
        return query ? query : '';
    }
};

module.exports = request;
复制代码

通过es6新出的get修饰的方法,来获取url时。最终执行this.req.url来获取原生对象的url参数。这里的this指的就是ctx。因为是通过ctx.request.url执行的。有些参数需要经过加工再返回。比如path和query方法。通过“url.parse(this.req.url)”解析之后再做操作。原生的req上没有这些属性。这也是为什么ctx参数上挂载两个自己创造的request和response对象,就是为了减少获取某些参数的复杂度。比如method、query、querystring方法。

那么问题来了,怎么直接通过ctx.url来获取url参数呢?
我们来看一下context.js代码:

let context = {};
function defineGetter(property, key) {
    context.__defineGetter__(key, function () {
        // 这里的this指代的是context,这个context是我们自己创建的
        // let ctx = Object.create(context)
        return this[property][key];
    })
}

// 实现代理功能
defineGetter('request', 'url');
defineGetter('request', 'method');
defineGetter('request', 'path');


module.exports = context;
复制代码

定义一个defineGetter函数,通过js对象上的__defineGetter__方法,可以给对象指定一个参数并且绑定一个函数。执行此方法defineGetter('request', 'url'),通过“ this[property][key]”,拿到ctx对象request属性绑定的对象的url参数。也就是上文中提到的ctx.request.url。最终通过get url()方法中的this.req.url拿到url值。到这里,饶了一大圈,终于连接起来了。

response.js实现

通过同样的方式实现response.js:

let response = {
    _body: '',

    get body() {
        return this._body;
    },

    set body(newValue) {
        this._body = newValue;
    },
};

module.exports = response;
复制代码

response中的body存储的值,就是我们响应给浏览器的数据。

给context.js文件添加defineSetter方法:

······省略······
function defineSetter(property, key) {
    context.__defineSetter__(key, function (newValue) {
        this[property][key] = newValue;
    })
}

defineGetter('response', 'body');
defineSetter('response', 'body');
······省略······
复制代码

洋葱模型及其js实现

通过利用 async 函数,Koa 帮你丢弃回调函数,并有力地增强错误处理。我们直接进入主题,说一说koa中最重要的一个概念:洋葱模型。 我们先来看一个demo:

const Koa = require('koa');

// 应用程序
const app = new Koa();

const logger = () => {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            console.log("logger");
            resolve();
        }, 1000);
    });
};

app.use(async (ctx, next) => {
    console.log(1);
    await next();
    ctx.body = "<h1>ctx</h1>";
    // ctx.body = { name: 1 };
    // ctx.body = fs.createReadStream('2.server.js');
});

app.use(async (ctx, next) => {
    // 上下文
    console.log(2);
    // throw new Error('出错');
    await logger();
    next();
});

app.use(async (ctx, next) => {
    // 上下文
    console.log(3);
    await next();
    console.log(4);
});

const port = 4000;
app.listen(port, () => { console.log(`${port}端口已经启动`); });
复制代码

浏览器输入localhost:9000,控制台会有如下打印:

4000端口已经启动
1
2
logger
3
4
复制代码

其实很明显,在koa的中间件中,通过next函数,将中间件分成了两部分,next上面的一部分会首先执行,而下面的一部分则会在所有后续的中间件调用之后执行。

我们看第二张图,其实不难发现,会有两次进入同一个中间件的行为,而且是在所有第一次的中间件执行之后,才依次返回上一个中间件。当我们使用koa进行开发的时候,因为读取数据库或是http请求等都是异步请求,所以我们为了保证洋葱模型会使用号称异步终极解决方案的async/await。

现在我们来通过js实现它,还记得我们之前写的 Application 中的 handleRequest方法吗

    handleRequest(req, res) {
        // 创建上下文
        let ctx = this.createContext(req, res);
        // this.fn(ctx);
复制代码

这明显是伪代码,只是告诉你怎么调用通过use api注册的方法,接下来,我们来实现它,首先,实现一个compose方法,它是基于Promise的方式依次处理middleWares数组中保存的所有方法, 在处理完后,在 handleRequest 中以then的形式,返回给浏览器相应的数据。

class Application extends EventEmitter {
......省略......
compose(ctx) {
        if (!Array.isArray(this.middleWares)) throw new TypeError('Middleware stack must be an array!')
        for (const fn of this.middleWares) {
            if (typeof fn !== 'function') throw new TypeError('Middleware must be composed of functions!')
        }

        let dispatch = (index) => {
            if (index == this.middleWares.length) {
                return Promise.resolve();
            }

            let middle = this.middleWares[index];
            return Promise.resolve(middle(ctx, () => dispatch(index + 1)));
        }
        return dispatch(0);
	}
......省略......
}
复制代码

中间件中的 next 参数实现

以递归的形式调用的middleWares数组中保存的所有方法:

let middle = this.middleWares[index];
复制代码

并通过“() => dispatch(index + 1))”的方式将数组中的下一个方法以next参数赋值给当前方法,通过 dispatch(0)执行第一个方法时,会依次递归执行数组中的所有方法,直到到达数组边界。并通过Promise.resolve 将所有方法的执行包装起来,这样能确保所有的方法执行完后,才将数据返回给浏览器。

重写 handleRequest 方法:

handleRequest(req, res) {
    // 创建上下文
    let ctx = this.createContext(req, res);
    // this.fn(ctx);
    this.compose(ctx).then(() => {
        let _body = ctx.body;
        if (typeof _body === 'object') {
            return res.end(JSON.stringify(_body))
        } else if (_body instanceof Stream) {
            return _body.pipe(res);
        } else {
            return res.end(_body);
        }
    }).catch((err) => { this.emit('error', err) })
}
复制代码

this.compose(ctx)里的所有方法执行完后才通过then函数响应浏览器,捕获到异常时,通过继承的 EventEmitter 类抛出错误。EventEmitter 对象如果在实例化时发生错误,会触发 error 事件。
至此,通过 Application 类中的handleRequest和compose方法,将koa2中的异步处理方式实现完毕。

koa2 Router的实现

路由(Routing)是由一个URI(或者叫路径)和一个特定的HTTP 方法(GET、POST 等) 组成的,涉及到应用如何响应客户端对某个网站节点的访问。通俗的讲:路由就是根据不同的URL 地址,加载不同的页面实现不同的功能。

Koa 中的路由和Express 有所不同,在Express 中直接引入Express 就可以配置路由,但是在 Koa 中我们需要安装对应的koa-router 路由模块来实现。

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

let app = new Koa();
let router = new Router();
router.get('/', function (ctx, next) { // router的原理比koa还复杂
    console.log(1);
    next();
})

router.get('/', function (ctx, next) {
    console.log(2);
})

router.get('/user', function (ctx, next) {
    console.log(3);
    next();
})

router.get('/user', function (ctx, next) {
    console.log(4);
    next();
})

app.use(router.routes());

app.use(async (ctx, next) => {
    console.log(5);
})
复制代码

在Router.js中实现我们的Router类:

class Router {
    constructor() {
        this.middleWares = [];
    }

    get(pathname, middleware) {
        this.middleWares.push({
            path: pathname,
            middleware,
            method: 'get'
        })
    }

    compose(arr, next, ctx) {
        function dispatch(index) {
            // koa核心
            // 如果越界调用默认中间件
            if (index === arr.length) return next();
            // 先取出第一个路由执行
            let middle = arr[index];
            // 把第二个路由传入
            return Promise.resolve(middle.middleware(ctx, () => dispatch(index + 1)));
        }
        return dispatch(0);
    }

    routes() {
        return async (ctx, next) => {
            let method = ctx.method.toLowerCase();
            let path = ctx.path;
            // 过滤出匹配的路由
            let arr = this.middleWares.filter((middleware) => {
                return middleware.method === method && middleware.path === path;
            });

            // 如果组合后 一直调用next 最终 会走到原生的next中
            await this.compose(arr, next, ctx);
        }
    }
}

module.exports = Router;
复制代码

通过实现Application类中的compose方法,你一定对Router类中的方法不陌生了,其实大同小异。

  1. 首先,通过this.middleWares数组收集Router 类 get 方法注册的所有函数,注意,收集 到的是一个对象,包含路径和方法名
    this.middleWares.push({
         path: pathname,
         middleware,
         method: 'get'
     })
    复制代码
  2. app.use(router.routes())方法执行,在中间件中添加如下方法:
    async (ctx, next) => {
        let method = ctx.method.toLowerCase();
        let path = ctx.path;
        // 过滤出匹配的路由
        let arr = this.middleWares.filter((middleware) => {
            return middleware.method === method && middleware.path === path;
        });
    
        // 如果组合后 一直调用next 最终 会走到原生的next中
        await this.compose(arr, next, ctx);
    }
    复制代码
    这个方法的意思是通过传入的路径和方法名筛选出匹配的路由,并且放入到一个新的数组中, 并且传入 this.compose(arr, next, ctx)中;
  3. 在 compose 方法中通过dispatch递归的形式依次调用所有的路由,如果越界,则通过传入的 next参数调用app.use中注册的中间件,app.use(router.routes())返回的是关于路由的中间件。
    function dispatch(index) {
          // koa核心
          // 如果越界调用默认中间件
          if (index === arr.length) return next();
          // 先取出第一个路由执行
          let middle = arr[index];
          // 把第二个路由传入
          return Promise.resolve(middle.middleware(ctx, () => dispatch(index + 1)));
      }
      return dispatch(0);
    复制代码

总结

至此,koa2框架的核心功能基本实现,当然还有很多功能需要读者朋友自己去理解,比如关于post请求传递的数据的处理、静态资源中间件的实现。但是经过以上代码,你应该对koa2的核心源码有了一个基本的认识,知道它的大体流程是怎样的。

分类:
前端
标签:
分类:
前端
标签:
收藏成功!
已添加到「」, 点击更改