源码阅读之koa-router

156 阅读13分钟

1. 前言

Koa是一个新的web框架,由Express幕后的原班人马打造,致力于成为web应用和API开发领域中的一个更小、更富有表现力、更健壮的基石。通过利用async函数,Koa帮你丢弃回调函数,并有力地增强错误处理。Koa并没有捆绑任何中间件,而是提供了一套优雅的方法,帮助您快速而愉快地编写服务端应用程序。

正如以上Koa官方介绍所说,Koa并没有捆绑任何中间件,而是提供了一套优雅的中间件机制来编写服务端代码。Koa相关的第三方功能库(比如本文要重点介绍的路由库koa-router)都是以中间件的形式与框架协同。

1.1 koa-compose

因此我们先来分析下Koa优雅的中间件机制。说到Koa的中间件机制,一定要先探寻一下koa-compose这个包。

直切主题,我们来看一下koa-compose包,这个包很简单,就导出了一个compose函数,而恰恰就是这个函数,它是整个中间件机制的关键。

function compose (middleware) {
  if (!Array.isArray(middleware)) throw new TypeError('Middleware stack must be an array!')
  for (const fn of middleware) {
    if (typeof fn !== 'function') throw new TypeError('Middleware must be composed of functions!')
  }
  return function (context, next) {
    let index = -1
    return dispatch(0)
    function dispatch (i) {
      if (i <= index) return Promise.reject(new Error('next() called multiple times'))
      index = i
      let fn = middleware[i]
      if (i === middleware.length) fn = next
      if (!fn) return Promise.resolve()
      try {
        return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
      } catch (err) {
        return Promise.reject(err)
      }
    }
  }
}

接触过Koa的同学应该都知道Koa的中间件的函数签名形式是**(ctx,next)=>{ ... next();},只有调用next函数才会继续触发下一个中间件,那为什么只有调用next**函数才会触发呢?我们先带着这个问题继续往下看?

事实上compose函数的作用只是将中间件队列中的所有中间件包装成一个总的中间件函数。

从以下代码入手:

Promise.resolve(fn(context, dispatch.bind(null, i + 1)))

根据上面所说的Koa中间件函数签名对照来看,fn即是每一个被触发的中间件,而**dispatch.bind(null, i + 1)即是next函数,则调用next就是触发dispatch(i+1),然后依次触发中间件队列里的每一个中间件函数。看到这里,想必大家也就明白了为什么只有在中间件中调用了next**函数才会触发下一个中间件的原因了。

在这里,我们来看一些细节处理,我们在compose函数中会看到以下代码

compose函数要求middleware参数(也就是中间件队列)必须为一个数组类型。

if (!Array.isArray(middleware)) throw new TypeError('Middleware stack must be an array!')

同时要求所有中间件都必须是函数

for (const fn of middleware) {
	if (typeof fn !== 'function') throw new TypeError('Middleware must be composed of functions!')
}

中间件数组长度是否为0

  1. middleware为空数组时,第一次调用dispatch(0)时,则会直接命中if (i === middleware.length) fn = next,然后触发next函数进入下一个中间件调用,逻辑直接走到if (!fn) return Promise.resolve()

  2. middleware不为空数组时,则依次调用**dispatch(i + 1)**,最后触发if (!fn) return Promise.resolve()

从源码中我们不难发现,dispatch函数调用成功后的返回值是一个由Promise.resolve生成的Promise,意味着中间件函数中调用next函数返回的也是一个Promise,方便中间件的异步链式调用(比如在router.allowedMethods中就有这样的情况出现)。

2. koa-router

2.1 router.routes

我们先来看一下router.routes方法,因为我们使用koa-router的时候,在一开始就会声明个router实例,然后利用router.routes生成中间件处理函数

const Koa = require('koa');
const Router = require('koa-router');
const app = new Koa();
const router = new Router();
...
app.use(router.routes()); //app使用这个中间件
...

