koa源码阅读[2]-koa-router

第三篇,有关koa生态中比较重要的一个中间件:koa-routerjavascript

第一篇: 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同名的函数用来注册回调:
expressjava

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

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

可是koa作了不少的精简,将不少逻辑都拆分出来做为独立的中间件来存在。
因此致使不少express项目迁移为koa时,须要额外的安装一些中间件,koa-router应该说是最经常使用的一个。
因此在koa中则须要额外的安装koa-router来实现相似的路由功能:
koanode

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

koa-router的大体结构

koa-router的结构并非很复杂,也就分了两个文件:github

.
├── layer.js
└── router.ja

layer主要是针对一些信息的封装,主要路基由router提供:正则表达式

File Description
layer 信息存储:路径、METHOD、路径对应的正则匹配、路径中的参数、路径对应的中间件
router 主要逻辑:对外暴露注册路由的函数、提供处理路由的中间件,检查请求的URL并调用对应的layer中的路由处理

koa-router的运行流程

能够拿上边所抛出的基本例子来讲明koa-router是怎样的一个执行流程:express

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中只是简单的被描述为:npm

Param Type Description
[opts] Object
[opts.prefix] String prefix router paths(路由的前缀)

告诉咱们能够添加一个Router注册时的前缀,也就是说若是按照模块化分,能够没必要在每一个路径匹配的前端都添加巨长的前缀:segmentfault

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

相关文章
相关标签/搜索