express 原始碼閱讀

W_Z_C發表於2017-09-19

1. 簡介

這篇文章主要的目的是分析理解express的原始碼,網路上關於原始碼的分析已經數不勝數,這篇文章準備另闢蹊徑,仿製一個express的輪子,通過測試驅動的開發方式不斷迭代,正向理解express的程式碼。

這篇文章中的express原始碼是參考官網最新版本(v4.15.4),文章的整體思路是參考早期創作的另一篇文章,這篇算是其升級版本。

如果你準備通過本文學習express的基本原理,前提條件最好有一定的express使用經驗,寫過一兩個基於express的應用程式,否則對於其背後的原理理解起來難以產生共鳴,不易掌握。

程式碼連結:github.com/WangZhechao…

2. 框架初始化

在仿製express框架前,首先完成兩件事。

  • 確認需求。
  • 確認結構。

首先確認需求,從express的官方網站入手。網站有一個Hello world 的事例程式,想要仿製express,該程式肯定需要通過測試,將改程式碼複製儲存到測試目錄test/index.js

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

app.get('/', function (req, res) {
  res.send('Hello World!')
})

app.listen(3000, function () {
  console.log('Example app listening on port 3000!')
})複製程式碼

接下來,確認框架的名稱以及目錄結構。框架的名稱叫做expross。目錄結構如下:

expross
  |
  |-- lib
  |    | 
  |    |-- expross.js
  |
  |-- test
  |    |
  |    |-- index.js
  |
  |-- index.js複製程式碼

expross/index.js檔案載入lib目錄下的expross.js檔案。

module.exports = require('./lib/expross');複製程式碼

通過測試程式前兩行可以推斷lib/expross.js匯出結果應該是一個函式,所以在expross.js檔案中新增如下程式碼:

function createApplication() {
  return {};
}

exports = module.exports = createApplication;複製程式碼

測試程式中包含兩個函式,所以暫時將createApplication函式體實現如下:

function createApplication() {
    return {
        get: function() {
            console.log('expross().get function');
        },

        listen: function() {
            console.log('expross().listen function');
        }
    }
}複製程式碼

雖然無法得到想要的結果,但至少可以將測試程式跑通,函式的核心內容可以在接下來的步驟中不斷完善。

至此,初始框架搭建完畢,修改test/index.js檔案的前兩行:

const expross = require('../');
const app = expross();複製程式碼

執行node test/index.js檢視結果。

2. 第一次迭代

本節是expross的第一次迭代,主要實現的目標是將當前的測試用例功能完整實現,一共分兩部分:

  • 實現http伺服器。
  • 實現get路由請求。

實現http伺服器比較簡單,可以參考nodejs官網的實現。

const http = require('http');

const hostname = '127.0.0.1';
const port = 3000;

const server = http.createServer((req, res) => {
  res.statusCode = 200;
  res.setHeader('Content-Type', 'text/plain');
  res.end('Hello World\n');
});

server.listen(port, hostname, () => {
  console.log(`Server running at http://${hostname}:${port}/`);
});複製程式碼

參考該案例,實現exprosslisten函式。

listen: function(port, cb) {
    var server = http.createServer(function(req, res) {
        console.log('http.createServer...');
    });

    return server.listen(port, cb);
}複製程式碼

當前listen函式包含了兩個引數,但是http.listen有很多過載函式,為了和http.listen一致,可以將函式設定為http.listen的“代理”,這樣可以保持expross().listenhttp.listen的引數統一。

listen: function(port, cb) {
    var server = http.createServer(function(req, res) {
        console.log('http.createServer...');
    });

      //代理
    return server.listen.apply(server, arguments);
}複製程式碼

listen函式中,我們攔截了所有http請求,每次http請求都會列印http.createServer ...,之所以攔截http請求,是因為expross需要分析每次http請求,根據http請求的不同來處理不同的業務邏輯。

在底層:

一個http請求主要包括請求行、請求頭和訊息體,nodejs將常用的資料封裝為http.IncomingMessage類,在上面的程式碼中req就是該類的一個物件。

每個http請求都會對應一個http響應。一個http響應主要包括狀態行、響應頭、訊息體,nodejs將常用的資料封裝為http.ServerResponse類,在上面的程式碼中res就是該類的一個物件。

不僅僅是nodejs,基本上所有的http服務框架都會包含request和response兩個物件,分別代表著http的請求和響應,負責服務端和瀏覽器的互動。

在上層:

伺服器後臺程式碼根據http請求的不同,繫結不同的邏輯。在真正的http請求來臨時,匹配這些http請求,執行與之對應的邏輯,這個過程就是web伺服器基本的執行流程。

對於這些http請求的管理,有一個專有名詞 —— “路由管理”,每個http請求就預設為一個路由,常見的路由區分策略包括URL、HTTP請求名詞等等,但不僅僅限定這些,所有的http請求頭上的引數其實都可以進行判斷區分,例如使用user-agent欄位判斷移動端。

不同的框架對於路由的管理規則略有不同,但不管怎樣,都需要一組管理http請求和業務邏輯對映的函式,測試用例中的get函式就是路由管理中的一個函式,主要負責新增get請求。

既然知道路由管理的重要,這裡就建立一個router陣列,負責管理所有路由對映。參考express框架,抽象出每個路由的基本屬性:

  • path 請求路徑,例如:/books、/books/1。
  • method 請求方法,例如:GET、POST、PUT、DELETE。
  • handle 處理函式。
var router = [{
    path: '*',
    method: '*',
    handle: function(req, res) {
        res.writeHead(200, {'Content-Type': 'text/plain'});
        res.end('404');
    }
}];複製程式碼

修改listen函式,將http請求攔截邏輯改為匹配router路由表,如果匹配成功,執行對應的handle函式,否則執行router[0].handle函式。

listen: function(port, cb) {
    var server = http.createServer(function(req, res) {

        for(var i=1,len=router.length; i<len; i++) {
            if((req.url === router[i].path || router[i].path === '*') &&
                (req.method === router[i].method || router[i].method === '*')) {
                return router[i].handle && router[i].handle(req, res);
            }
        }

        return router[0].handle && router[0].handle(req, res);
    });

    return server.listen.apply(server, arguments);
}複製程式碼

實現get路由請求非常簡單,該函式主要是新增get請求路由,只需要將其資訊加入到router陣列即可。

get: function(path, fn) {
    router.push({
        path: path,
        method: 'GET',
        handle: fn
    });
}複製程式碼

執行測試用例,報錯,提示res.send不存在。該函式並不是nodejs原生函式,這裡在res上臨時新增函式send,負責傳送相應到瀏覽器。

listen: function(port, cb) {
    var server = http.createServer(function(req, res) {
        if(!res.send) {
            res.send = function(body) {
                res.writeHead(200, {
                    'Content-Type': 'text/plain'
                });
                res.end(body);
            };
        }

        ......
    });

    return server.listen.apply(server, arguments);
}複製程式碼

在結束這次迭代之前,拆分整理一下程式目錄:

建立application.js檔案,將createApplication函式中的程式碼轉移到該檔案,expross.js檔案只保留引用。

var app = require('./application');

function createApplication() {
    return app;
}

exports = module.exports = createApplication;複製程式碼

整個目錄結構如下:

