您所在的位置:小祥子 » 编程 » JavaScript » 正文

【原创】express3.4.8源码解析之路由中间件

时间:2015-08-15 编辑:Lovesueee 来源:CnBlogs

前言

注意:旧文章转成markdown格式。

跟大家聊一个中间件,叫做路由中间件,它并非是connect中内置的中间件,而是在express中集成进去的。

显而易见,该中间件的用途就是 ------ 路由分发 ,表面上看它的路由机制有点像Backbone.Router,实际上它的实现要比Backbone.Router复杂的多,功能也相应地要强很多。

它可以做到:

  1. 根据http采用不同的请求方法来采用不同的路由(如:get,post等)
  2. 根据请求的url与path生成的正则表达式进行匹配的结果进行不同路由(如:/user/:id => /^\/user\/(?:([^\/]+?))\/?$/)
  3. 根据path生成的表达式获取请求参数,并传递给路由处理函数(如:/user/:id => /user/322 => req.params.id为322)
  4. 根据不同的情况,选择性决定是否执行下一个路由处理函数(通过调用next()),或者是下一个路由(通过调用next('route'))

例子

var express = require('express');
var app = express();

app.set('port', 3000);

// 加载路由中间件
app.use(app.router);

// 当作数据库
var users = [
    { id: 0, name: 'lovesueee'}
];

function loadUser(req, res, next) {

    var user = users[req.params.id];
    if (user) {
        req.user = user;
        next();
    } else {
        next(new Error('Failed to load user ' + req.params.id));
    }
}

// 第一个get路由,两个回调
app.get('/user/:id', loadUser, function(req, res){
    res.send('Viewing user ' + req.user.name);
});

// 第二个get路由,一个回调,path和第一个路由相同
app.get('/user/:id', function(req, res){
    res.send('Looking user ' + req.user.name);
});

app.listen(app.get('port'), function () {
     console.log('server listening...');
});

上面是一个简单的例子(文件名为test.JS),我们在终端执行:node test.js,并打开浏览器输入地址:http://127.0.0.1:3000/user/0,此时我们看到会看到页面显示:'Viewing user lovesueee'。

ok,我们理一下思路,程序是这样执行的:

  1. 服务端通过app.use(app.router);添加路由中间件,通过app.get(..)添加了两个path相同,回调函数不同的路由。
  2. 然后开始监听3000端口,客户端便输入http://127.0.0.1:3000/user/0发起请求
  3. 服务端接受请求,程序必然是先经过中间件的处理,app.router作为路由中间件也不例外(ps: 注意app.router是一个中间接接口函数)
  4. 当程序经过app.router中间件的时候,会进行路由匹配,显然这里是匹配上了,那么将会顺序执行loadUser以及其后面的匿名函数。

值得注意的几点:

  1. app.get(..)方法能够接受多个回调作为路由处理函数,就像第一个路由那样(有loadUser函数以及跟着后面的匿名函数)。
  2. 在loadUser函数中的req.params.id为0,显然是'/user/:id'生成的正则表达式/^\/user\/(?:([^\/]+?))\/?$/匹配后得到的,然后传递给req.params.id的。
  3. 最后能在浏览器中输出结果:'Viewing user lovesueee',显然是第一个路由的第二个回调函数得以执行才造成的,那么该函数的执行我们自然会联想到是loadUser函数中的next()方法的调用触发的(就像之前的中间件一样),而且req.user也是loadUser函数通过req变量传递过来的。

几个思考?

  1. 如果去掉loadUser中的next();,会怎样? --- 显然是第二个回调函数不再被调用,客户端长久处于pending状态,最后超时。
  2. 如果访问url是http://127.0.0.1:3000/user/1,会怎样? --- 向客户端返回错误信息,根据环境的(即process.env.NODE_ENV为development还是production)的不同显示不同的错误信息。
  3. 如果我们执行的不是next()而是next('route');,会怎样? --- 这时浏览器显示的将是:'Looking user lovesueee',程序没有执行第一个路由的第二个回调,而是立马执行了第二个路由的回调。

源码分析

这里主要讲解两个函数:

  • app.get(name) | app.get(path, fn1, [fn2, fn3]) --- 注册路由
  • app.router --- 路由执行入口

app.get

