koa-router源码解析

261 阅读8分钟

前言

最近需要做Node中间层的需求,优雅的名字是back-end for front-end(BEF),实现前后端彻底解耦。BFE由来已久,服务端服务前端的一种逻辑分层模式,这种分层模式由来已久。在做中间层API接口开发时,选用Koa作为创建Node服务的JavaScript库,因此在各种文档中经常见到路由挂载的代码。

const Koa = require('koa');
const Router = require('koa-router');

const app = new Koa();
const router = new Router();

router
  .get('/', async (ctx, next) => {
    // do something
  })
  .post('/login', async (ctx, next) => {
    // do something
  });

app
  .use(router.routes())
  .use(router.allowedMethods());

app.listen(3000, () => {
  console.log('server is runnung: http://localhost:3000');
});

在进行路由挂载的时候一直很好奇:

  • new Router()到底做了什么呢?
  • router如何实现具体路径匹配到置顶函数的?
  • router.routes()到底做了什么呢?
  • router.allowedMethods()到底做了什么呢?
  • 能不能不要写这样的.get/.put的样板代码进行路由挂载,而通过一个router-config.js文件生成server端路由呢?

基础

再搞懂这些问题首先我们需要明确亮点:1、没有Koa使用nodehttp模块如何使用路由;2、Koa是中间件机制的(时刻记住,Koa实现各种功能就是执行对应的moddleware)。

// 使用 http 模块实现路由
const http = require('http');
const { parse } = require('url');

function callback(req, res) {
  // 根据req.url执行对应的action
  const { pathname } = parse(req.url);
  switch (pathname) {
    case '/':
      // do something
      break;
      ...
    default:
      break;
  }
};
const server = http.createServer(callback);
// 使用事件监听执行callback
server.on('request', callback);
server.listen(3000);

// 使用 koa-router 实现路由
const Koa = require('koa');
const KoaRouter = require('koa-router');

const app = new Koa();
// 创建 router 实例对象
const router = new KoaRouter();

//注册路由
router.get('/', async (ctx, next) => {
  ctx.body = {
    code: 200,
    msg: 'success',
    data: 'index'
  };
});

app.use(router.routes());  // 添加路由中间件
app.use(router.allowedMethods()); // 对请求进行一些限制处理

app.listen(3000);

而且我们知道Koa实际上就是将中间件应用在callback中,每次request进来时执行一次所有的中间件函数,从而响应对应的请求。也就是说,其实koa-router也是在对应的路由匹配到之后,执行相应的中间件函数,从而响应对应的请求的。因此我们可以大胆的猜测一下,.get/.put等方法是实现路由和操作函数的绑定,router.routes()是将路由中间件进行注册到Koamiddleware数组中等待执行,router.allowedMethods()是执行一些异常兜底操作的。

源码架构

koa-router的源码很简单主要是lib目录下的两个文件layer.jsrouter.js

koa-router源码目录
首先我们看一下入口文件router.js文件做了什么。

// 依赖文件导入
var debug = require('debug')('koa-router');
var compose = require('koa-compose');
var HttpError = require('http-errors');
var methods = require('methods');
var Layer = require('./layer');
// 导出 Router
module.exports = Router;
// 声明 Router
function Router(opts) {
  // do somethig
}
// 挂载对应操作方法
for (var i = 0; i < methods.length; i++) {
  // do somethig
}
// 为 delete 方法声明一个别名 del
Router.prototype.del = Router.prototype['delete'];
// 为router应用middleware
Router.prototype.use = function () {
  // do somethig
}
// 设置路由前缀
Router.prototype.prefix = function (prefix) {
  // do somethig
}
// 路由中间件,这里也看到了 .routes 实际是 .middleware 的别名
Router.prototype.routes = Router.prototype.middleware = function () {
  // do somethig
}
// 中间件的异常处理逻辑和兜底操作
Router.prototype.allowedMethods = function (options) {
  // do somethig
}
// 提供便捷的方法一次注册所有的 methods 方法
Router.prototype.all = function (name, path, middleware) {
  // do something
}
// 路由重定向
Router.prototype.redirect = function (source, destination, code) {
  // do something
}
// 将路由路径与操作函数绑定
Router.prototype.register = function (path, methods, middleware, opts) {
  // do something
}
// 通过name查询命名路由
Router.prototype.route = function (name) {
  // do something
}
// 路由拼装
Router.prototype.url = function (name, params) {
  // do something
}
// 根据path和method匹配到对应到路由中间件
Router.prototype.match = function (path, method) {
  // do something
}
// 对路由添加校验中间件
Router.prototype.param = function(param, middleware) {
  // do something
}
// 为类Router添加静态路由拼装方法
Router.url = function (path) {
  // do something
}