expross
  |
  |-- lib
  |    | 
  |    |-- expross.js
  |    |-- application.js
  |
  |-- test
  |    |
  |    |-- index.js
  |
  |-- index.js複製程式碼

最後,執行node test/index.js,開啟瀏覽器訪問http://127.0.0.1:3000/

3. 第二次迭代

本節是expross的第二次迭代,主要的目標是構建一個初步的路由系統。根據上一節的改動,目前的路由是用一個router陣列進行描述管理,對於router的操作有兩個,分別是在application.get函式和application.listen函式,前者用於新增,後者用來處理。

按照物件導向的封裝法則,接下來將路由系統的資料和路由系統的操作封裝到一起定義一個 Router類負責整個路由系統的主要工作。

var Router = function() {
    this.stack = [{
        path: '*',
        method: '*',
        handle: function(req, res) {
            res.writeHead(200, {
                'Content-Type': 'text/plain'
            });
            res.end('404');
        }
    }];
};


Router.prototype.get = function(path, fn) {
    this.stack.push({
        path: path,
        method: 'GET',
        handle: fn
    });
};


Router.prototype.handle = function(req, res) {
    for(var i=1,len=this.stack.length; i<len; i++) {
        if((req.url === this.stack[i].path || this.stack[i].path === '*') &&
            (req.method === this.stack[i].method || this.stack[i].method === '*')) {
            return this.stack[i].handle && this.stack[i].handle(req, res);
        }
    }

    return this.stack[0].handle && this.stack[0].handle(req, res);
};複製程式碼

修改原有的application.js檔案的內容。

var http = require('http');
var Router = require('./router');


exports = module.exports = {
    _router: new Router(),

    get: function(path, fn) {
        return this._router.get(path, fn);
    },

    listen: function(port, cb) {
        var self = this;

        var server = http.createServer(function(req, res) {
            if(!res.send) {
                res.send = function(body) {
                    res.writeHead(200, {
                        'Content-Type': 'text/plain'
                    });
                    res.end(body);
                };
            }

            return self._router.handle(req, res);
        });

        return server.listen.apply(server, arguments);
    }
};複製程式碼

這樣以後路由方面的操作只和Router本身有關,與application分離,使程式碼更加清晰。

這個路由系統正常執行時沒有問題的,但是如果路由不斷的增多,this.stack陣列會不斷的增大,匹配的效率會不斷降低,為了解決效率的問題,需要仔細分析路由的組成成分。

目前在expross中,一個路由是由三個部分構成:路徑、方法和處理函式。前兩者的關係並不是一對一的關係,而是一對多的關係,如:

GET books/1
PUT books/1
DELETE books/1複製程式碼

如果將路徑一樣的路由整合成一組,顯然效率會提高很多,於是引入Layer的概念。

這裡將Router系統中this.stack陣列的每一項,代表一個Layer。每個Layer內部含有三個變數。

  • path,表示路由的路徑。
  • handle,代表路由的處理函式。
  • route,代表真正的路由。

整體結構如下圖所示:

------------------------------------------------
|     0     |     1     |     2     |     3     |      
------------------------------------------------
| Layer     | Layer     | Layer     | Layer     |
|  |- path  |  |- path  |  |- path  |  |- path  |
|  |- handle|  |- handle|  |- handle|  |- handle|
|  |- route |  |- route |  |- route |  |- route |
------------------------------------------------
                  router 內部複製程式碼

這裡先建立Layer類。

function Layer(path, fn) {
    this.handle = fn;
    this.name = fn.name || '<anonymous>';
    this.path = path;
}


//簡單處理
Layer.prototype.handle_request = function (req, res) {
  var fn = this.handle;

  if(fn) {
      fn(req, res);
  }
};


//簡單匹配
Layer.prototype.match = function (path) {
    if(path === this.path || path === '*') {
        return true;
    }

    return false;
};複製程式碼

再次修改Router類。

var Router = function() {
    this.stack = [new Layer('*', function(req, res) {
        res.writeHead(200, {
            'Content-Type': 'text/plain'
        });
        res.end('404');        
    })];
};


Router.prototype.handle = function(req, res) {
    var self = this;

    for(var i=1,len=self.stack.length; i<len; i++) {
        if(self.stack[i].match(req.url)) {
            return self.stack[i].handle_request(req, res);
        }
    }

    return self.stack[0].handle_request(req, res);
};


Router.prototype.get = function(path, fn) {
    this.stack.push(new Layer(path, fn));
};複製程式碼

執行node test/index.js,訪問http://127.0.0.1:3000/一切看起來很正常,但是上面的程式碼忽略了路由的屬性method。這樣的結果會導致如果測試用例存在問題:

app.put('/', function(req, res) {
    res.send('put Hello World!');
});

app.get('/', function(req, res) {
    res.send('get Hello World!');
});複製程式碼

程式無法分清PUT和GET的區別。

所以需要繼續完善路由系統中的Layer類中的route屬性,這個屬性才是真正包含method屬性的路由。

route的結構如下:

------------------------------------------------
|     0     |     1     |     2     |     3     |      
------------------------------------------------
| item      | item      | item      | item      |
|  |- method|  |- method|  |- method|  |- method|
|  |- handle|  |- handle|  |- handle|  |- handle|
------------------------------------------------
                  route 內部複製程式碼

建立Route類。

var Route = function(path) {
    this.path = path;
    this.stack = [];

    this.methods = {};
};

Route.prototype._handles_method = function(method) {
    var name = method.toLowerCase();
    return Boolean(this.methods[name]);
};

Route.prototype.get = function(fn) {
    var layer = new Layer('/', fn);
    layer.method = 'get';

    this.methods['get'] = true;
    this.stack.push(layer);

    return this;
};

Route.prototype.dispatch = function(req, res) {
    var self = this,
        method = req.method.toLowerCase();

    for(var i=0,len=self.stack.length; i<len; i++) {
        if(method === self.stack[i].method) {
            return self.stack[i].handle_request(req, res);
        }
    }
};複製程式碼

在上面的程式碼中,並沒有定義前面結構圖中的item物件,而是使用了Layer物件進行替代,主要是為了方便快捷,從另一種角度看,其實二者是存在很多共同點的。另外,為了利於理解,程式碼中只實現了GET方法,其他方法的程式碼實現是類似的。

既然有了Route類,接下來就改修改原有的Router類,將route整合其中。

Router.prototype.handle = function(req, res) {
    var self = this,
        method = req.method;

    for(var i=0,len=self.stack.length; i<len; i++) {
        if(self.stack[i].match(req.url) && 
            self.stack[i].route && self.stack[i].route._handles_method(method)) {
            return self.stack[i].handle_request(req, res);
        }
    }

    return self.stack[0].handle_request(req, res);
};


Router.prototype.route = function route(path) {
    var route = new Route(path);

    var layer = new Layer(path, function(req, res) {
        route.dispatch(req, res);
    });

    layer.route = route;

    this.stack.push(layer);

    return route;
};


Router.prototype.get = function(path, fn) {
    var route = this.route(path);
    route.get(fn);

    return this;
};複製程式碼

執行node test/index.js,一切看起來和原來一樣。

這節內容主要是建立一個完整的路由系統,並在原始程式碼的基礎上引入了Layer和Route兩個概念,並修改了大量的程式碼,在結束本節前總結一下目前的資訊。

首先,當前程式的目錄結構如下:

