大衆點評的node框架Node-Server最大的特點是面向企業級 Web 全棧應用框架, 以Koa2爲基礎, 集成了架構中間件Pigeon、 Lion、 Cat、 Mapi、 Rhino等Node客戶端,支持了Node Thrift。最大程度上幫助應用在 Web 開發中提高可維護性和擴展性。其中router路由的解析主要是依賴於koa-router, 依賴於koa-router的router()
, match()
, stack
, layer
等能力,下面進行koa-router源碼的解析。node
koa 框架一直都保持着簡潔性, 它只對 node 的 HTTP 模塊進行了封裝, 而在真正實際使用, 咱們還須要更多地像xxx-router
路由這樣的模塊來構建咱們的應用,正則表達式
而koa-router
是經常使用的 koa 的路由庫. 經過解析 koa-router 的源碼來達到深刻學習的目的.json
咱們知道,在 node 原生裏面, 若是咱們須要實現路由功能, 那麼就能夠像下面這樣編寫代碼:api
const http = require('http');
const { parse } = require('url');
const server = http.createServer((req, res) => {
let { pathname } = parse(req.url);
if (pathname === '/') {
res.end('index page');
} else if (pathname === '/test') {
res.end('test page');
} else {
res.end('router is not found');
}
});
server.listen(3000);
複製代碼
上面的代碼經過解析原生 request IncomingMessage
的 url 屬性, 利用 if...else 判斷路徑返回不一樣的結果.數組
可是上面的代碼缺點也很明顯, 若是路由過多, if...else 的分支也會越龐大, 不利於代碼的維護與多人合做.所以,咱們須要一個特定的路由模塊來統一地模塊化地解決路由功能的問題.bash
若是是使用 koa-router
的話, 那麼能夠藉助下面的代碼來簡單創建一個 koa-router 庫的使用 demo:session
const Koa = require('koa');
const KoaRouter = require('koa-router');
const app = new Koa();
// 建立 router 實例對象
const router = new KoaRouter();
//註冊路由
router.get('/', async (ctx, next) => {
console.log('index');
ctx.body = 'index';
});
app.use(router.routes()); // 添加路由中間件
app.use(router.allowedMethods()); // 對請求進行一些限制處理
app.listen(3000);
複製代碼
運行上面的代碼, 訪問根路由 '/' 咱們能夠看到返回數據爲 'index', 這說明路由已經基本生效了.閉包
咱們來看上面的代碼, 使用 koa-router 第一步就是新建一個 router 實例對象:架構
const router = new KoaRouter();
複製代碼
而後在構建應用的時候, 咱們的首要目標就是建立多個 http 接口以適配不一樣的業務需求, 那麼接下來就須要註冊對應的路由:app
router.get('/', async (ctx, next) => {
console.log('index');
ctx.body = 'index';
});
複製代碼
上面的示例使用了 GET 方法來進行註冊根路由, 實際上不只可使用 GET 方法, 還可使用 POST, DELETE, PUT 等等node 支持的方法.
而後爲了讓 koa 實例使用咱們處理後的路由模塊, 咱們須要使用 routes 方法將路由加入到應用全局的中間件函數中:
app.use(router.routes()); // 添加路由中間件
app.use(router.allowedMethods()); // 對請求進行一些限制處
複製代碼
經過上面的代碼, 咱們已經知道了 koa-router 的簡單使用,接下來咱們須要深刻到代碼中, 理解它是怎麼作到匹配從客戶端傳過來的請求並跳轉執行對應的邏輯的.在此以前咱們先看一下代碼的結構圖:
第一步, 咱們須要新建一個 Router 的實例對象, 而對於一個 Router
的實例來講理解其屬性是相當重要的. 這裏是個function
,因此咱們能夠不須要new, 直接 requure(koa-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 = [];
};
複製代碼
能夠看到, 實際有用的屬性不過 3 個, 分別是 methods 數組, params 對象, stack 數組.
methods
數組存放的是容許使用的 HTTP 方法名, 會在 Router.prototype.allowedMethods 方法中使用, 咱們在建立 Router 實例的時候能夠進行配置, 容許使用哪些方法.
params
對象它存儲的是鍵爲參數名與值爲對應的參數校驗函數, 這樣是爲了經過在全局存儲參數的校驗函數, 方便在註冊路由的時候爲路由的中間件函數數組添加校驗函數.
stack
數組則是存儲每個路由, 也就是 Layer 的實例對象, 每個路由都至關於一個 Layer 實例對象. 對於 Layer 類來講, 建立一個實例對象用於管理每一個路由:
JavaScript
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];
// 存儲路由方法
methods.forEach(function(method) {
var l = this.methods.push(method.toUpperCase());
if (this.methods[l-1] === 'GET') {
this.methods.unshift('HEAD');
}
}, this);
// 將添加的回調處理中間件函數添加到 Layer 實例對象的 stack 數組中
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;
this.regexp = pathToRegExp(path, this.paramNames, this.opts);
debug('defined route %s %s', this.methods, this.opts.prefix + this.path);
};
複製代碼
咱們能夠看到, 對於 Layer 的實例對象, 核心的邏輯仍是在於將 path 轉化爲正則表達式用於匹配請求的路由, 而後將路由的處理中間件添加到 Layer 的 stack 數組中. 注意這裏的 stack 和 Router 裏面的 stack 是不同的, Router 的 stack 數組是存放每一個路由對應的 Layer 實例對象的, 而 Layer 實例對象裏面的 stack 數組是存儲每一個路由的處理函數中間件的, 換言之, 一個路由能夠添加多個處理函數.
所謂 method 就是 HTTP 協議中或者說是在 node 中支持的 HTTP 請求方法.其實咱們能夠經過打印 node 中的 HTTP 的方法來查看 node 支持的 HTTP method:
JavaScript
require('http').METHODS; // ['ACL', ...., 'GET', 'POST', 'PUT', ...]
複製代碼
在 koa-router 裏面的體現就是咱們能夠經過在 router 實例對象上調用對應的方法函數來註冊對應的 HTTP 方法的路由並且每一個方法的核心邏輯都相似, 就是將傳入的路由路徑與對應的回調函數綁定, 因此咱們能夠遍歷一個方法數組來快速構建原型的 method 方法:
methods.forEach(function (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;
};
});
複製代碼
上面函數中先判斷 path 是不是字符串或者正則表達式是由於註冊路由的時候還能夠爲路由進行命名(命名空間方便管理), 而後準確地獲取回調的函數數組(註冊路由能夠接收多個回調), 這樣若是匹配到某個路由, 回調函數數組中的函數就會依次執行. 留意到每一個方法都會返回對象自己, 也就是說註冊路由的時候是能夠支持鏈式調用的. 此外, 咱們能夠看到, 每一個方法的核心其實仍是 register 函數, 因此咱們下面看看 register 函數的邏輯.
register 是註冊路由的核心函數, 舉個例子, 若是咱們須要註冊一個路徑爲 '/test' 的接收 GET 方法的路由, 那麼:
JavaScript
router.get('/test', async (ctx, next) => {});
複製代碼
其實它至關於下面這段代碼:
router.register('/test', ['GET'], [async (ctx, next) => {}], { name: null });
複製代碼
咱們能夠看到, 函數將路由做爲第一個參數傳入, 而後方法名放入到方法數組中做爲第二個參數, 第三個函數是路由的回調數組, 其實每一個路由註冊的時候, 後面均可以添加不少個函數, 而這些函數都會被添加到一個數組裏面, 若是被匹配到, 就會利用中間件機制來逐個執行這些函數. 最後一個函數是將路由的命名空間傳入. 這裏避免篇幅過長, 再也不陳列 register 函數的代碼, 請移步 koa-router 源碼倉庫關於 register 函數部分 查看. register 函數的邏輯其實也很簡單, 由於核心的代碼所有都交由 Layer 類去完成了, register 函數只是負責處理 path 若是是數組的話那麼須要遞歸調用 register 函數, 而後新建一個 Layer 類的實例對象, 而且檢查在註冊這個路由之間有沒有註冊過 param 路由參數校驗函數, 若是有的話, 那麼就使用 Layer.prototype.param
函數將校驗函數加入到路由的中間件函數數組前面.
經過上面的模塊, 咱們已經註冊好了路由, 可是, 若是請求過來了, 請求是怎麼匹配而後進行到相對應的處理函數去的呢? 答案就是利用 match 函數.先看一下 match 函數的代碼:
Router.prototype.match = function (path, method) {
// 取全部路由 Layer 實例
var layers = this.stack;
var layer;
// 匹配結果
var matched = {
path: [],
pathAndMethod: [],
route: false
};
// 遍歷路由 Router 的 stack 逐個判斷
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)) {
// 將對應的 Layer 實例加入到結果集的 path 數組中
matched.path.push(layer);
// 若是對應的 layer 實例中 methods 數組爲空或者數組中有找到對應的方法
if (layer.methods.length === 0 || ~layer.methods.indexOf(method)) {
// 將 layer 放入到結果集的 pathAndMethod 中
matched.pathAndMethod.push(layer);
// 這裏是用於判斷是否有真正匹配到路由處理函數
// 由於像 router.use(session()); 這樣的中間件也是經過 Layer 來管理的, 它們的 methods 數組爲空
if (layer.methods.length) matched.route = true;
}
}
}
return matched;
};
複製代碼
經過上面返回的結果集, 咱們知道一個請求來臨的時候, 咱們可使用正則來匹配路由是否符合, 而後在 path 數組或者 pathAndMethod 數組中找到對應的 Layer 實例對象.
若是根據一開始的 demo 例子, 在上面註冊好了路由以後, 咱們就可使用 router.routes 來將路由模塊添加到 koa 的中間件處理機制當中了. 因爲 koa 的中間件插件是以一個函數的形式存在的, 因此 routes 函數返回值就是一個函數:
Router.prototype.routes = Router.prototype.middleware = function () {
var router = this;
var dispatch = function dispatch(ctx, next) {
...
};
dispatch.router = this;
return dispatch;
};
複製代碼
咱們能夠看到返回的 dispatch 函數在 routes 內部造成了一個閉包, 而且按照 koa 的中間件形式編寫函數.對於 dispatch 函數內部邏輯就以下:
var dispatch = function dispatch(ctx, next) {
debug('%s %s', ctx.method, ctx.path);
var path = router.opts.routerPath || ctx.routerPath || ctx.path;
// 根據 path 值取的匹配的路由 Layer 實例對象
var matched = router.match(path, ctx.method);
var layerChain, layer, i;
if (ctx.matched) {
ctx.matched.push.apply(ctx.matched, matched.path);
} else {
ctx.matched = matched.path;
}
ctx.router = router;
// 若是沒有匹配到對應的路由模塊, 那麼就直接跳過下面的邏輯
if (!matched.route) return next();
// 取路徑與方法都匹配了的 Layer 實例對象
var matchedLayers = matched.pathAndMethod
var mostSpecificLayer = matchedLayers[matchedLayers.length - 1]
ctx._matchedRoute = mostSpecificLayer.path;
if (mostSpecificLayer.name) {
ctx._matchedRouteName = mostSpecificLayer.name;
}
// 構建路徑對應路由的處理中間件函數數組
// 這裏的目的是在每一個匹配的路由對應的中間件處理函數數組前添加一個用於處理
// 對應路由的 captures, params, 以及路由命名的函數
layerChain = matchedLayers.reduce(function(memo, layer) {
memo.push(function(ctx, next) {
// captures 是存儲路由中參數的值的數組
ctx.captures = layer.captures(path, ctx.captures);
// params 是一個對象, 鍵爲參數名, 根據參數名能夠獲取路由中的參數值, 值從 captures 中拿
ctx.params = layer.params(path, ctx.captures, ctx.params);
ctx.routerName = layer.name;
return next();
});
return memo.concat(layer.stack);
}, []);
// 使用 compose 模塊將對應路由的處理中間件數組中的函數逐個執行
// 當路由的處理函數中間件函數所有執行完, 再調用上一層級的 next 函數進入下一個中間件
return compose(layerChain)(ctx, next);
};
複製代碼
對於 allowedMethod 方法來講, 它的做用就是用於處理請求的錯誤, 因此它做爲路由模塊的最後一個函數來執行.一樣地, 它也是以一個 koa 的中間件插件函數的形式出現, 一樣在函數內部造成了一個閉包:
Router.prototype.allowedMethods = function (options) {
options = options || {};
var implemented = this.methods;
return function allowedMethods(ctx, next) {
...
};
};
複製代碼
上面的代碼很簡單, 就是保存 Router 配置中容許的 HTTP 方法數組在閉包內部
return function allowedMethods(ctx, next) {
// 從這裏能夠看出, allowedMethods 函數是用於在中間件機制中處理返回結果的函數
// 先執行 next 函數, next 函數返回的是一個 Promise 對象
return next().then(function() {
var allowed = {};
// allowedMethods 函數的邏輯創建在 statusCode 沒有設置或者值爲 404 的時候
if (!ctx.status || ctx.status === 404) {
// 這裏的 matched 就是在 match 函數執行以後返回結果集中的 path 數組
// 也就是說請求路徑與路由正則匹配的 layer 實例對象數組
ctx.matched.forEach(function (route) {
// 將這些 layer 路由的 HTTP 方法存儲起來
route.methods.forEach(function (method) {
allowed[method] = method;
});
});
// 將上面的 allowed 整理爲數組
var allowedArr = Object.keys(allowed);
// implemented 就是 Router 配置中的 methods 數組, 也就是容許的方法
// 這裏經過 ~ 運算判斷當前的請求方法是否在配置容許的方法中
// 若是該方法不被容許
if (!~implemented.indexOf(ctx.method)) {
// 若是 Router 配置中配置 throw 爲 true
if (options.throw) {
var notImplementedThrowable;
// 若是配置中規定了 throw 拋出錯誤的函數, 那麼就執行對應的函數
if (typeof options.notImplemented === 'function') {
notImplementedThrowable = options.notImplemented(); // set whatever the user returns from their function
} else {
// 若是沒有則直接拋出 HTTP Error
notImplementedThrowable = new HttpError.NotImplemented();
}
// 拋出錯誤
throw notImplementedThrowable;
} else {
// Router 配置 throw 爲 false
// 設置狀態碼爲 501
ctx.status = 501;
// 而且設置 Allow 頭部, 值爲上面獲得的容許的方法數組 allowedArr
ctx.set('Allow', allowedArr.join(', '));
}
} else if (allowedArr.length) {
// 來到這裏說明該請求的方法是被容許的, 那麼爲何會沒有狀態碼 statusCode 或者 statusCode 爲 404 呢?
// 緣由在於除卻特殊狀況, 咱們通常在業務邏輯裏面不會處理 OPTIONS 請求的
// 發出這個請求通常常見就是非簡單請求, 則會發出預檢請求 OPTIONS
// 例如 application/json 格式的 POST 請求
// 若是是 OPTIONS 請求, 狀態碼爲 200, 而後設置 Allow 頭部, 值爲容許的方法數組 methods
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(); // set whatever the user returns from their function
} else {
notAllowedThrowable = new HttpError.MethodNotAllowed();
}
throw notAllowedThrowable;
} else {
// 這裏的狀態碼爲 405
ctx.status = 405;
ctx.set('Allow', allowedArr.join(', '));
}
}
}
}
});
};
複製代碼
值得注意的是, Router.methods 數組裏面的方法是服務端須要實現並支持的方法, 若是客戶端發送過來的請求方法不被容許, 那麼這是一個服務端錯誤 501, 可是若是這個方法被容許, 可是找不到對應這個方法的路由處理函數(好比相同路由的 POST 路由可是用 GET 方法來獲取數據), 這是一個客戶端錯誤 405.
use 函數就是用於添加中間件的, 只不過不一樣於 koa 中的 use 函數, router 的 use 函數添加的中間件函數會在全部路由執行以前執行.此外, 它還能夠對某些特定路徑的進行中間件函數的綁定執行.
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;
};
複製代碼
經過上面咱們就清楚, 在 koa-router 裏面, 它將全部的路由與全部路由都適用的中間件函數都看作 Layer, 經過 Layer 來處理, 而後將他們的回調函數存儲在 Layer 實例自己的 stack 數組中, 而後全局的 router 實例對象的 stack 數組存放全部的 Layer 達到全局管理的目的.
上面就是 koa-router 的核心 API, 下面咱們經過一張圖來總結一下, 看一下當一個請求來臨, koa-router 是如何處理的:
爲何須要在 GET 請求放一個 HEAD 請求 ? 咱們能夠看到在 Layer 的構建函數裏面, 在對於 methods 的處理中, 會進行判斷若是該請求爲 GET 請求, 那麼就須要在 GET 請求前面添加一個 HEAD 方法, 其緣由在於 HEAD 方法與 GET 方法基本是一致的, 因此 koa-router 在處理 GET 請求的時候順帶將 HEAD 請求一併處理, 由於二者的區別在於 HEAD 請求不響應數據體.