本文将会介绍文件路径、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端的各种客户端应用。