解读并实现一个简单的koa-router

Koa 应用程序是一个包含一组中间件函数的对象,它是按照类似堆栈的方式组织和执行的。

这是 koa 对自己的介绍,其他 koa 依赖的库其实都可以算是中间件,koa-router 也不例外。

ps: 本文代码中的中文解释是对代码的讲解,省略号(...)代表省略部分代码
文章最后有简版router的项目地址

对 koa-router 的猜想

通过 koa 最简单的 hellow world 例子可以看出原生对请求的处理方式:

const Koa = require('koa');
const app = new Koa();

app.use(async ctx => {
  ctx.body = 'Hello World';
});

app.listen(3000);

要是我们想简单的实现路由的话,可以添加一些判断条件

app.use(async ctx => {
  if (ctx.path === '/one' && ctx.method === 'get') {
    ctx.body = 'Hello World';
  } else {
    ctx.status = 404;
    ctx.body = '';
  }
});

这样的话能实现简单对路由的实现,不过路由越多的话消耗的性能也就越大,而且不容易对特殊路由添加中间件。而更好的方法是使用面向对象的方式,根据请求的 path 和 method 返回相应的中间件处理函数和执行函数。

解读思路

这里要介绍下我解读 koa-router 源码的方法,我会先把 koa-router 的源码下载到本地,然后通读一遍(因为源码算是比较少的),从大体上知道 koa-router 执行流程,然后通过单元测试去 debug 分析。

Router 执行流程图

koa-router 流程.png

我认为 koa-router 最基本且核心的API有四个:

  1. router.match
    可以根据请求的 path 和 method 筛选出匹配的 route
  2. router.register
    注册 route
  3. router.routes
    返回用于 koa 加载的中间件,通过 koa-compose 将middlewares 压缩成一个函数
  4. router.method(get、post等)
    可以根据path、method 定义 router,并且可以将middleware绑定在路由上

解读

我们可以结合代码和单元测试对源码进行理解,由最简单的测试开始debug:

it('router can be accecced with ctx', function (done) {
      var app = new Koa();
      var router = new Router();
      router.get('home', '/', function (ctx) {
          ctx.body = {
            url: ctx.router.url('home')
          };
      });

      console.log(router.routes()); // 这是我加的,查看最后加载的routes
      app.use(router.routes());
      request(http.createServer(app.callback()))
          .get('/')
          .expect(200)
          .end(function (err, res) {
              if (err) return done(err);
              expect(res.body.url).to.eql("/");
              done();
          });
  });

router.routes() 返回:

function dispatch(ctx, next) {
    debug('%s %s', ctx.method, ctx.path);
    var path = router.opts.routerPath || ctx.routerPath || ctx.path;
    var matched = router.match(path, ctx.method);
    var layerChain, layer, i;
    ...
    ctx.router = router;
    if (!matched.route) return next();
    // 获取已匹配的 routes (实例化 Layer 对象)
    var matchedLayers = matched.pathAndMethod
    ...
    // 若匹配了多个 route,则将多个执行函数 push 进一个数组
    layerChain = matchedLayers.reduce(function(memo, layer) {
      ...
      return memo.concat(layer.stack);
    }, []);

    return compose(layerChain)(ctx, next);
  }

router.routes() 返回一个 dispatch 函数,从中可以看出请求进来会经过 router.match(后面有分析),然后将匹配到的 route 的执行函数 push 进数组,并通过 compose(koa-compose) 函数合并返回。

然后在打印出 compose(layerChain) 方法,可以看到其实最后请求执行的函数是对ctx.body = {url: ctx.router.url('home')}; 的 compose 封装函数,在效果上相当于

app.use(ctx => {
  ctx.body = {
    url: ctx.router.url('home')
  };
});
  • 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 = {};
  // 初始化定义 route 栈
  this.stack = [];
};
  • 分析 router.method 方法
// methods ['get', 'post', 'delete', 'put', 'patch', ...]
methods.forEach(function (method) {
  Router.prototype[method] = function (name, path, middleware) {
    var middleware;

    if (typeof path === 'string' || path instanceof RegExp) {
      // 若第二个参数是 string 或 正则表达式,则将后面的参数归为 middleware
      middleware = Array.prototype.slice.call(arguments, 2);
    } else {
      // 否则说明没有传 name 参数,将第一个参数置为path,之后的参数归为 middleware
      middleware = Array.prototype.slice.call(arguments, 1);
      path = name;
      name = null;
    }

    // 注册 route(下面会讲到 register 方法)
    this.register(path, [method], middleware, {
      name: name
    });
    
    // 返回 Router 对象,可以链式调用
    return this;
  };
});
  • 分析 router.register 方法
Router.prototype.register = function (path, methods, middleware, opts) {
  opts = opts || {};

  var stack = this.stack;
  ...
  // create route
  // 实例化一个 Layer 对象,Layer 对象将 path 转为 regexp,并增加了匹配 path 的可选 ops 参数
  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
  });

  console.log(route);
  /**
   * Layer {
   * ...省略部分属性
   * methods: [ 'HEAD', 'GET' ],
   * stack: [ [Function] ],
   * path: '/',
   * regexp: { /^(?:\/(?=$))?$/i keys: [] } } // 用于匹配 path
   */
  ...
  // 将注册的 route 存放在 stack 队列中
  stack.push(route);

  return route;
};

register 方法主要用于实例化 Layer 对象,并支持多各 path 同时注册、添加路由前缀等功能(展示代码忽略)。

  • 分析 router.match
Router.prototype.match = function (path, method) {
  // 获取已经注册的 routes (实例化Layer对象)
  var layers = this.stack;
  var layer;
  var matched = {
    path: [],
    pathAndMethod: [],
    route: false
  };

  // 循环查找能够匹配的route
  for (var len = layers.length, i = 0; i < len; i++) {
    layer = layers[i];

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

    // 根据layer.regexp.test(path) 匹配
    if (layer.match(path)) {
      matched.path.push(layer);

      // todo ~操作符暂时没懂
      if (layer.methods.length === 0 || ~layer.methods.indexOf(method)) {
        matched.pathAndMethod.push(layer);
        // 将匹配标志 route 设为 true,这里我觉得改为 hitRoute 更容易理解
        if (layer.methods.length) matched.route = true;
      }
    }
  }

  return matched;
};

实现简版Router

通过上面的分析,其实已经讲解了 koa-router 核心的部分:构造 Router 对象 => 定义 router 入口 => 匹配路由 => 合并中间件和执行函数输出;这4个API可以处理简单的 restful 请求,额外的API例如重定向、router.use、路由前缀等在了解核心代码后阅读起来就简单很多了;简版其实就是上面api的精简版,原理一致,可以到我的项目看下
simple-koa-router:https://github.com/masongzhi/simple-koa-router

总结

koa-router 帮我们定义并选择相应的路由,对路由添加中间件和一些兼容和验证的工作;在 koa 中间件应用的基础上,比较容易理解中间件的实现,koa-router 为我们做了更好的路由层管理,在设计上可以参考实现,同时研究优美源码也是对自己的一种提升。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,817评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,329评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,354评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,498评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,600评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,829评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,979评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,722评论 0 266
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,189评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,519评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,654评论 1 340
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,329评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,940评论 3 313
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,762评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,993评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,382评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,543评论 2 349

推荐阅读更多精彩内容