玩轉Koa -- koa-router原理解析

descire發表於2018-12-27

一、前言

  Koa為了保持自身的簡潔,並沒有捆綁中介軟體。但是在實際的開發中,我們需要和形形色色的中介軟體打交道,本文將要分析的是經常用到的路由中介軟體 -- koa-router。

  如果你對Koa的原理還不瞭解的話,可以先檢視Koa原理解析

二、koa-router概述

  koa-router的原始碼只有兩個檔案:router.js和layer.js,分別對應Router物件和Layer物件。

  Layer物件是對單個路由的管理,其中包含的資訊有路由路徑(path)、路由請求方法(method)和路由執行函式(middleware),並且提供路由的驗證以及params引數解析的方法。

  相比較Layer物件,Router物件則是對所有註冊路由的統一處理,並且它的API是面向開發者的。

  接下來從以下幾個方面全面解析koa-router的實現原理:

  • Layer物件的實現
  • 路由註冊
  • 路由匹配
  • 路由執行流程

三、Layer

  Layer物件主要是對單個路由的管理,是整個koa-router中最小的處理單元,後續模組的處理都離不開Layer中的方法,這正是首先介紹Layer的重要原因。

function Layer(path, methods, middleware, opts) {
  this.opts = opts || {};
  // 支援路由別名
  this.name = this.opts.name || null;
  this.methods = [];
  this.paramNames = [];
  // 將路由執行函式儲存在stack中,支援輸入多個處理函式
  this.stack = Array.isArray(middleware) ? middleware : [middleware];

  methods.forEach(function(method) {
    var l = this.methods.push(method.toUpperCase());
    // HEAD請求頭部資訊與GET一致,這裡就一起處理了。
    if (this.methods[l-1] === 'GET') {
      this.methods.unshift('HEAD');
    }
  }, this);

  // 確保型別正確
  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;
  // 1、根據路由路徑生成路由正規表示式
  // 2、將params引數資訊儲存在paramNames陣列中
  this.regexp = pathToRegExp(path, this.paramNames, this.opts);
};
複製程式碼

  Layer建構函式主要用來初始化路由路徑、路由請求方法陣列、路由處理函式陣列、路由正規表示式以及params引數資訊陣列,其中主要採用path-to-regexp方法根據路徑字串生成正規表示式,通過該正規表示式,可以實現路由的匹配以及params引數的捕獲:

// 驗證路由
Layer.prototype.match = function (path) {
  return this.regexp.test(path);
}

// 捕獲params引數
Layer.prototype.captures = function (path) {
  // 後續會提到 對於路由級別中介軟體 無需捕獲params
  if (this.opts.ignoreCaptures) return [];
  return path.match(this.regexp).slice(1);
}
複製程式碼

  根據paramNames中的引數資訊以及captrues方法,可以獲取到當前路由params引數的鍵值對:

Layer.prototype.params = function (path, captures, existingParams) {
  var params = existingParams || {};
  for (var len = captures.length, i=0; i<len; i++) {
    if (this.paramNames[i]) {
      var c = captures[i];
      params[this.paramNames[i].name] = c ? safeDecodeURIComponent(c) : c;
    }
  }
  return params;
};
複製程式碼

  需要注意上述程式碼中的safeDecodeURIComponent方法,為了避免伺服器收到不可預知的請求,對於任何使用者輸入的作為URI部分的內容都需要採用encodeURIComponent進行轉義,否則當使用者輸入的內容中含有'&'、'='、'?'等字元時,會出現預料之外的情況。而當我們獲取URL上的引數時,則需要通過decodeURIComponent進行解碼,而decodeURIComponent只能解碼由encodeURIComponent方法或者類似方法編碼,如果編碼方法不符合要求,decodeURIComponent則會丟擲URIError,所以作者在這裡對該方法進行了安全化的處理:

function safeDecodeURIComponent(text) {
  try {
    return decodeURIComponent(text);
  } catch (e) {
    // 編碼方式不符合要求,返回原字串
    return text;
  }
}
複製程式碼

  Layer還提供了對於單個param前置處理的方法:

Layer.prototype.param = function (param, fn) {
  var stack = this.stack;
  var params = this.paramNames;
  var middleware = function (ctx, next) {
    return fn.call(this, ctx.params[param], ctx, next);
  };
  middleware.param = param;
  var names = params.map(function (p) {
    return p.name;
  });
  var x = names.indexOf(param);
  if (x > -1) {
    stack.some(function (fn, i) {
      if (!fn.param || names.indexOf(fn.param) > x) {
        // 將單個param前置處理函式插入正確的位置
        stack.splice(i, 0, middleware);
        return true; // 跳出迴圈
      }
    });
  }

  return this;
};
複製程式碼

  上述程式碼中通過some方法尋找單個param處理函式的原因在於以下兩點:

  • 保持param處理函式位於其他路由處理函式的前面;
  • 路由中存在多個param引數,需要保持param處理函式的前後順序。
Layer.prototype.setPrefix = function (prefix) {
  if (this.path) {
    this.path = prefix + this.path; // 拼接新的路由路徑
    this.paramNames = [];
    // 根據新的路由路徑字串生成正規表示式
    this.regexp = pathToRegExp(this.path, this.paramNames, this.opts);
  }
  return this;
};
複製程式碼

  Layer中的setPrefix方法用於設定路由路徑的字首,這在巢狀路由的實現中尤其重要。

  最後,Layer還提供了根據路由生成url的方法,主要採用path-to-regexp的compile和parse對路由路徑中的param進行替換,而在拼接query的環節,正如前面所說需要對鍵值對進行繁瑣的encodeURIComponent操作,作者採用了urijs提供的簡潔api進行處理。

四、路由註冊

1、Router建構函式

  首先看了解一下Router建構函式:

function Router(opts) {
  if (!(this instanceof Router)) {
    // 限制必須採用new關鍵字
    return new Router(opts);
  }

  this.opts = opts || {};
  // 伺服器支援的請求方法, 後續allowedMethods方法會用到
  this.methods = this.opts.methods || [
    'HEAD',
    'OPTIONS',
    'GET',
    'PUT',
    'PATCH',
    'POST',
    'DELETE'
  ];

  this.params = {}; // 儲存param前置處理函式
  this.stack = []; // 儲存layer
};
複製程式碼

  在建構函式中初始化的params和stack屬性最為重要,前者用來儲存param前置處理函式,後者用來儲存例項化的Layer物件。並且這兩個屬性與接下來要講的路由註冊息息相關。

  koa-router中提供兩種方式註冊路由:

  • 具體的HTTP動詞註冊方式,例如:router.get('/users', ctx => {})
  • 支援所有的HTTP動詞註冊方式,例如:router.all('/users', ctx => {})
2、http METHODS

  原始碼中採用methods模組獲取HTTP請求方法名,該模組內部實現主要依賴於http模組:

http.METHODS && http.METHODS.map(function lowerCaseMethod (method) {
  return method.toLowerCase()
})
複製程式碼
3、router.verb() and router.all()

  這兩種註冊路由的方式的內部實現基本類似,下面以router.verb()的原始碼為例:

methods.forEach(function (method) {
  Router.prototype[method] = function (name, path, middleware) {
    var middleware;

    // 1、處理是否傳入name引數
    // 2、middleware引數支援middleware1, middleware2...的形式
    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;
  };
});
複製程式碼

  該方法第一部分是對傳入引數的處理,對於middleware引數的處理會讓大家聯想到ES6中的rest引數,但是rest引數與arguments其中一個致命的區別:

  rest引數只包含那些沒有對應形參的實參,而arguments則包含傳給函式的所有實參。
複製程式碼

  如果採用rest引數的方式,上述函式則必須要求開發者傳入name引數。但是也可以將name和path引數整合成物件,再結合rest引數:

Router.prototype[method] = function (options, ...middleware) {
  let { name, path } = options
  if (typeof options === 'string' || options instanceof RegExp) {
    path = options
    name = null
  }
  // ...
  return this;
};
複製程式碼

  採用ES6的新特性,程式碼變得簡潔多了。

  第二部分是register方法,傳入的method引數的形式就是router.verb()與router.all()的最大區別,在router.verb()中傳入的method是單個方法,後者則是以陣列的形式傳入HTTP所有的請求方法,所以對於這兩種註冊方法的實現,本質上是沒有區別的。

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

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

  // 註冊路由中介軟體時,允許path為陣列
  if (Array.isArray(path)) {
    path.forEach(function (p) {
      router.register.call(router, p, methods, middleware, opts);
    });
    return this;
  }

  // 例項化Layer
  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);
  }

  // 設定param前置處理函式
  Object.keys(this.params).forEach(function (param) {
    route.param(param, this.params[param]);
  }, this);

  stack.push(route);

  return route;
};

