koa-router源码解析
Posted 小平果118
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了koa-router源码解析相关的知识,希望对你有一定的参考价值。
大众点评的node框架Node-Server最大的特色是面向企业级 Web 全栈应用框架, 以Koa2为基础, 集成了架构中间件Pigeon、 Lion、 Cat、 Mapi、 Rhino等Node客户端,支持了Node Thrift。最大程度上帮助应用在 Web 开发中提升可维护性和扩展性。其中router路由的解析主要是依赖于koa-router, 依赖于koa-router的router()
, match()
, stack
, layer
等能力,下面进行koa-router源码的解析。
koa-router原理解析
koa 框架一直都保持着简洁性, 它只对 node 的 HTTP 模块进行了封装, 而在真正实际使用, 我们还需要更多地像xxx-router
路由这样的模块来构建我们的应用,
而koa-router
是常用的 koa 的路由库. 通过解析 koa-router 的源码来达到深入学习的目的.
1.深入浅出koa-router
我们知道,在 node 原生里面, 如果我们需要实现路由功能, 那么就可以像下面这样编写代码:
const http = require('http');
const parse = require('url');
const server = http.createServer((req, res) =>
let pathname = parse(req.url);
if (pathname === '/')
res.end('index page');
else if (pathname === '/test')
res.end('test page');
else
res.end('router is not found');
);
server.listen(3000);
上面的代码通过解析原生 request IncomingMessage
的 url 属性, 利用 if…else 判断路径返回不同的结果.
但是上面的代码缺点也很明显, 如果路由过多, if…else 的分支也会越庞大, 不利于代码的维护与多人合作.因此,我们需要一个特定的路由模块来统一地模块化地解决路由功能的问题.
如果是使用 koa-router
的话, 那么可以借助下面的代码来简单建立一个 koa-router 库的使用 demo:
const Koa = require('koa');
const KoaRouter = require('koa-router');
const app = new Koa();
// 创建 router 实例对象
const router = new KoaRouter();
//注册路由
router.get('/', async (ctx, next) =>
console.log('index');
ctx.body = 'index';
);
app.use(router.routes()); // 添加路由中间件
app.use(router.allowedMethods()); // 对请求进行一些限制处理
app.listen(3000);
运行上面的代码, 访问根路由 ‘/’ 我们可以看到返回数据为 ‘index’, 这说明路由已经基本生效了.
我们来看上面的代码, 使用 koa-router 第一步就是新建一个 router 实例对象:
const router = new KoaRouter();
然后在构建应用的时候, 我们的首要目标就是创建多个 http 接口以适配不同的业务需求, 那么接下来就需要注册对应的路由:
router.get('/', async (ctx, next) =>
console.log('index');
ctx.body = 'index';
);
上面的示例使用了 GET 方法来进行注册根路由, 实际上不仅可以使用 GET 方法, 还可以使用 POST, DELETE, PUT 等等node 支持的方法.
然后为了让 koa 实例使用我们处理后的路由模块, 我们需要使用 routes 方法将路由加入到应用全局的中间件函数中:
app.use(router.routes()); // 添加路由中间件
app.use(router.allowedMethods()); // 对请求进行一些限制处
2. 源码架构与解析
通过上面的代码, 我们已经知道了 koa-router 的简单使用,接下来我们需要深入到代码中, 理解它是怎么做到匹配从客户端传过来的请求并跳转执行对应的逻辑的.在此之前我们先看一下代码的结构图:
2.1 Router & Layer
第一步, 我们需要新建一个 Router 的实例对象, 而对于一个 Router
的实例来说理解其属性是至关重要的. 这里是个function
,所以我们可以不需要new, 直接 requure(koa-router)()
也可以。
function Router(opts)
if (!(this instanceof Router))
return new Router(opts);
this.opts = opts || ;
this.methods = this.opts.methods || [
'HEAD',
'OPTIONS',
'GET',
'PUT',
'PATCH',
'POST',
'DELETE'
];
this.params = ;
this.stack = [];
;
可以看到, 实际有用的属性不过 3 个, 分别是 methods 数组, params 对象, stack 数组.
methods
数组
存放的是允许使用的 HTTP 方法名, 会在 Router.prototype.allowedMethods 方法中使用, 我们在创建 Router 实例的时候可以进行配置, 允许使用哪些方法.
params
对象
它存储的是键为参数名与值为对应的参数校验函数, 这样是为了通过在全局存储参数的校验函数, 方便在注册路由的时候为路由的中间件函数数组添加校验函数.
stack
数组
则是存储每一个路由, 也就是 Layer 的实例对象, 每一个路由都相当于一个 Layer 实例对象.
对于 Layer 类来说, 创建一个实例对象用于管理每个路由:
js
function Layer(path, methods, middleware, opts)
this.opts = opts || ;
// 路由命名
this.name = this.opts.name || null;
// 路由对应的方法
this.methods = [];
// 路由参数名数组
this.paramNames = [];
// 路由处理中间件数组
this.stack = Array.isArray(middleware) ? middleware : [middleware];
// 存储路由方法
methods.forEach(function(method)
var l = this.methods.push(method.toUpperCase());
if (this.methods[l-1] === 'GET')
this.methods.unshift('HEAD');
, this);
// 将添加的回调处理中间件函数添加到 Layer 实例对象的 stack 数组中
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;
this.regexp = pathToRegExp(path, this.paramNames, this.opts);
debug('defined route %s %s', this.methods, this.opts.prefix + this.path);
;
我们可以看到, 对于 Layer 的实例对象, 核心的逻辑还是在于将 path 转化为正则表达式用于匹配请求的路由, 然后将路由的处理中间件添加到 Layer 的 stack 数组中. 注意这里的 stack 和 Router 里面的 stack 是不一样的, Router 的 stack 数组是存放每个路由对应的 Layer 实例对象的, 而 Layer 实例对象里面的 stack 数组是存储每个路由的处理函数中间件的, 换言之, 一个路由可以添加多个处理函数.
2.2 method 相关函数
所谓 method 就是 HTTP 协议中或者说是在 node 中支持的 HTTP 请求方法.其实我们可以通过打印 node 中的 HTTP 的方法来查看 node 支持的 HTTP method:
js
require('http').METHODS; // ['ACL', ...., 'GET', 'POST', 'PUT', ...]
在 koa-router 里面的体现就是我们可以通过在 router 实例对象上调用对应的方法函数来注册对应的 HTTP 方法的路由而且每个方法的核心逻辑都类似, 就是将传入的路由路径与对应的回调函数绑定, 所以我们可以遍历一个方法数组来快速构建原型的 method 方法:
methods.forEach(function (method)
Router.prototype[method] = function (name, path, middleware)
var middleware; // 判断有没有传入 name 参数, 如果有则处理参数个数问题
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;
;
);
上面函数中先判断 path 是否是字符串或者正则表达式是因为注册路由的时候还可以为路由进行命名(命名空间方便管理), 然后准确地获取回调的函数数组(注册路由可以接收多个回调), 这样如果匹配到某个路由, 回调函数数组中的函数就会依次执行. 留意到每个方法都会返回对象本身, 也就是说注册路由的时候是可以支持链式调用的.
此外, 我们可以看到, 每个方法的核心其实还是 register 函数, 所以我们下面看看 register 函数的逻辑.
2.2 Router.prototype.register
register 是注册路由的核心函数, 举个例子, 如果我们需要注册一个路径为 ‘/test’ 的接收 GET 方法的路由, 那么:
js
router.get('/test', async (ctx, next) => );
其实它相当于下面这段代码:
router.register('/test', ['GET'], [async (ctx, next) => ], name: null );
我们可以看到, 函数将路由作为第一个参数传入, 然后方法名放入到方法数组中作为第二个参数, 第三个函数是路由的回调数组, 其实每个路由注册的时候, 后面都可以添加很多个函数, 而这些函数都会被添加到一个数组里面, 如果被匹配到, 就会利用中间件机制来逐个执行这些函数. 最后一个函数是将路由的命名空间传入.
这里避免篇幅过长, 不再陈列 register 函数的代码, 请移步 koa-router 源码仓库关于 register 函数部分 查看.
register 函数的逻辑其实也很简单, 因为核心的代码全部都交由 Layer 类去完成了, register 函数只是负责处理 path 如果是数组的话那么需要递归调用 register 函数, 然后新建一个 Layer 类的实例对象, 并且检查在注册这个路由之间有没有注册过 param 路由参数校验函数, 如果有的话, 那么就使用 Layer.prototype.param
函数将校验函数加入到路由的中间件函数数组前面.
2.3 Router.prototype.match
通过上面的模块, 我们已经注册好了路由, 但是, 如果请求过来了, 请求是怎么匹配然后进行到相对应的处理函数去的呢? 答案就是利用 match 函数.先看一下 match 函数的代码:
Router.prototype.match = function (path, method)
// 取所有路由 Layer 实例
var layers = this.stack;
var layer;
// 匹配结果
var matched =
path: [],
pathAndMethod: [],
route: false
;
// 遍历路由 Router 的 stack 逐个判断
for (var len = layers.length, i = 0; i < len; i++)
layer = layers[i];
debug('test %s %s', layer.path, layer.regexp);
// 这里是使用路由字符串生成的正则表达式判断当前路径是否符合该正则
if (layer.match(path))
// 将对应的 Layer 实例加入到结果集的 path 数组中
matched.path.push(layer);
// 如果对应的 layer 实例中 methods 数组为空或者数组中有找到对应的方法
if (layer.methods.length === 0 || ~layer.methods.indexOf(method))
// 将 layer 放入到结果集的 pathAndMethod 中
matched.pathAndMethod.push(layer);
// 这里是用于判断是否有真正匹配到路由处理函数
// 因为像 router.use(session()); 这样的中间件也是通过 Layer 来管理的, 它们的 methods 数组为空
if (layer.methods.length) matched.route = true;
return matched;
;
通过上面返回的结果集, 我们知道一个请求来临的时候, 我们可以使用正则来匹配路由是否符合, 然后在 path 数组或者 pathAndMethod 数组中找到对应的 Layer 实例对象.
2.4 Router.prototype.routes(middlewares)
如果根据一开始的 demo 例子, 在上面注册好了路由之后, 我们就可以使用 router.routes 来将路由模块添加到 koa 的中间件处理机制当中了. 由于 koa 的中间件插件是以一个函数的形式存在的, 所以 routes 函数返回值就是一个函数:
Router.prototype.routes = Router.prototype.middleware = function ()
var router = this;
var dispatch = function dispatch(ctx, next)
...
;
dispatch.router = this;
return dispatch;
;
我们可以看到返回的 dispatch 函数在 routes 内部形成了一个闭包, 并且按照 koa 的中间件形式编写函数.对于 dispatch 函数内部逻辑就如下:
var dispatch = function dispatch(ctx, next)
debug('%s %s', ctx.method, ctx.path);
var path = router.opts.routerPath || ctx.routerPath || ctx.path;
// 根据 path 值取的匹配的路由 Layer 实例对象
var matched = router.match(path, ctx.method);
var layerChain, layer, i;
if (ctx.matched)
ctx.matched.push.apply(ctx.matched, matched.path);
else
ctx.matched = matched.path;
ctx.router = router;
// 如果没有匹配到对应的路由模块, 那么就直接跳过下面的逻辑
if (!matched.route) return next();
// 取路径与方法都匹配了的 Layer 实例对象
var matchedLayers = matched.pathAndMethod
var mostSpecificLayer = matchedLayers[matchedLayers.length - 1]
ctx._matchedRoute = mostSpecificLayer.path;
if (mostSpecificLayer.name)
ctx._matchedRouteName = mostSpecificLayer.name;
// 构建路径对应路由的处理中间件函数数组
// 这里的目的是在每个匹配的路由对应的中间件处理函数数组前添加一个用于处理
// 对应路由的 captures, params, 以及路由命名的函数
layerChain = matchedLayers.reduce(function(memo, layer)
memo.push(function(ctx, next)
// captures 是存储路由中参数的值的数组
ctx.captures = layer.captures(path, ctx.captures);
// params 是一个对象, 键为参数名, 根据参数名可以获取路由中的参数值, 值从 captures 中拿
ctx.params = layer.params(path, ctx.captures, ctx.params);
ctx.routerName = layer.name;
return next();
);
return memo.concat(layer.stack);
, []);
// 使用 compose 模块将对应路由的处理中间件数组中的函数逐个执行
// 当路由的处理函数中间件函数全部执行完, 再调用上一层级的 next 函数进入下一个中间件
return compose(layerChain)(ctx, next);
;
2.5 Router.prototype.allowedMethod
对于 allowedMethod 方法来说, 它的作用就是用于处理请求的错误, 所以它作为路由模块的最后一个函数来执行.同样地, 它也是以一个 koa 的中间件插件函数的形式出现, 同样在函数内部形成了一个闭包:
Router.prototype.allowedMethods = function (options)
options = options || ;
var implemented = this.methods;
return function allowedMethods(ctx, next)
...
;
;
上面的代码很简单, 就是保存 Router 配置中允许的 HTTP 方法数组在闭包内部
return function allowedMethods(ctx, next)
// 从这里可以看出, allowedMethods 函数是用于在中间件机制中处理返回结果的函数
// 先执行 next 函数, next 函数返回的是一个 Promise 对象
return next().then(function()
var allowed = ;
// allowedMethods 函数的逻辑建立在 statusCode 没有设置或者值为 404 的时候
if (!ctx.status || ctx.status === 404)
// 这里的 matched 就是在 match 函数执行之后返回结果集中的 path 数组
// 也就是说请求路径与路由正则匹配的 layer 实例对象数组
ctx.matched.forEach(function (route)
// 将这些 layer 路由的 HTTP 方法存储起来
route.methods.forEach(function (method)
allowed[method] = method;
);
);
// 将上面的 allowed 整理为数组
var allowedArr = Object.keys(allowed);
// implemented 就是 Router 配置中的 methods 数组, 也就是允许的方法
// 这里通过 ~ 运算判断当前的请求方法是否在配置允许的方法中
// 如果该方法不被允许
if (!~implemented.indexOf(ctx.method))
// 如果 Router 配置中配置 throw 为 true
if (options.throw)
var notImplementedThrowable;
// 如果配置中规定了 throw 抛出错误的函数, 那么就执行对应的函数
if (typeof options.notImplemented === 'function')
notImplementedThrowable = options.notImplemented(); // set whatever the user returns from their function
else
// 如果没有则直接抛出 HTTP Error
notImplementedThrowable = new HttpError.NotImplemented();
// 抛出错误
throw notImplementedThrowable;
else
// Router 配置 throw 为 false
// 设置状态码为 501
ctx.status = 501;
// 并且设置 Allow 头部, 值为上面得到的允许的方法数组 allowedArr
ctx.set('Allow', allowedArr.join(', '));
else if (allowedArr.length)
// 来到这里说明该请求的方法是被允许的, 那么为什么会没有状态码 statusCode 或者 statusCode 为 404 呢?
// 原因在于除却特殊情况, 我们一般在业务逻辑里面不会处理 OPTIONS 请求的
// 发出这个请求一般常见就是非简单请求, 则会发出预检请求 OPTIONS
// 例如 application/json 格式的 POST 请求
// 如果是 OPTIONS 请求, 状态码为 200, 然后设置 Allow 头部, 值为允许的方法数组 methods
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(); // set whatever the user returns from their function
else
notAllowedThrowable = new HttpError.MethodNotAllowed();
throw notAllowedThrowable;
else
// 这里的状态码为 405
ctx.status = 405;
ctx.set('Allow', allowedArr.join(', '));
);
;
值得注意的是, Router.methods 数组里面的方法是服务端需要实现并支持的方法, 如果客户端发送过来的请求方法不被允许, 那么这是一个服务端错误 501, 但是如果这个方法被允许, 但是找不到对应这个方法的路由处理函数(比如相同路由的 POST 路由但是用 GET 方法来获取数据), 这是一个客户端错误 405.
2.6 Router.prototype.use
use 函数就是用于添加中间件的, 只不过不同于 koa 中的 use 函数, router 的 use 函数添加的中间件函数会在所有路由执行之前执行.此外, 它还可以对某些特定路径的进行中间件函数的绑定执行.
Router.prototype.use = function ()
var router = this;
// 中间件函数数组
var middleware = Array.prototype.slice.call(arguments);
var path;
// 支持同时为多个路由绑定中间件函数: router.use(['/use', '/admin'], auth());
if (Array.isArray(middleware[0]) && typeof middleware[0][0] === 'string')
middleware[0].forEach(function (p)
// 递归调用
router.use.apply(router, [p].concat(middleware.slice(1)));
);
// 直接返回, 下面是非数组 path 的逻辑
return this;
// 如果第一个参数有传值为字符串, 说明有传路径
var hasPath = typeof middleware[0] === 'string';
if (hasPath)
path = middleware.shift();
middleware.forEach(function (m)
// 如以上是关于koa-router源码解析的主要内容,如果未能解决你的问题,请参考以下文章