玩转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 相关文章推荐
YUI 读码日记之 YAHOO.lang.is*
Mar 22 Javascript
jqgrid 表格数据导出实例
Nov 21 Javascript
浅谈jQuery异步对象(XMLHttpRequest)
Nov 17 Javascript
JS实现点击复选框将按钮或文本框变为灰色不可用的方法
Aug 11 Javascript
纯javascript代码实现计算器功能(三种方法)
Sep 07 Javascript
基于JavaScript判断浏览器到底是关闭还是刷新(超准确)
Feb 01 Javascript
AngularJS实现根据变量改变动态加载模板的方法
Nov 04 Javascript
vue axios请求拦截实例代码
Mar 29 Javascript
vue配置font-awesome5的方法步骤
Jan 27 Javascript
AJAX在JQuery中的应用详解
Jan 30 jQuery
mpvue性能优化实战技巧(小结)
Apr 17 Javascript
自定义Vue组件打包、发布到npm及使用教程
May 22 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的APC模块实现上传进度条
2015/10/27 PHP
PHP实现的oracle分页函数实例
2016/01/25 PHP
一段实用的php验证码函数
2016/05/19 PHP
Laravel框架实现修改登录和注册接口数据返回格式的方法
2018/08/17 PHP
Laravel框架处理用户的请求操作详解
2019/12/20 PHP
javascript smipleChart 简单图标类
2011/01/12 Javascript
js 编码转换 gb2312 和 utf8 互转的2种方法
2013/08/07 Javascript
JS注释所产生的bug 即使注释也会执行
2013/11/19 Javascript
JavaScript基础语法之js表达式
2016/06/07 Javascript
Node.js 数据加密传输浅析
2016/11/16 Javascript
Vue指令的钩子函数使用方法
2017/03/20 Javascript
原生js实现简单的Ripple按钮实例代码
2017/03/24 Javascript
angular.js4使用 RxJS 处理多个 Http 请求
2017/09/23 Javascript
JS实现简单获取最近7天和最近3天日期的方法
2018/04/18 Javascript
Vue.directive使用注意(小结)
2018/08/31 Javascript
node.js实现微信开发之获取用户授权
2019/03/18 Javascript
微信小程序开发实现的选项卡(窗口顶部/底部TabBar)页面切换功能图文详解
2019/05/14 Javascript
[52:12]FNATIC vs Infamous 2019国际邀请赛小组赛 BO2 第一场 8.16
2019/08/19 DOTA
PyQt5每天必学之日历控件QCalendarWidget
2018/04/19 Python
Python+selenium 获取一组元素属性值的实例
2018/06/22 Python
python 3.6.4 安装配置方法图文教程
2018/09/18 Python
python 多维高斯分布数据生成方式
2019/12/09 Python
win10下python3.8的PIL库安装过程
2020/06/08 Python
Python Tornado核心及相关原理详解
2020/06/24 Python
python 读取.nii格式图像实例
2020/07/01 Python
HTML5打开手机扫码功能及优缺点
2017/11/27 HTML / CSS
HTML5 播放 RTSP 视频的实例代码
2019/07/29 HTML / CSS
HTML5 textarea高度自适应的两种方案
2020/04/08 HTML / CSS
adidas美国官网:adidas US
2016/09/21 全球购物
Carter’s OshKosh加拿大:购买婴幼儿服装和童装
2018/11/27 全球购物
北大研究生linux应用求职信
2013/10/29 职场文书
银行会计业务的个人自我评价
2013/11/02 职场文书
全神贯注教学反思
2014/02/03 职场文书
阅兵口号
2014/06/19 职场文书
2015年保安个人工作总结
2015/04/02 职场文书
2019财务毕业实习报告
2019/06/27 职场文书