koa原始碼閱讀[2]-koa-router
第三篇,有關koa生態中比較重要的一箇中介軟體:koa-router
koa-router是什麼
首先,因為koa是一個管理中介軟體的平臺,而註冊一箇中介軟體使用use
來執行。
無論是什麼請求,都會將所有的中介軟體執行一遍(如果沒有中途結束的話)
所以,這就會讓開發者很困擾,如果我們要做路由該怎麼寫邏輯?
app.use(ctx => { switch (ctx.url) { case `/`: case `/index`: ctx.body = `index` break case `list`: ctx.body = `list` break default: ctx.body = `not found` } })
誠然,這樣是一個簡單的方法,但是必然不適用於大型專案,數十個介面通過一個switch
來控制未免太繁瑣了。
更何況請求可能只支援get
或者post
,以及這種方式並不能很好的支援URL中包含引數的請求/info/:uid
。
在express
中是不會有這樣的問題的,自身已經提供了get
、post
等之類的與METHOD
同名的函式用來註冊回撥:
express
const express = require(`express`) const app = express() app.get(`/`, function (req, res) { res.send(`hi there.`) })
但是koa
做了很多的精簡,將很多邏輯都拆分出來作為獨立的中介軟體來存在。
所以導致很多express
專案遷移為koa
時,需要額外的安裝一些中介軟體,koa-router
應該說是最常用的一個。
所以在koa
中則需要額外的安裝koa-router
來實現類似的路由功能:
koa
const Koa = require(`koa`) const Router = require(`koa-router`) const app = new Koa() const router = new Router() router.get(`/`, async ctx => { ctx.body = `hi there.` }) app.use(router.routes()) .use(router.allowedMethods())
看起來程式碼確實多了一些,畢竟將很多邏輯都從框架內部轉移到了中介軟體中來處理。
也算是為了保持一個簡練的koa框架所取捨的一些東西吧。
koa-router的邏輯確實要比koa的複雜一些,可以將koa想象為一個市場,而koa-router則是其中一個攤位
koa僅需要保證市場的穩定執行,而真正和顧客打交道的確是在裡邊擺攤的koa-router
koa-router的大致結構
koa-router
的結構並不是很複雜,也就分了兩個檔案:
.
├── layer.js
└── router.ja
layer
主要是針對一些資訊的封裝,主要路基由router
提供:
tag | desc |
---|---|
layer |
資訊儲存:路徑、METHOD、路徑對應的正則匹配、路徑中的引數、路徑對應的中介軟體 |
router |
主要邏輯:對外暴露註冊路由的函式、提供處理路由的中介軟體,檢查請求的URL並呼叫對應的layer中的路由處理 |
koa-router的執行流程
可以拿上邊所丟擲的基本例子來說明koa-router
是怎樣的一個執行流程:
const router = new Router() // 例項化一個Router物件 // 註冊一個路由的監聽 router.get(`/`, async ctx => { ctx.body = `hi there.` }) app .use(router.routes()) // 將該Router物件的中介軟體註冊到Koa例項上,後續請求的主要處理邏輯 .use(router.allowedMethods()) // 新增針對OPTIONS的響應處理,一些預檢請求會先觸發 OPTIONS 然後才是真正的請求
建立例項時的一些事情
首先,在koa-router
例項化的時候,是可以傳遞一個配置項引數作為初始化的配置資訊的。
然而這個配置項在readme
中只是簡單的被描述為:
Param | Type | Description |
---|---|---|
[opts] |
Object |
|
[opts.prefix] |
String |
prefix router paths(路由的字首) |
告訴我們可以新增一個Router
註冊時的字首,也就是說如果按照模組化分,可以不必在每個路徑匹配的前端都新增巨長的字首:
const Router = require(`koa-router`) const router = new Router({ prefix: `/my/awesome/prefix` }) router.get(`/index`, ctx => { ctx.body = `pong!` }) // curl /my/awesome/prefix/index => pong!
P.S. 不過要記住,如果prefix
以/
結尾,則路由的註冊就可以省去字首的/
了,不然會出現/
重複的情況
例項化Router
時的程式碼:
function Router(opts) { if (!(this instanceof Router)) { return new Router(opts) } this.opts = opts || {} this.methods = this.opts.methods || [ `HEAD`, `OPTIONS`, `GET`, `PUT`, `PATCH`, `POST`, `DELETE` ] this.params = {} this.stack = [] }
可見的只有一個methods
的賦值,但是在檢視了其他原始碼後,發現除了prefix
還有一些引數是例項化時傳遞進來的,但是不太清楚為什麼文件中沒有提到:
Param | Type | Default | Description |
---|---|---|---|
sensitive |
Boolean |
false |
是否嚴格匹配大小寫 |
strict |
Boolean |
false |
如果設定為false 則匹配路徑後邊的/ 是可選的 |
methods |
Array[String] |
[`HEAD`,`OPTIONS`,`GET`,`PUT`,`PATCH`,`POST`,`DELETE`] |
設定路由可以支援的METHOD |
routerPath |
String | null |
sensitive
如果設定了sensitive
,則會以更嚴格的匹配規則來監聽路由,不會忽略URL中的大小寫,完全按照註冊時的來匹配:
const Router = require(`koa-router`) const router = new Router({ sensitive: true }) router.get(`/index`, ctx => { ctx.body = `pong!` }) // curl /index => pong! // curl /Index => 404
strict
strict
與sensitive
功能類似,也是用來設定讓路徑的匹配變得更加嚴格,在預設情況下,路徑結尾處的/
是可選的,如果開啟該引數以後,如果在註冊路由時尾部沒有新增/
,則匹配的路由也一定不能夠新增/
結尾:
const Router = require(`koa-router`) const router = new Router({ strict: true }) router.get(`/index`, ctx => { ctx.body = `pong!` }) // curl /index => pong! // curl /Index => pong! // curl /index/ => 404
methods
methods
配置項存在的意義在於,如果我們有一個介面需要同時支援GET
和POST
,router.get
、router.post
這樣的寫法必然是醜陋的。
所以我們可能會想到使用router.all
來簡化操作:
const Router = require(`koa-router`) const router = new Router() router.all(`/ping`, ctx => { ctx.body = `pong!` }) // curl -X GET /index => pong! // curl -X POST /index => pong!
這簡直是太完美了,可以很輕鬆的實現我們的需求,但是如果再多實驗一些其他的methods
以後,尷尬的事情就發生了:
> curl -X DELETE /index => pong! > curl -X PUT /index => pong!
這顯然不是符合我們預期的結果,所以,在這種情況下,基於目前koa-router
需要進行如下修改來實現我們想要的功能:
const Koa = require(`koa`) const Router = require(`router`) const app = new Koa() // 修改處1 const methods = [`GET`, `POST`] const router = new Router({ methods }) // 修改處2 router.all(`/`, async (ctx, next) => { // 理想情況下,這些判斷應該交由中介軟體來完成 if (!~methods.indexOf(ctx.method)) { return await next() } ctx.body = `pong!` })
這樣的兩處修改,就可以實現我們所期望的功能:
> curl -X GET /index => pong! > curl -X POST /index => pong! > curl -X DELETE /index => Not Implemented > curl -X PUT /index => Not Implemented
我個人覺得這是allowedMethods
實現的一個邏輯問題,不過也許是我沒有get到作者的點,allowedMethods
中比較關鍵的一些原始碼:
Router.prototype.allowedMethods = function (options) { options = options || {} let implemented = this.methods return function allowedMethods(ctx, next) { return next().then(function() { let allowed = {} // 如果進行了ctx.body賦值,必然不會執行後續的邏輯 // 所以就需要我們自己在中介軟體中進行判斷 if (!ctx.status || ctx.status === 404) { if (!~implemented.indexOf(ctx.method)) { if (options.throw) { let notImplementedThrowable if (typeof options.notImplemented === `function`) { notImplementedThrowable = options.notImplemented() // set whatever the user returns from their function } else { notImplementedThrowable = new HttpError.NotImplemented() } throw notImplementedThrowable } else { ctx.status = 501 ctx.set(`Allow`, allowedArr.join(`, `)) } } else if (allowedArr.length) { // ... } } }) } }
首先,allowedMethods
是作為一個後置的中介軟體存在的,因為在返回的函式中先呼叫了next
,其次才是針對METHOD
的判斷,而這樣帶來的一個後果就是,如果我們在路由的回撥中進行類似ctx.body = XXX
的操作,實際上會修改本次請求的status
值的,使之並不會成為404
,而無法正確的觸發METHOD
檢查的邏輯。
想要正確的觸發METHOD
邏輯,就需要自己在路由監聽中手動判斷ctx.method
是否為我們想要的,然後在跳過當前中介軟體的執行。
而這一判斷的步驟實際上與allowedMethods
中介軟體中的!~implemented.indexOf(ctx.method)
邏輯完全是重複的,不太清楚koa-router
為什麼會這麼處理。
當然,allowedMethods
是不能夠作為一個前置中介軟體來存在的,因為一個Koa
中可能會掛在多個Router
,Router
之間的配置可能不盡相同,不能保證所有的Router
都和當前Router
可處理的METHOD
是一樣的。
所以,個人感覺methods
引數的存在意義並不是很大。。
routerPath
這個引數的存在。。感覺會導致一些很詭異的情況。
這就要說到在註冊完中介軟體以後的router.routes()
的操作了:
Router.prototype.routes = Router.prototype.middleware = function () { let router = this let dispatch = function dispatch(ctx, next) { let path = router.opts.routerPath || ctx.routerPath || ctx.path let matched = router.match(path, ctx.method) // 如果匹配到則執行對應的中介軟體 // 執行後續操作 } return dispatch }
因為我們實際上向koa
註冊的是這樣的一箇中介軟體,在每次請求傳送過來時,都會執行dispatch
,而在dispatch
中判斷是否命中某個router
時,則會用到這個配置項,這樣的一個表示式:router.opts.routerPath || ctx.routerPath || ctx.path
,router
代表當前Router
例項,也就是說,如果我們在例項化一個Router
的時候,如果填寫了routerPath
,這會導致無論任何請求,都會優先使用routerPath
來作為路由檢查:
const router = new Router({ routerPath: `/index` }) router.all(`/index`, async (ctx, next) => { ctx.body = `pong!` }) app.use(router.routes()) app.listen(8888, _ => console.log(`server run as http://127.0.0.1:8888`))
如果有這樣的程式碼,無論請求什麼URL,都會認為是/index
來進行匹配:
> curl http://127.0.0.1:8888 pong! > curl http://127.0.0.1:8888/index pong! > curl http://127.0.0.1:8888/whatever/path pong!
巧用routerPath實現轉發功能
同樣的,這個短路運算子一共有三個表示式,第二個的ctx
則是當前請求的上下文,也就是說,如果我們有一個早於routes
執行的中介軟體,也可以進行賦值來修改路由判斷所使用的URL
:
const router = new Router() router.all(`/index`, async (ctx, next) => { ctx.body = `pong!` }) app.use((ctx, next) => { ctx.routerPath = `/index` // 手動改變routerPath next() }) app.use(router.routes()) app.listen(8888, _ => console.log(`server run as http://127.0.0.1:8888`))
這樣的程式碼也能夠實現相同的效果。
例項化中傳入的routerPath
讓人捉摸不透,但是在中介軟體中改變routerPath
的這個還是可以找到合適的場景,這個可以簡單的理解為轉發的一種實現,轉發的過程是對客戶端不可見的,在客戶端看來依然訪問的是最初的URL,但是在中介軟體中改變ctx.routerPath
可以很輕易的使路由匹配到我們想轉發的地方去
// 老版本的登入邏輯處理 router.post(`/login`, ctx => { ctx.body = `old login logic!` }) // 新版本的登入處理邏輯 router.post(`/login-v2`, ctx => { ctx.body = `new login logic!` }) app.use((ctx, next) => { if (ctx.path === `/login`) { // 匹配到舊版請求,轉發到新版 ctx.routerPath = `/login-v2` // 手動改變routerPath } next() }) app.use(router.routes())
這樣就實現了一個簡易的轉發:
> curl -X POST http://127.0.0.1:8888/login new login logic!
註冊路由的監聽
上述全部是關於例項化Router
時的一些操作,下面就來說一下使用最多的,註冊路由相關的操作,最熟悉的必然就是router.get
,router.post
這些的操作了。
但實際上這些也只是一個快捷方式罷了,在內部呼叫了來自Router
的register
方法:
Router.prototype.register = function (path, methods, middleware, opts) { opts = opts || {} let router = this let stack = this.stack // support array of paths if (Array.isArray(path)) { path.forEach(function (p) { router.register.call(router, p, methods, middleware, opts) }) return this } // create route let route = new Layer(path, methods, middleware, { end: opts.end === false ? opts.end : true, name: opts.name, sensitive: opts.sensitive || this.opts.sensitive || false, strict: opts.strict || this.opts.strict || false, prefix: opts.prefix || this.opts.prefix || ``, ignoreCaptures: opts.ignoreCaptures }) if (this.opts.prefix) { route.setPrefix(this.opts.prefix) } // add parameter middleware Object.keys(this.params).forEach(function (param) { route.param(param, this.params[param]) }, this) stack.push(route) return route }
該方法在註釋中標為了 private 但是其中的一些引數在程式碼中各種地方都沒有體現出來,鬼知道為什麼會留著那些引數,但既然存在,就需要了解他是幹什麼的
這個是路由監聽的基礎方法,函式簽名大致如下:
Param | Type | Default | Description |
---|---|---|---|
path |
String /Array[String] |
– | 一個或者多個的路徑 |
methods |
Array[String] |
– | 該路由需要監聽哪幾個METHOD |
middleware |
Function /Array[Function] |
– | 由函式組成的中介軟體陣列,路由實際呼叫的回撥函式 |
opts |
Object |
{} |
一些註冊路由時的配置引數,上邊提到的strict 、sensitive 和prefix 在這裡都有體現 |
可以看到,函式大致就是實現了這樣的流程:
- 檢查
path
是否為陣列,如果是,遍歷item
進行呼叫自身 - 例項化一個
Layer
物件,設定一些初始化引數 - 設定針對某些引數的中介軟體處理(如果有的話)
- 將例項化後的物件放入
stack
中儲存
所以在介紹這幾個引數之前,簡單的描述一下Layer
的建構函式是很有必要的:
function Layer(path, methods, middleware, opts) { this.opts = opts || {} this.name = this.opts.name || null this.methods = [] this.paramNames = [] this.stack = Array.isArray(middleware) ? middleware : [middleware] methods.forEach(function(method) { var l = this.methods.push(method.toUpperCase()); if (this.methods[l-1] === `GET`) { this.methods.unshift(`HEAD`) } }, this) // ensure middleware is a function this.stack.forEach(function(fn) { var type = (typeof fn) if (type !== `function`) { throw new Error( methods.toString() + " `" + (this.opts.name || path) +"`: `middleware` " + "must be a function, not `" + type + "`" ) } }, this) this.path = path this.regexp = pathToRegExp(path, this.paramNames, this.opts) }
layer是負責儲存路由監聽的資訊的,每次註冊路由時的URL,URL生成的正規表示式,該URL中存在的引數,以及路由對應的中介軟體。
統統交由Layer
來儲存,重點需要關注的是例項化過程中的那幾個陣列引數:
- methods
- paramNames
- stack
methods
儲存的是該路由監聽對應的有效METHOD
,並會在例項化的過程中針對METHOD
進行大小寫的轉換。paramNames
因為用的外掛問題,看起來不那麼清晰,實際上在pathToRegExp
內部會對paramNames
這個陣列進行push
的操作,這麼看可能會舒服一下pathToRegExp(path, &this.paramNames, this.opts)
,在拼接hash
結構的路徑引數時會用到這個陣列stack
儲存的是該路由監聽對應的中介軟體函式,router.middleware
部分邏輯會依賴於這個陣列
path
在函式頭部的處理邏輯,主要是為了支援多路徑的同時註冊,如果發現第一個path
引數為陣列後,則會遍歷path
引數進行呼叫自身。
所以針對多個URL
的相同路由可以這樣來處理:
router.register([`/`, [`/path1`, [`/path2`, `path3`]]], [`GET`], ctx => { ctx.body = `hi there.` })
這樣完全是一個有效的設定:
> curl http://127.0.0.1:8888/ hi there. > curl http://127.0.0.1:8888/path1 hi there. > curl http://127.0.0.1:8888/path3 hi there.
methods
而關於methods
引數,則預設認為是一個陣列,即使是隻監聽一個METHOD
也需要傳入一個陣列作為引數,如果是空陣列的話,即使URL
匹配,也會直接跳過,執行下一個中介軟體,這個在後續的router.routes
中會提到
middleware
middleware
則是一次路由真正執行的事情了,依舊是符合koa
標準的中介軟體,可以有多個,按照洋蔥模型的方式來執行。
這也是koa-router
中最重要的地方,能夠讓我們的一些中介軟體只在特定的URL
時執行。
這裡寫入的多箇中介軟體都是針對該URL
生效的。
P.S. 在koa-router
中,還提供了一個方法,叫做router.use
,這個會註冊一個基於router
例項的中介軟體
opts
opts
則是用來設定一些路由生成的配置規則的,包括如下幾個可選的引數:
Param | Type | Default | Description |
---|---|---|---|
name |
String |
– | 設定該路由所對應的name ,命名router |
prefix |
String |
– | 非常雞肋的引數,完全沒有卵用,看似會設定路由的字首,實際上沒有一點兒用 |
sensitive |
Boolean |
false |
是否嚴格匹配大小寫,覆蓋例項化Router 中的配置 |
strict |
Boolean |
false |
是否嚴格匹配大小寫,如果設定為false 則匹配路徑後邊的/ 是可選的 |
end |
Boolean |
true |
路徑匹配是否為完整URL的結尾 |
ignoreCaptures |
Boolean |
– | 是否忽略路由匹配正則結果中的捕獲組 |
name
首先是name
,主要是用於這幾個地方:
- 丟擲異常時更方便的定位
- 可以通過
router.url(<name>)
、router.route(<name>)
獲取到對應的router
資訊 - 在中介軟體執行的時候,
name
會被塞到ctx.routerName
中
router.register(`/test1`, [`GET`], _ => {}, { name: `module` }) router.register(`/test2`, [`GET`], _ => {}, { name: `module` }) console.log(router.url(`module`) === `/test1`) // true try { router.register(`/test2`, [`GET`], null, { name: `error-module` }) } catch (e) { console.error(e) // Error: GET `error-module`: `middleware` must be a function, not `object` }
如果多個router
使用相同的命名,則通過router.url
呼叫返回最先註冊的那一個:
// route用來獲取命名路由 Router.prototype.route = function (name) { var routes = this.stack for (var len = routes.length, i=0; i<len; i++) { if (routes[i].name && routes[i].name === name) { return routes[i] // 匹配到第一個就直接返回了 } } return false } // url獲取該路由對應的URL,並使用傳入的引數來生成真實的URL Router.prototype.url = function (name, params) { var route = this.route(name) if (route) { var args = Array.prototype.slice.call(arguments, 1) return route.url.apply(route, args) } return new Error(`No route found for name: ` + name) }
跑題說下router.url的那些事兒
如果在專案中,想要針對某些URL
進行跳轉,使用router.url
來生成path
則是一個不錯的選擇:
router.register( `/list/:id`, [`GET`], ctx => { ctx.body = `Hi ${ctx.params.id}, query: ${ctx.querystring}` }, { name: `list` } ) router.register(`/`, [`GET`], ctx => { // /list/1?name=Niko ctx.redirect( router.url(`list`, { id: 1 }, { query: { name: `Niko` } }) ) }) // curl -L http://127.0.0.1:8888 => Hi 1, query: name=Niko
可以看到,router.url
實際上呼叫的是Layer
例項的url
方法,該方法主要是用來處理生成時傳入的一些引數。
原始碼地址:layer.js#L116
函式接收兩個引數,params
和options
,因為本身Layer
例項是儲存了對應的path
之類的資訊,所以params
就是儲存的在路徑中的一些引數的替換,options
在目前的程式碼中,僅僅存在一個query
欄位,用來拼接search
後邊的資料:
const Layer = require(`koa-router/lib/layer`) const layer = new Layer(`/list/:id/info/:name`, [], [_ => {}]) console.log(layer.url({ id: 123, name: `Niko` })) console.log(layer.url([123, `Niko`])) console.log(layer.url(123, `Niko`)) console.log( layer.url(123, `Niko`, { query: { arg1: 1, arg2: 2 } }) )
上述的呼叫方式都是有效的,在原始碼中有對應的處理,首先是針對多引數的判斷,如果params
不是一個object
,則會認為是通過layer.url(引數, 引數, 引數, opts)
這種方式來呼叫的。
將其轉換為layer.url([引數, 引數], opts)
形式的。
這時候的邏輯僅需要處理三種情況了:
- 陣列形式的引數替換
hash
形式的引數替換- 無引數
這個引數替換指的是,一個URL
會通過一個第三方的庫用來處理連結中的引數部分,也就是/:XXX
的這一部分,然後傳入一個hash
實現類似模版替換的操作:
// 可以簡單的認為是這樣的操作: let hash = { id: 123, name: `Niko` } `/list/:id/:name`.replace(/(?:/:)(w+)/g, (_, $1) => `/${hash[$1]}`)
然後layer.url
的處理就是為了將各種引數生成類似hash
這樣的結構,最終替換hash
獲取完整的URL
。
prefix
上邊例項化Layer
的過程中看似是opts.prefix
的權重更高,但是緊接著在下邊就有了一個判斷邏輯進行呼叫setPrefix
重新賦值,在翻遍了整個的原始碼後發現,這樣唯一的一個區別就在於,會有一條debug
應用的是註冊router
時傳入的prefix
,而其他地方都會被例項化Router
時的prefix
所覆蓋。
而且如果想要路由正確的應用prefix
,則需要呼叫setPrefix
,因為在Layer
例項化的過程中關於path
的儲存就是來自遠傳入的path
引數。
而應用prefix
字首則需要手動觸發setPrefix
:
// Layer例項化的操作 function Layer(path, methods, middleware, opts) { // 省略不相干操作 this.path = path this.regexp = pathToRegExp(path, this.paramNames, this.opts) } // 只有呼叫setPrefix才會應用字首 Layer.prototype.setPrefix = function (prefix) { if (this.path) { this.path = prefix + this.path this.paramNames = [] this.regexp = pathToRegExp(this.path, this.paramNames, this.opts) } return this }
這個在暴露給使用者的幾個方法中都有體現,類似的get
、set
以及use
。
當然在文件中也提供了可以直接設定所有router
字首的方法,router.prefix
:
文件中就這樣簡單的告訴你可以設定字首,prefix
在內部會迴圈呼叫所有的layer.setPrefix
:
router.prefix(`/things/:thing_id`)
但是在翻看了layer.setPrefix
原始碼後才發現這裡其實是含有一個暗坑的。
因為setPrefix
的實現是拿到prefix
引數,拼接到當前path
的頭部。
這樣就會帶來一個問題,如果我們多次呼叫setPrefix
會導致多次prefix
疊加,而非替換:
router.register(`/index`, [`GET`], ctx => { ctx.body = `hi there.` }) router.prefix(`/path1`) router.prefix(`/path2`) // > curl http://127.0.0.1:8888/path2/path1/index // hi there.
prefix方法會疊加字首,而不是覆蓋字首
sensitive與strict
這倆引數沒啥好說的,就是會覆蓋例項化Router
時所傳遞的那倆引數,效果都一致。
end
end
是一個很有趣的引數,這個在koa-router
中引用的其他模組中有體現到,path-to-regexp:
if (end) { if (!strict) route += `(?:` + delimiter + `)?` route += endsWith === `$` ? `$` : `(?=` + endsWith + `)` } else { if (!strict) route += `(?:` + delimiter + `(?=` + endsWith + `))?` if (!isEndDelimited) route += `(?=` + delimiter + `|` + endsWith + `)` } return new RegExp(`^` + route, flags(options))
endWith
可以簡單地理解為是正則中的$
,也就是匹配的結尾。
看程式碼的邏輯,大致就是,如果設定了end: true
,則無論任何情況都會在最後新增$
表示匹配的結尾。
而如果end: false
,則只有在同時設定了strict: false
或者isEndDelimited: false
時才會觸發。
所以我們可以通過這兩個引數來實現URL的模糊匹配:
router.register( `/list`, [`GET`], ctx => { ctx.body = `hi there.` }, { end: false, strict: true } )
也就是說上述程式碼最後生成的用於匹配路由的正規表示式大概是這樣的:
/^/list(?=/|$)/i // 可以通過下述程式碼獲取到正則 require(`path-to-regexp`).tokensToRegExp(`/list/`, {end: false, strict: true})
結尾的$
是可選的,這就會導致,我們只要傳送任何開頭為/list
的請求都會被這個中介軟體所獲取到。
ignoreCaptures
ignoreCaptures
引數用來設定是否需要返回URL
中匹配的路徑引數給中介軟體。
而如果設定了ignoreCaptures
以後這兩個引數就會變為空物件:
router.register(`/list/:id`, [`GET`], ctx => { console.log(ctx.captures, ctx.params) // [`1`], { id: `1` } }) // > curl /list/1 router.register(`/list/:id`, [`GET`], ctx => { console.log(ctx.captures, ctx.params) // [ ], { } }, { ignoreCaptures: true }) // > curl /list/1
這個是在中介軟體執行期間呼叫了來自layer
的兩個方法獲取的。
首先呼叫captures
獲取所有的引數,如果設定了ignoreCaptures
則會導致直接返回空陣列。
然後呼叫params
將註冊路由時所生成的所有引數以及引數們實際的值傳了進去,然後生成一個完整的hash
注入到ctx
物件中:
// 中介軟體的邏輯 ctx.captures = layer.captures(path, ctx.captures) ctx.params = layer.params(path, ctx.captures, ctx.params) ctx.routerName = layer.name return next() // 中介軟體的邏輯 end // layer提供的方法 Layer.prototype.captures = function (path) { if (this.opts.ignoreCaptures) return [] return path.match(this.regexp).slice(1) } Layer.prototype.params = function (path, captures, existingParams) { var params = existingParams || {} for (var len = captures.length, i=0; i<len; i++) { if (this.paramNames[i]) { var c = captures[i] params[this.paramNames[i].name] = c ? safeDecodeURIComponent(c) : c } } return params } // 所做的事情大致如下: // [18, `Niko`] + [`age`, `name`] // => // { age: 18, name: `Niko` }
router.param的作用
上述是關於註冊路由時的一些引數描述,可以看到在register
中例項化Layer
物件後並沒有直接將其放入stack
中,而是執行了這樣的一個操作以後才將其推入stack
:
Object.keys(this.params).forEach(function (param) { route.param(param, this.params[param]) }, this) stack.push(route) // 裝載
這裡是用作新增針對某個URL
引數的中介軟體處理的,與router.param
兩者關聯性很強:
Router.prototype.param = function (param, middleware) { this.params[param] = middleware this.stack.forEach(function (route) { route.param(param, middleware) }) return this }
兩者操作類似,前者用於對新增的路由監聽新增所有的param
中介軟體,而後者用於針對現有的所有路由新增param
中介軟體。
因為在router.param
中有著this.params[param] = XXX
的賦值操作。
這樣在後續的新增路由監聽中,直接迴圈this.params
就可以拿到所有的中介軟體了。
router.param
的操作在文件中也有介紹,文件地址
大致就是可以用來做一些引數校驗之類的操作,不過因為在layer.param
中有了一些特殊的處理,所以我們不必擔心param
的執行順序,layer
會保證param
一定是早於依賴這個引數的中介軟體執行的:
router.register(`/list/:id`, [`GET`], (ctx, next) => { ctx.body = `hello: ${ctx.name}` }) router.param(`id`, (param, ctx, next) => { console.log(`got id: ${param}`) ctx.name = `Niko` next() }) router.param(`id`, (param, ctx, next) => { console.log(`param2`) next() }) // > curl /list/1 // got id: 1 // param2 // hello: Niko
最常用的get/post之類的快捷方式
以及說完了上邊的基礎方法register
,我們可以來看下暴露給開發者的幾個router.verb
方法:
// get|put|post|patch|delete|del // 迴圈註冊多個METHOD的快捷方式 methods.forEach(function (method) { Router.prototype[method] = function (name, path, middleware) { let middleware if (typeof path === `string` || path instanceof RegExp) { middleware = Array.prototype.slice.call(arguments, 2) } else { middleware = Array.prototype.slice.call(arguments, 1) path = name name = null } this.register(path, [method], middleware, { name: name }) return this } }) Router.prototype.del = Router.prototype[`delete`] // 以及最後的一個別名處理,因為del並不是有效的METHOD
令人失望的是,verb
方法將大量的opts
引數都砍掉了,預設只留下了一個name
欄位。
只是很簡單的處理了一下命名name
路由相關的邏輯,然後進行呼叫register
完成操作。
router.use-Router內部的中介軟體
以及上文中也提到的router.use
,可以用來註冊一箇中介軟體,使用use
註冊中介軟體分為兩種情況:
- 普通的中介軟體函式
- 將現有的
router
例項作為中介軟體傳入
普通的use
這裡是use
方法的關鍵程式碼:
Router.prototype.use = function () { var router = this middleware.forEach(function (m) { if (m.router) { // 這裡是通過`router.routes()`傳遞進來的 m.router.stack.forEach(function (nestedLayer) { if (path) nestedLayer.setPrefix(path) if (router.opts.prefix) nestedLayer.setPrefix(router.opts.prefix) // 呼叫`use`的Router例項的`prefix` router.stack.push(nestedLayer) }) if (router.params) { Object.keys(router.params).forEach(function (key) { m.router.param(key, router.params[key]) }) } } else { // 普通的中介軟體註冊 router.register(path || `(.*)`, [], m, { end: false, ignoreCaptures: !hasPath }) } }) } // 在routes方法有這樣的一步操作 Router.prototype.routes = Router.prototype.middleware = function () { function dispatch() { // ... } dispatch.router = this // 將router例項賦值給了返回的函式 return dispatch }
第一種是比較常規的方式,傳入一個函式,一個可選的path
,來進行註冊中介軟體。
不過有一點要注意的是,.use(`path`)
這樣的用法,中介軟體不能獨立存在,必須要有一個可以與之路徑相匹配的路由監聽存在:
router.use(`/list`, ctx => { // 如果只有這麼一箇中介軟體,無論如何也不會執行的 }) // 必須要存在相同路徑的`register`回撥 router.get(`/list`, ctx => { }) app.use(router.routes())
原因是這樣的:
.use
和.get
都是基於.register
來實現的,但是.use
在methods
引數中傳遞的是一個空陣列- 在一個路徑被匹配到時,會將所有匹配到的中介軟體取出來,然後檢查對應的
methods
,如果length !== 0
則會對當前匹配組標記一個flag
- 在執行中介軟體之前會先判斷有沒有這個
flag
,如果沒有則說明該路徑所有的中介軟體都沒有設定METHOD
,則會直接跳過進入其他流程(比如allowedMethod)
Router.prototype.match = function (path, method) { var layers = this.stack var layer var matched = { path: [], pathAndMethod: [], route: false } for (var len = layers.length, i = 0; i < len; i++) { layer = layers[i] if (layer.match(path)) { matched.path.push(layer) if (layer.methods.length === 0 || ~layer.methods.indexOf(method)) { matched.pathAndMethod.push(layer) // 只有在發現不為空的`methods`以後才會設定`flag` if (layer.methods.length) matched.route = true } } } return matched } // 以及在`routes`中有這樣的操作 Router.prototype.routes = Router.prototype.middleware = function () { function dispatch(ctx, next) { // 如果沒有`flag`,直接跳過 if (!matched.route) return next() } return dispatch }
將其他router例項傳遞進來
可以看到,如果選擇了router.routes()
來方式來複用中介軟體,會遍歷該例項的所有路由,然後設定prefix
。
並將修改完的layer
推出到當前的router
中。
那麼現在就要注意了,在上邊其實已經提到了,Layer
的setPrefix
是拼接的,而不是覆蓋的。
而use
是會操作layer
物件的,所以這樣的用法會導致之前的中介軟體路徑也被修改。
而且如果傳入use
的中介軟體已經註冊在了koa
中就會導致相同的中介軟體會執行兩次(如果有呼叫next
的話):
const middlewareRouter = new Router() const routerPage1 = new Router({ prefix: `/page1` }) const routerPage2 = new Router({ prefix: `/page2` }) middlewareRouter.get(`/list/:id`, async (ctx, next) => { console.log(`trigger middleware`) ctx.body = `hi there.` await next() }) routerPage1.use(middlewareRouter.routes()) routerPage2.use(middlewareRouter.routes()) app.use(middlewareRouter.routes()) app.use(routerPage1.routes()) app.use(routerPage2.routes())
就像上述程式碼,實際上會有兩個問題:
- 最終有效的訪問路徑為
/page2/page1/list/1
,因為prefix
會拼接而非覆蓋 - 當我們在中介軟體中呼叫
next
以後,console.log
會連續輸出三次,因為所有的routes
都是動態的,實際上prefix
都被修改為了/page2/page1
一定要小心使用,不要認為這樣的方式可以用來實現路由的複用
請求的處理
以及,終於來到了最後一步,當一個請求來了以後,Router
是怎樣處理的。
一個Router
例項可以丟擲兩個中介軟體註冊到koa
上:
app.use(router.routes())
app.use(router.allowedMethods())
routes
負責主要的邏輯。allowedMethods
負責提供一個後置的METHOD
檢查中介軟體。
allowedMethods
沒什麼好說的,就是根據當前請求的method
進行的一些校驗,並返回一些錯誤資訊。
而上邊介紹的很多方法其實都是為了最終的routes
服務:
Router.prototype.routes = Router.prototype.middleware = function () { var router = this var dispatch = function dispatch(ctx, next) { var path = router.opts.routerPath || ctx.routerPath || ctx.path var matched = router.match(path, ctx.method) var layerChain, layer, i if (ctx.matched) { ctx.matched.push.apply(ctx.matched, matched.path) } else { ctx.matched = matched.path } ctx.router = router if (!matched.route) return next() var matchedLayers = matched.pathAndMethod var mostSpecificLayer = matchedLayers[matchedLayers.length - 1] ctx._matchedRoute = mostSpecificLayer.path if (mostSpecificLayer.name) { ctx._matchedRouteName = mostSpecificLayer.name } layerChain = matchedLayers.reduce(function(memo, layer) { memo.push(function(ctx, next) { ctx.captures = layer.captures(path, ctx.captures) ctx.params = layer.params(path, ctx.captures, ctx.params) ctx.routerName = layer.name return next() }) return memo.concat(layer.stack) }, []) return compose(layerChain)(ctx, next) }; dispatch.router = this return dispatch }
首先可以看到,koa-router
同時還提供了一個別名middleware
來實現相同的功能。
以及函式的呼叫最終會返回一箇中介軟體函式,這個函式才是真正被掛在到koa
上的。koa
的中介軟體是純粹的中介軟體,不管什麼請求都會執行所包含的中介軟體。
所以不建議為了使用prefix
而建立多個Router
例項,這會導致在koa
上掛載多個dispatch
用來檢查URL是否符合規則
進入中介軟體以後會進行URL的判斷,就是我們上邊提到的可以用來做foraward
實現的地方。
匹配呼叫的是router.match
方法,雖說看似賦值是matched.path
,而實際上在match
方法的實現中,裡邊全部是匹配到的Layer
例項:
Router.prototype.match = function (path, method) { var layers = this.stack // 這個就是獲取的Router例項中所有的中介軟體對應的layer物件 var layer var matched = { path: [], pathAndMethod: [], route: false } for (var len = layers.length, i = 0; i < len; i++) { layer = layers[i] if (layer.match(path)) { // 這裡就是一個簡單的正則匹配 matched.path.push(layer) if (layer.methods.length === 0 || ~layer.methods.indexOf(method)) { // 將有效的中介軟體推入 matched.pathAndMethod.push(layer) // 判斷是否存在METHOD if (layer.methods.length) matched.route = true } } } return matched } // 一個簡單的正則匹配 Layer.prototype.match = function (path) { return this.regexp.test(path) }
而之所以會存在說判斷是否有ctx.matched
來進行處理,而不是直接對這個屬性進行賦值。
這是因為上邊也提到過的,一個koa
例項可能會註冊多個koa-router
例項。
這就導致一個router
例項的中介軟體執行完畢後,後續可能還會有其他的router
例項也命中了某個URL
,但是這樣會保證matched
始終是在累加的,而非每次都會覆蓋。
path
與pathAndMethod
都是match
返回的兩個陣列,兩者的區別在於path
返回的是匹配URL成功的資料,而pathAndMethod
則是匹配URL且匹配到METHOD的資料
const router1 = new Router() const router2 = new Router() router1.post(`/`, _ => {}) router1.get(`/`, async (ctx, next) => { ctx.redirectBody = `hi` console.log(`trigger router1, matched length: ${ctx.matched.length}`) await next() }) router2.get(`/`, async (ctx, next) => { ctx.redirectBody = `hi` console.log(`trigger router2, matched length: ${ctx.matched.length}`) await next() }) app.use(router1.routes()) app.use(router2.routes()) // > curl http://127.0.0.1:8888/ // => trigger router1, matched length: 2 // => trigger router2, matched length: 3
關於中介軟體的執行,在koa-router
中也使用了koa-compose
來合併洋蔥:
var matchedLayers = matched.pathAndMethod layerChain = matchedLayers.reduce(function(memo, layer) { memo.push(function(ctx, next) { ctx.captures = layer.captures(path, ctx.captures) ctx.params = layer.params(path, ctx.captures, ctx.params) ctx.routerName = layer.name return next() }) return memo.concat(layer.stack) }, []) return compose(layerChain)(ctx, next)
這坨程式碼會在所有匹配到的中介軟體之前新增一個ctx
屬性賦值的中介軟體操作,也就是說reduce
的執行會讓洋蔥模型對應的中介軟體函式數量至少X2
。
layer中可能包含多箇中介軟體,不要忘了middleware
,這就是為什麼會在reduce
中使用concat
而非push
因為要在每一箇中介軟體執行之前,修改ctx
為本次中介軟體觸發時的一些資訊。
包括匹配到的URL引數,以及當前中介軟體的name
之類的資訊。
[ layer1[0], // 第一個register中對應的中介軟體1 layer1[1], // 第一個register中對應的中介軟體2 layer2[0] // 第二個register中對應的中介軟體1 ] // => [ (ctx, next) => { ctx.params = layer1.params // 第一個register對應資訊的賦值 return next() }, layer1[0], // 第一個register中對應的中介軟體1 layer1[1], // 第一個register中對應的中介軟體2 (ctx, next) => { ctx.params = layer2.params // 第二個register對應資訊的賦值 return next() }, layer2[0] // 第二個register中對應的中介軟體1 ]
在routes
最後,會呼叫koa-compose
來合併reduce
所生成的中介軟體陣列,以及用到了之前在koa-compose
中提到了的第二個可選的引數,用來做洋蔥執行完成後最終的回撥處理。
小記
至此,koa-router
的使命就已經完成了,實現了路由的註冊,以及路由的監聽處理。
在閱讀koa-router
的原始碼過程中感到很迷惑:
- 明明程式碼中已經實現的功能,為什麼在文件中就沒有體現出來呢。
- 如果文件中不寫明可以這樣來用,為什麼還要在程式碼中有對應的實現呢?
兩個最簡單的舉證:
- 可以通過修改
ctx.routerPath
來實現forward
功能,但是在文件中不會告訴你 - 可以通過
router.register(path, [`GET`, `POST`])
來快速的監聽多個METHOD
,但是register
被標記為了@private
參考資料:
示例程式碼在倉庫中的位置:learning-koa-router