koa源碼閱讀[2]-koa-router

  • 2019 年 12 月 9 日
  • 筆記

koa源碼閱讀[2]-koa-router

第三篇,有關koa生態中比較重要的一個中間件:koa-router

第一篇:koa源碼閱讀-0 第二篇:koa源碼閱讀-1-koa與koa-compose

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中是不會有這樣的問題的,自身已經提供了getpost等之類的與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提供:

File

Description

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的響應處理,以及一些METHOD不支援的處理

創建實例時的一些事情

首先,在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

strictsensitive功能類似,也是用來設置讓路徑的匹配變得更加嚴格,在默認情況下,路徑結尾處的/是可選的,如果開啟該參數以後,如果在註冊路由時尾部沒有添加/,則匹配的路由也一定不能夠添加/結尾:

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配置項存在的意義在於,如果我們有一個介面需要同時支援GETPOSTrouter.getrouter.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中可能會掛在多個RouterRouter之間的配置可能不盡相同,不能保證所有的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.pathrouter代表當前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.getrouter.post這些的操作了。 但實際上這些也只是一個快捷方式罷了,在內部調用了來自Routerregister方法:

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在這裡都有體現

可以看到,函數大致就是實現了這樣的流程:

  1. 檢查path是否為數組,如果是,遍歷item進行調用自身
  2. 實例化一個Layer對象,設置一些初始化參數
  3. 設置針對某些參數的中間件處理(如果有的話)
  4. 將實例化後的對象放入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,主要是用於這幾個地方:

  1. 拋出異常時更方便的定位
  2. 可以通過router.url(<name>)router.route(<name>)獲取到對應的router資訊
  3. 在中間件執行的時候,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 函數接收兩個參數,paramsoptions,因為本身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)形式的。 這時候的邏輯僅需要處理三種情況了:

  1. 數組形式的參數替換
  2. hash形式的參數替換
  3. 無參數

這個參數替換指的是,一個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  }

這個在暴露給使用者的幾個方法中都有體現,類似的getset以及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註冊中間件分為兩種情況:

  1. 普通的中間件函數
  2. 將現有的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())

原因是這樣的:

  1. .use.get都是基於.register來實現的,但是.usemethods參數中傳遞的是一個空數組
  2. 在一個路徑被匹配到時,會將所有匹配到的中間件取出來,然後檢查對應的methods,如果length !== 0則會對當前匹配組標記一個flag
  3. 在執行中間件之前會先判斷有沒有這個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中。 那麼現在就要注意了,在上邊其實已經提到了,LayersetPrefix是拼接的,而不是覆蓋的。 而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())

就像上述程式碼,實際上會有兩個問題:

  1. 最終有效的訪問路徑為/page2/page1/list/1,因為prefix會拼接而非覆蓋
  2. 當我們在中間件中調用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始終是在累加的,而非每次都會覆蓋。

pathpathAndMethod都是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的執行會讓洋蔥模型對應的中間件函數數量至少X2layer中可能包含多個中間件,不要忘了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的源碼過程中感到很迷惑:

  • 明明程式碼中已經實現的功能,為什麼在文檔中就沒有體現出來呢。
  • 如果文檔中不寫明可以這樣來用,為什麼還要在程式碼中有對應的實現呢?

兩個最簡單的舉證:

  1. 可以通過修改ctx.routerPath來實現forward功能,但是在文檔中不會告訴你
  2. 可以通過router.register(path, ['GET', 'POST'])來快速的監聽多個METHOD,但是register被標記為了@private

參考資料:

示例程式碼在倉庫中的位置:learning-koa-router