系列文章:javascript
周末阅读完了 koa 的源码,其中的关键在于 koa-compose 对中间件的处理,核心代码只有二十多行,但实现了以下的洋葱模型,赋予了中间件强大的能力,网上有许多相关的文章,强烈建议你们阅读一下。html
今天阅读的模块是 koa-route,当前版本是 3.2.0,虽然周下载量只有 1.8 万(由于不多在生产环境中直接使用),可是该库一样是由 TJ 所写,能够帮助咱们很好的理解 koa 中间件的实现与使用。java
在不使用中间件的状况下,须要手动经过 switch-case
语句或者 if
语句实现路由的功能:node
const Koa = require('koa');
const app = new Koa();
// 经过 switch-case 手撸路由
const route = ctx => {
switch (ctx.path) {
case '/name':
ctx.body = 'elvin';
return;
case '/date':
ctx.body = '2018.09.12';
return;
default:
// koa 抛出 404
return;
}
};
app.use(route);
app.listen(3000);
复制代码
经过 node.js 执行上面的代码,而后在浏览器中访问 http://127.0.0.1:3000/name ,能够看到返回的内容为 elvin
;访问 http://127.0.0.1:3000/date ,能够看到返回的内容为 2018.09.12
;访问 http://127.0.0.1:3000/hh ,能够看到返回的内容为 Not Found。git
这种原生方式十分的不方便,能够经过中间件 koa-route 进行简化:github
const Koa = require('koa');
const route = require('koa-route');
const app = new Koa();
const name = ctx => ctx.body = 'elvin';
const date = ctx => ctx.body = '2018.09.11';
const echo = (ctx, param1) => ctx.body = param1;
app.use(route.get('/name', name));
app.use(route.get('/date', date));
app.use(route.get('/echo/:param1', echo));
app.listen(3000);
复制代码
经过 node.js 执行上面的代码,而后在浏览器中访问 http://127.0.0.1:3000/echo/tencent ,能够看到返回的内容为 tencent
;访问 http://127.0.0.1:3000/echo/cool ,能够看到返回的内容为 cool
—— 路由拥有自动解析参数的功能了!正则表达式
将这两种方式进行对比,能够看出 koa-route 主要有两个优势:npm
在看具体的初始化代码以前,须要先了解 Methods 这个包,它十分简单,导出的内容为 Node.js 支持的 HTTP 方法造成的数组,形如 ['get', 'post', 'delete', 'put', 'options', ...]
。segmentfault
那正式看一下 koa-route 初始化的源码:数组
// 源码 8-1
const methods = require('methods');
methods.forEach(function(method){
module.exports[method] = create(method);
});
function create(method) {
return function(path, fn, opts){
// ...
const createRoute = function(routeFunc){
return function (ctx, next){
// ...
};
};
return createRoute(fn);
}
}
复制代码
上面的代码主要作了一件事情:遍历 Methods 中的每个方法 method,经过 module.exports[method]
进行了导出,且每个导出值为 create(method)
的执行结果,即类型为函数。因此咱们能够看到 koa-route 模块导出值为:
const route = require('koa-route');
console.log(route);
// => {
// => get: [Function],
// => post: [Function],
// => delete: [Function],
// => ...
// => }
复制代码
这里须要重点说一下 create(method)
这个函数,它函数套函数,一共有三个函数,很容易就晕掉了。
以 method 为 get
进行举例说明:
function(path, fn, opts){ ... }
。app.use(route.get('/name', name));
中,route.get('/name', name)
的执行结果为 function (ctx, next) { ... }
,即 koa 中间件的标准函数参数形式。function (ctx, next) { ... }
进行处理。做为一个路由中间件,最关键的就是路由的匹配了。当设置了 app.use(route.get('/echo/:param1', echo))
以后,对于一个形如 http://127.0.0.1:3000/echo/tencent 的请求,路由是怎么匹配的呢?相关代码以下。
// 源码 8-2
const pathToRegexp = require('path-to-regexp');
function create(method) {
return function(path, fn, opts){
const re = pathToRegexp(path, opts);
const createRoute = function(routeFunc){
return function (ctx, next){
// 判断请求的 method 是否匹配
if (!matches(ctx, method)) return next();
// path
const m = re.exec(ctx.path);
if (m) {
// 路由匹配上了
// 在这里调用响应函数
}
// miss
return next();
}
};
return createRoute(fn);
}
}
复制代码
上面代码的关键在于 path-to-regexp 的使用,它会将字符串 '/echo/:param1'
转化为正则表达式 /^\/echo\/((?:[^\/]+?))(?:\/(?=$))?$/i
,而后再调用 re.exec
进行正则匹配,若匹配上了则调用相应的处理函数,不然调用 next()
交给下一个中间件进行处理。
初看这个正则表达式比较复杂(就没见过不复杂的正则表达式😓),这里强烈推荐 regexper 这个网站,能够将正则表达式图像化,十分直观。例如 /^\/echo\/((?:[^\/]+?))(?:\/(?=$))?$/i
能够用以下图像表示:
这个生成的正则表达式 /^\/echo\/((?:[^\/]+?))(?:\/(?=$))?$/i
涉及到两个点能够扩展一下:零宽正向先行断言与非捕获性分组。
这个正则表达式其实能够简化为
/^\/echo\/([^\/]+?)\/?$/i
,之因此 path-to-regexp 会存在冗余,是由于做为一个模块,须要考虑到各类状况,因此生成冗余的正则表达式也是正常的。
/^\/echo\/((?:[^\/]+?))(?:\/(?=$))?$/i
末尾的 (?=$)
这种形如 (?=pattern)
的用法叫作零宽正向先行断言(Zero-Length Positive Lookaherad Assertions),即表明字符串中的一个位置,紧接该位置以后的字符序列可以匹配 pattern。这里的零宽即只匹配位置,而不占用字符。来看一下例子:
// 匹配 'Elvin' 且后面需接 ' Peng'
const re1 = /Elvin(?= Peng)/
// 注意这里只会匹配到 'Elvin',而不是匹配 'Elvin Peng'
console.log(re1.exec('Elvin Peng'));
// => [ 'Elvin', index: 0, input: 'Elvin Peng', groups: undefined ]
// 由于 'Elvin' 后面接的是 ' Liu',因此匹配失败
console.log(re1.exec('Elvin Liu'));
// => null
复制代码
与零宽正向先行断言相似的还有零宽负向先行断言(Zero-Length Negtive Lookaherad Assertions),形如 (?!pattern)
,表明字符串中的一个位置,紧接该位置以后的字符序列不可以匹配 pattern。来看一下例子:
// 匹配 'Elvin' 且后面接的不能是 ' Liu'
const re2 = /Elvin(?! Liu)/
console.log(re2.exec('Elvin Peng'));
// => [ 'Elvin', index: 0, input: 'Elvin Peng', groups: undefined ]
console.log(re2.exec('Elvin Liu'));
// => null
复制代码
/^\/echo\/((?:[^\/]+?))(?:\/(?=$))?$/i
中的 (?:[^\/]+?)
和 (?:/(?=$)) 这种形如 (?:pattern)
的正则用法叫作非捕获性分组,其和形如 (pattern)
的捕获性分组区别在于:非捕获性分组仅做为匹配的校验,而不会做为子匹配返回。来看一下例子:
// 捕获性分组
const r3 = /Elvin (\w+)/;
console.log(r3.exec('Elvin Peng'));
// => [ 'Elvin Peng',
// => 'Peng',
// => index: 0,
// => input: 'Elvin Peng' ]
// 非捕获性分组
const r4 = /Elvin (?:\w+)/;
console.log(r4.exec('Elvin Peng'));
// => [ 'Elvin Peng',
// => index: 0,
// => input: 'Elvin Peng']
复制代码
路由匹配后须要对路由中的参数进行解析,在上一节的源码 8-2 中故意隐藏了这一部分,完整代码以下:
// 源码 8-3
const createRoute = function(routeFunc){
return function (ctx, next){
// 判断请求的 method 是否匹配
if (!matches(ctx, method)) return next();
// path
const m = re.exec(ctx.path);
if (m) {
// 此处进行参数解析
const args = m.slice(1).map(decode);
ctx.routePath = path;
args.unshift(ctx);
args.push(next);
return Promise.resolve(routeFunc.apply(ctx, args));
}
// miss
return next();
};
};
function decode(val) {
if (val) return decodeURIComponent(val);
}
复制代码
以 re 为 /^\/echo\/((?:[^\/]+?))(?:\/(?=$))?$/i
, 访问连接http://127.0.0.1:3000/echo/你好
为例,上述代码主要作了五件事情:
经过 re.exec(ctx.path)
进行路由匹配,获得 m 值为 ['/echo/%E4%BD%A0%E5%A5%BD', '%E4%BD%A0%E5%A5%BD']
。这里之因此会出现 %E4%BD%A0%E5%A5%BD
是由于 URL中的中文会被浏览器自动编码:
console.log(encodeURIComponent('你好'));
// => '%E4%BD%A0%E5%A5%BD'
复制代码
m.slice(1)
获取所有的匹配参数造成的数组 ['%E4%BD%A0%E5%A5%BD']
调用 .map(decode)
对每个参数进行解码获得 ['你好']
console.log(decodeURIComponent('%E4%BD%A0%E5%A5%BD'));
// => '你好'
复制代码
对中间件函数的参数进行组装:由于 koa 中间件的函数参数通常为 (ctx, next)
,因此源码 8-3 中经过 args.unshift(ctx); args.push(next);
将参数组装为 [ctx, '你好', next],即将参数放在 ctx
和 next
之间
经过 return Promise.resolve(routeFunc.apply(ctx, args));
返回一个新生成的中间件处理函数。这里经过 Promise.resolve(fn)
的方式生成了一个异步的函数
这里补充一下 encodeURI
和 encodeURIComponent
的区别,虽然它们二者都是对连接进行编码,但仍是存在一些细微的区别:
encodeURI
用于直接对 URI 编码
encodeURI("http://www.example.org/a file with spaces.html")
// => 'http://www.example.org/a%20file%20with%20spaces.html'
复制代码
encodeURIComponent
用于对 URI 中的请求参数进行编码,若对完整的 URI 进行编码则会存储问题
encodeURIComponent("http://www.example.org/a file with spaces.html")
// => 'http%3A%2F%2Fwww.example.org%2Fa%20file%20with%20spaces.html'
// 上面的连接不会被浏览器识别,因此不能直接对 URI 编码
const URI = `http://127.0.0.1:3000/echo/${encodeURIComponent('你好')}`
// => 'http://127.0.0.1:3000/echo/%E4%BD%A0%E5%A5%BD'
复制代码
其实核心的区别在于 encodeURIComponent
会比 encodeURI
多编码 11 个字符:
关于这二者的区别也能够参考 stackoverflow - When are you supposed to use escape instead of encodeURI / encodeURIComponent?
koa-route 虽然是很好的源码阅读材料,可是因为它将每个路由都化为了一个中间件函数,因此哪怕其中一个路由匹配了,请求仍然会通过其它路由中间件函数,从而形成性能损失。例以下面的代码,模拟了 1000 个路由,经过 console.log(app.middleware.length);
能够打印中间件的个数,运行 node test-1.js
后能够看到输出为 1000,即有 1000 个中间件。
// test-1.js
const Koa = require('koa');
const route = require('koa-route');
const app = new Koa();
for (let i = 0; i < 1000; i++) {
app.use(route.get(`/get${i}`, async (ctx, next) => {
ctx.body = `middleware ${i}`
next();
}));
}
console.log(app.middleware.length);
app.listen(3000);
复制代码
另外经过 ab -n 12000 -c 60 http://127.0.0.1:3000/get123
进行总数为 12000,并发数为 60 的压力测试的话,获得的结果以下,能够看到请求的平均用时为 27ms
,并且波动较大。
同时,咱们能够写一个一样功能的原路由进行对比,其只会有一个中间件:
// test-2.js
const Koa = require('koa');
const route = require('koa-route');
const app = new Koa();
app.use(async (ctx, next) => {
const path = ctx.path;
for (let i = 0; i < 1000; i++) {
if (path === `/get${i}`) {
ctx.body = `middleware ${i}`;
break;
}
}
next();
})
console.log(app.middleware.length);
app.listen(3000);
复制代码
经过 node test-2.js
,再用 ab -n 12000 -c 60 http://127.0.0.1:3000/get123
进行总数为 12000,并发数为 60 的压力测试,能够获得以下的结果,能够看到平均用时仅为 19ms
,减少了约 30%:
因此在生产环境中,能够选择使用 koa-router,性能更好,并且功能也更强大。
关于我:毕业于华科,工做在腾讯,elvin 的博客 欢迎来访 ^_^