玩轉Koa之koa-router原理解析
一、前言
Koa為了保持自身的簡潔 , 并沒有捆綁中間件 。但是在實際的開發中 , 我們需要和形形色色的中間件打交道,本文將要分析的是經常用到的路由中間件 -- koa-router 。
如果你對Koa的原理還不了解的話,可以先查看Koa原理解析 。
二、koa-router概述
koa-router的源碼只有兩個文件:router.js和layer.js,分別對應Router對象和Layer對象 。
Layer對象是對單個路由的管理,其中包含的信息有路由路徑(path)、路由請求方法(method)和路由執行函數(middleware),并且提供路由的驗證以及params參數解析的方法 。
相比較Layer對象,Router對象則是對所有注冊路由的統一處理,并且它的API是面向開發者的 。
接下來從以下幾個方面全面解析koa-router的實現原理:
Layer對象的實現
路由注冊
路由匹配
路由執行流程
三、Layer
Layer對象主要是對單個路由的管理 , 是整個koa-router中最小的處理單元,后續模塊的處理都離不開Layer中的方法,這正是首先介紹Layer的重要原因 。

Layer構造函數主要用來初始化路由路徑、路由請求方法數組、路由處理函數數組、路由正則表達式以及params參數信息數組,其中主要采用path-to-regexp方法根據路徑字符串生成正則表達式 , 通過該正則表達式,可以實現路由的匹配以及params參數的捕獲:

根據paramNames中的參數信息以及captrues方法,可以獲取到當前路由params參數的鍵值對:

需要注意上述代碼中的safeDecodeURIComponent方法,為了避免服務器收到不可預知的請求,對于任何用戶輸入的作為URI部分的內容都需要采用encodeURIComponent進行轉義,否則當用戶輸入的內容中含有'&'、'='、'?'等字符時 , 會出現預料之外的情況 。而當我們獲取URL上的參數時,則需要通過decodeURIComponent進行解碼,而decodeURIComponent只能解碼由encodeURIComponent方法或者類似方法編碼,如果編碼方法不符合要求,decodeURIComponent則會拋出URIError , 所以作者在這里對該方法進行了安全化的處理:

Layer還提供了對于單個param前置處理的方法:

上述代碼中通過some方法尋找單個param處理函數的原因在于以下兩點:
保持param處理函數位于其他路由處理函數的前面;
路由中存在多個param參數,需要保持param處理函數的前后順序 。

Layer中的setPrefix方法用于設置路由路徑的前綴,這在嵌套路由的實現中尤其重要 。
最后,Layer還提供了根據路由生成url的方法,主要采用path-to-regexp的compile和parse對路由路徑中的param進行替換,而在拼接query的環節,正如前面所說需要對鍵值對進行繁瑣的encodeURIComponent操作,作者采用了urijs提供的簡潔api進行處理 。
四、路由注冊
1、Router構造函數
首先看了解一下Router構造函數:

在構造函數中初始化的params和stack屬性最為重要,前者用來保存param前置處理函數,后者用來保存實例化的Layer對象 。并且這兩個屬性與接下來要講的路由注冊息息相關 。
koa-router中提供兩種方式注冊路由:
具體的HTTP動詞注冊方式,例如:router.get('/users', ctx => {})
支持所有的HTTP動詞注冊方式,例如:router.all('/users', ctx => {})
2、http METHODS
源碼中采用methods模塊獲取HTTP請求方法名,該模塊內部實現主要依賴于http模塊:

3、router.verb() and router.all()
這兩種注冊路由的方式的內部實現基本類似 , 下面以router.verb()的源碼為例:

該方法第一部分是對傳入參數的處理,對于middleware參數的處理會讓大家聯想到ES6中的rest參數,但是rest參數與arguments其中一個致命的區別:
rest參數只包含那些沒有對應形參的實參,而arguments則包含傳給函數的所有實參 。
如果采用rest參數的方式,上述函數則必須要求開發者傳入name參數 。但是也可以將name和path參數整合成對象,再結合rest參數:

