koa源码阅读[2【金沙官网线上】]-koa-router

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

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

第一篇:koa源码阅读-0
第二篇:koa源码阅读-1-koa与koa-compose

Koa 应用程序是一个包含一组中间件函数的对象,它是按照类似堆栈的方式组织和执行的。

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 对自己的介绍,其他 koa 依赖的库其实都可以算是中间件,koa-router 也不例外。

koa-router的大致结构

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

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

 

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

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

ps: 本文代码中的中文解释是对代码的讲解,省略号(...)代表省略部分代码
文章最后有简版router的项目地址

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 的猜想

通过 koa 最简单的 hellow world 例子可以看出原生对请求的处理方式:

const Koa = require('koa');
const app = new Koa();

app.use(async ctx => {
  ctx.body = 'Hello World';
});

app.listen(3000);

要是我们想简单的实现路由的话,可以添加一些判断条件

app.use(async ctx => {
  if (ctx.path === '/one' && ctx.method === 'get') {
    ctx.body = 'Hello World';
  } else {
    ctx.status = 404;
    ctx.body = '';
  }
});

这样的话能实现简单对路由的实现,不过路由越多的话消耗的性能也就越大,而且不容易对特殊路由添加中间件。而更好的方法是使用面向对象的方式,根据请求的 path 和 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  

解读思路

这里要介绍下我解读 koa-router 源码的方法,我会先把 koa-router 的源码下载到本地,然后通读一遍(因为源码算是比较少的),从大体上知道 koa-router 执行流程,然后通过单元测试去 debug 分析。

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

 

Router 执行流程图

金沙官网线上 1

koa-router 流程.png

我认为 koa-router 最基本且核心的API有四个:

  1. router.match
    可以根据请求的 path 和 method 筛选出匹配的 route
  2. router.register
    注册 route
  3. router.routes
    返回用于 koa 加载的中间件,通过 koa-compose 将middlewares 压缩成一个函数
  4. router.method(get、post等)
    可以根据path、method 定义 router,并且可以将middleware绑定在路由上

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

 

解读

我们可以结合代码和单元测试对源码进行理解,由最简单的测试开始debug:

it('router can be accecced with ctx', function (done) {
      var app = new Koa();
      var router = new Router();
      router.get('home', '/', function (ctx) {
          ctx.body = {
            url: ctx.router.url('home')
          };
      });

      console.log(router.routes()); // 这是我加的,查看最后加载的routes
      app.use(router.routes());
      request(http.createServer(app.callback()))
          .get('/')
          .expect(200)
          .end(function (err, res) {
              if (err) return done(err);
              expect(res.body.url).to.eql("/");
              done();
          });
  });

router.routes() 返回:

function dispatch(ctx, next) {
    debug('%s %s', ctx.method, ctx.path);
    var path = router.opts.routerPath || ctx.routerPath || ctx.path;
    var matched = router.match(path, ctx.method);
    var layerChain, layer, i;
    ...
    ctx.router = router;
    if (!matched.route) return next();
    // 获取已匹配的 routes (实例化 Layer 对象)
    var matchedLayers = matched.pathAndMethod
    ...
    // 若匹配了多个 route,则将多个执行函数 push 进一个数组
    layerChain = matchedLayers.reduce(function(memo, layer) {
      ...
      return memo.concat(layer.stack);
    }, []);

    return compose(layerChain)(ctx, next);
  }

router.routes() 返回一个 dispatch 函数,从中可以看出请求进来会经过 router.match(后面有分析),然后将匹配到的 route 的执行函数 push 进数组,并通过 compose(koa-compose) 函数合并返回。

然后在打印出 compose(layerChain) 方法,可以看到其实最后请求执行的函数是对ctx.body = {url: ctx.router.url('home')}; 的 compose 封装函数,在效果上相当于

app.use(ctx => {
  ctx.body = {
    url: ctx.router.url('home')
  };
});
  • 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 = {};
  // 初始化定义 route 栈
  this.stack = [];
};
  • 分析 router.method 方法
// methods ['get', 'post', 'delete', 'put', 'patch', ...]
methods.forEach(function (method) {
  Router.prototype[method] = function (name, path, middleware) {
    var middleware;

    if (typeof path === 'string' || path instanceof RegExp) {
      // 若第二个参数是 string 或 正则表达式,则将后面的参数归为 middleware
      middleware = Array.prototype.slice.call(arguments, 2);
    } else {
      // 否则说明没有传 name 参数,将第一个参数置为path,之后的参数归为 middleware
      middleware = Array.prototype.slice.call(arguments, 1);
      path = name;
      name = null;
    }

    // 注册 route(下面会讲到 register 方法)
    this.register(path, [method], middleware, {
      name: name
    });

    // 返回 Router 对象,可以链式调用
    return this;
  };
});
  • 分析 router.register 方法
Router.prototype.register = function (path, methods, middleware, opts) {
  opts = opts || {};

  var stack = this.stack;
  ...
  // create route
  // 实例化一个 Layer 对象,Layer 对象将 path 转为 regexp,并增加了匹配 path 的可选 ops 参数
  var 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
  });

  console.log(route);
  /**
   * Layer {
   * ...省略部分属性
   * methods: [ 'HEAD', 'GET' ],
   * stack: [ [Function] ],
   * path: '/',
   * regexp: { /^(?:/(?=$))?$/i keys: [] } } // 用于匹配 path
   */
  ...
  // 将注册的 route 存放在 stack 队列中
  stack.push(route);

  return route;
};

register 方法主要用于实例化 Layer 对象,并支持多各 path 同时注册、添加路由前缀等功能(展示代码忽略)。

  • 分析 router.match
Router.prototype.match = function (path, method) {
  // 获取已经注册的 routes (实例化Layer对象)
  var layers = this.stack;
  var layer;
  var matched = {
    path: [],
    pathAndMethod: [],
    route: false
  };

  // 循环查找能够匹配的route
  for (var len = layers.length, i = 0; i < len; i++) {
    layer = layers[i];

    debug('test %s %s', layer.path, layer.regexp);

    // 根据layer.regexp.test(path) 匹配
    if (layer.match(path)) {
      matched.path.push(layer);

      // todo ~操作符暂时没懂
      if (layer.methods.length === 0 || ~layer.methods.indexOf(method)) {
        matched.pathAndMethod.push(layer);
        // 将匹配标志 route 设为 true,这里我觉得改为 hitRoute 更容易理解
        if (layer.methods.length) matched.route = true;
      }
    }
  }

  return matched;
};

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参数的存在意义并不是很大。。

实现简版Router

通过上面的分析,其实已经讲解了 koa-router 核心的部分:构造 Router 对象 => 定义 router 入口 => 匹配路由 => 合并中间件和执行函数输出;这4个API可以处理简单的 restful 请求,额外的API例如重定向、router.use、路由前缀等在了解核心代码后阅读起来就简单很多了;简版其实就是上面api的精简版,原理一致,可以到我的项目看下
simple-koa-router:https://github.com/masongzhi/simple-koa-router

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!

 

总结

koa-router 帮我们定义并选择相应的路由,对路由添加中间件和一些兼容和验证的工作;在 koa 中间件应用的基础上,比较容易理解中间件的实现,koa-router 为我们做了更好的路由层管理,在设计上可以参考实现,同时研究优美源码也是对自己的一种提升。

巧用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实例的中间件

本文由金沙官网线上发布于Web前端,转载请注明出处:koa源码阅读[2【金沙官网线上】]-koa-router

您可能还会对下面的文章感兴趣: