乐趣区

玩转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 参数信息数组,其中主要采用 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。

退出移动版