# 每天阅读一个 npm 模块(8)- koa-route

栏目: Node.js · 发布时间: 6年前

内容简介:系列文章:周末阅读完了koa 的源码,其中的关键在于koa-compose 对中间件的处理,核心代码只有二十多行,但实现了如下的洋葱模型,赋予了中间件强大的能力,网上有许多相关的文章,强烈建议大家阅读一下。今天阅读的模块是koa-route,当前版本是 3.2.0,虽然周下载量只有 1.8 万(因为很少在生产环境中直接使用),但是该库同样是由

系列文章:

  1. 每天阅读一个 npm 模块(1)- username
  2. 每天阅读一个 npm 模块(2)- mem
  3. 每天阅读一个 npm 模块(3)- mimic-fn
  4. 每天阅读一个 npm 模块(4)- throttle-debounce
  5. 每天阅读一个 npm 模块(5)- ee-first
  6. 每天阅读一个 npm 模块(6)- pify
  7. 每天阅读一个 npm 模块(7)- delegates

周末阅读完了koa 的源码,其中的关键在于koa-compose 对中间件的处理,核心代码只有二十多行,但实现了如下的洋葱模型,赋予了中间件强大的能力,网上有许多相关的文章,强烈建议大家阅读一下。

# 每天阅读一个 npm 模块(8)- koa-route

一句话介绍

今天阅读的模块是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 主要有两个优点:

  1. 将不同的路由隔离开来,新增或删除路由更方便。
  2. 拥有自动解析路由参数的功能,避免了手动解析。

源码学习

初始化

在看具体的初始化代码之前,需要先了解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 可以用如下图像表示:

# 每天阅读一个 npm 模块(8)- koa-route

这个生成的正则表达式 /^\/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/你好 为例,上述代码主要做了五件事情:

  1. 通过 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'
    复制代码
  2. m.slice(1) 获取全部的匹配参数形成的数组 ['%E4%BD%A0%E5%A5%BD']

  3. 调用 .map(decode) 对每一个参数进行解码得到 ['你好']

    console.log(decodeURIComponent('%E4%BD%A0%E5%A5%BD'));
    // => '你好'
    复制代码
  4. 对中间件函数的参数进行组装:因为 koa 中间件的函数参数一般为 (ctx, next) ,所以源码 8-3 中通过 args.unshift(ctx); args.push(next); 将参数组装为 [ctx, '你好', next],即将参数放在 ctxnext 之间

  5. 通过 return Promise.resolve(routeFunc.apply(ctx, args)); 返回一个新生成的中间件处理函数。这里通过 Promise.resolve(fn) 的方式生成了一个异步的函数

这里补充一下 encodeURIencodeURIComponent 的区别,虽然它们两者都是对链接进行编码,但还是存在一些细微的区别:

  • 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 个字符:

# 每天阅读一个 npm 模块(8)- koa-route

关于这两者的区别也可以参考 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 ,而且波动较大。

# 每天阅读一个 npm 模块(8)- koa-route

同时,我们可以写一个同样功能的原路由进行对比,其只会有一个中间件:

// 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%:

# 每天阅读一个 npm 模块(8)- koa-route

所以在生产环境中,可以选择使用koa-router,性能更好,而且功能也更强大。

关于我:毕业于华科,工作在腾讯,elvin 的博客 欢迎来访 ^_^


以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

数字化崇拜

数字化崇拜

[加] 文森特·莫斯可 / 黄典林 / 北京大学出版社 / 2010-1 / 26.00元

与此前的许多技术发展一样,以互联网为标志的数字化时代同样为人们提供了社会根本性变革的许诺:通过电脑,我们可以超越时空和政治。在本书中,文森特·莫斯可透过技术发展和经济泡沫的迷雾,试图探明围绕数字化新技术出现了哪些迷思,以及为何人们对这些迷思坚信不疑。他认为互联网时代投资者如此狂热的动因并不是他们对经济规则的无知,而是对赛博空间开启了一个新世界这样的迷思的坚定信念。 莫斯可指出,迷思并不是一些......一起来看看 《数字化崇拜》 这本书的介绍吧!

Base64 编码/解码
Base64 编码/解码

Base64 编码/解码

URL 编码/解码
URL 编码/解码

URL 编码/解码

HEX CMYK 转换工具
HEX CMYK 转换工具

HEX CMYK 互转工具