複製程式碼

  register方法主要負責例項化Layer物件、更新路由字首和前置param處理函式,這些操作在Layer中已經提及過,相信大家應該輕車熟路了。

5、use

  熟悉Koa的同學都知道use是用來註冊中介軟體的方法,相比較Koa中的全域性中介軟體,koa-router的中介軟體則是路由級別的。

Router.prototype.use = function () {
  var router = this;
  var middleware = Array.prototype.slice.call(arguments);
  var path;

  // 支援多路徑在於中介軟體可能作用於多條路由路徑
  if (Array.isArray(middleware[0]) && typeof middleware[0][0] === 'string') {
    middleware[0].forEach(function (p) {
      router.use.apply(router, [p].concat(middleware.slice(1)));
    });

    return this;
  }
  // 處理路由路徑引數
  var hasPath = typeof middleware[0] === 'string';
  if (hasPath) {
    path = middleware.shift();
  }

  middleware.forEach(function (m) {
    // 巢狀路由
    if (m.router) {
      // 巢狀路由扁平化處理
      m.router.stack.forEach(function (nestedLayer) {
        // 更新巢狀之後的路由路徑
        if (path) nestedLayer.setPrefix(path);
        // 更新掛載到父路由上的路由路徑
        if (router.opts.prefix) nestedLayer.setPrefix(router.opts.prefix);

        router.stack.push(nestedLayer);
      }); 

      // 不要忘記將父路由上的param前置處理操作 更新到新路由上。
      if (router.params) {
        Object.keys(router.params).forEach(function (key) {
          m.router.param(key, router.params[key]);
        });
      }
    } else {
      // 路由級別中介軟體 建立一個沒有method的Layer例項
      router.register(path || '(.*)', [], m, { end: false, ignoreCaptures: !hasPath });
    }
  });

  return this;
};
複製程式碼

  koa-router中介軟體註冊方法主要完成兩項功能:

  • 將路由巢狀結構扁平化,其中涉及到路由路徑的更新和param前置處理函式的插入;
  • 路由級別中介軟體通過註冊一個沒有method的Layer例項進行管理。

五、路由匹配

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];
    if (layer.match(path)) {
      // 路由路徑滿足要求
      matched.path.push(layer);

      if (layer.methods.length === 0 || ~layer.methods.indexOf(method)) {
        // layer.methods.length === 0 該layer為路由級別中介軟體
        // ~layer.methods.indexOf(method) 路由請求方法也被匹配
        matched.pathAndMethod.push(layer);
        // 僅當路由路徑和路由請求方法都被滿足才算是路由被匹配
        if (layer.methods.length) matched.route = true;
      }
    }
  }
  return matched;
};
複製程式碼

  match方法主要通過layer.match方法以及methods屬性對layer進行篩選,返回的matched物件包含以下幾個部分:

  • path: 儲存所有路由路徑被匹配的layer;
  • pathAndMethod: 在路由路徑被匹配的前提下,儲存路由級別中介軟體和路由請求方法被匹配的layer;
  • route: 僅當存在路由路徑和路由請求方法都被匹配的layer,才能算是本次路由被匹配上。

  另外,在ES7之前,對於判斷陣列是否包含一個元素,都需要通過indexOf方法來實現, 而該方法返回元素的下標,這樣就不得不通過與-1的比較得到布林值:

  if (layer.methods.indexOf(method) > -1) {
    ...
  }
複製程式碼

  而作者巧妙地利用位運算省去了“討厭的-1”,當然在ES7中可以愉快地使用includes方法:

  if (layer.methods.includes(method)) {
    ...
  }
複製程式碼

六、路由執行流程

  理解koa-router中路由的概念以及路由註冊的方式,接下來就是如何作為一箇中介軟體在koa中執行。

  koa中註冊koa-router中介軟體的方式如下:

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

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

router.get('/', (ctx, next) => {
  // ctx.router available
});

app
  .use(router.routes())
  .use(router.allowedMethods());
