3种常见的路由方式

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

 

  

 

  

 

  

  

  

  

上一篇:java – 为路由中的可选查询参数赋予null默认值 – Play Framework


下一篇:php – 通过访问路由名称参数自动执行Entrust权限并检查用户的权限