前言
本章主要介紹如下知識客燕,通過了解這些知識谁鳍,進一步理解node為何適合在分布式網(wǎng)絡中扮演各種角色鹰椒。另外沼死,由于node跟網(wǎng)絡模型非常近似着逐,我們可以通過學習node來更好的理解網(wǎng)絡模型。本章,我們會仔細學習如下模塊耸别。
模塊 | 說明 |
---|---|
net | TCP |
dgram | UDP |
http | HTTP |
https | HTTPS |
構(gòu)建TCP服務
TCP全稱為傳輸控制協(xié)議健芭,在OSI模型上屬于傳輸層協(xié)議。我們看下邊的圖:
TCP三次握手
TCP在進行傳輸前需要進行三次握手秀姐,并形成會話吟榴,我們看一下模型:
在這三次握手中,服務器端和客戶端囊扳,分別提高一個套接字,這兩個套接字共同形成了一個連接兜看。因此锥咸,只有會話形成之后,服務器核客戶端之間才能相互發(fā)送數(shù)據(jù)细移。這些數(shù)據(jù)都是通過套接字的讀寫進行傳輸?shù)摹?/p>
創(chuàng)建TCP服務器端程序
var net = require('net');
var server = net.createServer(function (socket) {
// 新的連接
socket.on('data', function (data) {
socket.write("hello") ;
});
socket.on('end', function () {
console.log('連接斷開');
});
socket.write("hello world搏予,my dear\n");
});
server.listen(8124, function () {
console.log('server bound');
});
//為了體現(xiàn)listener是連接事件connection的監(jiān)聽器,也可以采用另外一種方式進行監(jiān)聽
var server = net.createServer();
server.on('connection', function (socket) {
// 新的連接
});
server.listen(8124);
然后弧轧,我們就可以使用telnet作為客戶端雪侥,對服務進行會話交流了:
$ telnet 127.0.0.1 8124
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
hello world,my dear
hi
hello
除了端口外精绎,我們還可以使用Domain Socket進行監(jiān)聽速缨。
server.listen('/tmp/echo.sock');
通過nc工具進行會話:
$ nc -U /tmp/echo.sock
hello world,my dear
hi
hello
還可以通過net模塊自己構(gòu)建客戶端進行會話
var net = require('net');
var client = net.connect({ port: 8124 }, function () { //'connect' listener
console.log('client connected');
client.write('world!\r\n');
});
client.on('data', function (data) {
console.log(data.toString());
client.end();
});
client.on('end', function () {
console.log('client disconnected');
});
//如果是domain socket 可以這樣寫
var client = net.connect({path: '/tmp/echo.sock'});
執(zhí)行結(jié)果跟之前一樣代乃,此處不做描述
tcp服務的事件
主要是服務器事件和連接事件旬牲。
服務器事件
通過net.createServer()創(chuàng)建的服務器,它繼承了eventEmitter實例搁吓,同時還是一個stream實例原茅,有如下事件:
1.listening,在調(diào)用server.listen()綁定端口或者domain socket后觸發(fā)堕仔,可以寫為:server.listen(port,listeningListener)
2.connection擂橘,每個客戶端套接字連接到服務器端時觸發(fā),簡介寫法為net.createServer()
3.close摩骨,調(diào)用server.close()后會停止接收新的套接字連接通贞,保持當前存在的連接,等待所以連接都斷開后仿吞,觸發(fā)該事件
4.error滑频,服務器出錯時,如果不監(jiān)聽該事件唤冈,net會拋出異常峡迷,因此,必須監(jiān)聽該事件。
連接事件
服務器可以連接多個客戶端,每個連接都是一個讀寫流(讀寫套接字),這是一個全雙工怎燥。
1.data抚恒,socket一端發(fā)起write,另外一端就會觸發(fā)data佩谷,這個data就是write寫過來的數(shù)據(jù)。
2.end,任意一段發(fā)送FIN數(shù)據(jù)圆米,另一端將會觸發(fā)該事件。
3.connect啄栓,客戶端與服務器連接成功后娄帖,客戶端觸發(fā)該事件
4.drain,當任意一段調(diào)用write()時昙楚,觸發(fā)該事件
5.error近速,異常觸發(fā)該事件
6.close,完全關閉socket堪旧,觸發(fā)該事件
7.timeout削葱,當一定時間后,連接不活躍淳梦,將觸發(fā)該事件析砸,告知當前用戶,該連接已經(jīng)被限制爆袍。
管道操作
既然是流干厚,就可以變成管道,我們感受一下:
var net = require('net');
var server = net.createServer(function (socket) {
socket.write('Echo server\r\n');
socket.pipe(socket);
});
server.listen(1337, '127.0.0.1');
tcp針對網(wǎng)絡中的小數(shù)據(jù)包有優(yōu)化政策螃宙,nagle算法蛮瞄,nagle要求網(wǎng)絡中緩沖區(qū)數(shù)據(jù)達到一定數(shù)量或一定時間后,才將其觸發(fā)谆扎,小數(shù)據(jù)包會被nagle合并挂捅,來優(yōu)化網(wǎng)絡。這個方法會帶來一定的傳輸延遲堂湖。
我們可以通過socket.setNoDelay(true)來去掉nagle算法闲先,是的write可以立即發(fā)送數(shù)據(jù)。但是无蜂,data事件還是要進行小包合并后觸發(fā)的伺糠,這個需要注意。
構(gòu)建UDP服務
udp斥季,用戶數(shù)據(jù)包協(xié)議训桶,也是傳輸層協(xié)議累驮。udp不是面向連接的,也就是說udp無需連接舵揭,它是面向事務的簡單不可靠信息傳輸服務谤专,在網(wǎng)絡差的情況下存在丟包嚴重的問題,由于無需連接午绳,資源消耗低置侍,處理塊速且靈活,常常用于那種偶爾丟幾個包也不產(chǎn)生重大影響的場景拦焚,例如蜡坊,音頻、視頻等赎败,DNS就是基于udp實現(xiàn)的算色。另外,一個udp套接字可以與多個udp服務進行通信螟够。
創(chuàng)建udp套接字
var dgram = require('dgram');
var socket = dgram.createSocket("udp4");
udp socket創(chuàng)建后,即是客戶端又是服務器峡钓。
創(chuàng)建udp服務器端
創(chuàng)建完udp socket后妓笙,我們需要綁定端口,也就是讓網(wǎng)卡和端口進行綁定能岩,這樣就完成了服務器端的開發(fā)寞宫,當然,這個也可以認為是客戶端拉鹃。
var dgram = require("dgram");
var server = dgram.createSocket("udp4");
server.on("message", function (msg, rinfo) {
console.log("server got: " + msg + " from " +
rinfo.address + ":" + rinfo.port);
});
server.on("listening", function () {
var address = server.address();
console.log("server listening " +
address.address + ":" + address.port);
});
server.bind(41234);
創(chuàng)建udp客戶端
var dgram = require('dgram');
var message = new Buffer("hi");
var client = dgram.createSocket("udp4");
//socket.send(buf, offset, length, port, address, [callback])
//socket.send(要發(fā)送的buf, buf的偏移, buf長度, port, address, [callback])
client.send(message, 0, message.length, 41234, "localhost", function(err, bytes) {
client.close();
});
//
$ node server.js
server listening 0.0.0.0:41234
server got: hi from 127.0.0.1:58682
我們可以看出辈赋,udp是無需建立連接的,因此膏燕,高效快速不可靠钥屈。
udpsocket 事件
udp socket只是一個eventemitter實例,不是stream實例坝辫,事件如下:
1.message篷就,udp監(jiān)聽網(wǎng)卡后,接收到消息時觸發(fā)該事件近忙,觸發(fā)攜帶的數(shù)據(jù)為消息buf對象和一個遠程地址信息
2.listening竭业,udp開始監(jiān)聽時,觸發(fā)該事件
3.close,調(diào)用close()時觸發(fā)該事件及舍,并不再觸發(fā)message事件未辆,如需再次觸發(fā)message事件,重新綁定即可
4.error,異常觸發(fā)該事件锯玛,如果不監(jiān)聽咐柜,則模塊拋錯,線程退出
構(gòu)建HTTP服務
如果想要構(gòu)建高效的網(wǎng)絡應用,就應該從傳輸層的TCP炕桨、UDP入手饭尝,進行開發(fā)。但是献宫,對于一些經(jīng)典的應用場景钥平,例如,一問一答的形式的web姊途,這個就不需要自己動手寫應用層協(xié)議了涉瘾,使用經(jīng)典的HTTP就可以了,另外捷兰,例如郵件服務立叛,也可以直接使用SMTP協(xié)議,就可以了贡茅。這一節(jié)說的HTTP秘蛇,我們將會使用node的核心模塊http和https進行構(gòu)建,這兩個模塊分別對http和https協(xié)議進行了抽象和封裝顶考,最大限度的模擬http協(xié)議和https協(xié)議的行為赁还。我們來看一下代碼的例子:
var http = require('http');
http.createServer(function (req, res) {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('Hello World\n');
}).listen(1337, '127.0.0.1');
console.log('Server running at http://127.0.0.1:1337/');
HTTP介紹
HTTP是HyperText Transfer Protocol的縮寫,它也是構(gòu)建與TCP協(xié)議之上的驹沿。在http的兩端分別是客戶端和服務器艘策,這就是經(jīng)典的B/S模式。另外渊季,這里的B朋蔫,就是瀏覽器的意思,瀏覽器成為了http的代理却汉,用戶的行為將會通過瀏覽器轉(zhuǎn)化為http請求報文驯妄,發(fā)送給服務器,服務器也就是S合砂,會處理請求富玷,然后發(fā)送響應報文給代理,也就是瀏覽器既穆,瀏覽器解析響應報文后赎懦,將用戶界面展示給用戶。這里我們看到幻工,基于http或者https的B/S模式中國励两,瀏覽器只負責發(fā)送報文、接收報文囊颅、解析報文当悔、展示界面傅瞻,服務器負責處理http請求和發(fā)送http響應。
http報文
我們先來看一下剛才的http報文盲憎,我們使用curl http://127.0.0.1:1337 -v這條命令嗅骄。
$ curl -v http://127.0.0.1:1337
* About to connect() to 127.0.0.1 port 1337 (#0)
* Trying 127.0.0.1...
* connected
* Connected to 127.0.0.1 (127.0.0.1) port 1337 (#0)
> GET / HTTP/1.1
> User-Agent: curl/7.24.0 (x86_64-apple-darwin12.0) libcurl/7.24.0 OpenSSL/0.9.8r zlib/1.2.5
> Host: 127.0.0.1:1337
> Accept: */*
>
< HTTP/1.1 200 OK
< Content-Type: text/plain
< Date: Sat, 06 Apr 2013 08:01:44 GMT
< Connection: keep-alive
< Transfer-Encoding: chunked
<
Hello World
* Connection #0 to host 127.0.0.1 left intact
* Closing connection #0
接下來我們來簡單分析一下這個報文。這段報文分為3部分饼疙,我們來詳細分析一下:
報文第一部分
第一部分是經(jīng)典的TCP三次握手溺森,這樣就建立了連接
* About to connect() to 127.0.0.1 port 1337 (#0)
* Trying 127.0.0.1...
* connected
* Connected to 127.0.0.1 (127.0.0.1) port 1337 (#0)
報文第二部分
在完成握手之后,客戶端向服務器端發(fā)送請求報文窑眯。
> GET / HTTP/1.1
> User-Agent: curl/7.24.0 (x86_64-apple-darwin12.0) libcurl/7.24.0 OpenSSL/0.9.8r zlib/1.2.5
> Host: 127.0.0.1:1337
> Accept: */*
>
報文第三部分
第三部分展示的是服務端完成處理后屏积,向客戶端發(fā)送的響應內(nèi)容,包括響應頭和響應體:
< HTTP/1.1 200 OK
< Content-Type: text/plain
< Date: Sat, 06 Apr 2013 08:01:44 GMT
< Connection: keep-alive
< Transfer-Encoding: chunked
<
Hello World
另外磅甩,最后部分是結(jié)束會話:
* Connection #0 to host 127.0.0.1 left intact
* Closing connection #0
注意:報文的內(nèi)容主要是兩部分炊林,報文頭和報文體,上一個例子中卷要,使用的是get請求渣聚,報文頭的部分是上邊報文信息中>和<的部分。在響應報文中僧叉,有一個報文體奕枝,是Hello World。
http模塊
node1中彪标,http服務繼承tcp服務,也是就http模塊繼承net模塊掷豺,基于http模塊捞烟,可以實現(xiàn)客戶端的并發(fā)訪問,由于采用事件驅(qū)動的方式当船,因此题画,并不為每一個連接創(chuàng)建額外的線程或進程,保持很低的內(nèi)存占用德频,所以能實現(xiàn)高并發(fā)苍息。http服務模型與tcp服務模型有區(qū)別的地方在于,在開啟keepalive之后壹置,一個tcp會話可以用于多次請求和響應竞思,tcp服務以connection為單位進行服務,http以request為單位進行服務钞护。http模塊也就是將connection到request的過程進行了封裝:
http模塊將連接所用的套接字的讀寫抽象為ServerRequest和ServerResponse對象盖喷,在請求產(chǎn)生的過程中,http模塊拿到連接中傳來的數(shù)據(jù)难咕,調(diào)用二進制模塊http_parser進行解析课梳,在解析完請求報文的報文頭后距辆,觸發(fā)request事件,之后調(diào)用用戶的業(yè)務邏輯暮刃。我們看一下流程:
我們再來看看服務器端的處理程序和響應:
function (req, res) {
res.writeHead(200, {'Content-Type': 'text/plain'});
res.end('Hello World\n');
}
http請求
tcp連接的讀操作跨算,http模塊將其封裝為ServerRequest對象,我們再來看看報文頭椭懊,此處報文頭會被http_parser進行解析:
> GET / HTTP/1.1
> User-Agent: curl/7.24.0 (x86_64-apple-darwin12.0) libcurl/7.24.0 OpenSSL/0.9.8r zlib/1.2.5
> Host: 127.0.0.1:1337
> Accept: */*
>
第一行報文頭GET / HTTP/1.1會被解析為诸蚕,如下屬性:
屬性 | 說明 |
---|---|
req.method | 值為GET,也就是req.method='GET'灾搏,這個就是請求方法挫望,我們常見的請求方法有GET、POST狂窑、DELETE媳板、PUT、CONNECT等 |
req.url | 值為/泉哈,也就是req.url='/' |
req.httpVersion | 值為1.1蛉幸,也就是req.httpVersion='1.1' |
其余的報文頭都會被解析為很有規(guī)律的json,也就是key和value丛晦。這些值奕纫,被解析到req.headers屬性上。
headers:
{ 'user-agent': 'curl/7.24.0 (x86_64-apple-darwin12.0) libcurl/7.24.0 OpenSSL/0.9.8r zlib/1.2.5',
host: '127.0.0.1:1337',
accept: '*/*' }
報文體部分則被抽象為一個只讀流對象烫沙,如果業(yè)務邏輯需要讀取報文體中的數(shù)據(jù)匹层,則要在這個數(shù)據(jù)流結(jié)束后才能進行操作:
function (req, res) {
// console.log(req.headers);
var buffers = [];
req.on('data', function (trunk) {
buffers.push(trunk);
}).on('end', function () {
var buffer = Buffer.concat(buffers);
// TODO
res.end('Hello world');
});
}
其實,我們通過分析node關于http協(xié)議的實現(xiàn)锌蓄,我們可以發(fā)現(xiàn)升筏,node目前還是非常底層的一個技術(shù),因此瘸爽,我們可以看見一個http服務的底層實現(xiàn)是什么您访,這也是目前學習node的好處之一,這樣剪决,我們就可以從源頭了解一項技術(shù)灵汪。
http響應
http響應,也就是對套接字的寫操作進行了封裝柑潦,可以將其看成一個可寫的流對象享言,此處的api是res.setHeader()和res.writeHead():
res.writeHead(200, {'Content-Type': 'text/plain'});
在http模塊的封裝下,我們實際生成的報文如下:
< HTTP/1.1 200 OK
< Content-Type: text/plain
我們可以多次調(diào)用setHeader進行多次設置渗鬼,但是只能調(diào)用一次writeHead担锤,并且也只有調(diào)用了writeHead后,才會將響應報文頭寫入到連接中乍钻,除此之外肛循,http模塊還會自動幫你設置一些頭信息:
< Date: Sat, 06 Apr 2013 08:01:44 GMT
< Connection: keep-alive
< Transfer-Encoding: chunked
<
報文體部分則是通過調(diào)用res.write()和res.end()實現(xiàn)的铭腕,res.end()會先調(diào)用write()發(fā)送數(shù)據(jù),然后多糠,發(fā)送信號通知服務器這次響應結(jié)束累舷,響應的結(jié)果就是我們之前發(fā)送的hello world。
響應介紹后夹孔,http服務器可能會將當前的連接用于下一個請求被盈,或者關閉連接。另外搭伤,一旦開始了數(shù)據(jù)的發(fā)送只怎,再次調(diào)用writeHead和setHead將不再有效,這是因為協(xié)議的特性所決定的怜俐,跟tcp還是有差別的身堡。
另外,服務器不管是完成業(yè)務拍鲤,還是發(fā)生異常贴谎,都應該調(diào)用res.end()以結(jié)束請求,否則客戶端將會一直處于等待的狀態(tài)季稳。當然擅这,也可以通過延遲res.end()的方式,來實現(xiàn)與客戶端的長連接景鼠,但是結(jié)束時仲翎,務必關閉連接。
http服務事件
http服務也繼承了events模塊铛漓,因此也是一個EventEmitter實例或者對象溯香。
事件 | 說明 |
---|---|
connection | 在http請求和響應前,客戶端與服務器需要建立tcp連接票渠,這個連接可能因為開啟了keep-alive逐哈,可以在多次響應和請求之間使用芬迄,當建立連接時问顷,服務器觸發(fā)一次connection事件 |
request | 建立tcp連接后,http模塊底層將在數(shù)據(jù)流中抽象http請求和響應禀梳,當請求數(shù)據(jù)發(fā)送到服務器杜窄,在解析出http請求頭后,將會觸發(fā)該事件算途,在res.end()后塞耕,tcp連接可能用于下一次請求響應 |
close | 與tcp服務器的行為一致,調(diào)用server.close()停止接受新的連接嘴瓤,當已有的連接都斷開時扫外,觸發(fā)該事件莉钙,可以給server.close()傳遞一個回調(diào)函數(shù),來快速注冊該事件筛谚。 |
checkContinue | 客戶端發(fā)送較大的數(shù)據(jù)時磁玉,不會講數(shù)據(jù)直接發(fā)送,而是先發(fā)送一個頭部帶Expect:100-continue的請求到服務器驾讲,服務器將會觸發(fā)checkContinue事件蚊伞,如果沒有為服務器監(jiān)聽這個事件,服務器將會自動響應客戶端100 Continue的狀態(tài)碼吮铭,表示接受數(shù)據(jù)上傳时迫,如果不接受的數(shù)據(jù)較多時,響應客戶端400 Bad Request谓晌,拒絕客戶端繼續(xù)發(fā)送數(shù)據(jù)即可掠拳。需要注意的是,該事件發(fā)生時不會觸發(fā)request事件扎谎,兩個事件是互斥的碳想,當客戶端收到100 Continue后,重新發(fā)起請求時毁靶,才會觸發(fā)request事件 |
connect | 當客戶端發(fā)起CONNECT請求時觸發(fā)胧奔,而發(fā)起CONNECT請求,通常在HTTP代理出現(xiàn)预吆,如果不監(jiān)聽該事件龙填,發(fā)起該請求的連接將會關閉 |
upgrade | 當客戶端要求升級連接協(xié)議時,需要和服務器端協(xié)商拐叉,客戶端會在請求頭中帶上Upgrade字段岩遗,服務器端會在接收到這樣的請求時觸發(fā)該事件,這個會在websocket中詳細介紹凤瘦,同樣宿礁,如果不監(jiān)聽該事件發(fā)起該請求的連接將會關閉。 |
clientError | 連接客戶端觸發(fā)error事件蔬芥,這個錯誤會傳遞到服務器端梆靖,此時觸發(fā)該事件。 |
http客戶端
http客戶端會產(chǎn)生請求報文頭和報文體笔诵,接收響應報文頭和報文體返吻,并解析。除了瀏覽器乎婿,我們也可以通過http模塊提供的http.request(options,connect)來構(gòu)造http客戶端测僵。我們來感受一下:
var options = {
host:'127.0.0.1',
hostname: '127.0.0.1',
port: 1334,
path: '/',
method: 'GET'
};
var req = http.request(options, function (res) {
console.log('STATUS: ' + res.statusCode);
console.log('HEADERS: ' + JSON.stringify(res.headers));
res.setEncoding('utf8');
res.on('data', function (chunk) {
console.log(chunk);
});
});
req.end();
//得到的輸出:
$ node client.js
STATUS: 200
HEADERS: {"date":"Sat, 06 Apr 2013 11:08:01
GMT","connection":"keep-alive","transfer-encoding":"chunked"}
Hello World
在這個例子中,options決定了http請求頭的內(nèi)容:
參數(shù) | 說明 |
---|---|
host | 服務器的域名或IP地址谢翎,默認localhost |
hostname | 服務器名稱 |
port | 服務器端口捍靠,默認80 |
localAddress | 建立網(wǎng)絡連接的本地網(wǎng)卡 |
sockerPath | Domain套接字路徑 |
method | http請求方法沐旨,默認GET |
path | 請求路徑,默認為/ |
headers | 請求頭對象 |
auth | Basic認證榨婆,這個值將被計算成請求頭中的Authorization |
報文體的內(nèi)容則由請求對象的wirte()和end()方法實現(xiàn)希俩,通過write寫入數(shù)據(jù),通過end告知報文結(jié)束纲辽。這個和瀏覽器中的Ajax調(diào)用幾乎相同颜武,本質(zhì)上講,Ajax的實質(zhì)就是一個異步的網(wǎng)絡HTTP請求拖吼。
http客戶端響應
http客戶端的響應對象與服務器端較為類似鳞上,在ClientRequest對象中,它的事件也被稱為response吊档,ClientRequest在解析響應報文時篙议,解析完響應頭就會觸發(fā)response事件,同時傳遞一個響應對象以供操作ClientResponse怠硼,后續(xù)響應報文以只讀流的方式提供:
function(res) {
console.log('STATUS: ' + res.statusCode);
console.log('HEADERS: ' + JSON.stringify(res.headers));
res.setEncoding('utf8');
res.on('data', function (chunk) {
console.log(chunk);
});
}
這個行為與服務器端的ServerRequest讀取數(shù)據(jù)的行為基本一致鬼贱。
http代理
如同服務器端的實現(xiàn)一樣,http提供的ClientRequest對象也是基于tcp實現(xiàn)的香璃,在keepalive的情況下这难,一個底層會話連接可以多次用于請求,為了重用tcp連接葡秒,http模塊包含一個默認的客戶端代理對象http.globalAgent姻乓,它對每個服務器端的host+port創(chuàng)建的連接進行了管理,默認情況下眯牧,通過ClientRequest對象對同一個服務器端發(fā)起的HTTP請求最多可以創(chuàng)建5個連接蹋岩,它的實質(zhì)是一個連接池:
調(diào)用http客戶端同時對一個服務器發(fā)起10次http請求時,其實質(zhì)只有5個請求處于并發(fā)狀態(tài)学少,后續(xù)的請求需要等待某個請求完成服務后才真正發(fā)出剪个,這與瀏覽器對同一個域名有下載連接數(shù)的限制是相同的行為。
如果你的服務器端通過ClientRequest調(diào)用網(wǎng)絡中的其他Http服務版确,記得關注代理對象對網(wǎng)絡請求的限制扣囊,一旦請求量過大,連接限制將會限制服務性能阀坏,可以在options中傳遞agent選項如暖,調(diào)整連接數(shù)的限制笆檀,默認情況下忌堂,請求會采用全局的代理對象,默認連接數(shù)限制為5酗洒。接下來士修,我們自己自行構(gòu)造代理對象:
var agent = new http.Agent({
maxSockets: 10
});
var options = {
hostname: '127.0.0.1',
port: 1334,
path: '/',
method: 'GET',
agent: agent
};
也可以設置Agent選項為false枷遂,以脫離連接池的管理,使得請求不受并發(fā)的限制棋嘲。
Agent對象的sockets和requests屬性分別表示當前連接池中使用的連接數(shù)和處于等待狀態(tài)的請求數(shù)酒唉,在業(yè)務中監(jiān)視這兩個值有助于發(fā)現(xiàn)業(yè)務狀態(tài)的繁忙程度。
http客戶端事件
與服務器端一樣沸移,客戶端也有相應事件
事件 | 說明 |
---|---|
response | 處理服務器端返回的response痪伦,返回后,觸發(fā)該事件 |
socket | 當?shù)讓舆B接池中建立的連接分配給當前請求對象時雹锣,觸發(fā)該事件 |
connect | 當客戶端向服務器端發(fā)起CONNECT請求時网沾,如果服務器端響應了200狀態(tài)碼,客戶端會觸發(fā)該事件 |
upgrade | 客戶端向服務器端發(fā)起Upgrade請求時蕊爵,如果服務器端響應了101 Switching Protocols狀態(tài)辉哥,客戶端將會觸發(fā)該事件 |
continue | 客戶端向服務器端發(fā)起Expect: 100-continue頭信息,以試圖發(fā)送較大數(shù)據(jù)量攒射,如果服務器端響應100 Continue狀態(tài)醋旦,客戶端將觸發(fā)該事件。 |
構(gòu)建websocket服務
websocket與傳統(tǒng)的b/s模式有如下好處:
1.讓b端與服務器建立tcp連接会放,減少連接數(shù)
2.服務器實現(xiàn)了向b端推送數(shù)據(jù)的需求
3.更輕的頭協(xié)議饲齐,減少數(shù)據(jù)傳輸
websocket是RFC6455規(guī)范。現(xiàn)在大多數(shù)瀏覽器都支持這一規(guī)范咧最。我們來建立一個websocket客戶端程序
var socket = new WebSocket('ws://127.0.0.1:12010/updates');
socket.onopen = function () {
setInterval(function() {
if (socket.bufferedAmount == 0)
socket.send(getUpdateData());
}, 50);
};
socket.onmessage = function (event) {
// TODO: event.data
};
在websocket之前箩张,我們使用comet(long-polling長輪詢)或iframe流來實現(xiàn)推送,但是本質(zhì)還是客戶端不斷的發(fā)起http請求窗市,通過不斷獲取數(shù)據(jù)的方式實現(xiàn)推送的效果先慷。websocket是通過tcp重新擬定的新的協(xié)議,不是在http協(xié)議的基礎上的封裝咨察。websocket分為握手和數(shù)據(jù)傳輸兩部分论熙,其中握手使用了http進行,我們來看一下:
websocket握手
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Version: 13
握手的報文頭就是這樣的摄狱,與http請求的區(qū)別在于
Upgrade: websocket
Connection: Upgrade
也就是對于協(xié)議進行了升級
Sec-WebSocket-Key今缚,用于安全校驗
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Key的值是隨機生成的base64編碼的字符串。服務器端接收到之后瘪松,將其與字符串258EAFA5-E914-47DA-95CA-C5AB0DC85B11相連围小,形成字符串dGhlIHNhbXBsZSBub25jZQ==258EAFA5-E914-47DA-95CA-C5AB0DC85B11,然后通過sha1安全散列算法計算出結(jié)果后酣衷,再進行base64編碼交惯,最后,返回給客戶端,我們看一下這個算法:
var crypto = require('crypto');
var val = crypto.createHash('sha1').update(key).digest('base64');
另外席爽,下面兩個字段指定子協(xié)議和版本號:
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Version: 13
服務器端在處理完請求后意荤,響應如下報文:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Protocol: chat
這段報文將告訴客戶端,正在更換協(xié)議只锻,更新為應用層協(xié)議websocket玖像,并在當前的套接字上應用新的協(xié)議。
剩余的字段分別表示服務器端基于Sec-WebSocket-Key生成的字符串和選中的子協(xié)議齐饮【枇龋客戶端將會校驗Sec-WebSocket-Accept的值,如果成功祖驱,將開始接下來的數(shù)據(jù)傳輸上真。
我們使用node來模擬瀏覽器發(fā)起協(xié)議切換的行為:
var WebSocket = function (url) {
// 偽代碼,解析ws://127.0.0.1:12010/updates羹膳,用于請求
this.options = parseUrl(url);
this.connect();
};
WebSocket.prototype.onopen = function () {
// TODO
};
WebSocket.prototype.setSocket = function (socket) {
this.socket = socket;
};
WebSocket.prototype.connect = function () {
var this = that;
var key = new Buffer(this.options.protocolVersion + '-' + Date.now()).toString('base64');
var shasum = crypto.createHash('sha1');
var expected = shasum.update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11').digest('base64');
var options = {
port: this.options.port, // 12010
host: this.options.hostname, // 127.0.0.1
headers: {
'Connection': 'Upgrade',
'Upgrade': 'websocket',
'Sec-WebSocket-Version': this.options.protocolVersion,
'Sec-WebSocket-Key': key
}
};
var req = http.request(options);
req.end();
req.on('upgrade', function (res, socket, upgradeHead) {
// 連接成功
that.setSocket(socket);
//觸發(fā)open事件
that.onopen();
});
};
下面再寫一下服務器端的響應代碼
var server = http.createServer(function (req, res) {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('Hello World\n');
});
server.listen(12010);
// 在收到upgrade請求后睡互,告知客戶端允許切換協(xié)議
server.on('upgrade', function (req, socket, upgradeHead) {
var head = new Buffer(upgradeHead.length);
upgradeHead.copy(head);
var key = req.headers['sec-websocket-key'];
var shasum = crypto.createHash('sha1');
key = shasum.update(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").digest('base64');
var headers = [
'HTTP/1.1 101 Switching Protocols',
'Upgrade: websocket',
'Connection: Upgrade',
'Sec-WebSocket-Accept: ' + key,
'Sec-WebSocket-Protocol: ' + protocol
];
// 讓數(shù)據(jù)立即發(fā)送
socket.setNoDelay(true);
socket.write(headers.concat('', '').join('\r\n'));
// 建立服務器端WebSocket連接
var websocket = new WebSocket();
websocket.setSocket(socket);
});
一旦websocket握手成功,服務器端與客戶端就將會呈現(xiàn)對等的效果陵像,都能接收和發(fā)送消息就珠。
websocket數(shù)據(jù)傳輸
在順利握手后,當前連接將不再進行http交互醒颖,而是開始websocket的數(shù)據(jù)幀協(xié)議妻怎,實現(xiàn)客戶端與服務器的數(shù)據(jù)交換。我們來看一下這個協(xié)議升級的過程:
握完手后泞歉,客戶端的onopen()將會觸發(fā)執(zhí)行逼侦,代碼如下:
socket.onopen = function () {
// TODO: opened()
};
服務器端一般不寫onopen方法,我們按照tcp的解析習慣腰耙,讓websocket的數(shù)據(jù)幀協(xié)議在底層的data事件上完成封裝:
WebSocket.prototype.setSocket = function (socket) {
this.socket = socket;
this.socket.on('data', this.receiver);
};
//發(fā)送數(shù)據(jù)
WebSocket.prototype.send = function (data) {
this._send(data);
};
我們來簡單描述一下這個過程:
當客戶端調(diào)用send()發(fā)送數(shù)據(jù)時榛丢,服務器端觸發(fā)onmessage(),當服務器端調(diào)用send()發(fā)送數(shù)據(jù)時挺庞,客戶端的onmessage()觸發(fā)晰赞,當我們調(diào)用send()發(fā)送一條數(shù)據(jù)時,協(xié)議可能將這個數(shù)據(jù)封裝為一幀或多幀數(shù)據(jù)选侨,然后逐幀發(fā)送掖鱼。
為了安全考慮,客戶端需要發(fā)送的數(shù)據(jù)幀進行掩碼處理援制,服務器一旦收到無掩碼幀戏挡,比如中間攔截破壞,連接將會關閉晨仑。服務器發(fā)送到客戶端的數(shù)據(jù)幀無需做掩碼褐墅,如果客戶端收到了帶掩碼的數(shù)據(jù)幀拆檬,連接也將關閉。
在websocket中的數(shù)據(jù)幀的定義掌栅,每8位位一列,也就是一個字節(jié)码泛,我們看看這些位的意義:
1.fin猾封,如果這一幀是最后一幀,這個fin為為1噪珊,其余情況為0.
2.rsv1晌缘、rsv2、rsv3痢站,都是一位長磷箕,用于標識擴展,當有已協(xié)商的擴展時阵难,這些值可能為1岳枷,其余情況為0。
3.opcode呜叫,4位長空繁,可以用來表示0~15的值,用于解釋當前數(shù)據(jù)幀朱庆,0表示附加數(shù)據(jù)幀盛泡,1表示文本數(shù)據(jù)幀,2表示二進制數(shù)據(jù)幀娱颊,8表示發(fā)送一個連接關閉的數(shù)據(jù)幀傲诵,9表示ping數(shù)據(jù)幀,10表示pong數(shù)據(jù)幀箱硕,其余值暫時沒有定義拴竹。瓶數(shù)據(jù)幀和pong數(shù)據(jù)幀用于心跳檢測,當一端發(fā)送一個ping數(shù)據(jù)幀時剧罩,另一端必須發(fā)送pong數(shù)據(jù)幀作為回應殖熟,告知對方這一端仍然處于響應狀態(tài)。
4.masked斑响,表示是否進行掩碼處理菱属,1位長度,客戶端發(fā)送給服務器時為1舰罚,服務器發(fā)送回客戶端時為0.
5.payload length:一個7纽门、7+16或7+64位長的數(shù)據(jù)為,標識數(shù)據(jù)的長度营罢,如果值在0~125之間那么該值就是數(shù)據(jù)的真實長度赏陵,如果是126饼齿,則后面16位的值是數(shù)據(jù)的真實長度,如果是127蝙搔,則后面64位的值是數(shù)據(jù)的真實長度缕溉。
6.making key,當masked為1時吃型,這里是一個32位長的數(shù)據(jù)位证鸥,用于解密數(shù)據(jù)。
7.payload data勤晚,我們的目標數(shù)據(jù)枉层,位數(shù)為8的倍數(shù)。
客戶端發(fā)哦送你個消息時赐写,需要構(gòu)造一個或多個數(shù)據(jù)幀協(xié)議報文鸟蜡,例如我們發(fā)送一個hello world,這個比較短挺邀,不存在分割多個數(shù)據(jù)幀的情況揉忘,并且以文本方式發(fā)送,他的payload length長度為96(12字節(jié)*8位/字節(jié))端铛,二進制表示為110000癌淮。所以報文應該是:
fin(1) + res(000) + opcode(0001) + masked(1) + payload length(1100000) + masking key(32位) + payload
data(hello world!加密后的?二進制)
當以文本方式發(fā)送是,文本的編碼為utf-8沦补,由于這里發(fā)送的不存在中文乳蓄,所以一個字符占一個字節(jié),即8位夕膀。
客戶端發(fā)送消息后虚倒,服務器端在data事件中接收到這些編碼數(shù)據(jù),然后产舞,解析為相應的數(shù)據(jù)幀魂奥,再以數(shù)據(jù)幀的格式,通過掩碼將真正的數(shù)據(jù)解密出來易猫,然后觸發(fā)onmessage()執(zhí)行:
socket.onmessage = function (event) {
// TODO: event.data
};
假設耻煤,服務器回復的是yakexi,這個無需掩碼准颓,形式如下:
fin(1) + res(000) + opcode(0001) + masked(0) + payload length(1100000) + payload data(yakexi的?二進制)
這里的行為與tcp相似哈蝇,可以理解為tcp客戶端的connect和data事件。剩下的細節(jié)如如何解析數(shù)據(jù)幀和觸發(fā)onmessage()攘已,就請大家自己去看ws模塊或者socket.io模塊了炮赦。(當時的版本沒有原生的websocket,不知道node8或者node9中是否已經(jīng)支持了)
網(wǎng)絡服務與安全
SSL = secure socket layer样勃,這個協(xié)議在傳輸層提供對網(wǎng)絡連接的加密吠勘,在應用層實現(xiàn)加密和解密性芬。最開始使用這個協(xié)議的是網(wǎng)景的瀏覽器,然后剧防,為了被更多的服務器核瀏覽器支持植锉,IETF組織將其標準化,也就是TLS = transport layer security峭拘。
node在網(wǎng)絡安全方面提供了crypto俊庇、tls、https三個模塊棚唆,crypto用于加密解密暇赤,例如sha1心例、md5等加密算法宵凌,tls用于建立一個基于TLS/SSL的tcp鏈接,它可以看成是net模塊的加密升級版本止后。https用于提供一個加密版本的http瞎惫,也是http的加密升級版本,甚至提供的接口和事件也跟http模塊一樣译株。
TLS/SSL
密鑰
TLS/SSL是一個公鑰/私鑰的結(jié)構(gòu)瓜喇,這也是一個非對稱的結(jié)構(gòu),每個服務器核客戶端都有自己的公鑰和私鑰歉糜。公鑰用來加密要傳輸?shù)臄?shù)據(jù)乘寒,私鑰用來解密接收到的數(shù)據(jù)。公鑰和私鑰是配對的匪补,通過公鑰加密的數(shù)據(jù)伞辛,只有通過私鑰才能解密,所以在建立安全傳輸之前夯缺,客戶端和服務器端之間需要互換公鑰蚤氏。客戶端發(fā)送數(shù)據(jù)時要通過服務器端的公鑰進行加密踊兜,服務器端發(fā)送數(shù)據(jù)時則需要客戶端的公鑰進行加密竿滨,如此才能完成加密解密的過程:
node在底層采用openssl來實現(xiàn)TLS/SSL,為此要生成公鑰和私鑰需要通過openssl來完成捏境,我們分別為服務器核客戶端生成私鑰:
// 生成服務器端私鑰
$ openssl genrsa -out server.key 1024
// 生成客戶端私鑰
$ openssl genrsa -out client.key 1024
上述命令生成了兩個1024位長的RSA私鑰文件于游,我們繼續(xù)通過它生成公鑰:
$ openssl rsa -in server.key -pubout -out server.pem
$ openssl rsa -in client.key -pubout -out client.pem
公鑰和私鑰的非對稱性加密雖然很好,但是網(wǎng)絡中依然可能存在竊聽的情況垫言,典型的例子就是中間人攻擊曙砂。客戶端和服務器端在交換公鑰的過程中骏掀,中間人對客戶端扮演服務器端的角色鸠澈,對服務器端扮演客戶端的角色柱告,因此客戶端和服務器端幾乎感受不到中間人的存在,為了解決這個問題笑陈,數(shù)據(jù)傳輸過程中還需要對得到的公鑰進行認證际度,以確認得到的公鑰是出自目標服務器的,如果不能保證這種認證涵妥,中間人可能會將偽造的站點響應給用戶乖菱,從而造成經(jīng)濟損失。
為了解決中間人攻擊的問題蓬网,TLS/SSL引入了數(shù)字證書來進行認證窒所,與直接公鑰不同,數(shù)字證書中包含了服務器的名稱和主機名稱帆锋、服務器的公鑰吵取、簽名頒發(fā)機構(gòu)的名稱、來自簽名頒發(fā)機構(gòu)的簽名锯厢。在建立連接前皮官,會通過證書中的簽名確認收到的公鑰是來自目標服務器的,從而產(chǎn)生信任關系实辑,下面我們就看看這個數(shù)字證書捺氢。
數(shù)字證書
CA = Certificate Authority是數(shù)字證書的頒發(fā)機構(gòu),這個證書具有ca通過自己的公鑰和私鑰實現(xiàn)的簽名剪撬。
為了得到ca的簽名證書摄乒,服務器端需要通過自己的私鑰生成CSR = certificate signing request文件,ca機構(gòu)將通過這個文件頒發(fā)屬于該服務器的簽名證書残黑,只要通過ca機構(gòu)就能驗證證書是否合法馍佑。
通過ca機構(gòu)頒發(fā)證書通常是一個繁瑣的過程,需要付出一定的精力和費用萍摊,對于中小企業(yè)來說挤茄,可以采用自簽名證書來構(gòu)建安全的網(wǎng)絡,也就是自己給自己的服務器扮演ca機構(gòu)冰木,給自己的服務器頒發(fā)自己的ca生成的簽名證書穷劈。我們還是使用openssl來實現(xiàn)這一過程
//生ca成服務器私鑰
$ openssl genrsa -out ca.key 1024
//生成csr文件
$ openssl req -new -key ca.key -out ca.csr
//通過私鑰自簽名生成證書,此時還沒有業(yè)務服務器的簽名
$ openssl x509 -req -in ca.csr -signkey ca.key -out ca.crt
這樣就生成了自己的簽名證書踊沸,然后再次回到服務器端歇终,服務器需要向ca申請簽名,在申請簽名之前逼龟,依然需要創(chuàng)建自己的csr评凝,值得注意的是,這個過程中的common name需要匹配服務器域名腺律,否則在后續(xù)的認證過程中會出錯:
//生成自己的業(yè)務服務器csr
$ openssl req -new -key server.key -out server.csr
//向自己的ca申請簽名證書奕短,這個過程需要ca的證書和私鑰參與宜肉,最終生成帶簽名的證書
$ openssl x509 -req -CA ca.crt -CAkey ca.key -CAcreateserial -in server.csr -out server.crt
之后,客戶端發(fā)起安全連接前會去捕獲服務器端的證書翎碑,并通過ca的證書驗證服務器端證書的真?zhèn)蚊怠3蓑炞C真?zhèn)瓮猓ǔ_€含有對服務器名稱日杈、IP地址等進行檢驗的過程:
ca機構(gòu)將證書頒發(fā)給服務器端后遣铝,證書在請求的過程中會被發(fā)送給客戶端,客戶端需要通過ca的證書驗證真?zhèn)卫蚯堋H绻侵腸a機構(gòu)酿炸,他們的證書一般都會預裝在瀏覽器中,如果是自己扮演的ca涨冀,就需要讓客戶自己先去獲取這個ca然后才能進行驗證填硕。
另外,ca的證書一般被稱為根證書蝇裤,也就是不需要上級證書參與簽名的證書廷支。
TLS服務
先基于tls模塊創(chuàng)建服務器端程序
var tls = require('tls');
var fs = require('fs');
var options = {
key: fs.readFileSync('./keys/server.key'),
cert: fs.readFileSync('./keys/server.crt'),
requestCert: true,
ca: [fs.readFileSync('./keys/ca.crt')]
};
var server = tls.createServer(options, function (stream) {
console.log('server connected', stream.authorized ? 'authorized' : 'unauthorized');
stream.write("welcome!\n");
stream.setEncoding('utf8');
stream.pipe(stream);
});
server.listen(8000, function () {
console.log('server bound');
});
啟動服務后频鉴,可以通過openssl s_client -connect 127.0.0.1:8000來測試證書是否正常栓辜。
然后,我們通過tls模塊的connect()來構(gòu)建客戶端垛孔,首先藕甩,需要為客戶端生成屬于自己的私鑰和簽名:
// 創(chuàng)建私鑰
$ openssl genrsa -out client.key 1024
// 生成CSR
$ openssl req -new -key client.key -out client.csr
// 生成簽名證書
$ openssl x509 -req -CA ca.crt -CAkey ca.key -CAcreateserial -in client.csr -out client.crt
然后創(chuàng)建客戶端程序
var tls = require('tls');
var fs = require('fs');
var options = {
key: fs.readFileSync('./keys/client.key'),
cert: fs.readFileSync('./keys/client.crt'),
ca: [fs.readFileSync('./keys/ca.crt')]
};
var stream = tls.connect(8000, options, function () {
console.log('client connected', stream.authorized ? 'authorized' : 'unauthorized');
process.stdin.pipe(stream);
});
stream.setEncoding('utf8');
stream.on('data', function (data) {
console.log(data);
});
stream.on('end', function () {
server.close();
});
我們可以看到,客戶端用到了客戶端自己生成的私鑰周荐、證書狭莱、ca證書。
var options = {
key: fs.readFileSync('./keys/client.key'),
cert: fs.readFileSync('./keys/client.crt'),
ca: [fs.readFileSync('./keys/ca.crt')]
};
客戶端啟動之后概作,就可以在輸入流中輸入數(shù)據(jù)了腋妙,服務器端將會回應相同的數(shù)據(jù)。至此我們完成了TLS的服務器端和客戶端的創(chuàng)建讯榕,與普通的tcp服務器和客戶端相比骤素,TLS的服務器核客戶端僅僅只是需要配置證書,其他基本一樣愚屁。
HTTPS服務
HTTPS其實就是TLS/SSL基礎上的HTTP济竹。換句話說,net模塊對應http模塊霎槐,tls模塊對應https模塊送浊,我們來創(chuàng)建一個https服務:
1.準備證書
按照之前的步驟準備自己的證書,這個過程大家回去看前邊的實現(xiàn)
2.然后創(chuàng)建https服務
var https = require('https');
var fs = require('fs');
var options = {
key: fs.readFileSync('./keys/server.key'),
cert: fs.readFileSync('./keys/server.crt')
};
https.createServer(options, function (req, res) {
res.writeHead(200);
res.end("hello world\n");
}).listen(8000);
然后使用curl進行測試丘跌,由于是自簽名袭景,因此curl不能校驗這個證書是否正確唁桩,因此,這里會報錯
$ curl https://localhost:8000/
curl: (60) SSL certificate problem, verify that the CA cert is OK. Details:
error:14090086:SSL routines:SSL3_GET_SERVER_CERTIFICATE:certificate verify failed
More details here: http://curl.haxx.se/docs/sslcerts.html
curl performs SSL certificate verification by default, using a "bundle"
of Certificate Authority (CA) public keys (CA certs). If the default
bundle file isn't adequate, you can specify an alternate file
using the --cacert option.
If this HTTPS server uses a certificate signed by a CA represented in
the bundle, the certificate verification probably failed due to a
problem with the certificate (it might be expired, or the name might
not match the domain name in the URL).
If you'd like to turn off curl's verification of the certificate, use
the -k (or --insecure) op
我們可以通過直接忽略簽名的方式進行請求:
$ curl -k https://localhost:8000/
hello world
也可以使用自己的證書耸棒,也就是告知ca證書朵夏,完成服務器端證書的驗證:
$ curl --cacert keys/ca.crt https://localhost:8000/
hello world
有了curl的初步使用,我們還可以構(gòu)建自己的https客戶端
var https = require('https');
var fs = require('fs');
var options = {
hostname: 'localhost',
port: 8000,
path: '/',
method: 'GET',
key: fs.readFileSync('./keys/client.key'),
cert: fs.readFileSync('./keys/client.crt'),
ca: [fs.readFileSync('./keys/ca.crt')]
};
options.agent = new https.Agent(options);
var req = https.request(options, function (res) {
res.setEncoding('utf-8');
res.on('data', function (d) {
console.log(d);
});
});
req.end();
req.on('error', function (e) {
console.log(e);
});
//輸出結(jié)果
$ node client.js
hello world
//如果不設置ca的話榆纽,會報錯
[Error: UNABLE_TO_VERIFY_LEAF_SIGNATURE]
這個異逞霾可以通過添加屬性
rejectUnauthorized:false解決,這個與curl -k效果一致