走進Node.js 之 HTTP實現分析

iKcamp發表於2017-07-12

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

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

Node.js採用的網路請求處理模型是IO多路複用。它與傳統的主從多執行緒併發模型是有區別的:只使用有限的執行緒數(1個),所以佔用系統資源很少;作業系統級的非同步IO支援,可以減少使用者態/核心態切換,並且本身效能更高(因為直接與網路卡驅動互動);JavaScript天生具有保護程式執行現場的能力(閉包),傳統模型要麼依賴應用程式自己儲存現場,或者依賴執行緒切換時自動完成。當然,並不能說IO多路複用就是最好的併發模型,關鍵還是看應用場景。

我們來看“hello world”版Node.js網路伺服器:

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物件:

涉及到的C++類大多隻是對libuv做了一層包裝並公佈給JavaScript,所以不在這裡特別列出。我們有必要提一下http-parser,它是用來解析http請求/響應訊息的,本身十分高效:沒有任何系統呼叫,沒有記憶體分配操作,純C實現。

connection事件

當伺服器接受了一個連線請求後,會觸發connection事件。我們可以在這個結點獲取到套接字檔案描述符,之後就可以在這個檔案描述符上做流式讀或寫,也就是所謂的全雙工模式。上文提到net.Server的listen方法會建立TCP物件,並且提供TCP物件的onconnection事件回撥方法;這裡可以利用欄位net.Server.maxConnections做過載保護,後面會講到。並且會把clientHandle(本次連線的套接字檔案描述符)封裝成net.Socket物件,作為connection事件的引數。我們來看看呼叫過程:

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實際上是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雖然引入了一些開銷,但是從程式碼實現行數上確實要簡潔很多。

更多關於Node.js的技術內容,請關注滬江技術學院微信公眾號。

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

相關文章