玩转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 相关文章推荐
Javascript hasOwnProperty 方法 &amp; in 关键字
Nov 26 Javascript
动态加载jQuery的方法
Jun 16 Javascript
cocos2dx骨骼动画Armature源码剖析(一)
Sep 08 Javascript
AngularJS动态生成div的ID源码解析
Aug 29 Javascript
jQuery实现立体式数字滚动条增加效果
Dec 21 Javascript
详解如何优雅地在React项目中使用Redux
Dec 28 Javascript
JS实现的文字间歇循环滚动效果完整示例
Feb 13 Javascript
Jquery和CSS实现选择框重置按钮功能
Nov 08 jQuery
vue跳转页面的几种方法(推荐)
Mar 26 Javascript
微信小程序转化为uni-app项目的方法示例
May 22 Javascript
vue动画—通过钩子函数实现半场动画操作
Aug 09 Javascript
vue实现两个组件之间数据共享和修改操作
Nov 12 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数组相关函数汇总
2015/03/24 PHP
PHP单例模式实例分析【防继承,防克隆操作】
2019/05/22 PHP
PHP7 list() 函数修改
2021/03/09 PHP
jQuery入门知识简介
2010/03/04 Javascript
javascript preload&amp;lazy load
2010/05/13 Javascript
js里的prototype使用示例
2010/11/19 Javascript
JS Pro-深入面向对象的程序设计之继承的详解
2013/05/07 Javascript
IE6已终止操作问题的2种情况及解决
2014/04/23 Javascript
关于Javascript回调函数的一个妙用
2016/08/29 Javascript
从零开始学习Node.js系列教程之设置HTTP头的方法示例
2017/04/13 Javascript
微信小程序 action-sheet 反馈上拉菜单简单实例
2017/05/11 Javascript
vue-router权限控制(简单方式)
2018/10/29 Javascript
jQuery实现根据身份证号获取生日、年龄、性别等信息的方法
2019/01/09 jQuery
layui-table表复选框勾选的所有行数据获取的例子
2019/09/13 Javascript
vue实现修改图片后实时更新
2019/11/14 Javascript
微信小程序实现上传多张图片、删除图片
2020/07/29 Javascript
AngularJs的$http发送POST请求,php无法接收Post的数据问题及解决方案
2020/08/13 Javascript
vue实现可移动的悬浮按钮
2021/03/04 Vue.js
[00:59]DOTA2荣耀之路1:Doom is back!weapon X!
2018/05/22 DOTA
python 在指定范围内随机生成不重复的n个数实例
2019/01/28 Python
Python判断有效的数独算法示例
2019/02/23 Python
Python Django框架模板渲染功能示例
2019/11/08 Python
使用Python开发个京东上抢口罩的小实例(仅作技术研究学习使用)
2020/03/10 Python
使用Keras预训练模型ResNet50进行图像分类方式
2020/05/23 Python
澳大利亚票务和娱乐市场领导者:Ticketmaster
2017/03/03 全球购物
团支书的期末学习总结自我评价
2013/11/01 职场文书
电子商务专业毕业生工作推荐信
2013/11/17 职场文书
军训自我鉴定
2013/12/14 职场文书
中学教师管理制度
2014/01/14 职场文书
安全生产管理责任书
2014/04/16 职场文书
社区春季防火方案
2014/06/02 职场文书
个人对照检查材料思想汇报(四风问题)
2014/09/25 职场文书
2014幼儿园家长工作总结
2014/11/10 职场文书
幼儿园教师考核评语
2014/12/31 职场文书
党风廉政建设调研报告
2015/01/01 职场文书
Python 数据可视化之Matplotlib详解
2021/11/02 Python