玩转Koa之koa-router原理解析


Posted in Javascript onDecember 29, 2018

一、前言

Koa为了保持自身的简洁,并没有捆绑中间件。但是在实际的开发中,我们需要和形形色色的中间件打交道,本文将要分析的是经常用到的路由中间件 -- koa-router。

如果你对Koa的原理还不了解的话,可以先查看Koa原理解析。

二、koa-router概述

koa-router的源码只有两个文件:router.js和layer.js,分别对应Router对象和Layer对象。

Layer对象是对单个路由的管理,其中包含的信息有路由路径(path)、路由请求方法(method)和路由执行函数(middleware),并且提供路由的验证以及params参数解析的方法。

相比较Layer对象,Router对象则是对所有注册路由的统一处理,并且它的API是面向开发者的。

接下来从以下几个方面全面解析koa-router的实现原理:

  • Layer对象的实现
  • 路由注册
  • 路由匹配
  • 路由执行流程

三、Layer

Layer对象主要是对单个路由的管理,是整个koa-router中最小的处理单元,后续模块的处理都离不开Layer中的方法,这正是首先介绍Layer的重要原因。

function Layer(path, methods, middleware, opts) {
 this.opts = opts || {};
 // 支持路由别名
 this.name = this.opts.name || null;
 this.methods = [];
 this.paramNames = [];
 // 将路由执行函数保存在stack中,支持输入多个处理函数
 this.stack = Array.isArray(middleware) ? middleware : [middleware];

 methods.forEach(function(method) {
  var l = this.methods.push(method.toUpperCase());
  // HEAD请求头部信息与GET一致,这里就一起处理了。
  if (this.methods[l-1] === 'GET') {
   this.methods.unshift('HEAD');
  }
 }, this);

 // 确保类型正确
 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;
 // 1、根据路由路径生成路由正则表达式
 // 2、将params参数信息保存在paramNames数组中
 this.regexp = pathToRegExp(path, this.paramNames, this.opts);
};

Layer构造函数主要用来初始化路由路径、路由请求方法数组、路由处理函数数组、路由正则表达式以及params参数信息数组,其中主要采用path-to-regexp方法根据路径字符串生成正则表达式,通过该正则表达式,可以实现路由的匹配以及params参数的捕获:

// 验证路由
Layer.prototype.match = function (path) {
 return this.regexp.test(path);
}

// 捕获params参数
Layer.prototype.captures = function (path) {
 // 后续会提到 对于路由级别中间件 无需捕获params
 if (this.opts.ignoreCaptures) return [];
 return path.match(this.regexp).slice(1);
}

根据paramNames中的参数信息以及captrues方法,可以获取到当前路由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;
  }
 }
 return params;
};

需要注意上述代码中的safeDecodeURIComponent方法,为了避免服务器收到不可预知的请求,对于任何用户输入的作为URI部分的内容都需要采用encodeURIComponent进行转义,否则当用户输入的内容中含有'&'、'='、'?'等字符时,会出现预料之外的情况。而当我们获取URL上的参数时,则需要通过decodeURIComponent进行解码,而decodeURIComponent只能解码由encodeURIComponent方法或者类似方法编码,如果编码方法不符合要求,decodeURIComponent则会抛出URIError,所以作者在这里对该方法进行了安全化的处理:

function safeDecodeURIComponent(text) {
 try {
  return decodeURIComponent(text);
 } catch (e) {
  // 编码方式不符合要求,返回原字符串
  return text;
 }
}

Layer还提供了对于单个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);
 };
 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) {
    // 将单个param前置处理函数插入正确的位置
    stack.splice(i, 0, middleware);
    return true; // 跳出循环
   }
  });
 }

 return this;
};

上述代码中通过some方法寻找单个param处理函数的原因在于以下两点:

  • 保持param处理函数位于其他路由处理函数的前面;
  • 路由中存在多个param参数,需要保持param处理函数的前后顺序。
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;
};

Layer中的setPrefix方法用于设置路由路径的前缀,这在嵌套路由的实现中尤其重要。

最后,Layer还提供了根据路由生成url的方法,主要采用path-to-regexp的compile和parse对路由路径中的param进行替换,而在拼接query的环节,正如前面所说需要对键值对进行繁琐的encodeURIComponent操作,作者采用了urijs提供的简洁api进行处理。

四、路由注册

1、Router构造函数

首先看了解一下Router构造函数:

function Router(opts) {
 if (!(this instanceof Router)) {
  // 限制必须采用new关键字
  return new Router(opts);
 }

 this.opts = opts || {};
 // 服务器支持的请求方法, 后续allowedMethods方法会用到
 this.methods = this.opts.methods || [
  'HEAD',
  'OPTIONS',
  'GET',
  'PUT',
  'PATCH',
  'POST',
  'DELETE'
 ];

 this.params = {}; // 保存param前置处理函数
 this.stack = []; // 存储layer
};

