走進(jìn)Node.js 之 HTTP實(shí)現(xiàn)分析

上文“走進(jìn)Node.js啟動(dòng)過程”中我們算是成功入門了。既然Node.js的強(qiáng)項(xiàng)是處理網(wǎng)絡(luò)請求,那我們就來分析一個(gè)HTTP請求在Node.js中是怎么被處理的,以及JavaScript在這個(gè)過程中引入的開銷到底有多大。

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


class-diagram1.png

涉及到的C++類大多只是對libuv做了一層包裝并公布給JavaScript,所以不在這里特別列出。我們有必要提一下http-parser,它是用來解析http請求/響應(yīng)消息的,本身十分高效:沒有任何系統(tǒng)調(diào)用,沒有內(nèi)存分配操作,純C實(shí)現(xiàn)。

connection事件

當(dāng)服務(wù)器接受了一個(gè)連接請求后,會(huì)觸發(fā)connection事件。我們可以在這個(gè)結(jié)點(diǎn)獲取到套接字文件描述符,之后就可以在這個(gè)文件描述符上做流式讀或?qū)懀簿褪撬^的全雙工模式。上文提到net.Server的listen方法會(huì)創(chuàng)建TCP對象,并且提供TCP對象的onconnection事件回調(diào)方法;這里可以利用字段net.Server.maxConnections做過載保護(hù),后面會(huì)講到。并且會(huì)把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);
}

OnConnectionconnection_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ǔ)該字段。最后我們上一張流程圖:

connection1.png

request事件

connection事件的回調(diào)函數(shù)connectionListener(lib/_http_server.js)中,首先獲取http-parser對象,設(shè)置parser.onIncoming回調(diào)(馬上會(huì)用到)。當(dāng)連接套接字有數(shù)據(jù)到達(dá)時(shí),調(diào)用http-parser.execute方法。http-parser在解析過程中會(huì)觸發(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:當(dāng)所有頭解析完成時(shí)

on_body:解析http消息中包含的payload

on_message_complete:解析工作結(jié)束

Node.js中Parser類是對http-parser的包裝,它會(huì)注冊上面所有的回調(diào)函數(shù)。同時(shí),暴露給JavaScript5個(gè)事件:
kOnHeaders,kOnHeadersComplete,kOnBody,kOnMessageComplete,kOnExecute。在lib/_http_common.js中監(jiān)聽了這些事件。其中,當(dāng)需要強(qiáng)制把頭字段回傳到JavaScript時(shí)會(huì)觸發(fā)kOnHeaders;例如,頭字段個(gè)數(shù)超過32,或者解析結(jié)束時(shí)仍然有頭字段沒有回傳給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í)行流程如下:

request1.png

總結(jié)

說了那么多,其實(shí)仍然離不開最基礎(chǔ)的套接字編程步驟,對于服務(wù)器端依次是:create、bind,listen、accept和close??蛻舳藭?huì)經(jīng)歷create、bind、connect和close。想了解更多套接字編程的同學(xué)可以參考《UNIX網(wǎng)絡(luò)編程》。

HTTP場景分析

上面提到的Node.js版hello world只涵蓋了HTTP處理最基本的情況,但是也足以說明Node.js處理得非常簡潔?,F(xiàn)在,我們來分析一些典型的HTTP場景。

1. keep-alive

對于前端應(yīng)用,HTTP請求瞬間數(shù)量比較多,但每個(gè)請求傳輸?shù)臄?shù)據(jù)一般不大;這時(shí),用同一個(gè)TCP連接處理同一個(gè)用戶發(fā)出的HTTP請求可以顯著提高性能。但是keep-alive也不是萬能的,如果用戶每次只發(fā)起一個(gè)請求,它反而會(huì)因?yàn)檠娱L連接的生存時(shí)間,浪費(fèi)服務(wù)器資源。

針對同一個(gè)連接,Node.js會(huì)維持一個(gè)incoming隊(duì)列和一個(gè)outgoing隊(duì)列。應(yīng)用程序通過監(jiān)聽request事件,可以訪問ServerResponse和IncomingMessage對象,當(dāng)請求處理完成之后(調(diào)用response.end()),ServerResponse會(huì)響應(yīng)finish事件。如果它是本次連接上最后一個(gè)response對象,則準(zhǔn)備關(guān)閉連接;否則,繼續(xù)觸發(fā)request事件。每個(gè)連接最長超時(shí)時(shí)間默認(rèn)為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) => {
    // 為了讓請求模擬更真實(shí),會(huì)調(diào)整每個(gè)請求的響應(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');
});

客戶端代碼如下:

var http = require('http');

// 設(shè)置HTTP agent開啟keep-alive模式
// 套接字的打開時(shí)間維持1分鐘
var agent = new http.Agent({
    keepAlive: true,
    keepAliveMsecs: 60000
});

// 每次請求結(jié)束之后,都會(huì)再發(fā)起一次請求
// doReq每調(diào)用一次只會(huì)觸發(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ù)用的時(shí)序如下

keep-alive.png

2. Expect頭