我们知道Koa的实例方法use事实上是将中间件推入自身的中间件队列中,所以它的参数必须是一个中间件处理函数,而router.routes方法返回的就是一个中间件函数,函数签名形式应该是(ctx,next)=>{... next();}

细看router.routes返回的dispatch函数正好匹配这个函数签名形式。

var dispatch = function dispatch(ctx, next) {

	var path = router.opts.routerPath || ctx.routerPath || ctx.path;
	var matched = router.match(path, ctx.method);

	...
	
};

小插曲

我们知道在路由库中有两种类型的路径出现:

1. 真实请求路径,如/path/home/1
2. 路由路径规则,如/path/:to/:param

路由路径规则是用来匹配真实请求路径而设定的一个路由规则,它会对应某一个或很多个路由处理中间件函数。

这个中间件处理函数中的关键逻辑是router.match,根据当前真实请求路径path来匹配路由对象。在match方法中,一旦有路由对象匹配上真实请求路径path,就会被推入matched.path中。

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);

      ...
    }
  }
  return matched;
};

match方法中,我们会看到layer.match(path),(关键点:从这行代码我们也能看出,在koa-router中,利用路由路径规则path匹配真实请求路径path,然后路由路径规则又是映射路由对象的,这样,真实请求路径就和路由对象建立了联系)那么layer又是什么,layer.match又做了什么事?我们带着这个问题看下面的代码,分析清楚这个问题我们也就能彻底明白koa-router的工作原理。

从源码中,我们发现有一个Layer类,该类就是生成我们上面提到的layer路由对象的类,那它又是在哪里被生成的呢?

router实例事实上是一个中间件注册器,通过各种**router.verb()为router添加路由对象,同时为对应的路由路径规则**path注册相应的中间件处理函数栈。

methods.forEach(function (method) {
  //Router原型上注入方法
  Router.prototype[method] = function (name, path, middleware) {
    var 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;
  };
});

这里的methods代表了http所实现的请求方法,如GET、POST、HEAD、DELETE、OPTIONS等等。向Router原型上注入了对应请求方法的实例方法。它们都做同一件事,就是注册对应路由路径规则path的路由对象。

2.2 router.register

我们来看一下router内部是如何注册路由的,也就是router实例的register方法:

function (path, methods, middleware, opts) {
  opts = opts || {};

  var router = this;
  var stack = this.stack;
	
  ...	
		
  // 创建路由对象,每个路由对象都是Layer类的一个实例
  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
  });

  ...

  stack.push(route);

  return route;
};

在register方法内,会创建一个路由对象route,并将其推入router的stack里。这个路由对象route就是数据映射的存储者,内部维护了路由路径规则path对应的中间件函数栈。

这个路由对象route实例如此关键,我们来仔细分析一下其内部细节。

先看其构造函数:

function Layer(path, methods, middleware, opts) {

  ...
  
  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);

  ...

  this.path = path;
  this.regexp = pathToRegExp(path, this.paramNames, this.opts);
};

在其构造函数内,主要做了三件事:

  1. 存储当前路由对象的methods;存储当前路由对象的path
  2. 存储当前路由对象的中间件函数栈
  3. 最最重要的是利用pathToRegExp来解析路由路径path,将其转换成一个正则对象,并提取path中的params,存储到this.paramNames。具体转换过程大家可以自行查看pathToRegExp的API,这部分不是本文关键。

顺便来看下路由对象的实例方法match:

Layer.prototype.match = function (path) {
  return this.regexp.test(path);
};

路由对象的实例方法match正是利用经由pathToRegExp转换得来的regexp来匹配真实请求路径path。若当前真实请求路径匹配上某一路由对象的regexp,则意味着这个路由对象的中间函数栈会被触发来响应该真实请求路径。

我来来看下这个响应过程的细节,再回过头来看router.routes生成的dispatch函数:

首先将匹配到的路由对象数组中的全部路由对象推入到ctx.matched,或者追加到ctx.matched,因为当不是路由中间件调用时,需要为其他路由对象的路由路径规则缓存这些已经匹配到的中间件函数栈。

