koa-router源码解析

大众点评的node框架Node-Server最大的特点是面向企业级 Web 全栈应用框架, 以Koa2为基础, 集成了架构中间件Pigeon、 Lion、 Cat、 Mapi、 Rhino等Node客户端,支持了Node Thrift。最大程度上帮助应用在 Web 开发中提高可维护性和扩展性。其中router路由的解析主要是依赖于koa-router, 依赖于koa-router的router(), match(), stacklayer等能力,下面进行koa-router源码的解析。node

koa-router原理解析

koa 框架一直都保持着简洁性, 它只对 node 的 HTTP 模块进行了封装, 而在真正实际使用, 咱们还须要更多地像xxx-router路由这样的模块来构建咱们的应用,正则表达式

koa-router 是经常使用的 koa 的路由库. 经过解析 koa-router 的源码来达到深刻学习的目的.json

1.深刻浅出koa-router

咱们知道,在 node 原生里面, 若是咱们须要实现路由功能, 那么就能够像下面这样编写代码:api

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 的分支也会越庞大, 不利于代码的维护与多人合做.所以,咱们须要一个特定的路由模块来统一地模块化地解决路由功能的问题.bash

若是是使用 koa-router 的话, 那么能够借助下面的代码来简单创建一个 koa-router 库的使用 demo:session

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 接口以适配不一样的业务需求, 那么接下来就须要注册对应的路由:app

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 类来讲, 建立一个实例对象用于管理每一个路由:

JavaScript
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:

JavaScript
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 方法的路由, 那么:

JavaScript
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) {
    // 若是有 router 属性, 说明这个中间件函数是由 Router.prototype.routes 暴露出来的
    // 属于嵌套路由
    if (m.router) {
      // 这里的逻辑颇有意思, 若是是嵌套路由, 至关于将须要嵌套路由从新注册到如今的 Router 对象上
      m.router.stack.forEach(function (nestedLayer) {
        // 若是有 path, 那么为须要嵌套的路由加上路径前缀
        if (path) nestedLayer.setPrefix(path);
        // 若是自己的 router 有前缀配置, 也添加上
        if (router.opts.prefix) nestedLayer.setPrefix(router.opts.prefix);
        // 将须要嵌套的路由模块的 stack 中存储的 Layer 加入到本 router 对象上
        router.stack.push(nestedLayer);
      });
      // 这里与 register 函数的逻辑相似, 注册的时候检查添加参数校验函数 params
      if (router.params) {
        Object.keys(router.params).forEach(function (key) {
          m.router.param(key, router.params[key]);
        });
      }
    } else {
      // 没有 router 属性则是常规中间件函数, 若是有给定的 path 那么就生成一个 Layer 模块进行管理
      // 若是没有 path, 那么就生成通配的路径 (.*) 来生成 Layer 来管理
      router.register(path || '(.*)', [], m, { end: false, ignoreCaptures: !hasPath });
    }
  });
​
  return this;
};
复制代码

经过上面咱们就清楚, 在 koa-router 里面, 它将全部的路由与全部路由都适用的中间件函数都看作 Layer, 经过 Layer 来处理, 而后将他们的回调函数存储在 Layer 实例自己的 stack 数组中, 而后全局的 router 实例对象的 stack 数组存放全部的 Layer 达到全局管理的目的.

3. router 处理请求的流程

上面就是 koa-router 的核心 API, 下面咱们经过一张图来总结一下, 看一下当一个请求来临, koa-router 是如何处理的:

附录

为何须要在 GET 请求放一个 HEAD 请求 ? 咱们能够看到在 Layer 的构建函数里面, 在对于 methods 的处理中, 会进行判断若是该请求为 GET 请求, 那么就须要在 GET 请求前面添加一个 HEAD 方法, 其缘由在于 HEAD 方法与 GET 方法基本是一致的, 因此 koa-router 在处理 GET 请求的时候顺带将 HEAD 请求一并处理, 由于二者的区别在于 HEAD 请求不响应数据体.

相关文章
相关标签/搜索