解讀並實現一個簡單的koa-router

天驅發表於2018-04-11

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:github.com/masongzhi/s…

總結

koa-router 幫我們定義並選擇相應的路由,對路由新增中介軟體和一些相容和驗證的工作;在 koa 中介軟體應用的基礎上,比較容易理解中介軟體的實現,koa-router 為我們做了更好的路由層管理,在設計上可以參考實現,同時研究優美原始碼也是對自己的一種提升。

相關文章