if (ctx.matched) {
  ctx.matched.push.apply(ctx.matched, matched.path);
} else {
  ctx.matched = matched.path;
}

根据匹配到的是否是一个路由来判定逻辑走向

if (!matched.route) return next();

我们再看一下router的实例方法match中的一段关键逻辑:

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);
	    if (layer.methods.length) matched.route = true;
	  }
	}
}

我们知道在注册路由对象的时候,如果是通过router.verb()来注册的话,是会将当前的method传入到Layer构造器中,并在Layer构造器中存储传入的methods数组。

通过判断methods数组是否为空,我们就能知道当前是路由中间件调用还是普通中间件调用,来判断如何设置matched.route状态。同时检查当前请求链接的请求方法是不是能在当前路由对象中找到来判断是否将路由对象推入matched.pathAndMethodmatched.pathAndMethod是用来维护路由参数捕获和挂载的:

var matchedLayers = matched.pathAndMethod
var mostSpecificLayer = matchedLayers[matchedLayers.length - 1]

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);
}, []);

当是路由中间件调用时,会在每一个匹配到的路由对象的中间件函数栈前追加参数捕获参数挂载的中间件处理函数:

function(ctx, next) {
	ctx.captures = layer.captures(path, ctx.captures); //参数捕获
	ctx.params = layer.params(path, ctx.captures, ctx.params);//参数挂载,对ctx.params持续融合,这样我们就能在ctx.params取到我们在真实请求路径path中传递的参数值
	ctx.routerName = layer.name;
	return next();
}

捕获参数

ctx.captures = layer.captures(path, ctx.captures);

Layer.prototype.captures = function (path) {
  if (this.opts.ignoreCaptures) return [];
  return path.match(this.regexp).slice(1); //通过匹配regexp直接提取出所捕获到的参数值列表
};

参数挂载

ctx.params = layer.params(path, ctx.captures, ctx.params);

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; //映射到params对象中
    }
  }

  return params;
};

在dispatch函数最后我们看到:

return compose(layerChain)(ctx, next);

这里的compose正是我们上面介绍的koa-compose包里面的compose方法,大家现在看是不是会觉得简单很多,compose(layerChain)生成了一个总的中间件函数。

以上过程就是koa-router中关于路由注册、映射、响应的全过程。接下来想和大家一起来看一下koa-router中的一些有关方法调用顺序的细节处理:

router.param()与router.verb()

从源码中我们看到:

首先说一下router.param()方法的作用是

对命名参数添加中间件处理函数
Router.prototype.param = function (param, middleware) {
  this.params[param] = middleware;
  this.stack.forEach(function (route) {
    route.param(param, middleware);
  });
  return this;
};

对router的中间件栈逐一遍历并调用route.param为命名参数注册中间件处理函数。

Layer.prototype.param = function (param, fn) {
  var stack = this.stack;
  var params = this.paramNames; 
  var middleware = function (ctx, next) {
    return fn.call(this, ctx.params[param], ctx, next); //最终对应于命名参数的中间件处理函数签名,会将当前ctx的params中对应的参数值作为第一个参数传入
  };
  middleware.param = param;

  var names = params.map(function (p) {
    return p.name;
  });

  var x = names.indexOf(param);
  if (x > -1) {
    stack.some(function (fn, i) {
      if (!fn.param || names.indexOf(fn.param) > x) {
      //判断当前中间件栈中是否存在同为命名参数的而设置的中间件处理函数,
      //若果有且排序在当前命名参数后,则将当前命名参数的对应中间件处理函数排序在前插入路由对象的中间件栈中,
      //因为该命名参数在路由路径规则path里排在前位。
        stack.splice(i, 0, middleware); 
        return true;
      }
    });
  }

  return this;
};

因为router.param()中对router的中间件栈逐一遍历,因此调用时会先将param与middleware映射到router.params中,这样做的原因正是为了router.param()与router.verb()的调用顺序考虑。假设router.param()先调用,此时router.stack为空,则它只是做了一次映射。