expross
  |
  |-- lib
  |    | 
  |    |-- expross.js
  |    |-- application.js
  |    |-- router
  |          |
  |          |-- index.js
  |          |-- layer.js
  |          |-- route.js
  |
  |-- test
  |    |
  |    |-- index.js
  |
  |-- index.js複製程式碼

接著,總結一下當前expross各個部分的工作。

application代表一個應用程式,expross是一個工廠類負責建立application物件。Router代表路由元件,負責應用程式的整個路由系統。元件內部由一個Layer陣列構成,每個Layer代表一組路徑相同的路由資訊,具體資訊儲存在Route內部,每個Route內部也是一個Layer物件,但是Route內部的Layer和Router內部的Layer是存在一定的差異性。

  • Router內部的Layer,主要包含path、route屬性。
  • Route內部的Layer,主要包含method、handle屬性。

如果一個請求來臨,會現從頭至尾的掃描router內部的每一層,而處理每層的時候會先對比URI,相同則掃描route的每一項,匹配成功則返回具體的資訊,沒有任何匹配則返回未找到。

最後,整個路由系統的結構如下:

 --------------
| Application  |                                 ---------------------------------------------------------
|     |        |        ----- -----------        |     0     |     1     |     2     |     3     |  ...  |
|     |-router | ----> |     | Layer     |       ---------------------------------------------------------
 --------------        |  0  |   |-path  |       | Layer     | Layer     | Layer     | Layer     |       |
  application          |     |   |-route | ----> |  |- method|  |- method|  |- method|  |- method|  ...  |
                       |-----|-----------|       |  |- handle|  |- handle|  |- handle|  |- handle|       |
                       |     | Layer     |       ---------------------------------------------------------
                       |  1  |   |-path  |                                  route
                       |     |   |-route |       
                       |-----|-----------|       
                       |     | Layer     |
                       |  2  |   |-path  |
                       |     |   |-route |
                       |-----|-----------|
                       | ... |   ...     |
                        ----- ----------- 
                             router複製程式碼

3. 第三次迭代

本節是expross的第三次迭代,主要的目標是繼續完善路由系統,主要工作有部分:

  • 豐富介面,目前只支援get介面。
  • 增加路由系統的流程控制。

當前expross框架只支援get介面,具體的介面是由expross提供的,內部呼叫Router.get介面,而其內部是對Route.get的封裝。

HTTP顯然不僅僅只有GET這一個方法,還包括很多,例如:PUT、POST、DELETE等等,每個方法都單獨寫一個處理函式顯然是冗餘的,因為函式的內容除了和函式名相關外,其他都是一成不變的。根據JavaScript指令碼語言語言的特性,這裡可以通過HTTP的方法列表動態生成函式內容。

想要動態生成函式,首先需要確定函式名稱。函式名就是nodejs中HTTP伺服器支援的方法名稱,可以在官方文件中獲取,具體引數是http.METHODS。這個屬性是在v0.11.8新增的,如果nodejs低於該版本,需要手動建立一個方法列表,具體可以參考nodejs程式碼。

express框架HTTP方法名的獲取封裝到另一個包,叫做methods,內部給出了低版本的相容動詞列表。

function getBasicNodeMethods() {
  return [
    'get',
    'post',
    'put',
    'head',
    'delete',
    'options',
    'trace',
    'copy',
    'lock',
    'mkcol',
    'move',
    'purge',
    'propfind',
    'proppatch',
    'unlock',
    'report',
    'mkactivity',
    'checkout',
    'merge',
    'm-search',
    'notify',
    'subscribe',
    'unsubscribe',
    'patch',
    'search',
    'connect'
  ];
}複製程式碼

知道所支援的方法名列表陣列後,剩下的只需要一個for迴圈生成所有的函式即可。

所有的動詞處理函式的核心內容都在Route中。

http.METHODS.forEach(function(method) {
    method = method.toLowerCase();
    Route.prototype[method] = function(fn) {
        var layer = new Layer('/', fn);
        layer.method = method;

        this.methods[method] = true;
        this.stack.push(layer);

        return this;
    };
});複製程式碼

接著修改Router。

http.METHODS.forEach(function(method) {
    method = method.toLowerCase();
    Router.prototype[method] = function(path, fn) {
        var route = this.route(path);
        route[method].call(route, fn);

        return this;
    };
});複製程式碼

最後修改application.js的內容。這裡改動較大,重新定義了一個Application類,而不是使用字面量直接建立。

function Application() {
    this._router = new Router();
}


Application.prototype.listen = function(port, cb) {
    var self = this;

    var server = http.createServer(function(req, res) {
        self.handle(req, res);
    });

    return server.listen.apply(server, arguments);
};


Application.prototype.handle = function(req, res) {
    if(!res.send) {
        res.send = function(body) {
            res.writeHead(200, {
                'Content-Type': 'text/plain'
            });
            res.end(body);
        };
    }

    var router = this._router;
    router.handle(req, res);
};複製程式碼

接著增加HTTP方法函式。

http.METHODS.forEach(function(method) {
    method = method.toLowerCase();
    Application.prototype[method] = function(path, fn) {
        this._router[method].apply(this._router, arguments);
        return this;
    };
});複製程式碼

因為匯出的是Application類,所以修改expross.js檔案。

var Application = require('./application');

function createApplication() {
    var app = new Application();
    return app;
}複製程式碼

執行node test/index.js,走起。

如果你仔細研究路由系統的原始碼,會發現route本身的定義並不是像文字描述那樣。例如:

增加兩個同樣路徑的路由:

app.put('/', function(req, res) {
    res.send('put Hello World!');
});

app.get('/', function(req, res) {
    res.send('get Hello World!');
});複製程式碼

結果並不是想象中類似下面的結構:

                          ---------------------------------------------------------
 ----- -----------        |     0     |     1     |     2     |     3     |  ...  |
|     | Layer     |       ---------------------------------------------------------
|  0  |   |-path  |       | Layer     | Layer     | Layer     | Layer     |       |
|     |   |-route | ----> |  |- method|  |- method|  |- method|  |- method|  ...  |
|-----|-----------|       |  |- handle|  |- handle|  |- handle|  |- handle|       |
|     | Layer     |       ---------------------------------------------------------
|  1  |   |-path  |                                  route
|     |   |-route |       
|-----|-----------|       
|     | Layer     |
|  2  |   |-path  |
|     |   |-route |
|-----|-----------|
| ... |   ...     |
 ----- ----------- 
      router複製程式碼

而是如下的結構:

 ----- -----------        -------------
|     | Layer     | ----> | Layer     |
|  0  |   |-path  |       |  |- method|   route
|     |   |-route |       |  |- handle|
|-----|-----------|       -------------
|     | Layer     |       -------------      
|  1  |   |-path  | ----> | Layer     |
|     |   |-route |       |  |- method|   route     
|-----|-----------|       |  |- handle|        
|     | Layer     |       -------------
|  2  |   |-path  |       -------------  
|     |   |-route | ----> | Layer     |
|-----|-----------|       |  |- method|   route
| ... |   ...     |       |  |- handle| 
 ----- -----------        -------------
    router複製程式碼

之所以會這樣是因為路由系統存在這先後順序的關係,如果你前面的描述結構很有可能會丟失路由順序這個屬性。既然這樣,那Route的用處是在哪?

