上文“走進(jìn)Node.js啟動(dòng)過(guò)程”中我們算是成功入門(mén)了。既然Node.js的強(qiáng)項(xiàng)是處理網(wǎng)絡(luò)請(qǐng)求,那我們就來(lái)分析一個(gè)HTTP請(qǐng)求在Node.js中是怎么被處理的,以及JavaScript在這個(gè)過(guò)程中引入的開(kāi)銷(xiāo)到底有多大。
Node.js采用的網(wǎng)絡(luò)請(qǐng)求處理模型是IO多路復(fù)用。它與傳統(tǒng)的主從多線程并發(fā)模型是有區(qū)別的:只使用有限的線程數(shù)(1個(gè)),所以占用系統(tǒng)資源很少;操作系統(tǒng)級(jí)的異步IO支持,可以減少用戶(hù)態(tài)/內(nèi)核態(tài)切換,并且本身性能更高(因?yàn)橹苯优c網(wǎng)卡驅(qū)動(dòng)交互);JavaScript天生具有保護(hù)程序執(zhí)行現(xiàn)場(chǎng)的能力(閉包),傳統(tǒng)模型要么依賴(lài)應(yīng)用程序自己保存現(xiàn)場(chǎng),或者依賴(lài)線程切換時(shí)自動(dòng)完成。當(dāng)然,并不能說(shuō)IO多路復(fù)用就是最好的并發(fā)模型,關(guān)鍵還是看應(yīng)用場(chǎng)景。
我們來(lái)看“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對(duì)象,它繼承自net.Server。事實(shí)上,HTTP協(xié)議確實(shí)是基于TCP協(xié)議實(shí)現(xiàn)的。createServer的可選參數(shù)requestListener用于監(jiān)聽(tīng)request事件;另外,它也監(jiān)聽(tīng)connection事件,只不過(guò)回調(diào)函數(shù)是http.Server自己實(shí)現(xiàn)的。然后調(diào)用listen讓http.Server對(duì)象在端口3333上監(jiān)聽(tīng)連接請(qǐng)求并最終創(chuàng)建TCP對(duì)象,由tcp_wrap.h實(shí)現(xiàn)。最后會(huì)調(diào)用TCP對(duì)象的listen方法,這才真正在指定端口開(kāi)始提供服務(wù)。我們來(lái)看看涉及到的所有JavaScript對(duì)象:

涉及到的C++類(lèi)大多只是對(duì)libuv做了一層包裝并公布給JavaScript,所以不在這里特別列出。我們有必要提一下http-parser,它是用來(lái)解析http請(qǐng)求/響應(yīng)消息的,本身十分高效:沒(méi)有任何系統(tǒng)調(diào)用,沒(méi)有內(nèi)存分配操作,純C實(shí)現(xiàn)。
connection事件
當(dāng)服務(wù)器接受了一個(gè)連接請(qǐng)求后,會(huì)觸發(fā)connection事件。我們可以在這個(gè)結(jié)點(diǎn)獲取到套接字文件描述符,之后就可以在這個(gè)文件描述符上做流式讀或?qū)?,也就是所謂的全雙工模式。上文提到net.Server的listen方法會(huì)創(chuàng)建TCP對(duì)象,并且提供TCP對(duì)象的onconnection事件回調(diào)方法;這里可以利用字段net.Server.maxConnections做過(guò)載保護(hù),后面會(huì)講到。并且會(huì)把clientHandle(本次連接的套接字文件描述符)封裝成net.Socket對(duì)象,作為connection事件的參數(shù)。我們來(lái)看看調(diào)用過(guò)程:
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實(shí)際上是uv_accept的第二個(gè)參數(shù),指服務(wù)當(dāng)前連接的套接字文件描述符。net.Server的字段 _handle 會(huì)在JavaScript側(cè)存儲(chǔ)該字段。最后我們上一張流程圖:

request事件
connection事件的回調(diào)函數(shù)connectionListener(lib/_http_server.js)中,首先獲取http-parser對(duì)象,設(shè)置parser.onIncoming回調(diào)(馬上會(huì)用到)。當(dāng)連接套接字有數(shù)據(jù)到達(dá)時(shí),調(diào)用http-parser.execute方法。http-parser在解析過(guò)程中會(huì)觸發(fā)如下回調(diào)函數(shù):
on_message_begin:在開(kāi)始解析HTTP消息之前,可以設(shè)置http-parser的初始狀態(tài)(注意http-parse有可能是復(fù)用的而不是重每次新創(chuàng)建)
on_url:解析請(qǐng)求的url,對(duì)響應(yīng)消息不起作用
on_status, 解析狀態(tài)碼,只對(duì)http響應(yīng)消息起作用
on_head_field, 頭字段名稱(chēng)
on_head_value:頭字段對(duì)應(yīng)值
on_headers_complete:當(dāng)所有頭解析完成時(shí)
on_body:解析http消息中包含的payload
on_message_complete:解析工作結(jié)束
Node.js中Parser類(lèi)是對(duì)http-parser的包裝,它會(huì)注冊(cè)上面所有的回調(diào)函數(shù)。同時(shí),暴露給JavaScript5個(gè)事件:
kOnHeaders,kOnHeadersComplete,kOnBody,kOnMessageComplete,kOnExecute。在lib/_http_common.js中監(jiān)聽(tīng)了這些事件。其中,當(dāng)需要強(qiáng)制把頭字段回傳到JavaScript時(shí)會(huì)觸發(fā)kOnHeaders;例如,頭字段個(gè)數(shù)超過(guò)32,或者解析結(jié)束時(shí)仍然有頭字段沒(méi)有回傳給JavaScript。當(dāng)調(diào)用完http_parser_execute后觸發(fā)kOnExecute。kOnHeadersComplete事件觸發(fā)時(shí),會(huì)調(diào)用parser的onIncoming回調(diào)函數(shù)。僅僅HTTP頭解析完成之后,就會(huì)觸發(fā)request事件。執(zhí)行流程如下:

總結(jié)
說(shuō)了那么多,其實(shí)仍然離不開(kāi)最基礎(chǔ)的套接字編程步驟,對(duì)于服務(wù)器端依次是:create、bind,listen、accept和close??蛻?hù)端會(huì)經(jīng)歷create、bind、connect和close。想了解更多套接字編程的同學(xué)可以參考《UNIX網(wǎng)絡(luò)編程》。
HTTP場(chǎng)景分析
上面提到的Node.js版hello world只涵蓋了HTTP處理最基本的情況,但是也足以說(shuō)明Node.js處理得非常簡(jiǎn)潔?,F(xiàn)在,我們來(lái)分析一些典型的HTTP場(chǎng)景。
1. keep-alive
對(duì)于前端應(yīng)用,HTTP請(qǐng)求瞬間數(shù)量比較多,但每個(gè)請(qǐng)求傳輸?shù)臄?shù)據(jù)一般不大;這時(shí),用同一個(gè)TCP連接處理同一個(gè)用戶(hù)發(fā)出的HTTP請(qǐng)求可以顯著提高性能。但是keep-alive也不是萬(wàn)能的,如果用戶(hù)每次只發(fā)起一個(gè)請(qǐng)求,它反而會(huì)因?yàn)檠娱L(zhǎng)連接的生存時(shí)間,浪費(fèi)服務(wù)器資源。
針對(duì)同一個(gè)連接,Node.js會(huì)維持一個(gè)incoming隊(duì)列和一個(gè)outgoing隊(duì)列。應(yīng)用程序通過(guò)監(jiān)聽(tīng)request事件,可以訪問(wèn)ServerResponse和IncomingMessage對(duì)象,當(dāng)請(qǐng)求處理完成之后(調(diào)用response.end()),ServerResponse會(huì)響應(yīng)finish事件。如果它是本次連接上最后一個(gè)response對(duì)象,則準(zhǔn)備關(guān)閉連接;否則,繼續(xù)觸發(fā)request事件。每個(gè)連接最長(zhǎng)超時(shí)時(shí)間默認(rèn)為2分鐘,可以通過(guò)http.Server.setTimeout調(diào)整。
現(xiàn)在把我們的Node.js版hello world修改一下
var delay = [2000, 30, 500];
var i = 0;
require('http').createServer((req, res) => {
// 為了讓請(qǐng)求模擬更真實(shí),會(huì)調(diào)整每個(gè)請(qǐng)求的響應(yīng)時(shí)間
setTimeout(() => {
res.end('hello world');
}, delay[i]);
i = (i+1)%(delay.length);
}).listen(3333, () => {
// listen的回調(diào)函數(shù)
console.log('listen at 3333');
});
客戶(hù)端代碼如下:
var http = require('http');
// 設(shè)置HTTP agent開(kāi)啟keep-alive模式
// 套接字的打開(kāi)時(shí)間維持1分鐘
var agent = new http.Agent({
keepAlive: true,
keepAliveMsecs: 60000
});
// 每次請(qǐng)求結(jié)束之后,都會(huì)再發(fā)起一次請(qǐng)求
// doReq每調(diào)用一次只會(huì)觸發(fā)2次請(qǐng)求
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ā)起請(qǐng)求
request.end();
}
for (let i = 0; i < 3; i++) {
doReq(true, i);
}
套接字復(fù)用的時(shí)序如下