采用ES6的新特性 , 代碼變得簡潔多了 。
第二部分是register方法 , 傳入的method參數的形式就是router.verb()與router.all()的最大區別,在router.verb()中傳入的method是單個方法 , 后者則是以數組的形式傳入HTTP所有的請求方法 , 所以對于這兩種注冊方法的實現,本質上是沒有區別的 。
4、register

register方法主要負責實例化Layer對象、更新路由前綴和前置param處理函數,這些操作在Layer中已經提及過,相信大家應該輕車熟路了 。
5、use
熟悉Koa的同學都知道use是用來注冊中間件的方法 , 相比較Koa中的全局中間件,koa-router的中間件則是路由級別的 。
【玩轉Koa之koa-router原理解析】Router.prototype.use = function () {

koa-router中間件注冊方法主要完成兩項功能:
將路由嵌套結構扁平化 , 其中涉及到路由路徑的更新和param前置處理函數的插入;
路由級別中間件通過注冊一個沒有method的Layer實例進行管理 。
五、路由匹配

match方法主要通過layer.match方法以及methods屬性對layer進行篩選 , 返回的matched對象包含以下幾個部分:
path: 保存所有路由路徑被匹配的layer;
pathAndMethod: 在路由路徑被匹配的前提下,保存路由級別中間件和路由請求方法被匹配的layer;
route: 僅當存在路由路徑和路由請求方法都被匹配的layer , 才能算是本次路由被匹配上 。
另外,在ES7之前 , 對于判斷數組是否包含一個元素,都需要通過indexOf方法來實現,而該方法返回元素的下標,這樣就不得不通過與-1的比較得到布爾值:

而作者巧妙地利用位運算省去了“討厭的-1”,當然在ES7中可以愉快地使用includes方法:

六、路由執行流程
理解koa-router中路由的概念以及路由注冊的方式,接下來就是如何作為一個中間件在koa中執行 。
koa中注冊koa-router中間件的方式如下:

從代碼中可以看出koa-router提供了兩個中間件方法:routes和allowedMethods 。
1、allowedMethods()

allowedMethods()中間件主要用于處理options請求,響應405和501狀態 。上述代碼中的ctx.matched中保存的正是前面matched對象中的path(在routes方法中設置,后面會提到 。) , 在matched對象中的path數組不為空的前提條件下:
服務器不支持當前請求方法 , 返回501狀態碼;
當前請求方法為OPTIONS,返回200狀態碼;
path中的layer不支持該方法,返回405狀態;
對于上述三種情況 , 服務器都會設置Allow響應頭,返回該路由路徑上支持的請求方法 。
2、routes()

routes()中間件主要實現了四大功能 。
將matched對象的path屬性掛載在ctx.matched上,提供給后續的allowedMethods中間件使用 。(見代碼中的【1】)
將返回的dispatch函數設置router屬性,以便在前面提到的Router.prototype.use方法中區別路由級別中間件和嵌套路由 。(見代碼中的【2】)
插入一個新的路由前置處理中間件,將layer解析出來的params對象、路由別名以及捕獲數組掛載在ctx上下文中,這種操作同理Koa在處理請求之前先構建context對象 。(見代碼中的【3】)
而對于路由匹配到眾多layer,koa-router通過koa-compose進行處理,這和koa對于中間件處理的方式一樣的,所以koa-router完全就是一個小型洋蔥模型 。
七、總結
koa-router雖然是koa的一個中間件 , 但是其內部也包含眾多的中間件 , 這些中間件通過Layer對象根據路由路徑的不同進行劃分,使得它們不再像koa的中間件那樣每次請求都執行,而是針對每次請求采用match方法匹配出相應的中間件,再利用koa-compose形成一個中間件執行鏈 。
