# 每天閱讀一個 npm 模組(8)- koa-route
系列文章:
- ofollow,noindex">每天閱讀一個 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 的部落格 歡迎來訪 ^_^