Express深入理解與簡明實現

Cris_冷崢子發表於2018-03-03

導讀

我有一個問題和不太成熟的想法,不知道該不該提!

掘金既然支援目錄TOC,為什麼不能把目錄放在一個顯眼的地方,比如左邊?一大片空白不用非要放在右下角和其它皮膚搶,emmm...

  • express概要
    • 建立一個伺服器以及分發路由
    • 簡單實現1
      • 脈絡
      • 實現路由
  • .all方法和實現
    • 應用
    • 實現
      • 在app下新增.all方法(用來儲存路由資訊物件)
      • 對遍歷路由資訊物件時的規則判斷做出調整
  • 中介軟體
    • 概要
    • 中介軟體和路由的異同
      • 1.都會包裝成路由資訊物件
      • 2.匹配條數的不同
      • 3.匹配路徑上的不同
    • next與錯誤中介軟體
    • 實現
      • 在app下新增.use方法
      • 改造request方法
  • params
    • params常用屬性
    • params與動態路由
      • 實現
        • 動態路由與動態路由屬性的實現
        • params其它屬性的實現
  • .param方法
    • api一覽
    • 注意事項
    • 應用場景
    • 和中介軟體的區別
    • 實現
      • 新增param方法
      • 修改request

express概要

express是一個node模組,它是對node中http模組的二次封裝。

express相較於原生http模組,為我們提供了作為一個伺服器最重要的功能:路由。 路由功能能幫助我們根據不同的路徑不同的請求方法來返回不同的內容

除此之外express還支援 中介軟體 以及其他類似於 req.params 這些小功能。

建立一個伺服器以及分發路由

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

//針對不同的路由進行不同的返回
app.get('/eg1',function(req,res){
  res.end('hello');
});
app.post('/eg1',function(req,res){
  res.end('world');
});

app.listen(8080,function(){
  console.log(`server started at 8080`);
});
複製程式碼

可以發現,引入express後會返回一個函式,我們稱之為express。

express這個函式執行後又會返回一個物件,這個物件就是包裝後的http的server物件

這個物件下有很多方法,這些方法就是express框架為我們提供的新東東了。

上面用到了.get方法和.post方法,get和post方法能幫助我們對路由進行分發。

什麼是路由分發呢?其實就是在原生request回撥中依據請求方法請求路徑的不同來返回不同的響應內容。

就像在上面的示例中我們通過.get.post方法對路徑為/eg1的請求繫結了一個回撥函式, 但這個兩個回撥函式不會同時被呼叫,因為請求方法只能是一種(get或則post或則其它)。

如果請求方法是get請求路徑是/eg1則會返回.get中所放置的回撥

<<< 輸出
hello
複製程式碼

否則若請求路徑不變,請求方法是post則會返回.post方法中放置的回撥

<<< 輸出
world
複製程式碼

簡單實現1

脈絡

我們首先要有一個函式,這個函式執行時會返回一個app物件

function createApplication(){
  let app = function(req,res){};
  return app;
}
複製程式碼

這個app物件下還有一些方法.get.post.listen

app.get = function(){}
app.post = function(){}
app.listen = function(){}
複製程式碼

其中app.listen其實就是原生http中的server.listenapp就是原生中的request回撥。

app.listen = function(){
  let server = http.createServer(app);
  server.listen.apply(server,arguments); //事件回撥中,不管怎樣this始終指向繫結物件,這裡既是server,原生httpServer中也是如此
}
複製程式碼

實現路由

我們再來想想app.get這些方法到底做了什麼。 其實無非定義了一些路由規則,對匹配上這些規則的路由進行一些針對性的處理(執行回撥)。

上面一句話做了兩件事,匹配規則 和 執行回撥。 這兩件事執行的時機是什麼時候呢?是伺服器啟動的時候嗎?不是。 是當接收到客戶端請求的時候

這意味著什麼? 當伺服器啟動的時候,其實這些程式碼已經執行了,它們根本不會管請求是個什麼鬼,只要伺服器啟動,程式碼就執行。 所以我們需要將規則回撥存起來。(類似於釋出訂閱模式)

app.routes = []; 
app.get = function(path,handler){
  app.routes.push({
    method:'get'
    ,path
    ,handler
  })
}
複製程式碼