因為在express框架中,Route儲存的是真正的路由資訊,可以當做單獨的成員使用,如果想要真正前面的所描述的結果描述,你需要這樣建立你的程式碼:

var router = express.Router();

router.route('/users/:user_id')
.get(function(req, res, next) {
  res.json(req.user);
})
.put(function(req, res, next) {
  // just an example of maybe updating the user
  req.user.name = req.params.name;
  // save user ... etc
  res.json(req.user);
})
.post(function(req, res, next) {
  next(new Error('not implemented'));
})
.delete(function(req, res, next) {
  next(new Error('not implemented'));
});複製程式碼

而不是這樣:

var app = expross();

app.get('/users/:user_id', function(req, res) {

})

.put('/users/:user_id', function(req, res) {

})

.post('/users/:user_id', function(req, res) {

})

.delete('/users/:user_id', function(req, res) {

});複製程式碼

理解了Route的使用方法,接下來就要討論剛剛提到的順序問題。在路由系統中,路由的處理順序非常重要,因為路由是按照陣列的方式儲存的,如果遇見兩個同樣的路由,同樣的方法名,不同的處理函式,這時候前後宣告的順序將直接影響結果(這也是express中介軟體存在順序相關的原因),例如下面的例子:

app.get('/', function(req, res) {
    res.writeHead(200, {'Content-Type': 'text/plain'});
    res.end('first');
});

app.get('/', function(req, res) {
    res.writeHead(200, {'Content-Type': 'text/plain'});
    res.end('second');
});複製程式碼

上面的程式碼如果執行會發現永遠都返回first,但是有的時候會根據前臺傳來的引數動態判斷是否執行接下來的路由,怎樣才能跳過first進入second?這就涉及到路由系統的流程控制問題。

流程控制分為主動和被動兩種模式。

對於expross框架來說,路由繫結的處理邏輯、使用者設定的路徑引數這些都是不可靠的,在執行過程中很有可能會發生異常,被動流程控制就是當這些異常發生的時候,expross框架要擔負起捕獲這些異常的工作,因為如果不明確異常的發生位置,會導致js程式碼無法繼續執行,並且無法準確的報出故障。

主動流程控制則是處理函式內部的操作邏輯,以主動呼叫的方式來跳轉路由內部的執行邏輯。

目前express通過引入next引數的方式來解決流程控制問題。next是處理函式的一個引數,其本身也是一個函式,該函式有幾種使用方式:

  • 執行下一個處理函式。執行next()
  • 報告異常。執行next(err)
  • 跳過當前Route,執行Router的下一項。執行next('route')
  • 跳過整個Router。執行next('router')

接下來,我們嘗試實現以下這些需求。

首先修改最底層的Layer物件,該物件的handle_request函式是負責呼叫路由繫結的處理邏輯,這裡新增next引數,並且增加異常捕獲功能。

Layer.prototype.handle_request = function (req, res, next) {
  var fn = this.handle;

  try {
    fn(req, res, next);
  } catch (err) {
    next(err);
  }
};複製程式碼

接下來修改Route.dispath函式。因為涉及到內部的邏輯跳轉,使用for迴圈按部就班不太合適,這裡使用了類似遞迴的方式。

Route.prototype.dispatch = function(req, res, done) {
    var self = this,
        method = req.method.toLowerCase(),
        idx = 0, stack = self.stack;

    function next(err) {
        //跳過route
        if(err && err === 'route') {
            return done();
        }

        //跳過整個路由系統
        if(err && err === 'router') {
            return done(err);
        }

        //越界
        if(idx >= stack.length) {
            return done(err);
        }

        //不等列舉下一個
        var layer = stack[idx++];
        if(method !== layer.method) {
            return next(err);
        }

        if(err) {
            //主動報錯
            return done(err);
        } else {
            layer.handle_request(req, res, next);
        }
    }

    next();
};複製程式碼

整個處理過程本質上還是一個for迴圈,唯一的差別就是在處理函式中使用者主動呼叫next函式的處理邏輯。

如果使用者通過next函式返回錯誤、routerouter這三種情況,目前統一拋給Router處理。

因為修改了dispatch函式,所以呼叫該函式的Router.route函式也要修改,這回直接改徹底,以後無需根據引數的個數進行調整。

Router.prototype.route = function route(path) {
    ...

    //使用bind方式
    var layer = new Layer(path, route.dispatch.bind(route));

    ...
};複製程式碼

接著修改Router.handle的程式碼,邏輯和Route.dispatch類似。

Router.prototype.handle = function(req, res, done) {
    var self = this,
        method = req.method,
        idx = 0, stack = self.stack;

    function next(err) {
        var layerError = (err === 'route' ? null : err);

        //跳過路由系統
        if(layerError === 'router') {
            return done(null);
        }

        if(idx >= stack.length || layerError) {
            return done(layerError);
        }

        var layer = stack[idx++];
        //匹配,執行
        if(layer.match(req.url) && layer.route &&
            layer.route._handles_method(method)) {
            return layer.handle_request(req, res, next);
        } else {
            next(layerError);
        }
    }

    next();
};複製程式碼

修改後的函式處理過程和原來的類似,不過有一點需要注意,當發生異常的時候,會將結果返回給上一層,而不是執行原有this.stack第0層的程式碼邏輯。

最後增加expross框架異常處理的邏輯。

在之前,可以移除原有this.stack的初始化程式碼,將

var Router = function() {
    this.stack = [new Layer('*', function(req, res) {
        res.writeHead(200, {
            'Content-Type': 'text/plain'
        });
        res.end('404');        
    })];
};複製程式碼

改為

var Router = function() {
    this.stack = [];
};複製程式碼

然後,修改Application.handle函式。

Application.prototype.handle = function(req, res) {

    ...

    var done = function finalhandler(err) {
        res.writeHead(404, {
            'Content-Type': 'text/plain'
        });

        if(err) {
            res.end('404: ' + err);    
        } else {
            var msg = 'Cannot ' + req.method + ' ' + req.url;
            res.end(msg);    
        }
    };

    var router = this._router;
    router.handle(req, res, done);
};複製程式碼

這裡簡單的將done函式處理為返回404頁面,其實在express框架中,使用的是一個單獨的npm包,叫做finalhandler

簡單的修改一下測試用例證明一下成果。

var expross = require('../');
var app = expross();

app.get('/', function(req, res, next) {
    next();
})

.get('/', function(req, res, next) {
    next(new Error('error'));
})

.get('/', function(req, res) {
    res.send('third');
});

app.listen(3000, function() {
    console.log('Example app listening on port 3000!');
});複製程式碼

執行node test/index.js,訪問http://127.0.0.1:3000/,結果顯示:

404: Error: error複製程式碼

貌似目前一切都很順利,不過還有一個需求目前被忽略了。當前處理函式的異常全部是由框架捕獲,返回的資訊只能是固定的404頁面,對於框架使用者顯然很不方便,大多數時候,我們都希望可以捕獲錯誤,並按照一定的資訊封裝返回給瀏覽器,所以expross需要一個返回錯誤給上層使用者的介面。

目前和上層對接的處理函式的宣告如下:

function process_fun(req, res, next) {

}複製程式碼

如果增加一個錯誤處理函式,按照nodejs的規則,第一個引數是錯誤資訊,定義應該如下所示:

function process_err(err, req, res, next) {

}複製程式碼

