# 天天閱讀一個 npm 模塊(8)- koa-route

系列文章:javascript

  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 對中間件的處理,核心代碼只有二十多行,但實現了以下的洋蔥模型,賦予了中間件強大的能力,網上有許多相關的文章,強烈建議你們閱讀一下。html

koa 洋蔥模型

一句話介紹

今天閱讀的模塊是 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

  1. 將不一樣的路由隔離開來,新增或刪除路由更方便。
  2. 擁有自動解析路由參數的功能,避免了手動解析。

源碼學習

初始化

在看具體的初始化代碼以前,須要先了解 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 進行舉例說明:

  • 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() 交給下一個中間件進行處理。

初看這個正則表達式比較複雜(就沒見過不復雜的正則表達式😓),這裏強烈推薦 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/你好 爲例,上述代碼主要作了五件事情:

  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 個字符:

encodeURIComponent 與 encodeURI 的區別

關於這二者的區別也能夠參考 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,並且波動較大。

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

原生路由壓測

因此在生產環境中,能夠選擇使用 koa-router,性能更好,並且功能也更強大。

關於我:畢業於華科,工做在騰訊,elvin 的博客 歡迎來訪 ^_^

相關文章
相關標籤/搜索