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

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

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

koa-router是什麼

首先,由於koa是一個管理中間件的平臺,而註冊一箇中間件使用use來執行。
不管是什麼請求,都會將全部的中間件執行一遍(若是沒有中途結束的話)
因此,這就會讓開發者很困擾,若是咱們要作路由該怎麼寫邏輯?java

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同名的函數用來註冊回調:
expressnode

const express = require('express')
const app = express()

app.get('/', function (req, res) {
  res.send('hi there.')
})
複製代碼

可是koa作了不少的精簡,將不少邏輯都拆分出來做爲獨立的中間件來存在。
因此致使不少express項目遷移爲koa時,須要額外的安裝一些中間件,koa-router應該說是最經常使用的一個。
因此在koa中則須要額外的安裝koa-router來實現相似的路由功能:
koagit

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-routergithub

koa-router的大體結構

koa-router的結構並非很複雜,也就分了兩個文件:正則表達式

.
├── layer.js
└── router.ja
複製代碼

layer主要是針對一些信息的封裝,主要路基由router提供:express

File Description
layer 信息存儲:路徑、METHOD、路徑對應的正則匹配、路徑中的參數、路徑對應的中間件
router 主要邏輯:對外暴露註冊路由的函數、提供處理路由的中間件,檢查請求的URL並調用對應的layer中的路由處理

koa-router的運行流程

能夠拿上邊所拋出的基本例子來講明koa-router是怎樣的一個執行流程:npm

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 {} 一些註冊路由時的配置參數,上邊提到的strictsensitiveprefix在這裏都有體現

能夠看到,函數大體就是實現了這樣的流程:

  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的執行會讓洋蔥模型對應的中間件函數數量至少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的源碼過程當中感到很迷惑:

  • 明明代碼中已經實現的功能,爲何在文檔中就沒有體現出來呢。
  • 若是文檔中不寫明能夠這樣來用,爲何還要在代碼中有對應的實現呢?

兩個最簡單的舉證:

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

參考資料:

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

相關文章
相關標籤/搜索