上面我們定義了一個routes陣列,用來存放每一條規則和規則所對應的回撥以及請求方式,即路由資訊物件

但有一個地方需要我們優化。不同的請求方法所要做的事情都是相同的(只有method這個引數不同),我們不可能每增加一個就重複的寫一次,請求的方法是有非常多的,這樣的話程式碼會很冗餘。

//http.METHODS能列出所有的請求方法
>>>
console.log(http.METHODS.length);

<<<
33
複製程式碼

So,為了簡化我們的程式碼我們可以遍歷http.METHODS來建立函式

http.METHODS.forEach(method){
  let method = method.toLowerCase();
  app[method] = function(path,handler){
    app.routes.push({
      method
      ,path
      ,handler
    })
  }
}
複製程式碼

然後我們會在請求的響應回撥中用到這些路由資訊物件。而響應回撥在哪呢? 上面我們已經說過其實app這個函式物件就是原生的request回撥。 接下來我們只需要等待請求來臨然後執行這個app回撥,遍歷每一個路由資訊物件進行匹配,匹配上了則執行對應的回撥函式。

let app = function(req,res){
  for(let i=0;i<app.routes.length;++i){
    let route = app.routes[i];
    let {pathname} = url.parse(req.url);
    if(route.method==req.method&&route.path==pathname){
    	route.handler(req,res);
    }
  }
}
複製程式碼

.all方法和實現

應用

.all也是一個路由方法,

app.all('/eg1',function(req,res){})
複製程式碼

和普通的.get,.post這些和請求方法直接繫結的路由分發不同,.all方法只要路徑匹配得上各種請求方法去請求這個路由都會得到響應。

還有一種更暴力的使用方式

app.all('*',function(req,res){})
複製程式碼

這樣能匹配所有方法所有路勁,all! 通常它的使用場景是對那些沒有匹配上的請求做出相容處理。

實現

在app下新增.all方法(用來儲存路由資訊物件)

和一般的請求方法是一樣的,只是需要一個標識用以和普通方法區分開。 這裡是在method取了一個all關鍵字作為method的值。

app.all = function(path,handler){
  app.routs.push({
    method:'all'
    ,path
    ,handler
  })
}
複製程式碼

對遍歷路由資訊物件時的規則判斷做出調整

另外還需要在request回撥中對規則的匹配判斷做出一些調整

if((route.method==req.method||route.method=='all')&&(route.path==pathname||route.path=='*')){
    route.handler(req,res);
}
複製程式碼

中介軟體

概要

中介軟體是什麼鬼呢?中介軟體嘛,顧名思義中間的件。。。emmm,我們直接說說它的作用吧!

中介軟體主要是在請求和真正響應之間再加上一層處理, 處理什麼呢?比如說許可權驗證、資料加工神馬的。

這裡所謂的真正響應,你可以把它當做.get這些路由方法所要執行的那些個回撥。

app.use('/eg2',function(req,res,next){
  //do something
  next();
})
複製程式碼

中介軟體和路由的異同

1.都會包裝成路由資訊物件

伺服器啟動時,中介軟體也會像路由那樣被儲存為一個一個路由資訊物件

2.匹配條數的不同

路由只要匹配上了一條就會立馬返回資料並結束響應,不會再匹配第二條(原則上如此)。 而中介軟體只是一個臨時中轉站,對資料進行過濾或則加工後會繼續往下匹配。 So,中介軟體一般放在檔案的上方,路由放在下方。

3.匹配路徑上的不同

中介軟體進行路徑匹配時,只要開頭匹配的上就能執行對應的回撥。

這裡所謂的開頭意思是: 假若中介軟體要匹配的路徑是/eg2, 那麼只要url.path是以/eg2開頭,像/eg2/eg2/a/eg2/a/b即可。(/eg2a這種不行,且必須以/eg2開頭,a/eg2則不行)

而路由匹配路徑時必須是完全匹配,也就是說規則若是/eg2則只有/eg2匹配的上。這裡的完全匹配其實是針對路徑的 /的數量 來說的,因為動態路由中匹配的值不是定死的。

除此之外,中介軟體可以不寫路徑,當不寫路徑時express系統會為其預設填上/,即全部匹配。

next與錯誤中介軟體

