Express原始碼級實現の路由全解析(上闋)

Cris_冷崢子發表於2018-03-08
  • Pre-Notify
  • 專案目錄
  • express.js 和 application.js
  • app物件之http伺服器
  • app物件之路由功能
    • 註冊路由
    • 介面實現
    • 分發路由
    • 介面實現
  • router
    • 測試用例1與功能分析
    • 功能實現
      • router和route
      • layer
      • 註冊路由
      • 註冊流程圖
      • 路由分發
      • 分發流程圖
    • 測試用例2與功能分析
    • 功能實現
  • Q
    • 為什麼選用next遞迴遍歷而不選用for?
    • 我們從Express的路由系統設計中能學到什麼?
  • 原始碼

Pre-Notify

閱讀本文前可以先參考一下我之前那篇簡單版的express實現的文章。

Express深入理解與簡明實現

相較於之前那版,此次我們將實現Express所有核心功能。

預計分為:路由篇(上、下)、中介軟體篇(上、下)、炸雞篇~

(づ ̄ 3 ̄)づ Let's Go!

專案目錄

iExpress/
|
|   
| - application.js  #app物件
|
| - html.js         #模板引擎
|
| - route/
|   | - index.js    #路由系統(router)入口
|   | - route.js    #路由物件
|   | - layer.js    #router/route層
|
| - middle/
|   | - init.js     #內建中介軟體
|
| - test-case/
|    | - 測試用例檔案1
|    | - ...
|
·- express.js       #框架入口
複製程式碼

express.js 和 application.js

