内容简介:系列文章:周末阅读完了koa 的源码,其中的关键在于koa-compose 对中间件的处理,核心代码只有二十多行,但实现了如下的洋葱模型,赋予了中间件强大的能力,网上有许多相关的文章,强烈建议大家阅读一下。今天阅读的模块是koa-route,当前版本是 3.2.0,虽然周下载量只有 1.8 万(因为很少在生产环境中直接使用),但是该库同样是由
系列文章:
- 每天阅读一个 npm 模块(1)- username
- 每天阅读一个 npm 模块(2)- mem
- 每天阅读一个 npm 模块(3)- mimic-fn
- 每天阅读一个 npm 模块(4)- throttle-debounce
- 每天阅读一个 npm 模块(5)- ee-first
- 每天阅读一个 npm 模块(6)- pify
- 每天阅读一个 npm 模块(7)- delegates
周末阅读完了koa 的源码,其中的关键在于koa-compose 对中间件的处理,核心代码只有二十多行,但实现了如下的洋葱模型,赋予了中间件强大的能力,网上有许多相关的文章,强烈建议大家阅读一下。
一句话介绍
今天阅读的模块是koa-route,当前版本是 3.2.0,虽然周下载量只有 1.8 万(因为很少在生产环境中直接使用),但是该库同样是由 TJ 所写,可以帮助我们很好的理解 koa 中间件的实现与使用。
用法
在不使用中间件的情况下,需要手动通过 switch-case
语句或者 if
语句实现路由的功能:
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。
这种原生方式十分的不方便,可以通过中间件koa-route 进行简化:
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 主要有两个优点:
- 将不同的路由隔离开来,新增或删除路由更方便。
- 拥有自动解析路由参数的功能,避免了手动解析。
源码学习
初始化
在看具体的初始化代码之前,需要先了解Methods 这个包,它十分简单,导出的内容为 Node.js 支持的 HTTP 方法形成的数组,形如 ['get', 'post', 'delete', 'put', 'options', ...]
。
那正式看一下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
进行举例说明:
- 在koa-route 模块内,module.exports.get 为 create('get') 的执行结果,即
function(path, fn, opts){ ... }
。 - 在使用koa-route 时,如
app.use(route.get('/name', name));
中,route.get('/name', name)
的执行结果为function (ctx, next) { ... }
,即 koa 中间件的标准函数参数形式。 - 当请求来临时,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()
交给下一个中间件进行处理。
初看这个正则表达式比较复杂(就没见过不复杂的正则表达式:sweat:),这里强烈推荐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 的博客 欢迎来访 ^_^
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网
猜你喜欢:- Nginx源码阅读笔记-事件处理模块
- 每天阅读一个 npm 模块(2)- mem
- 每天阅读一个 npm 模块(1)- username
- 每天阅读一个 npm 模块(6)- pify
- # 每天阅读一个 npm 模块(7)- delegates
- 《WebKit技术内幕》阅读摘要 —— WebKit 架构和模块
本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。
Where Wizards Stay Up Late
Katie Hafner / Simon & Schuster / 1998-1-21 / USD 16.00
Twenty five years ago, it didn't exist. Today, twenty million people worldwide are surfing the Net. "Where Wizards Stay Up Late" is the exciting story of the pioneers responsible for creating the most......一起来看看 《Where Wizards Stay Up Late》 这本书的介绍吧!