因為兩個宣告的第一個引數資訊是不同的,如果區分傳入的處理函式是處理錯誤的函式還是處理正常的函式,這個是expross框架需要搞定的關鍵問題。

javascript中,Function.length屬性可以獲取傳入函式指定的引數個數,這個可以當做區分二者的關鍵資訊。

既然確定了原理,接下來直接在Layer類上增加一個專門處理錯誤的函式,和處理正常資訊的函式區分開。

//錯誤處理
Layer.prototype.handle_error = function (error, req, res, next) {
  var fn = this.handle;

  //如果函式引數不是標準的4個引數,返回錯誤資訊
  if(fn.length !== 4) {
    return next(error);
  }

  try {
    fn(error, req, res, next);
  } catch (err) {
    next(err);
  }
};複製程式碼

接著修改Route.dispatch函式。

Route.prototype.dispatch = function(req, res, done) {
    var self = this,
        method = req.method.toLowerCase(),
        idx = 0, stack = self.stack;

    function next(err) {

        ...

        if(err) {
            //主動報錯
            layer.handle_error(err, req, res, next);
        } else {
            layer.handle_request(req, res, next);
        }
    }

    next();
};複製程式碼

當發生錯誤的時候,Route會一直向後尋找錯誤處理函式,如果找到則返回,否則執行done(err),將錯誤拋給Router。

對於Router.handle的修改,因為涉及到一些中介軟體的概念,完整的錯誤處理將推移到下一節完成。

本節的內容基本上完成,包括HTTP相關的動作介面的新增、路由系統的流程跳轉以及Route級別的錯誤響應等等,涉及到路由系統剩下的一點內容暫時放到以後講解。

4. 第四次迭代

本節是expross的第四次迭代,主要的目標是建立中介軟體機制並繼續完善路由系統的功能。

在express中,中介軟體其實是一個介於web請求來臨後到呼叫處理函式前整個流程體系中間呼叫的元件。其本質是一個函式,內部可以訪問修改請求和響應物件,並調整接下來的處理流程。

express官方給出的解釋如下:

Express 是一個自身功能極簡,完全是由路由和中介軟體構成一個的 web 開發框架:從本質上來說,一個 Express 應用就是在呼叫各種中介軟體。

中介軟體(Middleware) 是一個函式,它可以訪問請求物件(request object (req)), 響應物件(response object (res)), 和 web 應用中處於請求-響應迴圈流程中的中介軟體,一般被命名為 next 的變數。

中介軟體的功能包括:

  • 執行任何程式碼。
  • 修改請求和響應物件。
  • 終結請求-響應迴圈。
  • 呼叫堆疊中的下一個中介軟體。

如果當前中介軟體沒有終結請求-響應迴圈,則必須呼叫 next() 方法將控制權交給下一個中介軟體,否則請求就會掛起。

Express 應用可使用如下幾種中介軟體:

使用可選則掛載路徑,可在應用級別或路由級別裝載中介軟體。另外,你還可以同時裝在一系列中介軟體函式,從而在一個掛載點上建立一個子中介軟體棧。

官方給出的定義其實已經足夠清晰,一箇中介軟體的樣式其實就是上一節所提到的處理函式,只不過並沒有正式命名。所以對於程式碼來說Router類中的this.stack屬性內部的每個handle都是一箇中介軟體,根據使用介面不同區別了應用級中介軟體路由級中介軟體,而四個引數的處理函式就是錯誤處理中介軟體

接下來就給expross框架新增中介軟體的功能。

首先是應用級中介軟體,其使用方法是Application類上的兩種方式:Application.use 和 Application.METHOD (HTTP的各種請求方法),後者其實在前面的小節裡已經實現了,前者則是需要新增的。

在前面的小節裡的程式碼已經說明Application.METHOD內部其實是Router.METHOD的代理,Application.use同樣如此。

Application.prototype.use = function(fn) {
    var path = '/',
        router = this._router;

    router.use(path, fn);

    return this;
};複製程式碼

因為Application.use支援可選路徑,所以需要增加處理路徑的過載程式碼。

Application.prototype.use = function(fn) {
    var path = '/',
        router = this._router;

    //路徑掛載
    if(typeof fn !== 'function') {
        path = fn;
        fn = arguments[1];
    }

    router.use(path, fn);

    return this;
};複製程式碼

其實express框架支援的引數不僅僅這兩種,但是為了便於理解剔除了一些旁枝末節,便於框架的理解。

接下來實現Router.use函式。

Router.prototype.use = function(fn) {
    var path = '/';

    //路徑掛載
    if(typeof fn !== 'function') {
        path = fn;
        fn = arguments[1];
    }

    var layer = new Layer(path, fn);
    layer.route = undefined;

    this.stack.push(layer);

    return this;
};複製程式碼

內部程式碼和Application.use差不多,只不過最後不再是呼叫Router.use,而是直接建立一個Layer物件,將其放到this.stack陣列中。

在這裡段程式碼裡可以看到普通路由和中介軟體的區別。普通路由放到Route中,且Router.route屬性指向Route物件,Router.handle屬性指向Route.dispatch函式;中介軟體的Router.route屬性為undefined,Router.handle指向中介軟體處理函式,被放到Router.stack陣列中。

對於路由級中介軟體,首先按照要求匯出Router類,便於使用。

exports.Router = Router;複製程式碼

上面的程式碼新增到expross.js檔案中,這樣就可以按照下面的使用方式建立一個單獨的路由系統。

var app = express();
var router = express.Router();

router.use(function (req, res, next) {
  console.log('Time:', Date.now());
});複製程式碼

現在問題來了,如果像上面的程式碼一樣建立一個新的路由系統是無法讓路由系統內部的邏輯生效的,因為這個路由系統沒法新增到現有的系統中。

一種辦法是增加一個專門新增新路由系統的介面,是完全是可行的,但是我更欣賞express框架的辦法,這可能是Router叫做路由級中介軟體的原因。express將Router定義成一個特殊的中介軟體,而不是一個單獨的類。

這樣將單獨建立的路由系統新增到現有的應用中的程式碼非常簡單通用:

var router = express.Router();

// 將路由掛載至應用
app.use('/', router);複製程式碼

這確實是一個好方法,現在就來將expross修改成類似的樣子。

首先建立一個原型物件,將現有的Router方法轉移到該物件上。

var proto = {};

proto.handle = function(req, res, done) {...};
proto.route = function route(path) {...};
proto.use = function(fn) { ... };

http.METHODS.forEach(function(method) {
    method = method.toLowerCase();
    proto[method] = function(path, fn) {
        var route = this.route(path);
        route[method].call(route, fn);

        return this;
    };
});複製程式碼

然後建立一箇中介軟體函式,使用Object.setPrototypeOf函式設定其原型,最後匯出一個生成這些過程的函式。

module.exports = function() {
    function router(req, res, next) {
        router.handle(req, res, next);
    }

    Object.setPrototypeOf(router, proto);

    router.stack = [];
    return router;
};複製程式碼

修改測試用例,測試一下效果。

app.use(function(req, res, next) {
    console.log('Time:', Date.now());
    next();
});

app.get('/', function(req, res, next) {
    res.send('first');
});


router.use(function(req, res, next) {
    console.log('Time: ', Date.now());
    next();
});