// add parameter middleware
Object.keys(this.params).forEach(function (param) {
	route.param(param, this.params[param]);
}, this);

在router.register中会触发route.param,这里会根据之前在router.param()中缓存在router.params中的映射关系来注册命名参数的中间件处理函数。

router.prefix()与router.verb()

router.prefix()的作用

设置路由路径规则的前缀,可视为基础根路径
Router.prototype.prefix = function (prefix) {
  prefix = prefix.replace(/\/$/, '');

  this.opts.prefix = prefix;

  this.stack.forEach(function (route) {
    route.setPrefix(prefix);
  });

  return this;
};

与router.param()非常类似,router.prefix()后于router.verb()调用,同样会遍历router.stack,触发route.setPrefix();router.prefix()先于router.verb()调用,只是设置了this.opts.prefix = prefix;,在register方法中:

if (this.opts.prefix) {
	route.setPrefix(this.opts.prefix);
}

同样会根据this.opts.prefix是否有值来触发route.setPrefix()

route.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;
};

它内部做的事很简单,将当前的路由路径规则前拼接上prefix,同时利用pathToRegExp来转换这个新的路由路径规则产生新的regexp,用来匹配真实请求路径。

最后一部分,当然是留给router.use

router.use实现了router路由对象的嵌套声明。它是如何实现的呢?我们来详细分析下源码:

先举个例子:

var forums = new Router();
var posts = new Router();
 
posts.get('/', (ctx, next) => {...});
posts.get('/:pid', (ctx, next) => {...});
forums.use('/forums/:fid/posts', posts.routes(), posts.allowedMethods());
 
app.use(forums.routes());

我们再看router.route实例方法,内部生成的dispatch方法上挂载了router属性,该属性即为当前的router实例

以上例子中posts作为forums的嵌套路由,posts.routes()生成的dispatch函数的router属性即为posts,我们通过这个例子来分析router.use方法。具体分析请看注释。


var router = this;
var middleware = Array.prototype.slice.call(arguments);
var path;

//use方法第一个参数可以传入路由路径规则path string
var hasPath = typeof middleware[0] === 'string';
if (hasPath) {
	path = middleware.shift();
}

//后续参数即为中间件队列	
middleware.forEach(function (m) {
	if (m.router) { //以例子中的posts.routes()为例,m.router即为posts,此时为嵌套路由
	  m.router.stack.forEach(function (nestedLayer) {
	    if (path) nestedLayer.setPrefix(path); //假设当前传入了path,即为嵌套路由对象的prefix
	    if (router.opts.prefix) nestedLayer.setPrefix(router.opts.prefix); //假设当前父路由设置了prefix,同样要为嵌套子路由再次设置prefix
	    router.stack.push(nestedLayer); //同时将嵌套子路由推入父路由的栈中,加了prefix之后的子路由与父路由栈中的其他原本中间件等价,层级相等,所以推入同一个栈中
	  });
		
	  if (router.params) { //同样的,针对于命名参数的中间件处理函数,对于子路由一样要为其注册
	    Object.keys(router.params).forEach(function (key) {
	      m.router.param(key, router.params[key]);
	    });
	  }
	} else { 
	  //当前为非嵌套路由,直接注册,hasPath为false时,代表当前没有传入path,即为普通中间件调用,此时则不会捕获命名参数
	  router.register(path || '(.*)', [], m, { end: false, ignoreCaptures: !hasPath });
	}
});

3. 总结

Koa整体思想是构建了一套优雅的中间件机制,方便编写服务端代码。而koa-router作为御用的路由库,也“继承”了Koa的编程思想,

通过以上源代码分析,koa-router本身就是一个中间件:

  1. 自身实现嵌套的中间件注册机制,无论是对特定路由路径规则还是全局都可以轻松注册中间件,极大的方便了我们快速编写健壮的服务端路由。
  2. 可以方便的设置路由根路径,对命名参数设置中间件处理函数,增强了我们编写路由功能的扩展性。

现在可以愉快的使用koa-router来编写服务端路由啦!