本文將會介紹文件路徑、MVC、RESTful三種常見的路由方式
--以下內容出自《深入淺出node.js》
1. 文件路徑型
1.1 靜態文件
這種方式的路由在路徑解析的部分有過簡單描述,其讓人舒服的地方在於URL的路徑與網站目錄的路徑一致,無須轉換,非常直觀。這種路由的處理方式也十分簡單,將請求路徑對應的文件發送給客戶端即可。如:
// 原生實現 http.createServer((req, res) => { if (req.url === '/home') { // 假設本地服務器將html靜態文件放在根目錄下的view文件夾 fs.readFile('/view/' + req.url + '.html', (err, data) => { res.writeHead(200, { 'Content-Type': 'text/html' }); res.end(data) }) } }).listen() // Express app.get('/home', (req, res) => { fs.readFile('/view/' + req.url + '.html', (err, data) => { res.writeHead(200, { 'Content-Type': 'text/html' }); res.status(200).send(data) }) })
1.2. 動態文件
在MVC模式流行起來之前,根據文件路徑執行動態腳本也是基本的路由方式,它的處理原理是Web服務器根據URL路徑找到對應的文件,如/index.asp或/index.php。Web服務器根據文件名后綴去尋找腳本的解析器,並傳入HTTP請求的上下文。
以下是Apache中配置PHP支持的方式:
AddType application/x-httpd-php .php
解析器執行腳本,並輸出響應報文,達到完成服務的目的。現今大多數的服務器都能很智能 地根據后綴同時服務動態和靜態文件。這種方式在Node中不太常見,主要原因是文件的后綴都是.js,分不清是后端腳本,還是前端腳本,這可不是什么好的設計。而且Node中Web服務器與應用業務腳本是一體的,無須按這種方式實現。
2. MVC
在MVC流行之前,主流的處理方式都是通過文件路徑進行處理的,甚至以為是常態。直到 有一天開發者發現用戶請求的URL路徑原來可以跟具體腳本所在的路徑沒有任何關系。
MVC模型的主要思想是將業務邏輯按職責分離,主要分為以下幾種。
控制器(Controller),一組行為的集合。
模型(Model),數據相關的操作和封裝。
視圖(View),視圖的渲染。
這是目前最為經典的分層模式,大致而言,它的工作模式如下說明。
路由解析,根據URL尋找到對應的控制器和行為。
行為調用相關的模型,進行數據操作。
數據操作結束后,調用視圖和相關數據進行頁面渲染,輸出到客戶端。
2.1 手工映射
手工映射除了需要手工配置路由外較為原始外,它對URL的要求十分靈活,幾乎沒有格式上的限制。如下的URL格式都能自由映射:
'/user/setting' , '/setting/user'
這里假設已經擁有了一個處理設置用戶信息的控制器,如下所示:
exports.setting = (req, res) => { // TODO }
再添加一個映射的方法(路由注冊)就行,為了方便后續的行文,這個方法名叫use(),如下所示:
const routes = [] const use = (path, action) => { routes.push([path, action]); }
我們在入口程序中判斷URL,然后執行對應的邏輯,於是就完成了基本的路由映射過程,如下所示:
(req, res) => { const pathname = url.parse(req.url).pathname for (let i = 0; i < routes.length; i++) { let route = routes[i]; if (pathname === route[0]) { let action = route[1] action(req, res) return } } // 處理404請求 handle404(req, res) }
手工映射十分方便,由於它對URL十分靈活,所以我們可以將兩個路徑都映射到相同的業務 邏輯,如下所示:
use('/user/setting', exports.setting);
use('/setting/user', exports.setting);
// 甚至
use('/setting/user/jacksontian', exports.setting);
2.1.1 正則匹配
對於簡單的路徑,采用上述的硬匹配方式即可,但是如下的路徑請求就完全無法滿足需求了:
'/profile/jacksontian' , '/profile/hoover'
這些請求需要根據不同的用戶顯示不同的內容,這里只有兩個用戶,假如系統中存在成千上 萬個用戶,我們就不太可能去手工維護所有用戶的路由請求,因此正則匹配應運而生,我們期望通過以下的方式就可以匹配到任意用戶:
use('/profile/:username', (req, res) => { // TODO });
於是我們改進我們的匹配方式,在通過use注冊路由時需要將路徑轉換為一個正則表達式, 然后通過它來進行匹配,如下所示:
const pathRegexp = (path) => {
let strict = path. path = path .concat(strict ? '' : '/?') .replace(/\/\(/g, '(?:/') .replace(/(\/)?(\.)?:(\w+)(?:(\(.*?\)))?(\?)?(\*)?/g, function (_, slash, format, key, capture, optional, star) { slash = slash || ''; return '' + (optional ? '' : slash) + '(?:' + (optional ? slash : '') + (format || '') + (capture || (format && '([^/.]+?)' || '([^/]+?)')) + ')' + (optional || '') + (star ? '(/*)?' : ''); }) .replace(/([\/.])/g, '\\$1') .replace(/\*/g, '(.*)'); return new RegExp('^' + path + '$'); }
上述正則表達式十分復雜,總體而言,它能實現如下的匹配:
/profile/:username => /profile/jacksontian, /profile/hoover
/user.:ext => /user.xml, /user.json
現在我們重新改進注冊部分:
const use = (path, action) => {
routes.push([pathRegexp(path), action]);
}
以及匹配部分:
(req, res) => { const pathname = url.parse(req.url).pathname; for (let i = 0; i < routes.length; i++) { let route = routes[i]; // 正則匹配 if (route[0].exec(pathname)) { let action = route[1]; action(req, res); return; } } // 處理404請求 handle404(req, res); }
現在我們的路由功能就能夠實現正則匹配了,無須再為大量的用戶進行手工路由映射了。
2.1.2 參數解析
盡管完成了正則匹配,可以實現相似URL的匹配,但是:username到底匹配了啥,還沒有解決。為此我們還需要進一步將匹配到的內容抽取出來,希望在業務中能如下這樣調用:
use('/profile/:username', function (req, res) { var username = req.params.username; // TODO });
這里的目標是將抽取的內容設置到req.params處。那么第一步就是將鍵值抽取出來,如下所示:
const pathRegexp = function (path) { const keys = []; path = path .concat(strict ? '' : '/?') .replace(/\/\(/g, '(?:/') .replace(/(\/)?(\.)?:(\w+)(?:(\(.*?\)))?(\?)?(\*)?/g, function (_, slash, format, key, capture, optional, star) { // 將匹配到的鍵值保存起來 keys.push(key); slash = slash || ''; return '' + (optional ? '' : slash) + '(?:' + (optional ? slash : '') + (format || '') + (capture || (format && '([^/.]+?)' || '([^/]+?)')) + ')' + (optional || '') + (star ? '(/*)?' : ''); }) .replace(/([\/.])/g, '\\$1') .replace(/\*/g, '(.*)'); return { keys: keys, regexp: new RegExp('^' + path + '$') }; }
我們將根據抽取的鍵值和實際的URL得到鍵值匹配到的實際值,並設置到req.params處,如 下所示:
(req, res) => { const pathname = url.parse(req.url).pathname; for (let i = 0; i < routes.length; i++) { let route = routes[i]; // 正則匹配 let reg = route[0].regexp; let keys = route[0].keys; let matched = reg.exec(pathname); if (matched) { // 抽取具體值 const params = {}; for (let i = 0, l = keys.length; i < l; i++) { let value = matched[i + 1]; if (value) { params[keys[i]] = value; } } req.params = params; let action = route[1]; action(req, res); return; } } // 處理404請求 handle404(req, res); }
至此,我們除了從查詢字符串(req.query)或提交數據(req.body)中取到值外,還能從路 徑的映射里取到值。
2.2. 自然映射
手工映射的優點在於路徑可以很靈活,但是如果項目較大,路由映射的數量也會很多。從前端路徑到具體的控制器文件,需要進行查閱才能定位到實際代碼的位置,為此有人提出,盡是路由不如無路由。實際上並非沒有路由,而是路由按一種約定的方式自然而然地實現了路由,而無須去維護路由映射。
上文的路徑解析部分對這種自然映射的實現有稍許介紹,簡單而言,它將如下路徑進行了划分處理:
/controller/action/param1/param2/param3
以/user/setting/12/1987為例,它會按約定去找controllers目錄下的user文件,將其require出來后,調用這個文件模塊的setting()方法,而其余的值作為參數直接傳遞給這個方法。
(req, res) => { let pathname = url.parse(req.url).pathname; let paths = pathname.split('/'); let controller = paths[1] || 'index'; let action = paths[2] || 'index'; let args = paths.slice(3); let module; try { // require的緩存機制使得只有第一次是阻塞的 module = require('./controllers/' + controller); } catch (ex) { handle500(req, res); return; } let method = module[action] if (method) { method.apply(null, [req, res].concat(args)); } else { handle500(req, res); } }
由於這種自然映射的方式沒有指明參數的名稱,所以無法采用req.params的方式提取,但是直接通過參數獲取更簡潔,如下所示:
exports.setting = (req, res, month, year) => { // 如果路徑為/user/setting/12/1987,那么month為12,year為1987 // TODO };
事實上手工映射也能將值作為參數進行傳遞,而不是通過req.params。但是這個觀點見仁見智,這里不做比較和討論。
自然映射這種路由方式在PHP的MVC框架CodeIgniter中應用十分廣泛,設計十分簡潔,在Node中實現它也十分容易。與手工映射相比,如果URL變動,它的文件也需要發生變動,手工映射只需要改動路由映射即可。
3. RESTful
MVC模式大行其道了很多年,直到RESTful的流行,大家才意識到URL也可以設計得很規范,請求方法也能作為邏輯分發的單元。
REST的全稱是Representational State Transfer,中文含義為表現層狀態轉化。符合REST規范的設計,我們稱為RESTful設計。它的設計哲學主要將服務器端提供的內容實體看作一個資源, 並表現在URL上。
比如一個用戶的地址如下所示:
/users/jacksontian
這個地址代表了一個資源,對這個資源的操作,主要體現在HTTP請求方法上,不是體現在URL上。過去我們對用戶的增刪改查或許是如下這樣設計URL的:
POST /user/add?username=jacksontian GET /user/remove?username=jacksontian POST /user/update?username=jacksontian GET /user/get?username=jacksontian
操作行為主要體現在行為上,主要使用的請求方法是POST和GET。在RESTful設計中,它是如下這樣的:
POST /user/jacksontian DELETE /user/jacksontian PUT /user/jacksontian GET /user/jacksontian
它將DELETE和PUT請求方法引入設計中,參與資源的操作和更改資源的狀態。
對於這個資源的具體表現形態,也不再如過去一樣表現在URL的文件后綴上。過去設計資源的格式與后綴有很大的關聯,例如:
GET /user/jacksontian.json
GET /user/jacksontian.xml
在RESTful設計中,資源的具體格式由請求報頭中的Accept字段和服務器端的支持情況來決定。如果客戶端同時接受JSON和XML格式的響應,那么它的Accept字段值是如下這樣的:
Accept: application/json,application/xml
靠譜的服務器端應該要顧及這個字段,然后根據自己能響應的格式做出響應。在響應報文中,通過Content-Type字段告知客戶端是什么格式,如下所示:
Content-Type: application/json
具體格式,我們稱之為具體的表現。所以REST的設計就是,通過URL設計資源、請求方法定義資源的操作,通過Accept決定資源的表現形式。
RESTful與MVC設計並不沖突,而且是更好的改進。相比MVC,RESTful只是將HTTP請求方法也加入了路由的過程,以及在URL路徑上體現得更資源化。
3.1 請求方法
為了讓Node能夠支持RESTful需求,我們改進了我們的設計。如果use是對所有請求方法的處理,那么在RESTful的場景下,我們需要區分請求方法設計。示例如下所示:
const routes = { 'all': [] }; const app = {}; app.use = function (path, action) { routes.all.push([pathRegexp(path), action]); }; ['get', 'put', 'delete', 'post'].forEach(function (method) { routes[method] = []; app[method] = function (path, action) { routes[method].push([pathRegexp(path), action]); }; });
上面的代碼添加了get()、put()、delete()、post()4個方法后,我們希望通過如下的方式完成路由映射:
// 增加用戶 app.post('/user/:username', addUser); // 刪除用戶 app.delete('/user/:username', removeUser); // 修改用戶 app.put('/user/:username', updateUser); // 查詢用戶 app.get('/user/:username', getUser);
這樣的路由能夠識別請求方法,並將業務進行分發。為了讓分發部分更簡潔,我們先將匹配的部分抽取為match()方法,如下所示:
const match = (pathname, routes) => { for (let i = 0; i < routes.length; i++) { let route = routes[i]; // 正則匹配 let reg = route[0].regexp; let keys = route[0].keys; let matched = reg.exec(pathname); if (matched) { // 抽取具體值 const params = {}; for (let i = 0, l = keys.length; i < l; i++) { let value = matched[i + 1]; if (value) { params[keys[i]] = value; } } req.params = params; let action = route[1]; action(req, res); return true; } } return false; };
然后改進我們的分發部分,如下所示:
(req, res) => { let pathname = url.parse(req.url).pathname; // 將請求方法變為小寫 let method = req.method.toLowerCase(); if (routes.hasOwnPerperty(method)) { // 根據請求方法分發 if (match(pathname, routes[method])) { return; } else { // 如果路徑沒有匹配成功,嘗試讓all()來處理 if (match(pathname, routes.all)) { return; } } } else { // 直接讓all()來處理 if (match(pathname, routes.all)) { return; } } // 處理404請求 handle404(req, res); }
如此,我們完成了實現RESTful支持的必要條件。這里的實現過程采用了手工映射的方法完成,事實上通過自然映射也能完成RESTful的支持,但是根據Controller/Action的約定必須要轉化為Resource/Method的約定,此處已經引出實現思路,不再詳述。
目前RESTful應用已經開始廣泛起來,隨着業務邏輯前端化、客戶端的多樣化,RESTful模式以其輕量的設計,得到廣大開發者的青睞。對於多數的應用而言,只需要構建一套RESTful服務接口,就能適應移動端、PC端的各種客戶端應用。