在簡單版Express實現中我們已經知道,將express引入到專案後會返回一個函式,當這個函式執行後會返回一個app物件。(這個app物件是原生http的超集

其中,express.js模組匯出的就是那個執行後會返回app物件的函式

let express = require('./express.js');
let app = express(); //app物件是原生http物件的超集
...
app.listen(8080); //呼叫的其實就是原生的server.listen
複製程式碼

上個版本中因為實現的功能較簡單,只用了一個express.js檔案就搞定了,而在這個版本中我們需要專門用一個模組application.js來存放app相關的部分

//express.js
const Application = require('./application.js'); //app

function createApplication(){
	return new Application(); //app物件
}

module.exports = createApplication;
複製程式碼

app物件之http伺服器

app物件 最重要的一個作用是用來啟動一個http伺服器,通過app.listen方法我們能間接呼叫到原生的.listen方法來啟動一個伺服器。

//application.js
function Application(){}
Application.prototype.listen = function(){
    function done(){}
    let server = http.createServer(function(req,res,done){
    	...
    })
    server.listen.apply(server,arguments);
}
複製程式碼

app物件之路由功能

app物件的另外一個重要作用,也就是Express框架的主要作用是實現路由功能。

路由功能是個蝦?

路由功能能讓伺服器針對客戶端不同的請求路徑和請求方法做出不同的回應。

而要實現這個功能我們需要做兩件事情:註冊路由路由分發

[warning] 為了保證app物件作為介面層的清晰明瞭,app物件只存放介面,而真正實現部分是委託給路由系統(router.js)來處理的。

註冊路由

當一個請求來臨時,我們可以依據它的請求方式和請求路徑來決定伺服器是否給予響應以及怎麼響應。

而我們怎麼讓伺服器知道哪些請求該給予響應以及怎樣響應呢? 這就是註冊路由所要做的事情了。

在伺服器啟動時,我們需要對伺服器想要給予回應的請求做上記錄,先存起來,這樣在請求來臨的時候伺服器就能對照這些記錄分別作出響應。

[warning]注意 每一條記錄都對應一條請求,記錄中一般都包含著這條請求的請求路徑和請求方式。但一條請求不一定只對應一條記錄(中介軟體、all方法什麼的)。

介面實現

我們通過在 app物件 上掛載.get.post這一類的方法來實現路由的註冊。

其中.get方法能匹配請求方式為get的請求,.post方法能匹配請求方式為post的請求。

請求方式一共有33種,每一種都對應一個app下的方法,emmm...我們不可能寫33遍吧?So我們需要利用一個methods包來幫助我們減少程式碼的冗餘。

const methods = require('methods');
// 這個包是http.METHODS的封裝,區別在於原生的方法名全文大寫,後者全為小寫。

methods.forEach(method){
    Application.prototype[method] = function(){
    	//記錄路由資訊到路由系統(router)
        this._router[method].apply(this._router,slice.call(arguments));
        return this; //支援app.get().get().post().listen()連寫
    }
}

//以上程式碼是以下的簡寫
Application.prototype.get = fn
Application.prototype.post = fn
...
複製程式碼

[info] 可以發現,app.get等只是一個對外介面,實際要做的事情我們都是委託給router這個類來做的。

分發路由

當請求來臨時我們就需要依據記錄的路由資訊來作出對應的響應了,這個過程我們稱之為分發路由/dispatch

上面是廣義的分發路由的含義,但其實分發路由其實包括兩個過程,匹配路由分發路由

  • 匹配路由 當一個請求來臨時,我們需要知道我們所記錄的路由資訊中是否囊括這條請求。(如果沒有囊括,一般來說伺服器會對客戶端作出一個提示性的回應)
  • 分發路由 當路由匹配上,則會執行被匹配上的路由資訊中所儲存的回撥。

介面實現

Application.prototype.listen = function(){
    let self = this;
    
    let server = http.createServer(function(req,res){
        function done(){ //沒有匹配上路由時的回撥
            res.end(`Cannot ${req.method} ${req.url}`);
        }
        //將路由匹配的具體處理交給路由系統的handle方法
    	//handle方法中會對匹配上的路由再進行路由分發
    	self._router.handle(req,res,done); 
    })
    server.listen.apply(server,arguments);
}
複製程式碼

router

測試用例1與功能分析

const express = require('../lib/express');
const app = express();

app
  .get('/hello',function(req,res,next){
    res.write('hello,');
    next(); 
  },function(req,res,next){
    res.write('world');
    next();
  })
  .get('/other',function(req,res,next){
    console.log('不會走這裡');
    next();
  })
  .get('/hello',function(req,res,next){
    res.end('!');
  })
.listen(8080,function(){
  let tip = `server is running at 8080`;
  console.log(tip);
});

<<< 輸出
hello,world!
複製程式碼

相較於之前簡單版的express實現,完整的express還支援同一條路由同時新增多個cb,以及分開對同一條路由新增cb

這是怎麼辦到的呢?

最主要的是,我們儲存路由資訊時,將路由方法組織成了一種類似於二維陣列的二維資料形式

即在router(路由容器)裡存放一層層route,而又在每一層route(路由)裡再存放一層層callbcak

這樣我們通過遍歷router中的route,匹配上一個route後,就能在這個route下找到所這個route註冊的callbacks。

功能實現

router和route

router(路由容器)裡存放一層層route,而又在每一層route(路由)裡再存放一層層callbcak

首先我們需要在有兩個建構函式來生產我們需要的router和route物件。

//router/index.js
function Router(){
    this.stack = [];
}
複製程式碼
//router/route.js
function Route(path){
    this.path = path;
    this.stack = [];
    this.methods = {};
}
複製程式碼

接著,我們在Router和Route中生產出的物件下都開闢了一個stack,這個stack用來存放一層層的層/layer。這個layer(層),在Router和Route中所存放的東東是不一樣的,在router中存放的是一層層的route(即Route的例項),而route中存放的是一層層的方法

它們各自的stack裡存放的物件大概是長這樣的

//router.stack
[
    {
    	path
        handler
    }
    ,{
    	...
    }
]

//route.stack
[
    {
    	handler	
    }
    ,{
    	...
    }
]
複製程式碼

可以發現,這兩種stack裡存放的物件都包含handler,並且第一種還包含一個path。

第一種包含path,這是因為在router.stack遍歷時是匹配路由,這就需要比對path

而兩種都需要有一個handler屬性是為什麼呢?

我們很容易理解第二個stack,route.stack裡存放的就是我們設計時準備要存放的callbacks那第一個stack裡的handler存放的是什麼呢?

當我們路由匹配成功時,我們需要接著遍歷這個路由,這個route,這就意味著我們需要個鉤子在我們路由匹配成功時執行這個操作,這個遍歷route.stack的鉤子就是第一個stack裡物件所存放的handler(即是下文中的route.dispatch方法)。

layer

實際專案中我們將router.stackroute.stack裡存放的物件們封裝成了同一種物件形式——layer

一方面是為了語義化,一方面是為了把對layer物件(原本的routes物件和methods物件)進行操作的方法都歸納到layer物件下,以便維護。

// router/layer.js
function Layer(path,handler){
    this.path = path;  //如果這一層代表的存放的callbcak,這為任意路徑即可
    this.handler =handler;
}
//路由匹配時,看路徑是否匹配得上
Layer.prototype.match = function(path){
    return this.path === path?true:false;
}
複製程式碼

註冊路由

//在router中註冊route

http.METHODS.forEach(METHOD){
    let method = METHOD.toLowercase();
    Router.prototype[method] = function(path){
    	let route = this.route(path); //在router.stack裡儲存一層層route
        route[method].apply(route,slice.call(arguments,1)); //在route.stack裡儲存一層層callbcak
    }
}

Router.prototype.route = function(path){
    let route = new Route(path);
    let layer = new Layer(path,route.dispatch.bind(route)); //註冊路由分發函式,用以在路由匹配成功時遍歷route.stack
    layer.route = route; //用以區分路由和中介軟體
    this.stack.push(layer);
    
    return route;
}
複製程式碼
//在route中註冊callback

http.METHODS.forEach(METHOD){
    let method = METHOD.toLowercase();
    Route.prototype[method] = function(){
    	let handlers = slice.call(arguments);
        this.methods[method] = true; //用以快速匹配
        for(let i=0;i<handlers.length;++i){
        	let layer = new Layer('/',handler[i]);
            layer.method = method; //在遍歷route中的callbacks依據請求方法進行篩選
        	this.stack.push(layer);
        }
        return this; //為了支援app.route(path).get().post()...
    }
}
複製程式碼
註冊流程圖

Express原始碼級實現の路由全解析(上闋)

路由分發

整個路由分發就是遍歷我們之前用router.stackroute.stack所組成的二維資料結構的過程。

我們將遍歷router.stack的過程稱之為匹配路由,將遍歷route.stack的過程稱之為路由分發

匹配路由:

// router/index.js

Router.prototype.handle = function(req,res,done){
    let self = this,i = 0,{pathname} = url.parse(req.url,true);
    function next(err){ //err主要用於錯誤中介軟體 下一章再講
    	if(i>=self.stack.length){
    	    return done;
        }
    	let layer = self.stack[i++];
        if(layer.match(pathname)){ //說明路徑匹配成功
    	    if(layer.route){ //說明是路由
            	if(layer.route.handle_method){ //快速匹配成功,說明route.stack裡存放有對應請求型別的callbcak
            	    layer.handle_request(req,res,next);
                }else{
            	    next(err);
                }
            }else{ //說明是中介軟體
            	//下一章講先跳過
                next(err);
            }
        }else{
        	next(err);
        }
    }
    next();
}
複製程式碼

路由分發

上面在我們最終匹配路由成功時,會執行layer.handle_request方法

// layer.js中

Layer.prototype.handle_request = function(req,res,next){
    this.handler(req,res,next);
}
複製程式碼

此時的handler為route.dispatch (忘記的同學可以往上檢視註冊路由部分)

//route.js中

Route.prototype.dispatch = function(req,res,out){ //注意這個out接收的是遍歷route.stack時的next()
    let self = this,i =0;
    
    function next(err){
    	if(err){ //說明回撥執行錯誤,跳過當前route.stack的遍歷交給錯誤中介軟體來處理
    	    return out(err);
        }
    	if(i>=self.stack.length){
    	    return out(err); //說明當前route.stack遍歷完成,繼續遍歷router.stack,進行下一條路由的匹配
        }
    	let layer = self.stack[i++];
        if(layer.method === req.method){
    	    self.handle_request();
        }else{
    	    next(err);
        }
    }
    next();
}
複製程式碼
分發流程圖

Express原始碼級實現の路由全解析(上闋)

測試用例2與功能分析

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

app
  .route('/user')
  .get(function(req,res){
    res.end('get');
  })
  .post(function(req,res){
    res.end('post');
  })
  .put(function(req,res){
    res.end('put');
  })
  .delete(function(req,res){
    res.end('delete');
  })
.listen(3000);
複製程式碼

以上是一種resful風格的藉口寫法,如果理清了我們上面的東東,其實這個實現起來相當簡單。

無非就是在呼叫.route()方法的時候返回我們的route(route.stack裡的一層),這樣再呼叫.get等其實就是呼叫Route.prototype.get等了,就能夠順利往這一層的route裡新增不同的callbcak了。

[warning] 注意: .listen此時不能與其它方法名連用,因為.get等此時返回的是route而不是app

功能實現

//application.js中

Application.prototype.route = function(path){
    this.lazyrouter();
    let route = this._router.route(path);
    return route;
}
複製程式碼

另外要注意的是,需要讓 route.prototype[method] 返回route以便連續呼叫。

So easy~

Q

為什麼選用next遞迴遍歷 而不 選用for?

emmm...我想說express原始碼是這麼設計的,嗯,這個答案好不好?ლ(′◉❥◉`ლ)

其實可以用for的哦,我有試過的啦,

修改router/index.js 下的 handle方法如下

 let self = this
    ,{pathname} = url.parse(req.url,true);

  for(let i=0;i<self.stack.length;++i){
    if(i>=self.stack.length){
      return done();
    }
    let layer = self.stack[i];
    if(layer.match(pathname)){
      if(!layer.route){
    
      }else{
    
        if(layer.route&&layer.route.handle_method(req.method)){
          // let flag = layer.handle_request(req,res);
    
          for(let j=0;j<layer.route.stack.length;++j){
            let handleLayer = layer.route.stack[j];
            if(handleLayer.method === req.method.toLowerCase()){
              handleLayer.handle_request(req,res);
              if(handleLayer.stop){
                return;
              }
            }
          }//遍歷handleLayer
    
        }//快速匹配成功
    
      }//說明是路由
    
    }//匹配路徑
  }
複製程式碼

我們呼叫.get等方法時就不再需要傳遞next和傳入next引數

app
  .get('/hello',function(req,res){
    res.write('hello,');
    // this.stop = true;
    this.error = true; //交給錯誤處理中介軟體來處理。。 中介軟體還沒實現,但原則上來說是能行的
    // next(); 
  },function(req,res,next){
    res.write('world');
    this.stop = true; //看這裡!!!!!!!!!!!!layer遍歷將在這裡結束
    // next();
  })
  .get('/other',function(req,res){
    console.log('不會走這裡');
    // next();
  })
  .get('/hello',function(req,res){
    res.end('!'); 	//不會執行,在上面已經結束了
  })
.listen(8080,function(){
  let tip = `server is running at 8080`;
  console.log(tip);
});
複製程式碼

在上面這段程式碼中this.stop=true的作用就相當於不呼叫next(),而不在回撥身上掛載this.stop時就相當於呼叫了next()。

原理很簡單,就是在遍歷每一層route.stack時(注意是route的stack不是router的stack),檢查layer.handler是否設定了stop,如果設定了就停止遍歷,不論是路由layer(router.stack)的遍歷還是callbacks layer(route.stack)的遍歷。

那麼問題來了,有什麼理由非要用next來遍歷嗎?

答案是:for無法支援非同步,而next能!

這裡的支援非同步是指,當一個callbcak執行後需要拿到它的非同步結果在下一個callbcak執行時用到

嗯...for就幹不成這事了,for無法感知它執行的函式中是否呼叫了非同步函式,也不知道這些非同步函式什麼能執行完畢。

我們從Express的路由系統設計中能學到什麼?

emmm...私認為layer這個抽象還是不錯的,把對每一層(不關心它具體是route還是callback)的層級相關操作都封裝掛載到這個物件下,嗯。。。回顧了一下類誕生的初衷~

當然next這種鉤子式遞迴遍歷也是可以的,我們知道了它的應用場景,支援非同步~

emmm...學到什麼...我們不僅要模仿寫一個框架,更重要的是,嗯..要思考!要思考!同學們,學到了個什麼,要學以致用...嗯...嘿哈!

所以我半夜還在碼這篇文章到底學到了個蝦??emmm...

世界那麼大——

原始碼

倉庫地址:點選獲取原始碼


To be continue...

相關文章