走進Node.js 之 HTTP實現分析

做者:正龍(滬江Web前端開發工程師)
本文爲原創文章,轉載請註明做者及出處javascript

上文「走進Node.js啓動過程」中咱們算是成功入門了。既然Node.js的強項是處理網絡請求,那咱們就來分析一個HTTP請求在Node.js中是怎麼被處理的,以及JavaScript在這個過程當中引入的開銷到底有多大。html

Node.js採用的網絡請求處理模型是IO多路複用。它與傳統的主從多線程併發模型是有區別的:只使用有限的線程數(1個),因此佔用系統資源不多;操做系統級的異步IO支持,能夠減小用戶態/內核態切換,而且自己性能更高(由於直接與網卡驅動交互);JavaScript天生具備保護程序執行現場的能力(閉包),傳統模型要麼依賴應用程序本身保存現場,或者依賴線程切換時自動完成。固然,並不能說IO多路複用就是最好的併發模型,關鍵仍是看應用場景。前端

咱們來看「hello world」版Node.js網絡服務器:java

require('http').createServer((req, res) => {
    res.end('hello world');
}).listen(3333);

代碼思路分析

createServer([requestListener])

createServer建立了http.Server對象,它繼承自net.Server。事實上,HTTP協議確實是基於TCP協議實現的。createServer的可選參數requestListener用於監聽request事件;另外,它也監聽connection事件,只不過回調函數是http.Server本身實現的。而後調用listen讓http.Server對象在端口3333上監聽鏈接請求並最終建立TCP對象,由tcp_wrap.h實現。最後會調用TCP對象的listen方法,這才真正在指定端口開始提供服務。咱們來看看涉及到的全部JavaScript對象:node

涉及到的C++類大多隻是對libuv作了一層包裝並公佈給JavaScript,因此不在這裏特別列出。咱們有必要提一下http-parser,它是用來解析http請求/響應消息的,自己十分高效:沒有任何系統調用,沒有內存分配操做,純C實現。git

connection事件

當服務器接受了一個鏈接請求後,會觸發connection事件。咱們能夠在這個結點獲取到套接字文件描述符,以後就能夠在這個文件描述符上作流式讀或寫,也就是所謂的全雙工模式。上文提到net.Server的listen方法會建立TCP對象,而且提供TCP對象的onconnection事件回調方法;這裏能夠利用字段net.Server.maxConnections作過載保護,後面會講到。而且會把clientHandle(本次鏈接的套接字文件描述符)封裝成net.Socket對象,做爲connection事件的參數。咱們來看看調用過程:github

tcp_wrap.ccweb

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中定義apache

// ...省略不重要的代碼
    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的第二個參數,指服務當前鏈接的套接字文件描述符。net.Server的字段 _handle 會在JavaScript側存儲該字段。最後咱們上一張流程圖:編程

request事件

connection事件的回調函數connectionListener(lib/_http_server.js)中,首先獲取http-parser對象,設置parser.onIncoming回調(立刻會用到)。當鏈接套接字有數據到達時,調用http-parser.execute方法。http-parser在解析過程當中會觸發以下回調函數:

on_message_begin:在開始解析HTTP消息以前,能夠設置http-parser的初始狀態(注意http-parse有多是複用的而不是重每次新建立)

on_url:解析請求的url,對響應消息不起做用

on_status, 解析狀態碼,只對http響應消息起做用

on_head_field, 頭字段名稱

on_head_value:頭字段對應值

on_headers_complete:當全部頭解析完成時

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

on_message_complete:解析工做結束

Node.js中Parser類是對http-parser的包裝,它會註冊上面全部的回調函數。同時,暴露給JavaScript5個事件:
kOnHeaders,kOnHeadersComplete,kOnBody,kOnMessageComplete,kOnExecute。在lib/_http_common.js中監聽了這些事件。其中,當須要強制把頭字段回傳到JavaScript時會觸發kOnHeaders;例如,頭字段個數超過32,或者解析結束時仍然有頭字段沒有回傳給JavaScript。當調用完http_parser_execute後觸發kOnExecute。kOnHeadersComplete事件觸發時,會調用parser的onIncoming回調函數。僅僅HTTP頭解析完成以後,就會觸發request事件。執行流程以下:

總結

說了那麼多,其實仍然離不開最基礎的套接字編程步驟,對於服務器端依次是:create、bind,listen、accept和close。客戶端會經歷create、bind、connect和close。想了解更多套接字編程的同窗能夠參考《UNIX網絡編程》。

HTTP場景分析

上面提到的Node.js版hello world只涵蓋了HTTP處理最基本的狀況,可是也足以說明Node.js處理得很是簡潔。如今,咱們來分析一些典型的HTTP場景。

1. keep-alive

對於前端應用,HTTP請求瞬間數量比較多,但每一個請求傳輸的數據通常不大;這時,用同一個TCP鏈接處理同一個用戶發出的HTTP請求能夠顯著提升性能。可是keep-alive也不是萬能的,若是用戶每次只發起一個請求,它反而會由於延長鏈接的生存時間,浪費服務器資源。

針對同一個鏈接,Node.js會維持一個incoming隊列和一個outgoing隊列。應用程序經過監聽request事件,能夠訪問ServerResponse和IncomingMessage對象,當請求處理完成以後(調用response.end()),ServerResponse會響應finish事件。若是它是本次鏈接上最後一個response對象,則準備關閉鏈接;不然,繼續觸發request事件。每一個鏈接最長超時時間默認爲2分鐘,能夠經過http.Server.setTimeout調整。
如今把咱們的Node.js版hello world修改一下

var delay = [2000, 30, 500];
var i = 0;
require('http').createServer((req, res) => {
    // 爲了讓請求模擬更真實,會調整每一個請求的響應時間
    setTimeout(() => {
        res.end('hello world');
    }, delay[i]);
    i = (i+1)%(delay.length);
}).listen(3333, () => {
    // listen的回調函數
    console.log('listen at 3333');
});

客戶端代碼以下:

var http = require('http');

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

// 每次請求結束以後,都會再發起一次請求
// doReq每調用一次只會觸發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);
        // 設置解析響應的編碼格式
        res.setEncoding('utf8');
        // 接收響應
        res.on('data', (chunk) => {
            console.log(`${new Date().valueOf()} ${iter} ${again} Body: ${chunk}`);
        });
        if (again) doReq(false, iter);
    });
    // 發起請求
    request.end();
}

for (let i = 0; i < 3; i++) {
    doReq(true, i);
}

套接字複用的時序以下:

2. Expect頭

若是客戶端在發送POST請求以前,因爲傳輸的數據量比較大,指望向服務器確認請求是否能被處理;這種狀況下,能夠先發送一個包含頭Expect:100-continue的http請求。若是服務器能處理此請求,則返回響應狀態碼100(Continue);不然,返回417(Expectation Failed)。默認狀況下,Node.js會自動響應狀態碼100;同時,http.Server會觸發事件checkContinue和checkExpectation來方便咱們作特殊處理。具體規則是:當服務器收到頭字段Expect時:若是其值爲100-continue,會觸發checkContinue事件,默認行爲是返回100;若是值爲其它,會觸發checkExpectation事件,默認行爲是返回417。

例如,咱們經過curl發送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個響應,分別是狀態碼100和200。前一個是Node.js的默認行爲,後一個是應用程序代碼行爲。

3. HTTP代理

在實際開發時,用到http代理的機會仍是挺多的,好比,測試說線上出bug了,觸屏版頁面顯示有問題;咱們通常第一時間會去看api返回是否正常,這個時候在手機上設置好代理就能輕鬆捕獲HTTP請求了。老牌的代理工具備fiddler,charles。其實,nodejs下也有,例如node-http-proxyanyproxy。基本思路是監聽request事件,當客戶端與代理創建HTTP鏈接以後,代理會向真正請求的服務器發起鏈接,而後把兩個套接字的流綁在一塊兒。咱們能夠實現一個簡單的代理服務器:

var http = require('http');
var url = require('url');