在构造函数中初始化的params和stack属性最为重要,前者用来保存param前置处理函数,后者用来保存实例化的Layer对象。并且这两个属性与接下来要讲的路由注册息息相关。

koa-router中提供两种方式注册路由:

  • 具体的HTTP动词注册方式,例如:router.get('/users', ctx => {})
  • 支持所有的HTTP动词注册方式,例如:router.all('/users', ctx => {})

2、http METHODS

源码中采用methods模块获取HTTP请求方法名,该模块内部实现主要依赖于http模块:

http.METHODS && http.METHODS.map(function lowerCaseMethod (method) {
 return method.toLowerCase()
})

3、router.verb() and router.all()

这两种注册路由的方式的内部实现基本类似,下面以router.verb()的源码为例:

methods.forEach(function (method) {
 Router.prototype[method] = function (name, path, middleware) {
  var middleware;

  // 1、处理是否传入name参数
  // 2、middleware参数支持middleware1, middleware2...的形式
  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;
 };
});

该方法第一部分是对传入参数的处理,对于middleware参数的处理会让大家联想到ES6中的rest参数,但是rest参数与arguments其中一个致命的区别:

rest参数只包含那些没有对应形参的实参,而arguments则包含传给函数的所有实参。

如果采用rest参数的方式,上述函数则必须要求开发者传入name参数。但是也可以将name和path参数整合成对象,再结合rest参数:

Router.prototype[method] = function (options, ...middleware) {
 let { name, path } = options
 if (typeof options === 'string' || options instanceof RegExp) {
  path = options
  name = null
 }
 // ...
 return this;
};

采用ES6的新特性,代码变得简洁多了。

第二部分是register方法,传入的method参数的形式就是router.verb()与router.all()的最大区别,在router.verb()中传入的method是单个方法,后者则是以数组的形式传入HTTP所有的请求方法,所以对于这两种注册方法的实现,本质上是没有区别的。

4、register

Router.prototype.register = function (path, methods, middleware, opts) {
 opts = opts || {};

 var router = this;
 var stack = this.stack;

 // 注册路由中间件时,允许path为数组
 if (Array.isArray(path)) {
  path.forEach(function (p) {
   router.register.call(router, p, methods, middleware, opts);
  });
  return this;
 }

 // 实例化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
 });

 // 设置前缀
 if (this.opts.prefix) {
  route.setPrefix(this.opts.prefix);
 }

 // 设置param前置处理函数
 Object.keys(this.params).forEach(function (param) {
  route.param(param, this.params[param]);
 }, this);

 stack.push(route);

 return route;
};

register方法主要负责实例化Layer对象、更新路由前缀和前置param处理函数,这些操作在Layer中已经提及过,相信大家应该轻车熟路了。

5、use

熟悉Koa的同学都知道use是用来注册中间件的方法,相比较Koa中的全局中间件,koa-router的中间件则是路由级别的。
Router.prototype.use = function () {

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

 // 支持多路径在于中间件可能作用于多条路由路径
 if (Array.isArray(middleware[0]) && typeof middleware[0][0] === 'string') {
  middleware[0].forEach(function (p) {
   router.use.apply(router, [p].concat(middleware.slice(1)));
  });

  return this;
 }
 // 处理路由路径参数
 var hasPath = typeof middleware[0] === 'string';
 if (hasPath) {
  path = middleware.shift();
 }

 middleware.forEach(function (m) {
  // 嵌套路由
  if (m.router) {
   // 嵌套路由扁平化处理
   m.router.stack.forEach(function (nestedLayer) {
    // 更新嵌套之后的路由路径
    if (path) nestedLayer.setPrefix(path);
    // 更新挂载到父路由上的路由路径
    if (router.opts.prefix) nestedLayer.setPrefix(router.opts.prefix);

    router.stack.push(nestedLayer);
   }); 

   // 不要忘记将父路由上的param前置处理操作 更新到新路由上。
   if (router.params) {
    Object.keys(router.params).forEach(function (key) {
     m.router.param(key, router.params[key]);
    });
   }
  } else {
   // 路由级别中间件 创建一个没有method的Layer实例
   router.register(path || '(.*)', [], m, { end: false, ignoreCaptures: !hasPath });
  }
 });

 return this;
};

koa-router中间件注册方法主要完成两项功能:

  • 将路由嵌套结构扁平化,其中涉及到路由路径的更新和param前置处理函数的插入;
  • 路由级别中间件通过注册一个没有method的Layer实例进行管理。

五、路由匹配

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)) {
    // layer.methods.length === 0 该layer为路由级别中间件
    // ~layer.methods.indexOf(method) 路由请求方法也被匹配
    matched.pathAndMethod.push(layer);
    // 仅当路由路径和路由请求方法都被满足才算是路由被匹配
    if (layer.methods.length) matched.route = true;
   }
  }
 }
 return matched;
};

