# 每天阅读一个 npm 模块(8)- koa-route
系列文章:
- 每天阅读一个 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 抛出 404return;}
};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();// pathconst m = re.exec(ctx.path);if (m) {// 路由匹配上了// 在这里调用响应函数}// missreturn 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();// pathconst 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));}// missreturn 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 的博客 欢迎来访 ^_^
# 每天阅读一个 npm 模块(8)- koa-route相关推荐
- 每天阅读一个 npm 模块(4)- throttle-debounce
系列文章: 每天阅读一个 npm 模块(1)- username 每天阅读一个 npm 模块(2)- mem 每天阅读一个 npm 模块(3)- mimic-fn 上一篇文章中介绍的属性描述符的知识太 ...
- 每天阅读一个 npm 模块(5)- ee-first
系列文章: 每天阅读一个 npm 模块(1)- username 每天阅读一个 npm 模块(2)- mem 每天阅读一个 npm 模块(3)- mimic-fn 每天阅读一个 npm 模块(4)- ...
- 每天阅读一个 npm 模块(1)- username
最近工作比较繁忙,每天能用于学习知识的时间越来越少,深感这样不利于自己的技术提升.恰好想起 狼叔 所说的 "迷茫时学习 Node.js 最好的方法 - 每天看十个 npm 模块", ...
- 每天看 10 个 NPM 模块?
最近看到阿里前端技术专家狼叔在 17 年的这篇<迷茫时学习 Node.js 最好的方法>[1]提到: 今天小弟过来找我,说迷茫,我告诉他一个密法:一天看 10 个 npm 模块,坚持一年就 ...
- [译] 在 Google Apps 脚本中使用 ES6 和 npm 模块
原文地址:Using ES6 and npm modules in Google Apps Script 原文作者:Prasanth Janardanan 译文出自:掘金翻译计划 本文永久链接:git ...
- nodejs安装及npm模块插件安装路径配置
在学习完js后,我们就要进入nodejs的学习,因此就必须配置nodejs和npm的属性了. 我相信,个别人在安装时会遇到这样那样的问题,看着同学都已装好,难免会焦虑起来.于是就开始上网查找解决方案, ...
- 如何在React Native中写一个自定义模块
前言 在 React Native 项目中可以看到 node_modules 文件夹,这是存放 node 模块的地方,Node.js 的包管理器 npm 是全球最大的开源库生态系统.提到npm,一般指 ...
- 如何在Node JS中卸载NPM模块?
本文翻译自:How to uninstall npm modules in node js? As commonly known, any npm module can be installed by ...
- 手撸一个npm包,安利一下duiba-sprite
背景 我所在组负责我司线上H5互动小游戏的开发,其中一部分开发者负责皮肤的开发.大致流程为:视觉出psd,开发者切图,开发者开发,开发者上传皮肤代码,运营验收.这里边有个奇葩的动作:开发者切图,为什么 ...
最新文章
- sqlconnection,sqlcommand,SqlDataAdapter ,ExecuteNonQuery,ExecuteScalar
- 开发中最常使用到那些设计模式?
- 【转载】关联分析中的支持度、置信度和提升度
- 概率图模型(PGM) —— 贝叶斯网络(Bayesian Network)
- 项目过程总结 和某个字段的更新
- zr-djypvp计算机电缆,ZR-DJYPVP计算机电缆ZR-DJYPVP-2X2X1.0
- Floyd + 传递闭包
- libcurl 使用说明和网页抓取 linux下的curl编程
- 查询数据库表数据量大小
- 深信服设备连接linux,deepin官方论坛-深度科技官网旗下网站
- 互联网风雨十年,我所经历的技术变迁
- PHPstorm 函数或者方法的注释的时间和用户名,PHPstorm里函数方法的注释是没有动态时间设置的,但是看了PHP file里面有时间日期的注释,而PHP Function Doc Commen
- Python金融应用编程(数据分析、定价与量化投资) !
- ELKF:日志可视化Discovery
- 求生之路官方服务器延迟过高,《求生之路2》服务器tickrate的作用与网络参数的优化...
- 内存管理-内存池的实现
- 要知道的命令:htop 助你心中有数 | Linux 系统下 htop 命令详解
- 简单实用的磁带转MP3方法图解
- 2020寒假【gmoj1747】【马蹄印】【DFS】
- activiti的简介
热门文章
- 生产车间生产管理系统详解
- linux内核移植lpa是什么,菜鸟学Linux移植lesson3之WebCamera_CMOS测试
- 如何提高网站性能优化
- 分层测试(1)分层测试是什么?【必备】
- oracle新建数据库卡85,oracle数据库(新建数据库)超小白篇
- IIS设置http 重定向到https
- 洛谷 P3674 小清新人渣的本愿 [莫队 bitset]
- Xcode 13.3 cycle in dependencies between targets
- excel2010中启用和禁用宏的多种方式及如何设置宏安全性
- 矩估计和极大似然估计