http.createServer((req, res) => {
    // request回調函數
    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
    };
    // 向目標服務器發起請求
    var proxyRequest = http.request(options, (proxyResponse) => {
        // 把目標服務器的響應返回給客戶端
        res.writeHead(proxyResponse.statusCode, proxyResponse.headers);
        proxyResponse.pipe(res);
    }).on('error', () => {
        res.end();
    });
    // 把客戶端請求數據轉給中間人請求
    req.pipe(proxyRequest);
}).listen(8089, '0.0.0.0');

驗證下是否真的起做用,curl經過代理服務器訪問咱們的「hello world」版Node.js服務器:

curl -x http://192.168.132.136:8089 http://localhost:3333/

優化策略

Node.js在實現HTTP服務器時,除了利用高性能的http-parser,自身也作了些性能優化。

1. http_parser對象緩存池

http-parser對象處理完一個請求以後不會被當即釋放,而是被放入緩存池(/lib/internal/freelist),最多緩存1000個http-parser對象。

2. 預設HTTP頭總數

HTTP協議規範並無限定能夠傳輸的HTTP頭總數上限,http-parser爲了不動態分配內存,設定上限默認值是32。其餘web服務器實現也有相似設置;例如,apache能處理的HTTP請求頭默認上限(LimitRequestFields)是100。若是請求消息中頭字段真超過了32個,Node.js也能處理,它會把已經解析的頭字段經過事件kOnHeaders保存到JavaScript這邊而後繼續解析。 若是頭字段不超過32個,http-parser會直接處理完並觸發on_headers_complete一次性傳遞全部頭字段;因此咱們在利用Node.js做爲web服務器時,應儘可能把頭字段控制在32個以內。

3. 過載保護

理論上,Node.js容許的同時鏈接數只與進程能夠打開的文件描述符上限有關。可是隨着鏈接數愈來愈多,佔用的系統資源也愈來愈多,頗有可能連正常的服務都沒法保證,甚至可能拖垮整個系統。這時,咱們能夠設置http.Server的maxConnections,若是當前併發量大於服務器的處理能力,則服務器會自動關閉鏈接。另外,也能夠設置socket的超時時間爲可接受的最長響應時間。

性能實測

爲了簡單分析下Node.js引入的開銷,如今基於libuv和http_parser編寫一個純C的HTTP服務器。基本思路是,在默認事件循環隊列上監聽指定TCP端口;若是該端口上有請求到達,會在隊列上插入一個一個的任務;當這些任務被消費時,會執行connection_cb。見核心代碼片斷:

int main() {
    // 初始化uv事件循環
    loop = uv_default_loop();
    uv_tcp_t server;
    struct sockaddr_in addr;
    // 指定服務器監聽地址與端口
    uv_ip4_addr("192.168.132.136", 3333, &addr);

    // 初始化TCP服務器,並與默認事件循環綁定
    uv_tcp_init(loop, &server);
    // 服務器端口綁定
    uv_tcp_bind(&server, (const struct sockaddr*)&addr, 0);
    // 指定鏈接處理回調函數connection_cb
    // 256爲TCP等待隊列長度
    int r = uv_listen((uv_stream_t*)&server, 256, connection_cb);

    // 開始處理默認時間循環上的消息
    // 若是TCP報錯,事件循環也會自動退出
    return uv_run(loop, UV_RUN_DEFAULT);
}

connection_cb調用uv_accept會負責與發起請求的客戶端實際創建套接字,並註冊流操做回調函數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用於讀取客戶端請求數據,併發送響應數據:

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請求數據是不是合法的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));
        // 響應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來作壓力測試:併發數爲5000,總請求數爲100000。

ab -c 5000 -n 100000 http://192.168.132.136:3333/

測試結果以下: 0.8秒(C) vs  5秒(Node.js)

咱們再看看內存佔用,0.6MB(C) vs  51MB(Node.js)

Node.js雖然引入了一些開銷,可是從代碼實現行數上確實要簡潔不少。

iKcamp原創新書《移動Web前端高效開發實戰》已在亞馬遜、京東、噹噹開售。

相關文章
相關標籤/搜索