match方法主要通过layer.match方法以及methods属性对layer进行筛选,返回的matched对象包含以下几个部分:

  • path: 保存所有路由路径被匹配的layer;
  • pathAndMethod: 在路由路径被匹配的前提下,保存路由级别中间件和路由请求方法被匹配的layer;
  • route: 仅当存在路由路径和路由请求方法都被匹配的layer,才能算是本次路由被匹配上。

另外,在ES7之前,对于判断数组是否包含一个元素,都需要通过indexOf方法来实现, 而该方法返回元素的下标,这样就不得不通过与-1的比较得到布尔值:

if (layer.methods.indexOf(method) > -1) {
  ...
 }

而作者巧妙地利用位运算省去了“讨厌的-1”,当然在ES7中可以愉快地使用includes方法:

if (layer.methods.includes(method)) {
  ...
 }

六、路由执行流程

理解koa-router中路由的概念以及路由注册的方式,接下来就是如何作为一个中间件在koa中执行。

koa中注册koa-router中间件的方式如下:

const Koa = require('koa');
const Router = require('koa-router');

const app = new Koa();
const router = new Router();

router.get('/', (ctx, next) => {
 // ctx.router available
});

app
 .use(router.routes())
 .use(router.allowedMethods());

从代码中可以看出koa-router提供了两个中间件方法:routes和allowedMethods。

1、allowedMethods()

Router.prototype.allowedMethods = function (options) {
 options = options || {};
 var implemented = this.methods;

 return function allowedMethods(ctx, next) {
  return next().then(function() {
   var allowed = {};

   if (!ctx.status || ctx.status === 404) {
    ctx.matched.forEach(function (route) {
     route.methods.forEach(function (method) {
      allowed[method] = method;
     });
    });

    var allowedArr = Object.keys(allowed);

    if (!~implemented.indexOf(ctx.method)) {
     // 服务器不支持该方法的情况
     if (options.throw) {
      var notImplementedThrowable;
      if (typeof options.notImplemented === 'function') {
       notImplementedThrowable = options.notImplemented();
      } else {
       notImplementedThrowable = new HttpError.NotImplemented();
      }
      throw notImplementedThrowable;
     } else {
      // 响应 501 Not Implemented
      ctx.status = 501;
      ctx.set('Allow', allowedArr.join(', '));
     }
    } else if (allowedArr.length) {
     if (ctx.method === 'OPTIONS') {
      // 获取服务器对该路由路径支持的方法集合
      ctx.status = 200;
      ctx.body = '';
      ctx.set('Allow', allowedArr.join(', '));
     } else if (!allowed[ctx.method]) {
      if (options.throw) {
       var notAllowedThrowable;
       if (typeof options.methodNotAllowed === 'function') {
        notAllowedThrowable = options.methodNotAllowed();
       } else {
        notAllowedThrowable = new HttpError.MethodNotAllowed();
       }
       throw notAllowedThrowable;
      } else {
       // 响应 405 Method Not Allowed
       ctx.status = 405;
       ctx.set('Allow', allowedArr.join(', '));
      }
     }
    }
   }
  });
 };
};

allowedMethods()中间件主要用于处理options请求,响应405和501状态。上述代码中的ctx.matched中保存的正是前面matched对象中的path(在routes方法中设置,后面会提到。),在matched对象中的path数组不为空的前提条件下:

  • 服务器不支持当前请求方法,返回501状态码;
  • 当前请求方法为OPTIONS,返回200状态码;
  • path中的layer不支持该方法,返回405状态;

对于上述三种情况,服务器都会设置Allow响应头,返回该路由路径上支持的请求方法。

2、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;

  // 【1】为后续的allowedMethods中间件准备
  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) {
   // 【3】路由的前置处理中间件 主要负责将params、路由别名以及捕获数组属性挂载在ctx上下文对象中。
   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);
  }, []);
  // 【4】利用koa中间件组织的方式,形成一个‘小洋葱'模型
  return compose(layerChain)(ctx, next);
 };

 // 【2】router属性用来use方法中区别路由级别中间件
 dispatch.router = this;
 return dispatch;
};

routes()中间件主要实现了四大功能。

  • 将matched对象的path属性挂载在ctx.matched上,提供给后续的allowedMethods中间件使用。(见代码中的【1】)
  • 将返回的dispatch函数设置router属性,以便在前面提到的Router.prototype.use方法中区别路由级别中间件和嵌套路由。(见代码中的【2】)
  • 插入一个新的路由前置处理中间件,将layer解析出来的params对象、路由别名以及捕获数组挂载在ctx上下文中,这种操作同理Koa在处理请求之前先构建context对象。(见代码中的【3】)
  • 而对于路由匹配到众多layer,koa-router通过koa-compose进行处理,这和koa对于中间件处理的方式一样的,所以koa-router完全就是一个小型洋葱模型。