router.use('/', function(req, res, next) {
    res.send('second');
});

app.use('/user', router);

app.listen(3000, function() {
    console.log('Example app listening on port 3000!');
});複製程式碼

結果並不理想,原有的應用程式還有兩個地方需要修改。

首先是邏輯處理問題。原有的Router.handle函式中並沒有處理中介軟體的情況,需要進一步修改。

proto.handle = function(req, res, done) {

    ...

    function next(err) {

        ...

        //注意這裡,layer.route屬性
        if(layer.match(req.url) && layer.route &&
            layer.route._handles_method(method)) {
            layer.handle_request(req, res, next);
        } else {
            layer.handle_error(layerError, req, res, next);
        }
    }

    next();
};複製程式碼

改成:

proto.handle = function(req, res, done) {

    ...

    function next(err) {

        ...

        //匹配,執行
        if(layer.match(path)) {
            if(!layer.route) {
                //處理中介軟體
                layer.handle_request(req, res, next);    
            } else if(layer.route._handles_method(method)) {
                //處理路由
                layer.handle_request(req, res, next);
            }    
        } else {
            layer.handle_error(layerError, req, res, next);
        }
    }

    next();
};複製程式碼

其次是路徑匹配的問題。原有的單一路徑被拆分成為不同中間的路徑組合,這裡判斷需要多步進行,因為每個中介軟體只是匹配自己的路徑是否通過,不過相對而言目前涉及的匹配都是全等匹配,還沒有涉及到類似express框架中的正則匹配,算是非常簡單了。

想要實現匹配邏輯就要清楚的知道哪段路徑和哪個處理函式匹配,這裡定義三個變數:

  • req.originalUrl 原始請求路徑。
  • req.url 當前路徑。
  • req.baseUrl 父路徑。

主要修改Router.handle函式,該函式主要負責提取當前路徑段,便於和事先傳入的路徑進行匹配。

proto.handle = function(req, res, done) {
    var self = this,
        method = req.method,
        idx = 0, stack = self.stack,
        removed = '', slashAdded = false;


    //獲取當前父路徑
    var parentUrl = req.baseUrl || '';
    //儲存父路徑
    req.baseUrl = parentUrl;
    //儲存原始路徑
    req.orginalUrl = req.orginalUrl || req.url;


    function next(err) {
        var layerError = (err === 'route' ? null : err);

        //如果有移除,復原原有路徑
        if(slashAdded) {
            req.url = '';
            slashAdded = false;
        }


        //如果有移除,復原原有路徑資訊
        if(removed.length !== 0) {
            req.baseUrl = parentUrl;
            req.url = removed + req.url;
            removed = '';
        }


        //跳過路由系統
        if(layerError === 'router') {
            return done(null);
        }


        if(idx >= stack.length || layerError) {
            return done(layerError);
        }

        //獲取當前路徑
        var path = require('url').parse(req.url).pathname;

        var layer = stack[idx++];
        //匹配,執行
        if(layer.match(path)) {

            //處理中介軟體
            if(!layer.route) {
                //要移除的部分路徑
                removed = layer.path;

                //設定當前路徑
                req.url = req.url.substr(removed.length);
                if(req.url === '') {
                    req.url = '/' + req.url;
                    slashAdded = true;
                }

                //設定當前路徑的父路徑
                req.baseUrl = parentUrl + removed;

                //呼叫處理函式
                layer.handle_request(req, res, next);    
            } else if(layer.route._handles_method(method)) {
                //處理路由
                layer.handle_request(req, res, next);
            }    
        } else {
            layer.handle_error(layerError, req, res, next);
        }
    }

    next();
};複製程式碼

這段程式碼主要處理兩種情況:

第一種,存在路由中介軟體的情況。如:

router.use('/1', function(req, res, next) {
    res.send('first user');
});

router.use('/2', function(req, res, next) {
    res.send('second user');
});

app.use('/users', router);複製程式碼

這種情況下,Router.handle順序匹配到中間的時候,會遞迴呼叫Router.handle,所以需要儲存當前的路徑快照,具體路徑相關資訊放到req.url、req.originalUrl 和req.baseUrl 這三個引數中。

第二種,非路由中介軟體的情況。如:

app.get('/', function(req, res, next) {
    res.send('home');
});

app.get('/books', function(req, res, next) {
    res.send('books');
});複製程式碼

這種情況下,Router.handle內部主要是按照棧中的次序匹配路徑即可。

改好了處理函式,還需要修改一下Layer.match這個匹配函式。目前建立Layer可能會有三種情況:

  • 不含有路徑的中介軟體。path屬性預設為/
  • 含有路徑的中介軟體。
  • 普通路由。如果path屬性為*,表示任意路徑。

修改原有Layer是建構函式,增加一個fast_star 標記用來判斷path是否為*。

function Layer(path, fn) {
  this.handle = fn;
  this.name = fn.name || '<anonymous>';
  this.path = undefined;

  //是否為*
  this.fast_star = (path === '*' ? true : false);
  if(!this.fast_star) {
    this.path = path;
  }
}複製程式碼

接著修改Layer.match匹配函式。

Layer.prototype.match = function(path) {

  //如果為*,匹配
  if(this.fast_star) {
    this.path = '';
    return true;
  }

  //如果是普通路由,從後匹配
  if(this.route && this.path === path.slice(-this.path.length)) {
    return true;
  }

  if (!this.route) {
    //不帶路徑的中介軟體
    if (this.path === '/') {
      this.path = '';
      return true;
    }

    //帶路徑中介軟體
    if(this.path === path.slice(0, this.path.length)) {
      return true;
    }
  }

  return false;
};複製程式碼

程式碼中一共判斷四種情況,根據this.route區分中介軟體和普通路由,然後分開判斷。

express除了普通的中介軟體外還要一種錯誤中介軟體,專門用來處理錯誤資訊。該中介軟體的宣告和上一小節最後介紹的錯誤處理函式是一樣的,同樣是四個引數分別是:err、 req、 res和 next。

目前Router.handle中,當遇見錯誤資訊的時候,會直接通過回撥函式返回錯誤資訊,顯示錯誤頁面。

if(idx >= stack.length || layerError) {
    return done(layerError);
}複製程式碼

這裡需要修改策略,將其改為繼續呼叫下一個中介軟體,直到碰到錯誤中介軟體為止。

//沒有找到
if(idx >= stack.length) {
    return done(layerError);
}複製程式碼

原有這一塊的程式碼只保留判斷列舉是否完成,將錯誤判斷轉移到最後執行處理函式的位置。之所以這樣做是因為不管是執行處理函式,或是執行錯誤處理都需要進行路徑匹配操作和路徑分析操作,所以放到後面正好合適。

//處理中介軟體
if(!layer.route) {

    ...

    //呼叫處理函式
    if(layerError)
        layer.handle_error(layerError, req, res, next);
    else
        layer.handle_request(req, res, next);

} else if(layer.route._handles_method(method)) {
    //處理路由
    layer.handle_request(req, res, next);
}複製程式碼

到此為止,expross的錯誤處理部分算是基本完成了。

路由系統和中介軟體兩個大的概念算是全部講解完畢,當然還有很多細節沒有完善,在剩下的文字裡如果有必要會繼續完善。

下一節主要的內容是介紹前後端互動的兩個重要成員:request和response。express在nodejs的基礎之上進行了豐富的擴充套件,所以很有必要仿製一下。

