精华 从express源码中探析其路由机制
发布于 9 年前 作者 zjh-neverstop 24787 次浏览 最后一次编辑是 8 年前 来自 分享

引言

        在web开发中,一个简化的处理流程就是:客户端发起请求,然后服务端进行处理,最后返回相关数据。不管对于哪种语言哪种框架,除去细节的处理,简化后的模型都是一样的。客户端要发起请求,首先需要一个标识,通常情况下是URL,通过这个标识将请求发送给服务端的某个具体处理程序,在这个过程中,请求可能会经历一系列全局处理,比如验证、授权、URL解析等,然后定位到某个处理程序进行业务处理,最后将生成的数据返回客户端,客户端将数据结合视图模版呈现出合适的样式。这个过程涉及到的模块比较多,本文只探讨前半部分,也就是从客户端请求到服务器端处理程序的过程,也可以叫做路由(其实就是如何定位到服务端处理程序的过程)。   为了作为对比,先简单介绍一下asp.net webform和asp.net mvc是如何实现路由的。   asp.net webform比较特殊,由于是postback原理,定位处理程序的过程与mvc是不一样的,对URL的格式没有严格的要求,url是直接对应后台文件的,aspx中的服务器表单默认是发送到对应的aspx.cs文件,它的定位是借助aspx页面中的两个隐藏域(__EVENTTARGET和__EVENTARGUMENT)以及IPostBackEventHandler接口来实现的,通过这两样东西就可以直接定位到某个具体方法中,通常是某个控件的某个事件处理程序。也就是说,在webform中,url仅能将请求定位到类中,要定位到真正的处理程序(方法)中,还需借助其他手段。   asp.net mvc与webform不同,url不再对应到后台文件,那么就必须通过某种手段来解析url,mvc中的后台处理程序称为Action,位于Controller类中,为了使url能够定位到action,mvc中的url有比较严格的格式要求,在url中需要包含controller和action,这样后台就可以通过反射来动态生成controller实例然后调用对应的action。也就是说,在mvc中完全依靠url来实现后台处理程序的定位。   通过上面两种方式的分析,我们发现url是不是指向文件是无所谓的,但最终都是要根据其定位到某个具体的处理程序,也就是url到handler有个路由处理过程,只不过不同的框架有不同的处理方法。在express框架的使用过程中,隐隐约约感觉其路由过程如下图所示: route1.png 到底是不是这样呢?

源码分析

我们知道,在使用express的时候,我们可以通过如下的方式来注册路由:

app.get("/",function(req,res){
    res.send("hello啊"); 
}); 

从表面上看,get方法可以将url中的path与后台处理程序关联起来,为了弄清楚这个过程,我们可以到application.js文件中查看源码。第一次看了一眼,发现里面居然没有这个方法,app.get(),app.post()等都没找到,仔细再一看,发现了如下方法:

methods.forEach(function(method){
  app[method] = function(path){
	if ('get' == method && 1 == arguments.length) return this.set(path);  //get的特殊处理,只有一个参数时会获取app.settings[path]
	this.lazyrouter();  
	var route = this._router.route(path);
	route[method].apply(route, slice.call(arguments, 1));   //取出第二个参数,即:处理程序,传入route[method]方法中
	return this;
  };
});

原来,这些方法都是动态添加的。methods是一个数组,里面存放了一系列web请求方法,以上方法通过对其进行遍历,给app添加了与请求方法同名的一系列方法,即:app.get()、app.post()、app.put()等,在这些方法中,首先通过调用lazyrouter实例化一个Router对象,然后调用this._router.route方法实例化一个Route对象,最后调用route[method]方法并传入对应的处理程序完成path与handler的关联。   在这个方法中需要注意以下几点:

  1. lazyrouter方法只会在首次调用时实例化Router对象,然后将其赋值给app._router字段
  2. 要注意Router与Route的区别,Router可以看作是一个中间件容器,不仅可以存放路由中间件(Route),还可以存放其他中间件,在lazyrouter方法中实例化Router后会首先添加两个中间件:query和init;而Route仅仅是路由中间件,封装了路由信息。Router和Route都各自维护了一个stack数组,该数组就是用来存放中间件和路由的。

        这里先声明一下,本文提到的路由容器(Router)代表“router/index.js”文件的到导出对象,路由中间件(Route)代表“router/route.js”文件的导出对象,app代表“application.js”的导出对象。   Router和Route的stack是有差别的,这个差别主要体现在存放的layer(layer是用来封装中间件的一个数据结构)不太一样, route2.png 由于Router.stack中存放的中间件包括但不限于路由中间件,而只有路由中间件的执行才会依赖与请求method,因此Router.stack里的layer没有method属性,而是将其动态添加(layer的定义中没有method字段)到了Route.stack的layer中;layer.route字段也是动态添加的,可以通过该字段来判断中间件是否是路由中间件。         可以通过两种方式添加中间件:app.use和app[method],前者用来添加非路由中间件,后者添加路由中间件,这两种添加方式都在内部调用了Router的相关方法来实现:

//添加非路由中间件
proto.use = function use(fn) {
  /* 此处略去部分代码 */
  callbacks.forEach(function (fn) {
	if (typeof fn !== 'function') {
	  throw new TypeError('Router.use() requires middleware function but got a ' + gettype(fn));
	}
	// add the middleware
	debug('use %s %s', path, fn.name || '<anonymous>');
	////实例化layer对象并进行初始化
	var layer = new Layer(path, {
	  sensitive: this.caseSensitive,
	  strict: false,
	  end: false
	}, fn);
	//非路由中间件,该字段赋值为undefined
	layer.route = undefined;
	this.stack.push(layer);
  }, this);
  return this;
};

//添加路由中间件
proto.route = function(path){
  //实例化路由对象
  var route = new Route(path);
  //实例化layer对象并进行初始化
  var layer = new Layer(path, {
	sensitive: this.caseSensitive,
	strict: this.strict,
	end: true
  }, route.dispatch.bind(route));
  //指向刚实例化的路由对象(非常重要),通过该字段将Router和Route关联来起来
  layer.route = route;
  this.stack.push(layer);
  return route;
};

对于路由中间件,路由容器中的stack(Router.stack)里面的layer通过route字段指向了路由对象,那么这样一来,Router.stack就和Route.stack发生了关联,关联后的示意模型如下图所示: route3.png 在运行过程中,路由容器(Router)只会有一个实例,而路由中间件会在每次调用app.route、app.use或app[method]的时候生成一个路由对象,在添加路由中间件的时候路由容器相当于是一个代理,Router[method]实际上是在内部调用了Route[method]来实现路由添加的,路由容器中有一个route方法,相当于是路由对象创建工厂。通过添加一个个的中间件,在处理请求的时候会按照添加的顺序逐个调用,如果遇到路由中间件,会逐个调用该路由对象中stack数组里存放的handler,这就是express的流式处理,是不是有点类似asp.net中的管道模型,调用过程如下图所示: route4.png   我们可以做个测试,在终端执行"express -e expresstest"命令(需要先安装express和express-generator),然后在"expresstest/app.js"文件中添加下面代码:

//添加非路由中间件
app.use('/test',function(req,res,next){console.log("app.use('/test') handler1");next()},function(req,res,next){console.log("app.use('/test') handler2");next()});
var r = app.route('/test'); //创建路由对象,并通过route[method]来添加路由中间件
r.get(function(req,res,next){
	console.log("route.get('/test') handler1");
	next();
}).get(function(req,res,next){
	console.log("route.get('/test') handler2");
	next();
});
/* 还可以这么写,直接传入多个function
r.get(function(req,res,next){
	console.log("route.get('/test') handler1");
	next();
},function(req,res,next){
	console.log("route.get('/test') handler2");
	next();
});
*/
/*
或者这么写,直接传入function数组,可以是多维数组
r.get([function(req,res,next){
	console.log("route.get('/test') handler1");
	next();
},[function(req,res,next){
	console.log("route.get('/test') handler2");
	next();
},function(req,res,next){
	console.log("route.get('/test') handler3");
	next();
}]]);
*/
app.get('/test',function(req,res,next){ //通过app[method]来添加路由中间件
	console.log("app.get('/test') handler1");
	next();
}).get('/test',function(req,res){
  console.log("app.get('/test') handler2");
   res.end();
});

在终端中输入"cd expresstest"、“npm start"来启动express,然后在浏览器中输入"http://localhost:3000/test”,我们发现在终端中输出的内容与我们之前分析的完全一致,如下图所示: route6.png 在示例中,我们通过app[method]和route[method]这两种方式来添加了路由中间件,从源码中可以看出这里有个很大的区别,app[method]方法中有这么一句代码:var route = this._router.route(path);,this._router.route()方法内部会实例化一个Route并返回,也就是说,每次调用app[method]都会重新创建一个新的Route对象,后面的处理程序就会添加到这个新Route对象的stack中,虽然可以通过链式写法来添加路由中间件,但每个处理程序都不在一个stack中(不过这样也不影响程序的执行);而route[method]则不同,该方法添加完路由中间件后会返回自身,在路由对象上调用method方法会把所有的处理程序全部添加在该对象的stack中,不过在使用route[method]之前需要先手动实例化一个Route对象。route[method]方法的处理手段与app[method]有所不同,不仅可以同时处理多个function参数,并且通过这句代码:var callbacks = utils.flatten([].slice.call(arguments));可以将arguments中的多位数组转换为一维数组,这样就使得参数的传入变得非常灵活。

中间件的添加主要依靠application.js、router/index.js和router/route.js这三个文件的导出对象(app,Router,Route)相互调用完成的,从三个文件的require上来看,app依赖Router,Router依赖Route,下面是app.use的代码:

app.use = function use(fn) {
  var offset = 0;  //该变量用来在arguments中定位handler的起始位置,在没有传入path的时候,handler是arguments的第一个元素,所以为0
  var path = '/';  //没有传入path参数的时候,默认为"/"
  // default path to '/'
  // disambiguate app.use([fn])
  if (typeof fn !== 'function') {
	var arg = fn;
	while (Array.isArray(arg) && arg.length !== 0) { //如果第一个参数是数组的话,取出数组第一个元素
	  arg = arg[0];
	}
	// first arg is the path
	if (typeof arg !== 'function') {  //如果arg不是function,将其作为path来处理
	  offset = 1;
	  path = fn;
	}
  }
  var fns = flatten(slice.call(arguments, offset)); //从参数中取出处理函数列表
  if (fns.length === 0) {
	throw new TypeError('app.use() requires middleware functions');
  }
  // setup router
  this.lazyrouter();  //实例化Router,并将其赋值给this._router
  var router = this._router;
  fns.forEach(function (fn) {  //遍历参数中的function,逐个调用router.use,从这个地方可以看出,app.use()中可以传入多个function,将其都添加到stack中
	// non-express app
	if (!fn || !fn.handle || !fn.set) {
	  return router.use(path, fn);
	}
	debug('.use app under %s', path);
	fn.mountpath = path;
	fn.parent = this;	
	// restore .app property on req and res
	router.use(path, function mounted_app(req, res, next) {
	  var orig = req.app;
	  fn.handle(req, res, function (err) {
		req.__proto__ = orig.request;
		res.__proto__ = orig.response;
		next(err);
	  });
	});	
	// mounted an app
	fn.emit('mount', this);
  }, this);
  return this;
};

从代码中可以看出,调用app.use的时候可以传入多个function,如果给指定路径添加function的话,路径要作为第一个参数,比如: app.use(’/test’,function(req,res,next){console.log(“use1”);next()},function(req,res,next){console.log(“use2”);next()}); app.use通过调用this._router.use来实现非路由中间件的添加。this._router.use的代码上面已经贴出,path的判断与app.use前面部分一样,在该该方法中实例化layer并赋值,然后加入this._router.stack中。 app[method]的代码上面已经说过,这里就不再说了,下面是app.use和app[method]的执行流程,从图中可以看出三个文件的联系: route5.png 对于Router还有一点需要说明一下,在其构造函数中有这么一句代码:router.proto = proto;,通过router的__proto__属性将其原型指向了proto对象,从而获得了proto中定义的各个方法。

总结

        啰啰嗦嗦了这么多,最后总结一下吧。

  1. 首先对于引言中的那个路由图,基本上是对的,只不过express要面临各种中间件的添加,所以将path与handler做了进一步的封装(Layer),然后将layer保存在Router.stack数组中。
  2. app.use用来添加非路由中间件,app[method]添加路由中间件,中间件的添加需要借助Router和Route来完成,app相当于是facade,对添加细节进行了包装。
  3. Router可以看做是一个存放了中间件的容器。对于里面存放的路由中间件,Router.stack中的layer有个route属性指向了对应的路由对象,从而将Router.stack与Route.stack关联起来,可以通过Router遍历到路由对象的各个处理程序。
14 回复

膜拜大牛! 分析的很透彻!

支持,顶上。

我们发现在终端中输出的内容与我们之前分析的完全一致…

我想说我不理解,分析的结果是什么?求教 @alsotang 不管是用app.get,还是用app.use,还是route.get,执行的结果顺序只与注册的先后顺序有关,不是吗?

app.get('/test',function(req,res,next){ //通过app[method]来添加路由中间件
	 console.log("app.get('/test') handler1");
 next();
}).get('/test',function(req,res,next){
	  console.log("app.get('/test') handler2");
	  next();
});
var r = app.route('/test'); //创建路由对象,并通过route[method]来添加路由中间件
r.get(function(req,res,next){
  console.log("route.get('/test') handler1");
 next();
}).get(function(req,res,next){
	 console.log("route.get('/test') handler2");
	next();
});
app.use('/test',function(req,res,next){console.log("app.use('/test') handler1");next()},function(req,res,next){console.log("app.use('/test') handler2");next()});

执行结果是 app.get(’/test’) handler1 app.get(’/test’) handler2 route.get(’/test’) handler1 route.get(’/test’) handler2 app.use(’/test’) handler1 app.use(’/test’) handler2

不错,很用心

@jinceon 对,执行的结果顺序只与注册的先后顺序有关,文中有描述,通过添加一个个的中间件,在处理请求的时候会按照添加的顺序逐个调用,如果遇到路由中间件,会逐个调用该路由对象中stack数组里存放的handler,这段代码就是验证了执行顺序与添加顺序是对应的,只不过是使用了多种写法来添加中间件,仅仅是为了展示express添加中间件的灵活性

学习了

来自酷炫的 CNodeMD

先收藏以后有能力在细看

赞 需要多读几次

回到顶部