七、总结

koa-router虽然是koa的一个中间件,但是其内部也包含众多的中间件,这些中间件通过Layer对象根据路由路径的不同进行划分,使得它们不再像koa的中间件那样每次请求都执行,而是针对每次请求采用match方法匹配出相应的中间件,再利用koa-compose形成一个中间件执行链。

以上便是koa-router实现原理的全部内容,希望可以帮助你更好的理解koa-router。也希望大家多多支持三水点靠木。

Javascript 相关文章推荐
使用正则替换变量
May 05 Javascript
JavaScript中两个感叹号的作用说明
Dec 28 Javascript
关于JS中的闭包浅谈
Aug 23 Javascript
jquery取消选择select下拉框示例代码
Feb 22 Javascript
JavaScript是如何实现继承的(六种方式)
Mar 31 Javascript
Bootstrap按钮下拉菜单组件详解
May 10 Javascript
JavaScript的Vue.js库入门学习教程
May 23 Javascript
Javascript中字符串replace方法的第二个参数探究
Dec 05 Javascript
JS隐藏号码中间4位代码实例
Apr 09 Javascript
js实现整体缩放页面适配移动端
Mar 31 Javascript
vue 修改 data 数据问题并实时显示操作
Sep 07 Javascript
为什么JavaScript中0.1 + 0.2 != 0.3
Dec 03 Javascript
Bootstrap Paginator+PageHelper实现分页效果
Dec 29 #Javascript
玩转Koa之核心原理分析
Dec 29 #Javascript
如何从0开始用node写一个自己的命令行程序
Dec 29 #Javascript
bootstrap与pagehelper实现分页效果
Dec 29 #Javascript
微信小程序实现滑动切换自定义页码的方法分析
Dec 29 #Javascript
vue实现分页组件
Jun 16 #Javascript
如何用RxJS实现Redux Form
Dec 29 #Javascript
You might like
php实现从ftp服务器上下载文件树到本地电脑的程序
2009/02/10 PHP
提高php运行速度的一些小技巧分享
2012/07/03 PHP
PHP 的比较运算与逻辑运算详解
2016/05/12 PHP
PHP生成加减算法方式的验证码实例
2018/03/12 PHP
PHP反射原理与用法深入分析
2019/09/28 PHP
Convert Seconds To Hours
2007/06/16 Javascript
初学Javascript的一些总结
2008/11/03 Javascript
javascript 另一种图片滚动切换效果思路
2012/04/20 Javascript
javascript中获取下个月一号,是星期几
2012/06/01 Javascript
JS不能跨域借助jquery获取IP地址的方法
2014/08/20 Javascript
jQuery实现将div中滚动条滚动到指定位置的方法
2016/08/10 Javascript
第一次动手实现bootstrap table分页效果
2016/09/22 Javascript
bootstrap——bootstrapTable实现隐藏列的示例
2017/01/14 Javascript
js记录点击某个按钮的次数-刷新次数为初始状态的实例
2017/02/15 Javascript
Vue.js父与子组件之间传参示例
2017/02/28 Javascript
一个有意思的鼠标点击文字特效jquery代码
2017/09/23 jQuery
fetch 使用及如何接收JS传值
2017/11/11 Javascript
JavaScript中 ES6变量的结构赋值
2018/07/10 Javascript
记一次webapck4 配置文件无效的解决历程
2018/09/19 Javascript
Vue 利用指令实现禁止反复发送请求的两种方法
2019/09/15 Javascript
微信小程序以7天为周期连续签到7天功能效果的示例代码
2020/08/20 Javascript
Python内置函数dir详解
2015/04/14 Python
python操作sqlite的CRUD实例分析
2015/05/08 Python
在pandas中一次性删除dataframe的多个列方法
2018/04/10 Python
Python语言进阶知识点总结
2019/05/28 Python
Python解析json时提示“string indices must be integers”问题解决方法
2019/07/31 Python
matplotlib命令与格式之tick坐标轴日期格式(设置日期主副刻度)
2019/08/06 Python
pytorch查看通道数 维数 尺寸大小方式
2020/05/26 Python
CSS3正方体旋转示例代码
2013/08/08 HTML / CSS
HTML5 Canvas锯齿图代码实例
2014/04/10 HTML / CSS
企业管理部经理岗位职责
2013/12/24 职场文书
项目总经理岗位职责
2014/02/14 职场文书
《雪儿》教学反思
2014/04/17 职场文书
洗手间标语
2014/06/23 职场文书
工作失职造成投诉的检讨书范文
2014/10/05 职场文书
2014年加油站工作总结
2014/12/04 职场文书