本文将会介绍文件路径、MVC、RESTful三种常见的路由方式
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端的各种客户端应用。
