中介軟體的回撥相較於路由多了一個引數next,next是一個函式。 這個函式能讓中介軟體的回撥執行完後繼續向下匹配,如果沒有寫next也沒有在中介軟體中結束響應,那麼請求會一直處於pending狀態。

next還可以進行傳參,如果傳了慘,表示程式執行出錯,將匹配錯誤中介軟體進行處理且只會交由錯誤中介軟體處理

錯誤中介軟體相較於普通中介軟體在回撥函式中又多了一個引數err,用以接收中介軟體next()傳遞過來的錯誤資訊。

app.use('/eg2',function(req,res,next){
  //something wrong
  next('something wrong!');
})

app.use('/eg2',function(err,req,res,next){
  console.log('i catch u'+err);
  next(err); //pass to another ErrorMiddle
});

// 錯誤中間接收了錯誤資訊後仍然允許接著向下傳遞

app.use('/eg2',function(err,req,res,next){
  res.end(err);
});
複製程式碼

其實錯誤中介軟體處理完成後也能匹配路由

app.use('/eg2',function(req,res,next){
  //something wrong
  next('something wrong!');
})

app.use('/eg2',function(err,req,res,next){
  console.log('i catch u'+err);
  next(err); //pass to another ErrorMiddle
});

app.get('/eg2',function(req,res){
  //do someting
})
複製程式碼

實現

在app下新增.use方法

像路由方法一樣,其實就是用來儲存路由資訊物件

app.use = function(path,handler){
    if(typeof handler != 'function'){ //說明只有一個引數,沒有path只有handler
      handler = path;
      path = "/"
    }
    app.routes.push({
      method:'middle' //需要一個標識來區分中介軟體
      ,path
      ,handler
    });
  };
複製程式碼

改造request方法

let app = function(req,res){
	const {pathname} = url.parse(req.url, true);
    let i = 0;
	function next(err){
        if(index>=app.routes.length){ //說明路由資訊物件遍歷完了仍沒匹配上,給出提示
        	return res.end(`Cannot ${req.method} ${pathname}`);
        }
    	let route = app.routes[i++];
        if(err){ //是匹配錯誤處理中介軟體
            //先判斷是不是中介軟體
            if(route.method == 'middle'){
                //如果是中介軟體再看路徑是否匹配
                if(route.path=='/'||pathname.startsWith(route.path+'/')||route.path==pathname){
                	//再看是否是錯誤處理中介軟體
                    if(route.handler.length==4){
                        route.handler(err,req,res,next);
                    }else{
                        next(err);
                    }
                }else{
                    next(err);
                }
            }else{
            	next(err); //將err向後傳遞直到找到錯誤處理中介軟體
            }
        }else{ //匹配路由和普通中介軟體
            if(route.method == 'middle'){ //說明是中介軟體
            	if(route.path=='/'||pathname.startsWith(route.path+'/')||route.path==pathname){
                	route.handler(req,res,next);
                }else{ //此條路由沒有匹配上,繼續向下匹配
                	next();
                }
            }else{ //說明是路由
                if((route.method==req.method||route.method=='all')&&(route.path==pathname||route.path=='*')){
                //說明匹配上了
                	route.handler(req,res);
                }else{
                	next();
                }
            }
        }
    }
	next();
}
複製程式碼

我們可以把對錯誤中介軟體的判斷封裝成一個函式

function checkErrorMiddleware(route){
  if(route.method == 'middle'&&(route.path=='/'||pathname.startsWith(route.path+'/')||route.path==pathname)&&route.handler.length==4){
    return true;
  }else{
    next(err);
  }
}
複製程式碼

params

params常用屬性

express為我們在request回撥中的req物件引數下封裝了一些常用的屬性

app.get('/eg3',function(req,res){
  console.log(req.hostname);
  console.log(req.query);
  console.log(req.path);
})
複製程式碼

params與動態路由

app.get('/article/:artid',function(req,res){
  console.log(req.artid);
})

>>>
/article/8

<<<
8
複製程式碼

實現

動態路由與動態路由屬性的實現

首先因為路由規則所對應的路徑我們看得懂,但機器看不懂。 So我們需要在儲存路由資訊物件時,對路由的規則進行正則提煉,將其轉換成正則的規則。