这个函数有两个作用:

  1. 当参数只有一个时,用来获取配置信息,与app.set(..)相对应。
  2. 当参数大于一个是,用来为get请求注册路由,也就是该方法注册的路由匹配条件有两个:1. get请求???2. url匹配path生成的正则。

注意:这里我们以app.get()方法为例,其他的路由方法同样原理,像app.post()

ok,我们首先当然得找到app.get()方法的源代码在哪里,如下(application.js中):

// 给app赋值所有的http method方法
methods.forEach(function(method){
  app[method] = function(path){

    // get时且参数唯一个时,表示到settings里取配置
    if ('get' == method && 1 == arguments.length) return this.set(path);

    // deprecated
    if (Array.isArray(path)) {
      console.trace('passing an array to app.VERB() is deprecated and will be removed in 4.0');
    }

    // if no router attached yet, attach the router
    // 如果没有调用路由中间件,这里自动调用
    if (!this._usedRouter) this.use(this.router);

    // setup route
    // 调用Router实例,创建一个路由route
    // 这里的参数可以是这样(path, fn1, [fn2, fn3], fn4,...)
    // fn代表callback
    this._router[method].apply(this._router, arguments);
    return this;
  };
});

这里通过循环methods数组,给app添加http的一系列方法,如:get, post ,delete等。

当我们调用app.get()时,

  1. if (!this._usedRouter) this.use(this.router);这样的一句,是为了防止我们在使用app.get()之前,没有先注册路由中间件(即app.use(app.router);),帮我们注册一下。
  2. 程序最后,调用this._router[method].apply(..);,(method这里为'get')告诉我们真正的路由注册其实在于this._router所引用的对象。

ok,于是我很好奇地在application.js中找到了this._router的定义,如下:

app.defaultConfiguration = function(){

  ...
  
  // 初始化Router实例
  // 用于路由请求
  this._router = new Router(this);

  // 搜集的路由的映射
  // 将map挂载到this对象上,那么可以通过app.routes访问
  // 结构如下:
  // {
  //   get : [
  //       Route1,
  //       Route2,
  //       ...
  //   ],
  //   post : [],
  //   ...
  // }
  this.routes = this._router.map;

  // 定义route的getter
  // 用户通过调用app.use(app.router),启动路由中间件
  this.__defineGetter__('router', function(){

    // 标识路由中间件已启用
    // 并通过从app.setttings中的配置设置router的相应配置
    // 如:路由大小写敏感和路由严格模式
    // 最后返回路由中间件的接口函数
    this._usedRouter = true;
    this._router.caseSensitive = this.enabled('case sensitive routing');
    this._router.strict = this.enabled('strict routing');
    return this._router.middleware;
  });
  
  ...
  
};

这里我们会有所发现:

  1. this._router = new Router(this);是初始化了一个Router实例,也就是说之前的app.get()其实是调用Router实例的get方法,我们待会再看。
  2. 这里定义了app.router的一个getter,在调用app.router时this._usedRouter被设置为true,表示调用过了;另外返回的this._router.middleware;就是路由中间件的接口函数,即路由的入口处。
  3. 我上面的中文注释暴露了this._router.map的结构,这个我们接下来说。

到这里,我们的目光应该同一放到这个Router实例上,我找到了它的类,在express/lib/router/index.js,如下:

function Router(options) {
  options = options || {};
  var self = this;
  this.map = {};
  this.params = {};
  this._params = [];
  this.caseSensitive = options.caseSensitive;
  this.strict = options.strict;
  this.middleware = function router(req, res, next){
    self._dispatch(req, res, next);
  };
}

很醒目,这里其实就是定义了一些对象属性,我们更关注的其实应该是this.middleware = function router(req, res, next){..},果然不出所料时中间件接口函数,当请求走到这里时,路由中间件调用自己_dispatch方法开始进行路由分发,具体的我们在讲app.router时再说,赶紧回到我们要深追的app.get()上。

刚才说到Router实例的get方法,我们在这个文件里找到:

methods.forEach(function(method){
  Router.prototype[method] = function(path){
    var args = [method].concat([].slice.call(arguments));
    this.route.apply(this, args);
    return this;
  };
});

同样是遍历methods赋予Router原型一系列http方法,归根结底还是调用this.route(..)方法,并将传入method(这里是'get'),顺着代码往上找,找到:

Router.prototype.route = function(method, path, callbacks){

  // flatten操作
  // [fn1, [fn2, fn3], fn4] => [fn1, fn2, fn3, fn4]
  var method = method.toLowerCase()
    , callbacks = utils.flatten([].slice.call(arguments, 2));

  // ensure path was given
  // 确保路由route是有路径path可依据的
  if (!path) throw new Error('Router#' + method + '() requires a path');

  // ensure all callbacks are functions
  // 保证所有callback都是函数
  callbacks.forEach(function(fn){
    if ('function' == typeof fn) return;
    var type = {}.toString.call(fn);
    var msg = '.' + method + '() requires callback functions but got a ' + type;
    throw new Error(msg);
  });

  // create the route
  debug('defined %s %s', method, path);
  // 创建Route实例
  var route = new Route(method, path, callbacks, {
    sensitive: this.caseSensitive,
    strict: this.strict
  });

  // add it
  // 向当前Router实例的map映射里添加该route
  (this.map[method] = this.map[method] || []).push(route);
  return this;
};

哈哈,终于到了柳暗花明的地方了,和中间件的注册一样,注册暂时先存储,看看上面的代码和注释,我们可以看到:

  1. 其实可以像app.get(path, fn1,[fn2, fn3]);这样调用,因为有这一句utils.flatten([].slice.call(arguments, 2));
  2. 每次调用app.get()都会产生一个Route实例(即便它们的path是一样的,就像上面例子中的第一个和第二个路由的path一样,却是两个Route实例)
  3. Router实例中最终维护了一个map映射,它的结构如下:
    {
    get : [
    Route实例1,
    Route实例2,
    ...
    ],],
    post : [],
    ...
    }
    ,建议大家可以看看我之前画的结构图。

  4. 最终焦点就都到了Route实例中去了,我们可以想到Router实例存储着多个Route实例,那么Route实例自然会存储着method,path,callbacks这些信息。

最后,找到express/lib/router/route.js,如我所说:

function Route(method, path, callbacks, options) {
  options = options || {};
  this.path = path;
  this.method = method;
  this.callbacks = callbacks;
  
  // 将path转换为正则
  this.regexp = utils.pathRegexp(path
    , this.keys = []
    , options.sensitive
    , options.strict);
}

// 通过正则进行路由匹配
Route.prototype.match = function(path){...};

总结下就是:

就是存在app._router,它是一个Router实例,Router实例有一个map,可以理解为路由映射,里面存储着各种http方法的路由集合,

每个集合的元素其实是一个Route实例,每个Route实例包含一个正则,一个method用来匹配url,一些callbacks,用来回调处理逻辑。

我这里还是给一张图吧,假设路由是这样的:

// 第一个get路由,两个回调
app.get('/user/:id', loadUser, function(req, res){
    res.send('Viewing user ' + req.user.name);
});

// 第二个get路由,一个回调,path和第一个路由相同
app.get('/user/:id', function(req, res){
    res.send('Looking user ' + req.user.name);
});

// 第三个get路由,path和第一个路由不同
app.get('/post/:pid', function(req, res){
    res.send('Viewing post');
});

// 第四个post路由
app.post('/post/:pid', function(req, res){
    // ...
});

对应的存储的map为:

app.router

前面已经提到app.router其实返回的是一个中间件处理函数function router(req, res, next) {self._dispatch(req, res, next);},那么我们就应该把焦点放到self._dispatch(req, res, next);这个函数上。

我们可以试想一下,前面我们通过app.get()注册了路由,也就是将相关的信息存储起来了,那么_dispatch(..)函数所做的就应该是遍历map映射查找是否与当前url匹配的Route实例,如果有,那么执行Route实例存储的callbacks来执行业务逻辑,并且可以在任何callback里决定是否要执行接下来的callback或者是匹配接下来的路由,听到这里是不是感觉跟中间件的实现很像?没错,答案是肯定的。

我们同样找到代码的位置:

Router.prototype._dispatch = function(req, res, next){
  var params = this.params
    , self = this;

  debug('dispatching %s %s (%s)', req.method, req.url, req.originalUrl);

  // route dispatch
  // 路由分发
  // i是索引,表示从第i个route开始匹配
  // err用于传递路由时的错误信息
  // 第一次自调用pass
  (function pass(i, err){
    var paramCallbacks
      , paramIndex = 0
      , paramVal
      , route
      , keys
      , key;

    // match next route
    // 匹配下一个合适的路由
    function nextRoute(err) {
      pass(req._route_index + 1, err);
    }

    // match route
    // 匹配路由
    req.route = route = self.matchRequest(req, i);

    // implied OPTIONS
    if (!route && 'OPTIONS' == req.method) return self._options(req, res, next);

    // no route
    // 如果匹配不到,那么直接执行下一个中间件
    if (!route) return next(err);
    debug('matched %s %s', route.method, route.path);

    // we have a route
    // start at param 0
    // params匹配到的变量值,如{id : 3, ...}
    // keys变量名数组,通过解析路由是获取而来,如(:id -> id)
    // param函数中的将会用到i进行索引,所以这里i必须重置为0,
    req.params = route.params;
    keys = route.keys;
    i = 0;

    // param callbacks
    function param(err) {

      // 重置为0
      paramIndex = 0;

      // 依次获取params的key,如 id
      // 获取params对应key的value
      // 获取params对应key的回调
      key = keys[i++];
      paramVal = key && req.params[key.name];
      paramCallbacks = key && params[key.name];

      try {

        // err为'route',进入下一个route匹配
        // 我们正常会通过调用next('route')走这一步
        if ('route' == err) {
          nextRoute();

        // 如果报错,那么
        // 首先先重置i为0(因为这里都引用同一个i)
        // 然后执行路由的回调函数(处理错误的)
        } else if (err) {
          i = 0;
          callbacks(err);

        // 如果存在paramCallbacks(即通过app.param('id', function() {...});)注册的
        // 且paramVal有值
        // 那么执行paramCallbacks
        } else if (paramCallbacks && undefined !== paramVal) {
          paramCallback();

        // 如果存在key,那么迭代执行param()
        } else if (key) {
          param();

        // 最后,再执行完paramCallbacks后,最后执行路由的callbacks
        } else {
          i = 0;
          callbacks();
        }
      } catch (err) {
        param(err);
      }
    };

    // 第一次调用param
    param(err);

    // single param callbacks
    // 调用每一个param 的回调函数列表
    // 通过自调用,循环函数列表
    // 如果出错,或者循环完毕,继续处理下一个param
    function paramCallback(err) {
      var fn = paramCallbacks[paramIndex++];
      if (err || !fn) return param(err);
      fn(req, res, paramCallback, paramVal, key.name);
    }

    // invoke route callbacks
    // 调用路由回调函数
    // 依然是将当前函数作为next传给回调fn,进行下一个函数的调用
    // 这里又是迭代
    function callbacks(err) {
      var fn = route.callbacks[i++];
      try {

        // 结束路由函数的调用,进入到下一个路由匹配
        if ('route' == err) {
          nextRoute();

        // 如果报错,且fn存在
        // 参数个数 < 4,进行下一个路由函数的调用
        // 否则,调用fn处理当前错误(错误处理函数)
        } else if (err && fn) {
          if (fn.length < 4) return callbacks(err);
          fn(err, req, res, callbacks);

        // 没有错误,且存在回调
        // 参数个数 < 4,处理正常函数逻辑(逻辑处理函数)
        // 否则,忽略回调,进行下一个路由回调调用
        } else if (fn) {
          if (fn.length < 4) return fn(req, res, callbacks);
          callbacks();

        // 最后如果不存在回调了,那么进行一个路由匹配
        } else {
          nextRoute(err);
        }
      } catch (err) {
        callbacks(err);
      }
    }
  })(0);
};

上面的代码稍微长了点,不过有了中文注释应该可以理解~~

好困好困,这里我就简单说一下:

  1. pass函数主要就是进行下一个路由(Route)的匹配,当我们在callback里面调用next('route'),最终就会迭代地执行pass函数。
  2. paramCallback函数与app.param()相关联,主要用于处理匹配到的请求参数(这里就不细说了),处理完后调用next(),让callbacks能够访问到处理过的请求参数,从而进行一定的逻辑处理。
  3. callbacks函数,就是调用Route实例中存储的一系列回调函数,同样是通过next(),调用下一个回调,如果有错误,也可以next(new Error('xxx')),最终反馈到中间件处理那边。

最后

希望文章对读者,欢迎多多交流。

关键词:源码