5. 第五次迭代

本節是expross的第五次迭代,主要的目標是封裝request和response兩個物件,方便使用。

其實nodejs已經給我們提供這兩個預設的物件,之所以要封裝是因為豐富一下二者的介面,方便框架使用者,目前框架在response物件上已經有一個介面:

if(!res.send) {
    res.send = function(body) {
        res.writeHead(200, {
            'Content-Type': 'text/plain'
        });
        res.end(body);
    };
}複製程式碼

如果需要繼續封裝,也要類似的結構在程式碼上新增顯然會給人一種很亂的感覺,因為request和response的原始版本是nodejs提供給框架的,框架獲取到的是兩個物件,並不是類,要想在二者之上提供另一組介面的辦法有很多,歸根結底就是將新的介面加到該物件上或者加到該物件的原型鏈上,目前的程式碼選擇了前者,express的程式碼選擇了後者。

首先建立兩個檔案:request.js 和 response.js,二者分別匯出req和res物件。

//request.js
var http = require('http');

var req = Object.create(http.IncomingMessage.prototype);

module.exports = req;


//response.js
var http = require('http');

var res = Object.create(http.ServerResponse.prototype);

module.exports = res;複製程式碼

二者檔案的程式碼都是建立一個物件,分別指向nodejs提供的request和response兩個物件的原型,以後expross自定的介面可以統一掛載到這兩個物件上。

接著修改Application.handle函式,因為這個函式裡面有新鮮出爐的request和response。思路很簡單,就是將二者的原型指向我們自建的req和res。因為req和res物件的原型和request和response的原型相同,所以並不影響原有nodejs的介面。

var request = require('./request');
var response = require('./response');

...

Application.prototype.handle = function(req, res) {

    Object.setPrototypeOf(req, request);
    Object.setPrototypeOf(res, response);


    ...
};複製程式碼

這裡將原有的res.send轉移到了response.js檔案中。

res.send = function(body) {
    this.writeHead(200, {
        'Content-Type': 'text/plain'
    });
    this.end(body);
};複製程式碼

注意函式中不在是res.writeHead和res.end,而是this.writeHead和this.end。

在整個路由系統中,Router.stack每一項其實都是一箇中介軟體,每個中介軟體都有可能用到req和res這兩個物件,所以程式碼中修改nodejs原生提供的request和response物件的程式碼放到了Application.handle中,這樣做並沒有問題,但是有一種更好的方式,expross框架將這部分程式碼封裝成了一個內部中介軟體。

為了確保框架中每個中介軟體接收這兩個引數的正確性,需要將該內部中間放到Router.stack的首項。這裡將原有Application的建構函式中的程式碼去掉,不再是直接建立Router()路由系統,而是用一種惰性載入的方式,動態建立。

去除原有Application建構函式的程式碼。

function Application() {}複製程式碼

新增惰性初始化函式。

Application.prototype.lazyrouter = function() {
    if(!this._router) {
        this._router = new Router();

        this._router.use(middleware.init());
    }
};複製程式碼

因為是惰性初始化,所以在使用this._router物件前,一定要先呼叫該函式動態建立this._router物件。類似如下程式碼:

//獲取router
this.lazyrouter();
router = this._router;複製程式碼

接下來建立一個叫middleware資料夾,專門放內部中介軟體的檔案,再建立一個init.js檔案,放置Application.handle中用來初始化res和req的程式碼。

var request = require('../request');
var response = require('../response');

exports.init = function expressInit(req, res, next) {
    //request檔案可能用到res物件
    req.res = res;

    //response檔案可能用到req物件
    res.req = req;

    //修改原始req和res原型
    Object.setPrototypeOf(req, request);
    Object.setPrototypeOf(res, response);

    //繼續
    next();
};複製程式碼

修改原有的Applicaton.handle函式。

Application.prototype.handle = function(req, res) {

    ...

    // 這裡無需呼叫lazyrouter,因為listen前一定呼叫了.use或者.METHODS方法。
    // 如果二者都沒有呼叫,沒有必要建立路由系統。this._router為undefined。
    var router = this._router;
    if(router) {
        router.handle(req, res, done);
    } else {
        done();
    }
};複製程式碼

執行node test/index.js走起……

express框架中,request和response兩個物件有很多非常好用的函式,不過大部分和框架結構無關,並且這些函式內部過於專研細節,對框架本身的理解沒有多少幫助。不過接下來有一個方面需要仔細研究一下,那就是前後端引數的傳遞,express如何獲取並分類這些引數的,這一點還是需要略微瞭解。

預設情況下,一共有三種引數獲取方式。

  • req.query 代表查詢字串。
  • req.params 代表路徑變數。
  • req.body 代表表單提交變數。

req.query 是最常用的方式,例如:

// GET /search?q=tobi+ferret
req.query.q
// => "tobi ferret"

// GET /shoes?order=desc&shoe[color]=blue&shoe[type]=converse
req.query.order
// => "desc"

req.query.shoe.color
// => "blue"

req.query.shoe.type
// => "converse"複製程式碼

後臺獲取這些引數最簡單的方式就是通過nodejs自帶的querystring模組分析URL。express使用的是另一個npm包,qs。並且將其封裝為另一個內部中介軟體,專門負責解析查詢字串,預設載入。

req.params 是另一種從URL獲取引數的方式,例如:

//路由規則  /user/:name
// GET /user/tj
req.params.name
// => "tj"複製程式碼

這是一種express框架規定的引數獲取方式,對於批量處理邏輯非常實用。在expross中並沒有實現,因為路徑匹配問題過於細節化,如果對此感興趣可以研究研究path-to-regexp模組,express也是在其上的封裝。

req.body 是獲取表單資料的方式,但是預設情況下框架是不會去解析這種資料,直接使用只會返回undefined。如果想要支援需要新增其他中介軟體,例如 body-parsermulter

本小節主要介紹了request和response兩個物件,並且講解了一下現有expross框架中獲取引數的方式,整體上並沒有太深入的仿製,主要是這方面內容涉及的細節過多,只可意會。研究了也就僅僅知道而已,並不能帶來多少積累,除非重頭再造一次輪子。

6. 第六次迭代

本小節是第六次迭代,主要的目的是介紹一下expresss是如何整合現有的渲染引擎的。與渲染引擎有關的事情涉及到下面幾個方面:

  • 如何開發或繫結一個渲染引擎。
  • 如何註冊一個渲染引擎。
  • 如何指定模板路徑。
  • 如何渲染模板引擎。

express通過app.engine(ext, callback) 方法即可建立一個你自己的模板引擎。其中,ext 指的是副檔名、callback 是模板引擎的主函式,接受檔案路徑、引數物件和回撥函式作為其引數。

//下面的程式碼演示的是一個非常簡單的能夠渲染 “.ntl” 檔案的模板引擎。

var fs = require('fs'); // 此模板引擎依賴 fs 模組
app.engine('ntl', function (filePath, options, callback) { // 定義模板引擎
  fs.readFile(filePath, function (err, content) {
    if (err) return callback(new Error(err));
    // 這是一個功能極其簡單的模板引擎
    var rendered = content.toString().replace('#title#', '<title>'+ options.title +'</title>')
    .replace('#message#', '<h1>'+ options.message +'</h1>');
    return callback(null, rendered);
  })
});複製程式碼

