玩转Koa之koa-router原理解析
一、前言
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参数信息数组,其中主要采用方法根据路径字符串生成正则表达式,通过该正则表达式,可以实现路由的匹配以及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的方法,主要采用的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
源码中采用模块获取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-router完全就是一个小型洋葱模型。
七、总结
koa-router虽然是koa的一个中间件,但是其内部也包含众多的中间件,这些中间件通过layer对象根据路由路径的不同进行划分,使得它们不再像koa的中间件那样每次请求都执行,而是针对每次请求采用match方法匹配出相应的中间件,再利用koa-compose形成一个中间件执行链。
以上便是koa-router实现原理的全部内容,希望可以帮助你更好的理解koa-router。也希望大家多多支持。
下一篇: 图文详解 Kubernetes,刺激…