从express源码中探析其路由机制
引言
在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框架的使用过程中,隐隐约约感觉其路由过程如下图所示:
到底是不是这样呢?
源码分析
我们知道,在使用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的关联。
在这个方法中需要注意以下几点:
- lazyrouter方法只会在首次调用时实例化Router对象,然后将其赋值给app._router字段
- 要注意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是用来封装中间件的一个数据结构)不太一样,
由于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发生了关联,关联后的示意模型如下图所示:
在运行过程中,路由容器(Router)只会有一个实例,而路由中间件会在每次调用app.route、app.use或app[method]的时候生成一个路由对象,在添加路由中间件的时候路由容器相当于是一个代理,Router[method]实际上是在内部调用了Route[method]来实现路由添加的,路由容器中有一个route方法,相当于是路由对象创建工厂。通过添加一个个的中间件,在处理请求的时候会按照添加的顺序逐个调用,如果遇到路由中间件,会逐个调用该路由对象中stack数组里存放的handler,这就是express的流式处理,是不是有点类似asp.net中的管道模型,调用过程如下图所示:
我们可以做个测试,在终端执行"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",我们发现在终端中输出的内容与我们之前分析的完全一致,如下图所示:
在示例中,我们通过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]的执行流程,从图中可以看出三个文件的联系:
对于Router还有一点需要说明一下,在其构造函数中有这么一句代码:router.__proto__ = proto;,通过router的__proto__属性将其原型指向了proto对象,从而获得了proto中定义的各个方法。
总结
啰啰嗦嗦了这么多,最后总结一下吧。
- 首先对于引言中的那个路由图,基本上是对的,只不过express要面临各种中间件的添加,所以将path与handler做了进一步的封装(Layer),然后将layer保存在Router.stack数组中。
- app.use用来添加非路由中间件,app[method]添加路由中间件,中间件的添加需要借助Router和Route来完成,app相当于是facade,对添加细节进行了包装。
- Router可以看做是一个存放了中间件的容器。对于里面存放的路由中间件,Router.stack中的layer有个route属性指向了对应的路由对象,从而将Router.stack与Route.stack关联起来,可以通过Router遍历到路由对象的各个处理程序。