由此koa-router的源码架构如下:

`koa-router`的源码架构

源码解析

  1. 实例化Router&Layer对象 进行路由操作之前首先需要实例化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 = [];
}

可以看到Router实际有用的属性包含三个methods数组,params对象,stack数组。methods数组存储http方法可用方法名,在allowMethods方法会使用到;params对象,存储的是键为参数名与值为对应的参数校验函数,这样是为了通过在全局存储参数的校验函数,方便在注册路由的时候为路由的中间件函数数组添加校验函数,在param方法会使用到;stack数组,则是存储每一个路由,也就是Layer的实例对象,每一个路由都相当于一个Layer实例对象。

对于Layer对象,使用一个实例化的Layer管理每一个路由:

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];

  for(var i = 0; i < methods.length; i++) {
    var l = this.methods.push(methods[i].toUpperCase());
    if (this.methods[l-1] === 'GET') {
     this.methods.unshift('HEAD');
    }
  }

  // ensure middleware is a function
  for (var i = 0; i < this.stack.length; i++) {
    var fn = this.stack[i];
    var type = (typeof fn);
    if (type !== 'function') {
      throw new Error(
        methods.toString() + " `" + (this.opts.name || path) +"`: `middleware` "
        + "must be a function, not `" + type + "`"
      );
    }
  }

  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转化为正则表达式用于匹配请求的路由,然后将路由的处理中间件添加到Layerstack数组中。

  1. method相关函数 method函数将对应的路中处理中间件进行注册,其中核心逻辑还在是this.register这个函数中。
for (var i = 0; i < methods.length; i++) {
  function setMethodVerb(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;
    };
  }
  setMethodVerb(methods[i]);
}

register函数对于每一个路由实例化一个Layer对象存储在router.stack数组理进行管理。其中Layerstack数组存储中每一个路由的中间件函数。

Router.prototype.register = function (path, methods, middleware, opts) {
  opts = opts || {};

  var router = this;
  var stack = this.stack;

  // support array of paths
  if (Array.isArray(path)) {
    for (var i = 0; i < path.length; i++) {
      var curPath = path[i];
      router.register.call(router, curPath, methods, middleware, opts);
    }

    return this;
  }

  // create route
  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);
  }

  // add parameter middleware
  for (var i = 0; i < Object.keys(this.params).length; i++) {
    var param = Object.keys(this.params)[i];
    route.param(param, this.params[param]);
  }

  stack.push(route);

  return route;
}
  1. Router.prototype.match 通过上面的模块注册好路由之后,当请求打进来之后就是通过match函数找到匹配路由中间件函数。
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];

    debug('test %s %s', layer.path, layer.regexp);

    if (layer.match(path)) {
      matched.path.push(layer);

      if (layer.methods.length === 0 || ~layer.methods.indexOf(method)) {
        matched.pathAndMethod.push(layer);
        if (layer.methods.length) matched.route = true;
      }
    }
  }

  return matched;
}
  1. Router.prototype.routes 使用router.routes将路由模块添加到koa的中间件处理机制当中,因此routes需要符合Koa的中间件机制。
Router.prototype.routes = Router.prototype.middleware = function () {
  var router = this;

  var dispatch = function dispatch(ctx, next) {};
  
  dispatch.router = this;

  return dispatch;
}

routes方法通过return dispatch进入到Koa的中间件执行机制中,并在dispatch方法中return compose(layerChain)(ctx, next);将每个路由注册的中间件处理函数进行执行。 5. Router.prototype.allowedMethod allowedMethod方法是处理错误请求的,需要进入到Koa中间件机制中,因此其也是返回一个中间处理函数。

Router.prototype.allowedMethods = function (options) {
  options = options || {};
  var implemented = this.methods;

  return function allowedMethods(ctx, next) {}
}
  1. Router.prototype.use use方法是为router添加中间件处理函数的,且该函数是在所有路由执行之前执行。
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;
}

由此可以看出整个路由执行的过程如下:

router流程图

解疑

koa-router源码Layer.js中有一段代码如下:

if (this.methods[l-1] === 'GET') {
  this.methods.unshift('HEAD');
}

可以看到在Layer的构建函数里面,在对于methods的处理中,会进行判断如果该请求为GET请求,那么就需要在GET请求前面添加一个HEAD方法,其原因在于HEAD 方法与GET方法基本是一致的,所以koa-router在处理GET请求的时候顺带将HEAD 请求一并处理,因为两者的区别在于HEAD请求没有相应数据具体内容。