...
app[method] = function(path,handler){
  let paramsNames = [];
  path = path.replace(/:([^\/]+)/g,function(/*/:aaa ,aaa*/){ 
    paramsNames.push(arguments[1]); //aaa
    return '([^\/]+)';  // /user/:aaa/:bbb 被提煉成 /user/([^\/]+)/([^\/]+)
  });
      
  layer.reg_path = new RegExp(path);
  layer.paramsNames = paramsNames;
}
...
複製程式碼

我們拿到了一個paramsNames包含所有路徑的分塊,並將每個分塊的值作為了一個新的param的名稱,

我們還拿到了一個reg_path,它能幫助我們對請求的路徑進行分塊匹配,匹配上的每一個子項就是我們新param的值。

request路由匹配部分做出修改

if(route.paramsNames){
    let matchers = pathname.match(req.reg_path);
    if(matchers){
    	let params = {};
        for(let i=0;i<route.paramsNames.length;++i){
            params[route.paramsNames[i]] = matchers[i+1]; //marchers從第二項開始才是匹配上的子項
        }
    	req.params = params;
    }
    route.handler(req,res);
}

複製程式碼

params其它屬性的實現

這裡是內建中介軟體,即在框架內部,它會在第一時間被註冊為路由資訊物件。

實現很簡單,就是利用url模組對req.url進行解析

app.use(function(req,res,next){
    const urlObj = url.parse(req.url,true);
    req.query = urlObj.query;
    req.path = urlObj.pathname;
    req.hostname = req.headers['host'].split(':')[0];
    next();
});
複製程式碼

.param方法

api一覽

app.param('userid',function(req,res,next,id){
  req.user = getUser(id);
  next();
});
複製程式碼

next和中介軟體那個不是一個意思,這個next執行的話會執行被匹配上的那條動態路由所對應的回撥

id為請求時userid這個路徑位置的實際值,比如

訪問路徑為:http://localhost/ahhh/9

動態路由規則為:/username/userid

userid即為9
複製程式碼

注意事項

必須配合動態路由!! param和其它方法最大的一點不同在於,它能對路徑進行擷取匹配

什麼意思呢, 上面我們講過,路由方法路徑匹配時必須完全匹配,而中介軟體路徑匹配時需要開頭一樣

param方法無需開頭一樣,也無需完全匹配,它只需要路徑中某一個分塊(即用/分隔開的每個路徑分塊)和方法的規則對上即可。

應用場景

當不同的路由中包含相同路徑分塊且使用了相同的操作時,我們就可以對這部分程式碼進行提取優化。

比如每個路由中都需要根據id獲取使用者資訊