為了讓應用程式可以渲染模板檔案,還需要做如下設定:

//views, 放模板檔案的目錄
app.set('views', './views')
//view engine, 模板引擎
app.set('view engine', 'jade')複製程式碼

一旦 view engine 設定成功,就不需要顯式指定引擎,或者在應用中載入模板引擎模組,Express 已經在內部載入。下面是如何渲染頁面的方法:

app.get('/', function (req, res) {
  res.render('index', { title: 'Hey', message: 'Hello there!'});
});複製程式碼

要想實現上述功能,首先在Application類中定義兩個變數,一個儲存app.set 和 app.get 這兩個方法儲存的值,另一個儲存模板引擎中副檔名和渲染函式的對應關係。

function Application() {
    this.settings = {};
    this.engines = {};
}複製程式碼

然後是實現app.set函式。

Application.prototype.set = function(setting, val) {
      if (arguments.length === 1) {
      // app.get(setting)
      return this.settings[setting];
    }

    this.settings[setting] = val;
    return this;
};複製程式碼

程式碼中不僅僅實現了設定,如何傳入的引數只有一個等價於get函式。

接著實現app.get函式。因為現在已經有了一個app.get方法用來設定路由,所以需要在該方法上進行過載。

http.METHODS.forEach(function(method) {
    method = method.toLowerCase();
    Application.prototype[method] = function(path, fn) {
        if(method === 'get' && arguments.length === 1) {
            // app.get(setting)
            return this.set(path);
        }

        ...
    };
});複製程式碼

最後實現app.engine進行副檔名和引擎函式的對映。

Application.prototype.engine = function(ext, fn) {
    // get file extension
    var extension = ext[0] !== '.'
      ? '.' + ext
      : ext;

    // store engine
    this.engines[extension] = fn;

    return this;
};複製程式碼

副檔名當做key,統一新增“.”。

到此設定模板引擎相關資訊的函式算是完成,接下來就是最重要的渲染引擎函式的實現。

res.render = function(view, options, callback) {
      var app = this.req.app;
    var done = callback;
    var opts = options || {};
    var self = this;

    //如果定義回撥,則返回,否則渲染
    done = done || function(err, str) {
        if(err) {
            return req.next(err);
        }

        self.send(str);
    };

    //渲染
    app.render(view, opts, done);
};複製程式碼

渲染函式一共有三個引數,view表示模板的名稱,options是模板渲染的變數,callback是渲染成功後的回撥函式。

函式內部直接呼叫render函式進行渲染,渲染完成後呼叫done回撥。

接下來建立一個view.js檔案,主要功能是負責各種模板引擎和框架間的隔離,保持對內介面的統一性。

function View(name, options) {
    var opts = options || {};

    this.defaultEngine = opts.defaultEngine;
    this.root = opts.root;

    this.ext = path.extname(name);
    this.name = name;


    var fileName = name;
    if (!this.ext) {
      // get extension from default engine name
      this.ext = this.defaultEngine[0] !== '.'
        ? '.' + this.defaultEngine
        : this.defaultEngine;

      fileName += this.ext;
    }


    // store loaded engine
    this.engine = opts.engines[this.ext];

    // lookup path
    this.path = this.lookup(fileName);
}複製程式碼

View類內部定義了很多屬性,主要包括引擎、根目錄、副檔名、檔名等等,為了以後的渲染做準備。

View.prototype.render = function render(options, callback) {
    this.engine(this.path, options, callback);
};複製程式碼

View的渲染函式內部就是呼叫一開始註冊的引擎渲染函式。

瞭解了View的定義,接下來實現app.render模板渲染函式。

Application.prototype.render = function(name, options, callback) {
    var done = callback;
    var engines = this.engines;
    var opts = options;

    view = new View(name, {
      defaultEngine: this.get('view engine'),
      root: this.get('views'),
      engines: engines
    });


    if (!view.path) {
      var err = new Error('Failed to lookup view "' + name + '"');
      return done(err);
    }


    try {
      view.render(options, callback);
    } catch (e) {
      callback(e);
    }
};複製程式碼

還有一些細節沒有在教程中展示出來,可以參考github上傳的案例程式碼。

貌似一切搞定,修改測試用例,嘗試一下。

var fs = require('fs'); // 此模板引擎依賴 fs 模組
app.engine('ntl', function (filePath, options, callback) { // 定義模板引擎
  fs.readFile(filePath, function (err, content) {
    if (err) return callback(new Error(err));
    // 這是一個功能極其簡單的模板引擎
    var rendered = content.toString().replace('#title#', '<title>'+ options.title +'</title>')
    .replace('#message#', '<h1>'+ options.message +'</h1>');
    return callback(null, rendered);
  });
});

app.set('views', './test/views'); // 指定檢視所在的位置
app.set('view engine', 'ntl'); // 註冊模板引擎


app.get('/', function(req, res, next) {
    res.render('index', { title: 'Hey', message: 'Hello there!'});
});複製程式碼

執行node test/index.js,檢視效果。

上面的程式碼是自己註冊的引擎,如果想要和現有的模板引擎結合還需要在回撥函式中引用模板自身的渲染方法,當然為了方便,express框架內部提供了一個預設方法,如果模板引擎匯出了該方法,則表示該模板引擎支援express框架,無需使用app.engine再次封裝。

該方法宣告如下:

 __express(filePath, options, callback)複製程式碼

可以參考ejs模板引擎的程式碼,看看它們是如何寫的:

//該行程式碼在lib/ejs.js檔案的355行左右
exports.__express = exports.renderFile;複製程式碼

express框架是如何實現這個預設載入的功能的呢?很簡單,只需要在View的建構函式中加一個判斷即可。

if (!opts.engines[this.ext]) {
  // load engine
  var mod = this.ext.substr(1);
  opts.engines[this.ext] = require(mod).__express;
}複製程式碼

程式碼很簡單,如果沒有找到引擎對應的渲染函式,那就嘗試載入__express函式。

後記

至此,算是結束本篇文章了。其實還有很多內容可以寫,但是寫的有些煩了,篇幅太長了,大概有一萬三千多字,後面有點應付了事的感覺。

簡單的說一下還有哪裡沒有介紹。

  1. 關於Application。

如果稍微看過expross程式碼的人都會發現,Application並不是想我寫的這樣是一個類,而是一箇中介軟體,一個物件,該物件使用了mixin方法的多繼承手段,express.js檔案中的createApplication函式算是整個框架的切入點。

  1. 關於Router.handle。

這個函式可以說是整個express框架的核心,如果理解了該函式,整個框架基本上就掌握了。我在仿製的時候捨棄了很多細節,在這裡個函式裡面內部有兩個關鍵點沒說。一、處理URL形式的引數,這裡涉及對params引數的提取過程。其中有一個restore函式使用高階函式的方法做了快取,仔細體會很有意思。二、setImmediate非同步返回,之所以要使用非同步處理,是因為下面的程式碼需要執行,包括路徑相關的引數,這些引數在下一個處理函式中可能會用到。

  1. 關於其他函式。

太多函式了,不一一列舉,前文已經提到,涉及的細節太多,正規表示式,http協議層,nodejs本身函式的使用,對於整個框架的理解幫助不大,全部捨棄。不過大多數函式都是自成體系,很好理解。

相關文章