複製程式碼

  從程式碼中可以看出koa-router提供了兩個中介軟體方法:routes和allowedMethods。

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

  return function allowedMethods(ctx, next) {
    return next().then(function() {
      var allowed = {};

      if (!ctx.status || ctx.status === 404) {
        ctx.matched.forEach(function (route) {
          route.methods.forEach(function (method) {
            allowed[method] = method;
          });
        });

        var allowedArr = Object.keys(allowed);

        if (!~implemented.indexOf(ctx.method)) {
          // 伺服器不支援該方法的情況
          if (options.throw) {
            var notImplementedThrowable;
            if (typeof options.notImplemented === 'function') {
              notImplementedThrowable = options.notImplemented();
            } else {
              notImplementedThrowable = new HttpError.NotImplemented();
            }
            throw notImplementedThrowable;
          } else {
            // 響應 501 Not Implemented
            ctx.status = 501;
            ctx.set('Allow', allowedArr.join(', '));
          }
        } else if (allowedArr.length) {
          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();
              } else {
                notAllowedThrowable = new HttpError.MethodNotAllowed();
              }
              throw notAllowedThrowable;
            } else {
              // 響應 405 Method Not Allowed
              ctx.status = 405;
              ctx.set('Allow', allowedArr.join(', '));
            }
          }
        }
      }
    });
  };
};
複製程式碼

  allowedMethods()中介軟體主要用於處理options請求,響應405和501狀態。上述程式碼中的ctx.matched中儲存的正是前面matched物件中的path(在routes方法中設定,後面會提到。),在matched物件中的path陣列不為空的前提條件下:

  • 伺服器不支援當前請求方法,返回501狀態碼;
  • 當前請求方法為OPTIONS,返回200狀態碼;
  • path中的layer不支援該方法,返回405狀態;
  • 對於上述三種情況,伺服器都會設定Allow響應頭,返回該路由路徑上支援的請求方法。
2、routes()
Router.prototype.routes = Router.prototype.middleware = function () {
  var router = this;
  // 返回中介軟體處理函式
  var dispatch = function dispatch(ctx, next) {
    var path = router.opts.routerPath || ctx.routerPath || ctx.path;
    var matched = router.match(path, ctx.method);
    var layerChain, layer, i;

    // 【1】為後續的allowedMethods中介軟體準備
    if (ctx.matched) {
      ctx.matched.push.apply(ctx.matched, matched.path);
    } else {
      ctx.matched = matched.path;
    }

    ctx.router = router;

    // 未匹配路由 直接跳過
    if (!matched.route) return next();

    var matchedLayers = matched.pathAndMethod
    var mostSpecificLayer = matchedLayers[matchedLayers.length - 1]
    ctx._matchedRoute = mostSpecificLayer.path;
    if (mostSpecificLayer.name) {
      ctx._matchedRouteName = mostSpecificLayer.name;
    }
    layerChain = matchedLayers.reduce(function(memo, layer) {
      // 【3】路由的前置處理中介軟體 主要負責將params、路由別名以及捕獲陣列屬性掛載在ctx上下文物件中。
      memo.push(function(ctx, next) {
        ctx.captures = layer.captures(path, ctx.captures);
        ctx.params = layer.params(path, ctx.captures, ctx.params);
        ctx.routerName = layer.name;
        return next();
      });
      return memo.concat(layer.stack);
    }, []);
    // 【4】利用koa中介軟體組織的方式,形成一個‘小洋蔥’模型
    return compose(layerChain)(ctx, next);
  };

  // 【2】router屬性用來use方法中區別路由級別中介軟體
  dispatch.router = this;
  return dispatch;
};
複製程式碼

  routes()中介軟體主要實現了四大功能。

  • 將matched物件的path屬性掛載在ctx.matched上,提供給後續的allowedMethods中介軟體使用。(見程式碼中的【1】)

  • 將返回的dispatch函式設定router屬性,以便在前面提到的Router.prototype.use方法中區別路由級別中介軟體和巢狀路由。(見程式碼中的【2】)

  • 插入一個新的路由前置處理中介軟體,將layer解析出來的params物件、路由別名以及捕獲陣列掛載在ctx上下文中,這種操作同理Koa在處理請求之前先構建context物件。(見程式碼中的【3】)

  • 而對於路由匹配到眾多layer,koa-router通過koa-compose進行處理,這和koa對於中介軟體處理的方式一樣的,所以koa-router完全就是一個小型洋蔥模型。

七、總結

  koa-router雖然是koa的一箇中介軟體,但是其內部也包含眾多的中介軟體,這些中介軟體通過Layer物件根據路由路徑的不同進行劃分,使得它們不再像koa的中介軟體那樣每次請求都執行,而是針對每次請求採用match方法匹配出相應的中介軟體,再利用koa-compose形成一箇中介軟體執行鏈。

  以上便是koa-router實現原理的全部內容,希望可以幫助你更好的理解koa-router。

相關文章