上文“走進Node.js啟動過程”中我們算是成功入門了菊霜。既然Node.js的強項是處理網(wǎng)絡(luò)請求,那我們就來分析一個HTTP請求在Node.js中是怎么被處理的济赎,以及JavaScript在這個過程中引入的開銷到底有多大鉴逞。
Node.js采用的網(wǎng)絡(luò)請求處理模型是IO多路復(fù)用。它與傳統(tǒng)的主從多線程并發(fā)模型是有區(qū)別的:只使用有限的線程數(shù)(1個)司训,所以占用系統(tǒng)資源很少构捡;操作系統(tǒng)級的異步IO支持,可以減少用戶態(tài)/內(nèi)核態(tài)切換壳猜,并且本身性能更高(因為直接與網(wǎng)卡驅(qū)動交互)勾徽;JavaScript天生具有保護程序執(zhí)行現(xiàn)場的能力(閉包),傳統(tǒng)模型要么依賴應(yīng)用程序自己保存現(xiàn)場统扳,或者依賴線程切換時自動完成喘帚。當然,并不能說IO多路復(fù)用就是最好的并發(fā)模型咒钟,關(guān)鍵還是看應(yīng)用場景朱嘴。
我們來看“hello world”版Node.js網(wǎng)絡(luò)服務(wù)器:
require('http').createServer((req, res) => {
res.end('hello world');
}).listen(3333);
代碼思路分析
createServer([requestListener])
createServer創(chuàng)建了http.Server對象腕够,它繼承自net.Server。事實上玫荣,HTTP協(xié)議確實是基于TCP協(xié)議實現(xiàn)的捅厂。createServer的可選參數(shù)requestListener用于監(jiān)聽request事件焙贷;另外,它也監(jiān)聽connection事件啡彬,只不過回調(diào)函數(shù)是http.Server自己實現(xiàn)的庶灿。然后調(diào)用listen讓http.Server對象在端口3333上監(jiān)聽連接請求并最終創(chuàng)建TCP對象往踢,由tcp_wrap.h實現(xiàn)峻呕。最后會調(diào)用TCP對象的listen方法瘦癌,這才真正在指定端口開始提供服務(wù)佩憾。我們來看看涉及到的所有JavaScript對象:
涉及到的C++類大多只是對libuv做了一層包裝并公布給JavaScript妄帘,所以不在這里特別列出抡驼。我們有必要提一下http-parser致盟,它是用來解析http請求/響應(yīng)消息的馏锡,本身十分高效:沒有任何系統(tǒng)調(diào)用杯道,沒有內(nèi)存分配操作党巾,純C實現(xiàn)。
connection事件
當服務(wù)器接受了一個連接請求后驳规,會觸發(fā)connection事件。我們可以在這個結(jié)點獲取到套接字文件描述符叹侄,之后就可以在這個文件描述符上做流式讀或?qū)懼捍簿褪撬^的全雙工模式撒强。上文提到net.Server的listen方法會創(chuàng)建TCP對象飘哨,并且提供TCP對象的onconnection事件回調(diào)方法芽隆;這里可以利用字段net.Server.maxConnections做過載保護胚吁,后面會講到腕扶。并且會把clientHandle(本次連接的套接字文件描述符)封裝成net.Socket對象半抱,作為connection事件的參數(shù)窿侈。我們來看看調(diào)用過程:
tcp_wrap.cc
void TCPWrap::Listen(const FunctionCallbackInfo<Value>& args) {
int err = uv_listen(reinterpret_cast<uv_stream_t*>(&wrap->handle_),
backlog,
OnConnection);
args.GetReturnValue().Set(err);
}
OnConnection 在connection_wrap.cc中定義
// ...省略不重要的代碼
uv_stream_t* client_handle =
reinterpret_cast<uv_stream_t*>(&wrap->handle_);
// uv_accept can fail if the new connection has already been closed, in
// which case an EAGAIN (resource temporarily unavailable) will be
// returned.
if (uv_accept(handle, client_handle))
return;
// Successful accept. Call the onconnection callback in JavaScript land.
argv[1] = client_obj;
// ...省略不重要的代碼
wrap_data->MakeCallback(env->onconnection_string(), arraysize(argv), argv);
上文提到的clientHandle實際上是uv_accept的第二個參數(shù)史简,指服務(wù)當前連接的套接字文件描述符乘瓤。net.Server的字段 _handle 會在JavaScript側(cè)存儲該字段衙傀。最后我們上一張流程圖:
request事件
connection事件的回調(diào)函數(shù)connectionListener(lib/_http_server.js)中统抬,首先獲取http-parser對象聪建,設(shè)置parser.onIncoming回調(diào)(馬上會用到)金麸。當連接套接字有數(shù)據(jù)到達時挥下,調(diào)用http-parser.execute方法。http-parser在解析過程中會觸發(fā)如下回調(diào)函數(shù):
on_message_begin:在開始解析HTTP消息之前现斋,可以設(shè)置http-parser的初始狀態(tài)(注意http-parse有可能是復(fù)用的而不是重每次新創(chuàng)建)
on_url:解析請求的url庄蹋,對響應(yīng)消息不起作用
on_status, 解析狀態(tài)碼限书,只對http響應(yīng)消息起作用
on_head_field, 頭字段名稱
on_head_value:頭字段對應(yīng)值
on_headers_complete:當所有頭解析完成時
on_body:解析http消息中包含的payload
on_message_complete:解析工作結(jié)束
Node.js中Parser類是對http-parser的包裝蔗包,它會注冊上面所有的回調(diào)函數(shù)调限。同時耻矮,暴露給JavaScript5個事件:
kOnHeaders裆装,kOnHeadersComplete哨免,kOnBody,kOnMessageComplete载荔,kOnExecute懒熙。在lib/_http_common.js中監(jiān)聽了這些事件工扎。其中肢娘,當需要強制把頭字段回傳到JavaScript時會觸發(fā)kOnHeaders蔬浙;例如贞远,頭字段個數(shù)超過32蓝仲,或者解析結(jié)束時仍然有頭字段沒有回傳給JavaScript袱结。當調(diào)用完http_parser_execute后觸發(fā)kOnExecute垢夹。kOnHeadersComplete事件觸發(fā)時果元,會調(diào)用parser的onIncoming回調(diào)函數(shù)犀盟。僅僅HTTP頭解析完成之后阅畴,就會觸發(fā)request事件。執(zhí)行流程如下:
總結(jié)
說了那么多颤专,其實仍然離不開最基礎(chǔ)的套接字編程步驟栖秕,對于服務(wù)器端依次是:create累魔、bind垦写,listen梯投、accept和close分蓖《恚客戶端會經(jīng)歷create味廊、bind余佛、connect和close。想了解更多套接字編程的同學(xué)可以參考《UNIX網(wǎng)絡(luò)編程》恨憎。
HTTP場景分析
上面提到的Node.js版hello world只涵蓋了HTTP處理最基本的情況憔恳,但是也足以說明Node.js處理得非常簡潔±觯現(xiàn)在者铜,我們來分析一些典型的HTTP場景。
1. keep-alive
對于前端應(yīng)用愉粤,HTTP請求瞬間數(shù)量比較多衣厘,但每個請求傳輸?shù)臄?shù)據(jù)一般不大影暴;這時型宙,用同一個TCP連接處理同一個用戶發(fā)出的HTTP請求可以顯著提高性能妆兑。但是keep-alive也不是萬能的毛仪,如果用戶每次只發(fā)起一個請求箱靴,它反而會因為延長連接的生存時間衡怀,浪費服務(wù)器資源狈癞。
針對同一個連接蝶桶,Node.js會維持一個incoming隊列和一個outgoing隊列真竖。應(yīng)用程序通過監(jiān)聽request事件恢共,可以訪問ServerResponse和IncomingMessage對象讨韭,當請求處理完成之后(調(diào)用response.end()),ServerResponse會響應(yīng)finish事件透硝。如果它是本次連接上最后一個response對象濒生,則準備關(guān)閉連接;否則丽声,繼續(xù)觸發(fā)request事件雁社。每個連接最長超時時間默認為2分鐘歧胁,可以通過http.Server.setTimeout調(diào)整厉碟。
現(xiàn)在把我們的Node.js版hello world修改一下
var delay = [2000, 30, 500];
var i = 0;
require('http').createServer((req, res) => {
// 為了讓請求模擬更真實箍鼓,會調(diào)整每個請求的響應(yīng)時間
setTimeout(() => {
res.end('hello world');
}, delay[i]);
i = (i+1)%(delay.length);
}).listen(3333, () => {
// listen的回調(diào)函數(shù)
console.log('listen at 3333');
});
客戶端代碼如下:
var http = require('http');
// 設(shè)置HTTP agent開啟keep-alive模式
// 套接字的打開時間維持1分鐘
var agent = new http.Agent({
keepAlive: true,
keepAliveMsecs: 60000
});
// 每次請求結(jié)束之后款咖,都會再發(fā)起一次請求
// doReq每調(diào)用一次只會觸發(fā)2次請求
function doReq(again, iter) {
let request = http.request({
hostname: '192.168.1.10',
port: 3333,
agent:agent
}, (res) => {
console.log(`${new Date().valueOf()} ${iter} ${again} Headers: ${JSON.stringify(res.headers)}`);
console.log(request.socket.localPort);
// 設(shè)置解析響應(yīng)的編碼格式
res.setEncoding('utf8');
// 接收響應(yīng)
res.on('data', (chunk) => {
console.log(`${new Date().valueOf()} ${iter} ${again} Body: ${chunk}`);
});
if (again) doReq(false, iter);
});
// 發(fā)起請求
request.end();
}
for (let i = 0; i < 3; i++) {
doReq(true, i);
}
套接字復(fù)用的時序如下
2. Expect頭
如果客戶端在發(fā)送POST請求之前海洼,由于傳輸?shù)臄?shù)據(jù)量比較大坏逢,期望向服務(wù)器確認請求是否能被處理赘被;這種情況下民假,可以先發(fā)送一個包含頭Expect:100-continue的http請求。如果服務(wù)器能處理此請求事秀,則返回響應(yīng)狀態(tài)碼100(Continue)秽晚;否則,返回417(Expectation Failed)菩浙。默認情況下劲蜻,Node.js會自動響應(yīng)狀態(tài)碼100先嬉;同時楚堤,http.Server會觸發(fā)事件checkContinue和checkExpectation來方便我們做特殊處理身冬。具體規(guī)則是:當服務(wù)器收到頭字段Expect時:如果其值為100-continue酥筝,會觸發(fā)checkContinue事件,默認行為是返回100掸掏;如果值為其它丧凤,會觸發(fā)checkExpectation事件息裸,默認行為是返回417。
例如年扩,我們通過curl發(fā)送HTTP請求:
curl -vs --header "Expect:100-continue" http://localhost:3333
交互過程如下
> GET / HTTP/1.1
> Host: localhost:3333
> User-Agent: curl/7.49.1
> Accept: */*
> Expect:100-continue
>
< HTTP/1.1 100 Continue
< HTTP/1.1 200 OK
< Date: Mon, 03 Apr 2017 14:15:47 GMT
< Connection: keep-alive
< Content-Length: 11
<
我們接收到2個響應(yīng)厨幻,分別是狀態(tài)碼100和200。前一個是Node.js的默認行為饭宾,后一個是應(yīng)用程序代碼行為看铆。
3. HTTP代理
在實際開發(fā)時弹惦,用到http代理的機會還是挺多的,比如棠隐,測試說線上出bug了啰扛,觸屏版頁面顯示有問題嗡贺;我們一般第一時間會去看api返回是否正常暑刃,這個時候在手機上設(shè)置好代理就能輕松捕獲HTTP請求了岩臣。老牌的代理工具有fiddler,charles炸宵。其實土全,nodejs下也有裹匙,例如node-http-proxy概页,anyproxy惰匙。基本思路是監(jiān)聽request事件哑梳,當客戶端與代理建立HTTP連接之后鸠真,代理會向真正請求的服務(wù)器發(fā)起連接弧哎,然后把兩個套接字的流綁在一起撤嫩。我們可以實現(xiàn)一個簡單的代理服務(wù)器:
var http = require('http');
var url = require('url');
http.createServer((req, res) => {
// request回調(diào)函數(shù)
console.log(`proxy request: ${req.url}`);
var urlObj = url.parse(req.url);
var options = {
hostname: urlObj.hostname,
port: urlObj.port || 80,
path: urlObj.path,
method: req.method,
headers: req.headers
};
// 向目標服務(wù)器發(fā)起請求
var proxyRequest = http.request(options, (proxyResponse) => {
// 把目標服務(wù)器的響應(yīng)返回給客戶端
res.writeHead(proxyResponse.statusCode, proxyResponse.headers);
proxyResponse.pipe(res);
}).on('error', () => {
res.end();
});
// 把客戶端請求數(shù)據(jù)轉(zhuǎn)給中間人請求
req.pipe(proxyRequest);
}).listen(8089, '0.0.0.0');
驗證下是否真的起作用序攘,curl通過代理服務(wù)器訪問我們的“hello world”版Node.js服務(wù)器:
curl -x http://192.168.132.136:8089 http://localhost:3333/
優(yōu)化策略
Node.js在實現(xiàn)HTTP服務(wù)器時,除了利用高性能的http-parser祭钉,自身也做了些性能優(yōu)化。
1. http_parser對象緩存池
http-parser對象處理完一個請求之后不會被立即釋放距境,而是被放入緩存池(/lib/internal/freelist)垫桂,最多緩存1000個http-parser對象诬滩。
2. 預(yù)設(shè)HTTP頭總數(shù)
HTTP協(xié)議規(guī)范并沒有限定可以傳輸?shù)腍TTP頭總數(shù)上限疼鸟,http-parser為了避免動態(tài)分配內(nèi)存空镜,設(shè)定上限默認值是32。其他web服務(wù)器實現(xiàn)也有類似設(shè)置男旗;例如察皇,apache能處理的HTTP請求頭默認上限(LimitRequestFields)是100什荣。如果請求消息中頭字段真超過了32個稻爬,Node.js也能處理桅锄,它會把已經(jīng)解析的頭字段通過事件kOnHeaders保存到JavaScript這邊然后繼續(xù)解析琉雳。 如果頭字段不超過32個,http-parser會直接處理完并觸發(fā)on_headers_complete一次性傳遞所有頭字段友瘤;所以我們在利用Node.js作為web服務(wù)器時翠肘,應(yīng)盡量把頭字段控制在32個之內(nèi)。
3. 過載保護
理論上辫秧,Node.js允許的同時連接數(shù)只與進程可以打開的文件描述符上限有關(guān)束倍。但是隨著連接數(shù)越來越多,占用的系統(tǒng)資源也越來越多盟戏,很有可能連正常的服務(wù)都無法保證绪妹,甚至可能拖垮整個系統(tǒng)。這時抓半,我們可以設(shè)置http.Server的maxConnections笛求,如果當前并發(fā)量大于服務(wù)器的處理能力,則服務(wù)器會自動關(guān)閉連接。另外辱揭,也可以設(shè)置socket的超時時間為可接受的最長響應(yīng)時間。
性能實測
為了簡單分析下Node.js引入的開銷,現(xiàn)在基于libuv和http_parser編寫一個純C的HTTP服務(wù)器熟呛“∷停基本思路是篷朵,在默認事件循環(huán)隊列上監(jiān)聽指定TCP端口;如果該端口上有請求到達,會在隊列上插入一個一個的任務(wù)坪创;當這些任務(wù)被消費時依沮,會執(zhí)行connection_cb。見核心代碼片段:
int main() {
// 初始化uv事件循環(huán)
loop = uv_default_loop();
uv_tcp_t server;
struct sockaddr_in addr;
// 指定服務(wù)器監(jiān)聽地址與端口
uv_ip4_addr("192.168.132.136", 3333, &addr);
// 初始化TCP服務(wù)器,并與默認事件循環(huán)綁定
uv_tcp_init(loop, &server);
// 服務(wù)器端口綁定
uv_tcp_bind(&server, (const struct sockaddr*)&addr, 0);
// 指定連接處理回調(diào)函數(shù)connection_cb
// 256為TCP等待隊列長度
int r = uv_listen((uv_stream_t*)&server, 256, connection_cb);
// 開始處理默認時間循環(huán)上的消息
// 如果TCP報錯岂座,事件循環(huán)也會自動退出
return uv_run(loop, UV_RUN_DEFAULT);
}
connection_cb調(diào)用uv_accept會負責(zé)與發(fā)起請求的客戶端實際建立套接字手素,并注冊流操作回調(diào)函數(shù)read_cb:
void connection_cb(uv_stream_t* server, int status) {
uv_tcp_t* client = (uv_tcp_t*)malloc(sizeof(uv_tcp_t));
uv_tcp_init(loop, client);
// 與客戶端建立套接字
uv_accept(server, (uv_stream_t*)client);
uv_read_start((uv_stream_t*)client, alloc_buffer, read_cb);
}
上文中read_cb用于讀取客戶端請求數(shù)據(jù)崩哩,并發(fā)送響應(yīng)數(shù)據(jù):
void read_cb(uv_stream_t* stream, ssize_t nread, const uv_buf_t* buf) {
if (nread > 0) {
memcpy(reqBuf + bufEnd, buf->base, nread);
bufEnd += nread;
free(buf->base);
// 驗證TCP請求數(shù)據(jù)是否是合法的HTTP報文
http_parser_execute(parser, &settings, reqBuf, bufEnd);
uv_write_t* req = (uv_write_t*)malloc(sizeof(uv_write_t));
uv_buf_t* response = malloc(sizeof(uv_buf_t));
// 響應(yīng)HTTP報文
response->base = "HTTP/1.1 200 OK\r\nConnection:close\r\nContent-Length:11\r\n\r\nhello world\r\n\r\n";
response->len = strlen(response->base);
uv_write(req, stream, response, 1, write_cb);
} else if (nread == UV_EOF) {
uv_close((uv_handle_t*)stream, close_cb);
}
}
全部源碼請參見simple HTTP server。我們使用apache benchmark來做壓力測試:并發(fā)數(shù)為5000,總請求數(shù)為100000。
ab -c 5000 -n 100000 http://192.168.132.136:3333/
測試結(jié)果如下: 0.8秒(C) vs??5秒(Node.js)
我們再看看內(nèi)存占用水评,0.6MB(C) vs??51MB(Node.js)
Node.js雖然引入了一些開銷疗涉,但是從代碼實現(xiàn)行數(shù)上確實要簡潔很多闹伪。
更多關(guān)于Node.js的技術(shù)內(nèi)容厅克,請關(guān)注滬江技術(shù)學(xué)院微信公眾號票编。