概述
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类中的方法不陌生了,其实大同小异。
- 首先,通过this.middleWares数组收集Router 类 get 方法注册的所有函数,注意,收集 到的是一个对象,包含路径和方法名;
this.middleWares.push({ path: pathname, middleware, method: 'get' }) 复制代码
- app.use(router.routes())方法执行,在中间件中添加如下方法:
这个方法的意思是通过传入的路径和方法名筛选出匹配的路由,并且放入到一个新的数组中, 并且传入 this.compose(arr, next, ctx)中;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); } 复制代码
- 在 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的核心源码有了一个基本的认识,知道它的大体流程是怎样的。