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

上文“走進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對象:


class-diagram1.png

涉及到的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);
}

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的第二個參數(shù)史简,指服務(wù)當前連接的套接字文件描述符乘瓤。net.Server的字段 _handle 會在JavaScript側(cè)存儲該字段衙傀。最后我們上一張流程圖:

connection1.png

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í)行流程如下:

request1.png

總結(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ù)用的時序如下

keep-alive.png

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)

overview.png

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

mem.png

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

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

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市吱肌,隨后出現(xiàn)的幾起案子桥氏,更是在濱河造成了極大的恐慌栗菜,老刑警劉巖,帶你破解...
    沈念sama閱讀 218,755評論 6 507
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異混弥,居然都是意外死亡,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,305評論 3 395
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來戚篙,“玉大人七冲,你說我怎么就攤上這事÷幔” “怎么了奉芦?”我有些...
    開封第一講書人閱讀 165,138評論 0 355
  • 文/不壞的土叔 我叫張陵,是天一觀的道長横侦。 經(jīng)常有香客問我银酗,道長,這世上最難降的妖魔是什么闻妓? 我笑而不...
    開封第一講書人閱讀 58,791評論 1 295
  • 正文 為了忘掉前任罩缴,我火速辦了婚禮焰薄,結(jié)果婚禮上飒泻,老公的妹妹穿的比我還像新娘。我一直安慰自己结序,他們只是感情好,可當我...
    茶點故事閱讀 67,794評論 6 392
  • 文/花漫 我一把揭開白布沫换。 她就那樣靜靜地躺著磕谅,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 51,631評論 1 305
  • 那天,我揣著相機與錄音洗搂,去河邊找鬼耘拇。 笑死挣棕,一個胖子當著我的面吹牛词身,可吹牛的內(nèi)容都是我干的诱桂。 我是一名探鬼主播掷漱,決...
    沈念sama閱讀 40,362評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼榄檬!你這毒婦竟也來了切威?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 39,264評論 0 276
  • 序言:老撾萬榮一對情侶失蹤丙号,失蹤者是張志新(化名)和其女友劉穎先朦,沒想到半個月后,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體犬缨,經(jīng)...
    沈念sama閱讀 45,724評論 1 315
  • 正文 獨居荒郊野嶺守林人離奇死亡喳魏,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,900評論 3 336
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了怀薛。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片刺彩。...
    茶點故事閱讀 40,040評論 1 350
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖枝恋,靈堂內(nèi)的尸體忽然破棺而出创倔,到底是詐尸還是另有隱情,我是刑警寧澤焚碌,帶...
    沈念sama閱讀 35,742評論 5 346
  • 正文 年R本政府宣布畦攘,位于F島的核電站,受9級特大地震影響十电,放射性物質(zhì)發(fā)生泄漏知押。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 41,364評論 3 330
  • 文/蒙蒙 一鹃骂、第九天 我趴在偏房一處隱蔽的房頂上張望台盯。 院中可真熱鬧,春花似錦畏线、人聲如沸静盅。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,944評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽蒿叠。三九已至,卻和暖如春杯矩,著一層夾襖步出監(jiān)牢的瞬間栈虚,已是汗流浹背袖外。 一陣腳步聲響...
    開封第一講書人閱讀 33,060評論 1 270
  • 我被黑心中介騙來泰國打工史隆, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人曼验。 一個月前我還...
    沈念sama閱讀 48,247評論 3 371
  • 正文 我出身青樓泌射,卻偏偏與公主長得像粘姜,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子熔酷,可洞房花燭夜當晚...
    茶點故事閱讀 44,979評論 2 355

推薦閱讀更多精彩內(nèi)容