app.get('/username/:userid/:name',function(req,res){}
app.get('/userage/:userid/:age',function(req,res){}
app.param('userid',function(req,res,next,id){
  req.user = getUser(id);
  next();
});
複製程式碼

和中介軟體的區別

相較於中介軟體它更像是一個真正的鉤子,它不存在放置的先後問題。 如果是中介軟體,一般來說它必須放在檔案的上方,而param方法不是。

導致這樣結果的本質原因在於,中介軟體類似於一個路由,它會在請求來臨時加入的路由匹配佇列中參與匹配。而param並不會包裝成一個路由資訊物件也就不會參與到佇列中進行匹配,

它的觸發時機是在它所對應的那些動態路由被匹配上時才會觸發。

實現

新增param方法

在app下新增了一個param方法,並且建立了一個paramHandlers物件來儲存這個方法所對應的回撥。

app.paramHandlers = {};
app.param = function(name,handler){
    app.paramHandlers[name] = handler; //userid
};
複製程式碼

修改request

修改request回撥中 動態路由被匹配上時的部分

當動態路由被匹配上時,通過它的動態路由引數來遍歷paramHandlers,看是否設定了對應的param回撥

if(route.paramsNames){
    let matchers = pathname.match(route.reg_path);

    if(matchers){
      let params = {};
      for(let i=0;i<route.paramsNames.length;++i){
        params[route.paramsNames[i]] = matchers[i+1];
      }
      req.params = params;
      for(let j=0;j<route.paramsNames.length;++j){
        let name = route.paramsNames[j];
        let handler = app.paramHandlers[name];
        if(handler){
        //回撥觸發更改在了這裡
        //第三個引數為next,這裡把route.handler放在了這裡,是讓param先執行再執行該條路由
          return handler(req,res,()=>route.handler(req,res),req.params[name]); 
        }else{
          return route.handler(req,res);
        }
      }

    }else{
      next();
    }
}
複製程式碼

原始碼

let http = require('http');
let url = require('url');

function createApplication() {
  //app其實就是真正的請求監聽函式

  let app = function (req, res) {
    const {pathname} = url.parse(req.url, true);
    let index = 0;
    function next(err){
      if(index>=app.routes.length){
        return res.end(`Cannot ${req.method} ${pathname}`);
      }
      let route = app.routes[index++];
      if(err){
        //先判斷是不是中介軟體
        if(route.method == 'middle'){
          //如果是中介軟體再看路徑是否匹配
          if(route.path=='/'||pathname.startsWith(route.path+'/')||route.path==pathname){
            //再看是否是錯誤處理中介軟體
            if(route.handler.length==4){
              route.handler(err,req,res,next);
            }else{
              next(err);
            }
          }else{
            next(err);
          }
        }else{
          next(err); //將err向後傳遞直到找到錯誤處理中介軟體
        }
      }else{
        if(route.method == 'middle'){ //中介軟體
          //只要請求路徑是以此中介軟體的路徑開頭即可
          if(route.path=='/'||pathname.startsWith(route.path+'/')||route.path==pathname){
            route.handler(req,res,next);
          }else{
            next();
          }
        }else{ //路由
          if(route.paramsNames){
            let matchers = pathname.match(route.reg_path);

            if(matchers){
              let params = {};
              for(let i=0;i<route.paramsNames.length;++i){
                params[route.paramsNames[i]] = matchers[i+1];
              }
              req.params = params;
              for(let j=0;j<route.paramsNames.length;++j){
                let name = route.paramsNames[j];
                let handler = app.paramHandlers[name];
                if(handler){ //如果存在paramHandlers 先執行paramHandler再執行路由的回撥
                  return handler(req,res,()=>route.handler(req,res),req.params[name]);
                }else{
                  return route.handler(req,res);
                }
              }

            }else{
              next();
            }
          }else{
            if ((route.method == req.method.toLowerCase() || route.method == 'all') && (route.path == pathname || route.path == '*')) {
              return route.handler(req, res);
            }else{
              next();
            }
          }
        }
      }

    }
    next();

  };

  app.listen = function () { //這個引數不一定
    let server = http.createServer(app);
    //server.listen作為代理,將可變引數透傳給它
    server.listen.apply(server, arguments);
  };
  app.paramHandlers = {};
  app.param = function(name,handler){
    app.paramHandlers[name] = handler; //userid
  };

  //此陣列用來儲存路由規則
  app.routes = [];
  // console.log(http.METHODS);
  http.METHODS.forEach(function (method) {
    method = method.toLowerCase();
    app[method] = function (path, handler) {
      //向陣列裡放置路由物件
      const layer = {method, path, handler};
      if(path.includes(':')){
        let paramsNames = [];
        //1.把原來的路徑轉成正規表示式
        //2.提取出變數名
        path = path.replace(/:([^\/]+)/g,function(){ //:name,name
          paramsNames.push(arguments[1]);
          return '([^\/]+)';
        });
        // /user/ahhh/12
        // /user/([^\/]+)/([^\/]+)
        layer.reg_path = new RegExp(path);
        layer.paramsNames = paramsNames;
      }

      app.routes.push(layer);
    };

  });

  //all方法可以匹配所有HTTP請求方法
  app.all = function (path, handler) {
    app.routes.push({
      method: 'all'
      , path
      , handler
    });
  };
  //新增一箇中介軟體
  app.use = function(path,handler){
    if(typeof handler != 'function'){ //說明只有一個引數,沒有path只有handler
      handler = path;
      path = "/"
    }
    app.routes.push({
      method:'middle' //需要一個標識來區分中介軟體
      ,path
      ,handler
    });
  };
  //系統內建中介軟體,用來為請求和響應物件新增一些方法和屬性
  app.use(function(req,res,next){
    const urlObj = url.parse(req.url,true);
    req.query = urlObj.query;
    req.path = urlObj.pathname;
    req.hostname = req.headers['host'].split(':')[0];
    next();
  });
  return app;
}

module.exports = createApplication;
複製程式碼

相關文章