如果客戶端在發(fā)送POST請求之前,由于傳輸?shù)臄?shù)據(jù)量比較大,期望向服務(wù)器確認(rèn)請求是否能被處理;這種情況下,可以先發(fā)送一個(gè)包含頭Expect:100-continue的http請求。如果服務(wù)器能處理此請求,則返回響應(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來方便我們做特殊處理。具體規(guī)則是:當(dāng)服務(wù)器收到頭字段Expect時(shí):如果其值為100-continue,會(huì)觸發(fā)checkContinue事件,默認(rèn)行為是返回100;如果值為其它,會(huì)觸發(fā)checkExpectation事件,默認(rèn)行為是返回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個(gè)響應(yīng),分別是狀態(tài)碼100和200。前一個(gè)是Node.js的默認(rèn)行為,后一個(gè)是應(yīng)用程序代碼行為。

3. HTTP代理

在實(shí)際開發(fā)時(shí),用到http代理的機(jī)會(huì)還是挺多的,比如,測試說線上出bug了,觸屏版頁面顯示有問題;我們一般第一時(shí)間會(huì)去看api返回是否正常,這個(gè)時(shí)候在手機(jī)上設(shè)置好代理就能輕松捕獲HTTP請求了。老牌的代理工具有fiddler,charles。其實(shí),nodejs下也有,例如node-http-proxy,anyproxy。基本思路是監(jiān)聽request事件,當(dāng)客戶端與代理建立HTTP連接之后,代理會(huì)向真正請求的服務(wù)器發(fā)起連接,然后把兩個(gè)套接字的流綁在一起。我們可以實(shí)現(xiàn)一個(gè)簡單的代理服務(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ā)起請求
    var proxyRequest = http.request(options, (proxyResponse) => {
        // 把目標(biāo)服務(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');

驗(yàn)證下是否真的起作用,curl通過代理服務(wù)器訪問我們的“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對象緩存池

http-parser對象處理完一個(gè)請求之后不會(huì)被立即釋放,而是被放入緩存池(/lib/internal/freelist),最多緩存1000個(gè)http-parser對象。

2. 預(yù)設(shè)HTTP頭總數(shù)

HTTP協(xié)議規(guī)范并沒有限定可以傳輸?shù)腍TTP頭總數(shù)上限,http-parser為了避免動(dòng)態(tài)分配內(nèi)存,設(shè)定上限默認(rèn)值是32。其他web服務(wù)器實(shí)現(xiàn)也有類似設(shè)置;例如,apache能處理的HTTP請求頭默認(rèn)上限(LimitRequestFields)是100。如果請求消息中頭字段真超過了32個(gè),Node.js也能處理,它會(huì)把已經(jīng)解析的頭字段通過事件kOnHeaders保存到JavaScript這邊然后繼續(xù)解析。 如果頭字段不超過32個(gè),http-parser會(huì)直接處理完并觸發(fā)on_headers_complete一次性傳遞所有頭字段;所以我們在利用Node.js作為web服務(wù)器時(shí),應(yīng)盡量把頭字段控制在32個(gè)之內(nèi)。

3. 過載保護(hù)

理論上,Node.js允許的同時(shí)連接數(shù)只與進(jìn)程可以打開的文件描述符上限有關(guān)。但是隨著連接數(shù)越來越多,占用的系統(tǒng)資源也越來越多,很有可能連正常的服務(wù)都無法保證,甚至可能拖垮整個(gè)系統(tǒng)。這時(shí),我們可以設(shè)置http.Server的maxConnections,如果當(dāng)前并發(fā)量大于服務(wù)器的處理能力,則服務(wù)器會(huì)自動(dòng)關(guān)閉連接。另外,也可以設(shè)置socket的超時(shí)時(shí)間為可接受的最長響應(yīng)時(shí)間。

性能實(shí)測

為了簡單分析下Node.js引入的開銷,現(xiàn)在基于libuv和http_parser編寫一個(gè)純C的HTTP服務(wù)器?;舅悸肥?,在默認(rèn)事件循環(huán)隊(duì)列上監(jiān)聽指定TCP端口;如果該端口上有請求到達(dá),會(huì)在隊(duì)列上插入一個(gè)一個(gè)的任務(wù);當(dāng)這些任務(wù)被消費(fèi)時(shí),會(huì)執(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ù)器,并與默認(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為TCP等待隊(duì)列長度
    int r = uv_listen((uv_stream_t*)&server, 256, connection_cb);

    // 開始處理默認(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ā)起請求的客戶端實(shí)際建立套接字,并注冊流操作回調(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);
        // 驗(yàn)證TCP請求數(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);
    }
}

全部源碼請參見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)

overview.png

我們再看看內(nèi)存占用,0.6MB(C) vs??51MB(Node.js)

mem.png

Node.js雖然引入了一些開銷,但是從代碼實(shí)現(xiàn)行數(shù)上確實(shí)要簡潔很多。

更多關(guān)于Node.js的技術(shù)內(nèi)容,請關(guān)注滬江技術(shù)學(xué)院微信公眾號。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容