2. Expect頭
如果客戶(hù)端在發(fā)送POST請(qǐng)求之前,由于傳輸?shù)臄?shù)據(jù)量比較大,期望向服務(wù)器確認(rèn)請(qǐng)求是否能被處理;這種情況下,可以先發(fā)送一個(gè)包含頭Expect:100-continue的http請(qǐng)求。如果服務(wù)器能處理此請(qǐng)求,則返回響應(yīng)狀態(tài)碼100(Continue);否則,返回417(Expectation Failed)。默認(rèn)情況下,Node.js會(huì)自動(dòng)響應(yīng)狀態(tài)碼100;同時(shí),http.Server會(huì)觸發(fā)事件checkContinue和checkExpectation來(lái)方便我們做特殊處理。具體規(guī)則是:當(dāng)服務(wù)器收到頭字段Expect時(shí):如果其值為100-continue,會(huì)觸發(fā)checkContinue事件,默認(rèn)行為是返回100;如果值為其它,會(huì)觸發(fā)checkExpectation事件,默認(rèn)行為是返回417。
例如,我們通過(guò)curl發(fā)送HTTP請(qǐng)求:
curl -vs --header "Expect:100-continue" http://localhost:3333
交互過(guò)程如下
> 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個(gè)響應(yīng),分別是狀態(tài)碼100和200。前一個(gè)是Node.js的默認(rèn)行為,后一個(gè)是應(yīng)用程序代碼行為。
3. HTTP代理
在實(shí)際開(kāi)發(fā)時(shí),用到http代理的機(jī)會(huì)還是挺多的,比如,測(cè)試說(shuō)線上出bug了,觸屏版頁(yè)面顯示有問(wèn)題;我們一般第一時(shí)間會(huì)去看api返回是否正常,這個(gè)時(shí)候在手機(jī)上設(shè)置好代理就能輕松捕獲HTTP請(qǐng)求了。老牌的代理工具有fiddler,charles。其實(shí),nodejs下也有,例如node-http-proxy,anyproxy。基本思路是監(jiān)聽(tīng)request事件,當(dāng)客戶(hù)端與代理建立HTTP連接之后,代理會(huì)向真正請(qǐng)求的服務(wù)器發(fā)起連接,然后把兩個(gè)套接字的流綁在一起。我們可以實(shí)現(xiàn)一個(gè)簡(jiǎ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
};
// 向目標(biāo)服務(wù)器發(fā)起請(qǐng)求
var proxyRequest = http.request(options, (proxyResponse) => {
// 把目標(biāo)服務(wù)器的響應(yīng)返回給客戶(hù)端
res.writeHead(proxyResponse.statusCode, proxyResponse.headers);
proxyResponse.pipe(res);
}).on('error', () => {
res.end();
});
// 把客戶(hù)端請(qǐng)求數(shù)據(jù)轉(zhuǎn)給中間人請(qǐng)求
req.pipe(proxyRequest);
}).listen(8089, '0.0.0.0');
驗(yàn)證下是否真的起作用,curl通過(guò)代理服務(wù)器訪問(wèn)我們的“hello world”版Node.js服務(wù)器:
curl -x http://192.168.132.136:8089 http://localhost:3333/
優(yōu)化策略
Node.js在實(shí)現(xiàn)HTTP服務(wù)器時(shí),除了利用高性能的http-parser,自身也做了些性能優(yōu)化。
1. http_parser對(duì)象緩存池
http-parser對(duì)象處理完一個(gè)請(qǐng)求之后不會(huì)被立即釋放,而是被放入緩存池(/lib/internal/freelist),最多緩存1000個(gè)http-parser對(duì)象。
2. 預(yù)設(shè)HTTP頭總數(shù)
HTTP協(xié)議規(guī)范并沒(méi)有限定可以傳輸?shù)腍TTP頭總數(shù)上限,http-parser為了避免動(dòng)態(tài)分配內(nèi)存,設(shè)定上限默認(rèn)值是32。其他web服務(wù)器實(shí)現(xiàn)也有類(lèi)似設(shè)置;例如,apache能處理的HTTP請(qǐng)求頭默認(rèn)上限(LimitRequestFields)是100。如果請(qǐng)求消息中頭字段真超過(guò)了32個(gè),Node.js也能處理,它會(huì)把已經(jīng)解析的頭字段通過(guò)事件kOnHeaders保存到JavaScript這邊然后繼續(xù)解析。 如果頭字段不超過(guò)32個(gè),http-parser會(huì)直接處理完并觸發(fā)on_headers_complete一次性傳遞所有頭字段;所以我們?cè)诶肗ode.js作為web服務(wù)器時(shí),應(yīng)盡量把頭字段控制在32個(gè)之內(nèi)。
3. 過(guò)載保護(hù)
理論上,Node.js允許的同時(shí)連接數(shù)只與進(jìn)程可以打開(kāi)的文件描述符上限有關(guān)。但是隨著連接數(shù)越來(lái)越多,占用的系統(tǒng)資源也越來(lái)越多,很有可能連正常的服務(wù)都無(wú)法保證,甚至可能拖垮整個(gè)系統(tǒng)。這時(shí),我們可以設(shè)置http.Server的maxConnections,如果當(dāng)前并發(fā)量大于服務(wù)器的處理能力,則服務(wù)器會(huì)自動(dòng)關(guān)閉連接。另外,也可以設(shè)置socket的超時(shí)時(shí)間為可接受的最長(zhǎng)響應(yīng)時(shí)間。
性能實(shí)測(cè)
為了簡(jiǎn)單分析下Node.js引入的開(kāi)銷(xiāo),現(xiàn)在基于libuv和http_parser編寫(xiě)一個(gè)純C的HTTP服務(wù)器?;舅悸肥牵谀J(rèn)事件循環(huán)隊(duì)列上監(jiān)聽(tīng)指定TCP端口;如果該端口上有請(qǐng)求到達(dá),會(huì)在隊(duì)列上插入一個(gè)一個(gè)的任務(wù);當(dāng)這些任務(wù)被消費(fèi)時(shí),會(huì)執(zhí)行connection_cb。見(jiàn)核心代碼片段:
int main() {
// 初始化uv事件循環(huán)
loop = uv_default_loop();
uv_tcp_t server;
struct sockaddr_in addr;
// 指定服務(wù)器監(jiān)聽(tīng)地址與端口
uv_ip4_addr("192.168.132.136", 3333, &addr);
// 初始化TCP服務(wù)器,并與默認(rèn)事件循環(huán)綁定
uv_tcp_init(loop, &server);
// 服務(wù)器端口綁定
uv_tcp_bind(&server, (const struct sockaddr*)&addr, 0);
// 指定連接處理回調(diào)函數(shù)connection_cb
// 256為T(mén)CP等待隊(duì)列長(zhǎng)度
int r = uv_listen((uv_stream_t*)&server, 256, connection_cb);
// 開(kāi)始處理默認(rèn)時(shí)間循環(huán)上的消息
// 如果TCP報(bào)錯(cuò),事件循環(huán)也會(huì)自動(dòng)退出
return uv_run(loop, UV_RUN_DEFAULT);
}
connection_cb調(diào)用uv_accept會(huì)負(fù)責(zé)與發(fā)起請(qǐng)求的客戶(hù)端實(shí)際建立套接字,并注冊(cè)流操作回調(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);
// 與客戶(hù)端建立套接字
uv_accept(server, (uv_stream_t*)client);
uv_read_start((uv_stream_t*)client, alloc_buffer, read_cb);
}
上文中read_cb用于讀取客戶(hù)端請(qǐng)求數(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);
// 驗(yàn)證TCP請(qǐng)求數(shù)據(jù)是否是合法的HTTP報(bào)文
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報(bào)文
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);
}
}
全部源碼請(qǐng)參見(jiàn)simple HTTP server。我們使用apache benchmark來(lái)做壓力測(cè)試:并發(fā)數(shù)為5000,總請(qǐng)求數(shù)為100000。
ab -c 5000 -n 100000 http://192.168.132.136:3333/
測(cè)試結(jié)果如下: 0.8秒(C) vs??5秒(Node.js)

我們?cè)倏纯磧?nèi)存占用,0.6MB(C) vs??51MB(Node.js)

Node.js雖然引入了一些開(kāi)銷(xiāo),但是從代碼實(shí)現(xiàn)行數(shù)上確實(shí)要簡(jiǎn)潔很多。
更多關(guān)于Node.js的技術(shù)內(nèi)容,請(qǐng)關(guān)注滬江技術(shù)學(xué)院微信公眾號(hào)。