Koa爲了保持自身的簡潔,並無捆綁中間件。可是在實際的開發中,咱們須要和形形色色的中間件打交道,本文將要分析的是常常用到的路由中間件 -- koa-router。git
若是你對Koa的原理還不瞭解的話,能夠先查看Koa原理解析。github
koa-router的源碼只有兩個文件:router.js和layer.js,分別對應Router對象和Layer對象。正則表達式
Layer對象是對單個路由的管理,其中包含的信息有路由路徑(path)、路由請求方法(method)和路由執行函數(middleware),而且提供路由的驗證以及params參數解析的方法。api
相比較Layer對象,Router對象則是對全部註冊路由的統一處理,而且它的API是面向開發者的。數組
接下來從如下幾個方面全面解析koa-router的實現原理:安全
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;
// 一、根據路由路徑生成路由正則表達式
// 二、將params參數信息保存在paramNames數組中
this.regexp = pathToRegExp(path, this.paramNames, this.opts);
};
複製代碼
Layer構造函數主要用來初始化路由路徑、路由請求方法數組、路由處理函數數組、路由正則表達式以及params參數信息數組,其中主要採用path-to-regexp方法根據路徑字符串生成正則表達式,經過該正則表達式,能夠實現路由的匹配以及params參數的捕獲:app
// 驗證路由
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參數的鍵值對:koa
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處理函數的緣由在於如下兩點:
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進行處理。
首先看了解一下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中提供兩種方式註冊路由:
源碼中採用methods模塊獲取HTTP請求方法名,該模塊內部實現主要依賴於http模塊:
http.METHODS && http.METHODS.map(function lowerCaseMethod (method) {
return method.toLowerCase()
})
複製代碼
這兩種註冊路由的方式的內部實現基本相似,下面以router.verb()的源碼爲例:
methods.forEach(function (method) {
Router.prototype[method] = function (name, path, middleware) {
var middleware;
// 一、處理是否傳入name參數
// 二、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全部的請求方法,因此對於這兩種註冊方法的實現,本質上是沒有區別的。
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中已經說起過,相信你們應該輕車熟路了。
熟悉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中間件註冊方法主要完成兩項功能:
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對象包含如下幾個部分:
另外,在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。
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數組不爲空的前提條件下:
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。