Node 網絡編程
前言
利用Node可以十分方便地搭建網絡服務器,在WEB領域,大多數編程語言需要專門的web服務器作爲容器,比如ASP,ASP.NET需要IIS作爲服務器,PHP需要搭載在Apache或者Nignx環境等,JSP需要Tomcat服務器等。當對於Node而言,只需要幾行代碼就可以構建一個服務器,無需額外的容器。
Node提供了net、http、https、dgram這四個模塊,分別用於處理TCP、HTTP、HTTPS、UDP,適用於服務器與客戶端。
一、構建 TCP 服務器
1. 七層模型與TCP協議
TCP全名傳輸控制協議,在OSI模型中有以下七層,被稱爲七層網絡協議。許多應用層議都是基於TCP構建,典型的有HTTP、SMTP、IMAP等協議。
TCP是面向連接的協議,其顯著特徵爲3次握手後才形成會話。
注意:只有在會話形成之後,服務器端和客戶端之間才能互相發送數據,在創建會話的過程中,服務器端和客戶端分別提供一個套接字,這兩個套接字共同形成了一個鏈接,服務器端與客戶端則通過套接字實現兩者之間連接的操作。
具體流程:請移步到
https://blog.csdn.net/Errrl/article/details/103662867
2. 創建TCP服務器
在基本瞭解TCP工作原理後,接下來就可以開始創建TCP服務器端來接受請求:
server.js
/* 引入net核心模塊 */
var net = require('net');
/* 創建一個TCP服務器 */
var server = net.createServer(function(socket){
socket.on('data',function(data){
socket.write('hello');
});
socket.on('end',function(){
socket.write('end');
});
socket.write('welcome to node tcp');
});
/* 監聽端口號 */
server.listen(8000,function(){
console.log('server is done');
})
利用win10自帶的telnet客戶端對上述的見到服務器進行會話
通過net模塊構造客戶端進行會話,測試上述構建的TCP服務器:
client.js
var net = require('net');
var client = net.connect({port:8000},function(){
console.log('client is connect');
client.write('world!\r\n');
});
client.on('data',function(data){
console.log(data.toString());
client.end();
})
client.on('end',function(){
console.log('client is disconnect');
})
3. TCP服務器事件
(1)服務器事件
對於通過net.createServer()創建的服務器而言,他是一個EventEmitter實例,他自定義事件有以下幾種:
- listening:在調用server.listen()綁定端口,簡介寫法爲server.listen(port,listeningListener),通過listen()方法的的第二個參數傳入。
- connection:每個客戶端套接字連接到服務端時觸發,簡介寫法爲通過net.createServer(),最後一個參數傳入。
- close:當服務器關閉時觸發,在調用server.close()後,服務器將停止接受新的套接字連接,但保持當前的連接,對待所有連接都斷開後會觸發該事件。
- error:當服務器發生異常時,將會觸發該事件。比如監聽一個使用中的端口,將會觸發一個異常,如果不偵聽error事件,服務器將會拋出異常。
(2)連接事件
服務器可以同時與多個客戶端保持連接,對於每個連接而言是典型的可寫可讀Stream對象。Stream對象可以用於服務器與客戶端之間的通信,既可以通過data事件從一端讀取另一端發來的數據,也可以通過write()方法從一端向另一端發送數據,它具有如下自定義事件:
- data:當一端調用write()發送數據時,另一端觸發data事件,事件傳遞的數據即是write()發送的數據。
- end:當連接中的任意一端發送FIN數據時,將會觸發該事件。
- connect:改時間用於客戶端,當套接字與服務器端連接成功時被觸發。
- drain:當任意一端調用write()發送數據時,當前這端會觸發該事件。
- error:當發生異常時觸發該事件。
- close:當套接字完全關閉時觸發事件。
- timout:當一定時間後連續不在活躍時,該事件會被觸發,通知用戶當前該連接已經被閒置了。
二、構建 UDP服務器
1. 與 TCP 協議的區別
請移步到:
https://blog.csdn.net/Errrl/article/details/103662867
2. 創建 UDP 套接字
創建UDP套接字十分簡單,UDP套接字一旦創建,既可以作爲客戶端發送數據,也可以作爲服務器端接收數據,創建一個UDP套接字:
var dgram = require('dgram');
var socket = dgram.createSocket ('udp4')
3. 創建 UDP 服務器端
如果要想UDP套接字接受網路消息,只要調用dgram.bind(port,[address])進行綁定即可。
server.js
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)
該套接字將接收所有網卡上41234端口信息上的消息。在綁定完成後,將會觸發listening事件。
4. 創建 UDP 客戶端
創建一個客戶端與服務器端進行對話:
client.js
var dgram = require('dgram');
var message = new Buffer('hello node udp');
var client = dgram .createSocket('udp4');
client.send(message,0,message.length,41234,"localhost",function(err,bytes){
client.close();
})
當套接字對象用在客戶端時,可以調用send()方法發送消息到網絡中。send()方法的參數如下:
socket.send(buf,offset,length,port,address,[callback])
- buf:buffer
- offset:buffer偏移量
- length:buffer的長度
- port:目標端口
- address:目標地址
- callback:發送完成後的回調
5. UDP 套接字事件
UDP套接字相對於TCP套接字使用起來更加簡單,它只是一個EventEmitter的實例,它具有如下自定義事件:
- message:當UDP套接字偵聽網卡端口後,接收到消息觸發該事件,出發攜帶的數據爲消息buffer對象和一個遠程地址信息。
- listening:當UDP套接字開始偵聽時觸發該事件。
- close:調用close()方法時觸發該事件,並不再觸發message事件。如需再次觸發message事件,重新綁定即可。
- error:當異常發生時出發該事件,如果不偵聽,異常將直接拋出,使進程退出。
三、構建 HTTP 服務器端、客戶端
在Node中構建HTTP服務極其容易,Node官網上的經典例子就展示瞭如何用幾行代碼實現一個HTTP服務器:
var http = require('http');
/* 創建服務器 */
http.createServer(function(req,res){
res.writeHead(200,{'content-type':'text/plain'});
res.end('hello world');
}).listen(8000,'127.0.0.1');
console.log('server run at http://127.0.0.1:8000/');
1. HTTP
(1)HTTP 報文
在啓動上述代碼後,我們對經典的示例代碼進行了一次報文的獲取,這裏使用的工具是curl,通過 -v選項,可以顯示這次網絡通訊所有的報文信息。
請求報文的四部分:
- TCP三次握手。
- 請求報文。(請求頭、請求體)
- 響應報文(包括響應頭、響應體)。
- 會話結束信息。
從上述可以看出HTTP的特點,它是基於請求響應式的,基於TCP協議。
2. http 模塊、HTTP服務器端
Node的http模塊包含對HTTP處理的封裝,在Node中,HTTP服務繼承自TCP服務器(net模塊),它能夠與多個客戶端保持連接,由於採用事件驅動的形式,並不爲每一個連接創建額外的線程或進程,並保持很低的內存佔有率,所以能實現高校併發。
(1)HTTP 請求
請求頭
> GET / HTTP/1.1
> Host: 127.0.0.1:8000
> User-Agent: curl/7.55.1
> Accept: */*
>
請求頭第一行GET / HTTP/1.1解析之後會分解成如下屬性:
- request.method:值是GET,一種請求方法,常用的請求方法還有:POST、DELETE、PUT、CONNECT等請求方法。
- request.url:值爲
/
,這就可以解釋爲什麼在項目中查詢request.url會返回/
,原因就是取決於報文。 - request.httpVersion:值爲1.1,表示版本(規則)。
其餘的包頭就以簡單、規律的key:value的格式,被解析後放置在request.headers屬性上傳遞給業務邏輯以供調用。
(2)HTTP 響應
響應頭
< HTTP/1.1 200 OK
< content-type: text/plain
在項目中經常要寫入響應頭,用於獲取符合類型的數據。
除此之外,http模塊會自動設置一些頭信息:(用於處理緩存)
< Date: Fri, 07 Feb 2020 14:30:32 GMT
< Connection: keep-alive
< Transfer-Encoding: chunked
<
響應體
調用respone.write()或者調用respone.end()傳入的內容稱爲響應體:
hello world
調用respone.write()或者調用respone.end()的區別在於:
前者只發送,不結束響應,會造成客戶端處於等待狀態。
而後者就會先調用write()發送完後調用end()結束響應。
(3)HTTP 服務的事件
同TCP服務一樣,HTTP服務器也抽象了一些事件,以供應用層使用,同樣典型的是,服務器也是一個EventEmitter實例:
- connection 事件:在開始HTTP請求和響應之前,客戶端與服務器需要建立底層的TCP連接,這個連接可能因爲開啓了keep-alive的原因,可以在多次請求與響應之間使用;當這個連接建立時,服務器會觸發一次connection事件。
- request 事件:建立TCP連接後,HTTP模塊底層將在數據流中抽出HTTP請求和HTTP響應,當請求數據發送到服務器端,在解析出HTTP請求頭後,將會觸發該事件;在res.end()後,TCP連接可能將用於下一次請求響應。
- close 事件:與TCP服務器行爲一致,調用server.close()方法停止接受新的連接,當已有的連接都斷開時,觸發該事件;可以給server.close()傳遞一個回調函數來快速註冊該事件。
- checkContinue 事件:某些客戶端在發送較大的數據時,並不會之間將數據發送,而是先發送一個頭部帶有
Expect:100-continue
的請求到服務器,服務器將會觸發checkContinue事件;如果沒有爲服務器監聽這個事件,服務器將會自動響應客戶端100 Continue
的狀態碼,表示可以接受數據上傳;如果不接受或者數據確實超出承載時響應客戶端400 Bad Request
拒絕客戶端繼續發送數據即可。需要注意的是:當該事件發生時不會觸發request事件,兩個事件互斥。當客戶端收到100 Continue
後重新發送請求時纔會觸發request事件。與預檢(Preflighted)的跨域請求類似 - connect 事件:當客戶端發起CONNECT請求時觸發,二發起CONNECT請求通常在HTTP代理時出現;如果不監聽該事件,發起該事件的連接就會中斷。
- upgrade 事件:當客戶端要求升級連接的協議時,需要和服務器端協商,客戶端會在請求頭中帶上Upgrade字段,服務器端會在接收到這樣的請求時觸發該事件。者在後面的WebSoket中會有詳細的流程介紹。如果不監聽該事件,發起該請求的連接就會中斷。
- clientError 事件:連接的客戶端觸發error事件時,這個錯誤會傳遞到服務器端,此時觸發該事件。
擴展
keep-alive
在http早期,每個http請求都要求打開一個tpc socket連接,並且使用一次之後就斷開這個tcp連接。
使用keep-alive可以改善這種狀態,即在一次TCP連接中可以持續發送多份數據而不會斷開連接。通過使用keep-alive機制,可以減少tcp連接建立次數,也意味着可以減少TIME_WAIT狀態連接,以此提高性能和提高httpd服務器的吞吐率(更少的tcp連接意味着更少的系統內核調用,socket的accept()和close()的調用)
3. HTTP 客戶端
http模塊提供了一個底層的API:http.request(options,connect),用於構建HTTP客戶端。
var http = require('http');
/* 請求報文 */
var options = {
hostname:'127.0.0.1',
port:8000,
path:'/',
method:'GET'
}
/* 發送報文 */
var req = http.request(options,function(res){
/* 獲取狀態碼 */
console.log('status:'+res.statusCode);
/* 獲取響應頭 */
console.log('headers:'+res.headers);
res.setEncoding('utf8');
/* 獲取響應體 */
res.on('data',function(datas){
console.log(datas);
})
})
/* 發送後斷開連接,緩解服務器壓力 */
req.end();
修改:
/* 獲取響應頭 */
console.log('headers:'+JSON.stringify(res.headers));
其中options的參數配置:
- host:服務器的域名或者ip地址,默認爲localhost。
- hostname:服務器名稱。
- port:端口號。默認80。
- method:HTTP請求方法,默認GET。
- path:請求路徑,默認
/
- headers:請求頭對象。
- auth:Basic認證,這個值將會被計算成請求頭中的Authorization部分。
報文體的內容由請求對象的write()和end()方式實現:通過write()方法向連接中寫入數據,通過end()方法告知報文結束。他與前端中的Ajax調用非常相似,Ajax的實質就是一個異步的網絡HTTP請求。
(1)HTTP 響應
HTTP客戶端的響應對象與服務器端類似,在客戶端請求對象中,它的事件名叫做response。客戶端請求後(也就是解析報文完成後)響應頭就會觸發response事件,同時傳遞一個響應對象以供客戶端進行響應操作。對於上述代碼而言,res就是response,datas就是響應對象。
(2)HTTP 代理
如服務器端的實現一般http模塊提供的客戶端請求對象也是基於TCP層實現的,在keep-alive機制下,一個底層會話連接可以多次用於請求。爲了重複使用TCP連接,http模塊包含一個默認的客戶端代理對象http.globalAgent。它對每一個服務端(host+port)創建連接進行了管理,默認情況下,通過客戶端請求對象對同一服務器端發起的HTTP請求最多可以創建5個連接。實際上它就是一個連接池(循環代理)
那麼如何進行代理,很簡單:
重構options
/* 設置代理 */
var agent = new http.Agent({
maxSochets: 10,
keepAlive: true,
})
var options = {
hostname: '127.0.0.1',
port: 8000,
path: '/',
method: 'GET',
agent: agent
}
/* 發送報文 */
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 (datas) {
console.log(datas);
})
})
/* 發送後斷開連接,緩解服務器壓力 */
req.end();
相關鏈接:
http://nodejs.cn/api/http.html#http_class_http_agent
(3)HTTP 客戶端事件
- response 事件:與服務器端的request事件對應的客戶端在請求發送後得到服務器的響應時會觸發該事件。
- socket 事件:在底層連接池中建立的連接分配給當前請求對象時,觸發該事件。
- connect 事件:當客戶端向服務器端發送CONNECT請求時,如果服務器響應了200狀態碼,客戶端將會觸發該事件。
- upgrade 事件:客戶端向服務器端發起Upgrade請求時,如果服務器端響應了101 Switching Protocols狀態,客戶端將會觸發該事件。
- continue 事件:客戶端向服務器端發起Expect:100-continue頭信息,以試圖發送叫大數據量,如果服務器響應了100 Continue狀態,客戶端將觸發該事件。
四、構建 webSocket 服務端
1. 客戶端下的 webSocket
HTML:(client.html)
<input id="content" type="text">
<button id="send">send</button>
以一個webSocket聊天室進行實例操作:
/* client.html */
var websocket = new WebSocket("ws://localhost:8000/");
websocket.onopen = function() {
console.log("webSocket open");
// 發送消息放在這裏
document.getElementById("send").onclick = function() {
var txt = document.getElementById("content").value;
if (txt) {
/* 發送數據 */
websocket.send(txt);
}
}
}
websocket.onclose = function() {
console.log("websocket close");
}
/* 接收響應數據 */
websocket.onmessage = function(e) {
console.log(e.data);
var mes = JSON.parse(e.data);
showMessage(mes.data, mes.type);
}
/* server.js */
var ws = require("nodejs-websocket");
/* 端口號 */
const PORT = 8000;
// 每進來一個客戶端就記錄一下
var clientCount = 0;
var server = ws.createServer(function (conn) {
console.log("New connection")
clientCount++;
conn.nickname = 'user' + clientCount;
let mes = {};
mes.type = "enter";
mes.data = conn.nickname + ' comes in'
broadcast(JSON.stringify(mes));
/* 收到 text 文本觸發 */
conn.on("text", function (str) {
console.log("Received " + str);
let mes = {};
mes.type = "message";
mes.data = conn.nickname + ' says: ' + str;
broadcast(JSON.stringify(mes));
})
/* 當任一側關閉連接時發出 */
conn.on("close", function (code, reason) {
console.log("Connection closed");
let mes = {};
mes.type = "leave";
mes.data = conn.nickname + ' left'
broadcast(JSON.stringify(mes));
})
/* 發生錯誤時發出(例如嘗試在仍然發送二進制數據的同時發送文本數據)。如果握手無效,也會發出響應。 */
conn.on("error", function (err) {
console.log("handle err");
console.log(err);
})
}).listen(PORT);//監聽端口號
console.log("websocket server running on port: " + PORT);
/* 響應數據 */
function broadcast(str) {
server.connections.forEach(function (connection) {
connection.sendText(str);
})
}
上述代碼中,瀏覽器與服務器端創建webSocket協議請求,onopen在請求完成後持續執行,通過事件綁定的方法綁定一個發送按鈕發送數據,同時還可以通過onmessage()方法接收服務器端相應的數據的數據。這種行爲與TCP客戶端很相似,相較於HTTP,它能夠雙向通信。
並且相比於HTTP,webSocket更接近於傳輸層協議,它並沒有在HTTP的基礎上模擬服務器端的推送,而是在TCP上定義獨立的協議,但是疑惑的是webSocket的握手部分由HTTP完成,這就是人們感覺webSocket是基於HTTP實現的原因。
webSocket協議主要分爲兩個部分:握手和數據傳輸。
2. webSocket 握手
客戶端建立連接時,通過HTTP發起的請求報文:
上面的報文告知客戶端正在更換協議(協議升級),更新應用層協議爲webSocket協議,並在當前的套接字連接上應用新的協議。剩餘的字段分別表示服務器端基於Sec-WebSocket-Key生成的字符串和選中的子協議。客戶端將會校驗Sec-WebSocket-Key的值,如果成功,將開始接下來的數據傳輸。
簡而言之就是websocket複用了http的握手通道,客戶端通過http請求與服務端進行協商,升級協議。協議升級完後校驗Sec-WebSocket-Key的值,若成功後面的數據交換則遵照websocket協議,若否反之。
流程:
1、客戶端申請協議升級
Request URL: ws://localhost:8888/
Request Method: GET
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Version: 13
Sec-WebSocket-Key: uR5YP/BMO6M24tAFcmHeXw==
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits
-
Connection: Upgrade 表示要升級協議
-
Upgrade: websocket 表示升級到websocket協議
-
Sec-WebSocket-Version: 13 表示websocket的版本
-
Sec-WebSocket-Key 表示websocket的驗證,防止惡意的連接,與服務端響應的Sec-WebSocket-Accept是配套。
2、服務端響應協議升級
Status Code: 101 Switching Protocols
Connection: Upgrade
Sec-WebSocket-Accept: eS92kXpBNI6fWsCkj6WxH6QeoHs=
Upgrade: websocket
- Status Code:101 表示狀態碼,協議切換。
- Sec-WebSocket-Accept 表示服務端響應的校驗,與客戶端的Sec-WebSocket-Key是配套的。
3、Sec-WebSocket-Accept是如何計算的
將 Sec-WebSocket-Key 的值與 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 拼接。
然後通過sha1計算,再轉成base64。
const crypto = require('crypto');
function getSecWebSocketAccept(key) {
return crypto.createHash('sha1')
.update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11')
.digest('base64');
}
console.log(getSecWebSocketAccept('uR5YP/BMO6M24tAFcmHeXw=='));
4、協議升級完後,後續的數據傳輸就需要按websocket協議來走。(瞭解即可)
websocket客戶端與服務端通信的最小單位是 幀,由1個或多個幀組成完整的消息。
客戶端:將消息切割成多個幀,發送給服務端。
服務端:接收到消息幀,將幀重新組裝成完整的消息。
數據幀的格式
單位是1個比特位,FIN,PSV1,PSV2,PSV3 佔1個比特位,opcode佔4個比特位。
0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len | Extended payload length |
|I|S|S|S| (4) |A| (7) | (16/64) |
|N|V|V|V| |S| | (if payload len==126/127) |
| |1|2|3| |K| | |
+-+-+-+-+-------+-+-------------+-------------------------------+
| Extended payload length continued, if payload len == 127 |
+-------------------------------+-------------------------------+
| |Masking-key, if MASK set to 1 |
+-------------------------------+-------------------------------+
| Masking-key (continued) | Payload Data |
+-------------------------------+-------------------------------+
| Payload Data continued ... |
+---------------------------------------------------------------+
| Payload Data continued ... |
+---------------------------------------------------------------+
6、掩碼的算法
Masking-key掩碼鍵是由客戶端生成的32位隨機數,掩碼操作不會影響數據載荷的長度。
function unmask(buffer, mask) {
const length = buffer.length;
for (var i = 0; i < length; i++) {
buffer[i] ^= mask[i & 3];
}
}
7、實現websocket的握手,數據傳輸
JavaScript:(up.js)
const crypto = require('crypto');
const net = require('net');
//計算websocket校驗
function getSecWebSocketAccept(key) {
return crypto.createHash('sha1')
.update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11')
.digest('base64');
}
//掩碼操作
function unmask(buffer, mask) {
const length = buffer.length;
for (var i = 0; i < length; i++) {
buffer[i] ^= mask[i & 3];
}
}
//創建一個tcp服務器
let server = net.createServer(function (socket) {
socket.once('data', function (data) {
data = data.toString();
//查看請求頭中是否有升級websocket協議的頭信息
if (data.match(/Upgrade: websocket/)) {
let rows = data.split('\r\n');
//去掉第一行的請求行
//去掉請求頭的尾部兩個空行
rows = rows.slice(1, -2);
let headers = {};
rows.forEach(function (value) {
let [k, v] = value.split(': ');
headers[k] = v;
});
//判斷websocket的版本
if (headers['Sec-WebSocket-Version'] == 13) {
let secWebSocketKey = headers['Sec-WebSocket-Key'];
//計算websocket校驗
let secWebSocketAccept = getSecWebSocketAccept(secWebSocketKey);
//服務端響應的內容
let res = [
'HTTP/1.1 101 Switching Protocols',
'Upgrade: websocket',
`Sec-WebSocket-Accept: ${secWebSocketAccept}`,
'Connection: Upgrade',
'\r\n'
].join('\r\n');
//給客戶端發送響應內容
socket.write(res);
//注意這裏不要斷開連接,繼續監聽'data'事件
socket.on('data', function (buffer) {
//注意buffer的最小單位是一個字節
//取第一個字節的第一位,判斷是否是結束位
let fin = (buffer[0] & 0b10000000) === 0b10000000;
//取第一個字節的後四位,得到的一個是十進制數
let opcode = buffer[0] & 0b00001111;
//取第二個字節的第一位是否是1,判斷是否掩碼操作
let mask = buffer[1] & 0b100000000 === 0b100000000;
//載荷數據的長度
let payloadLength = buffer[1] & 0b01111111;
//掩碼鍵,佔4個字節
let maskingKey = buffer.slice(2, 6);
//載荷數據,就是客戶端發送的實際數據
let payloadData = buffer.slice(6);
//對數據進行解碼處理
unmask(payloadData, maskingKey);
//向客戶端響應數據
let send = Buffer.alloc(2 + payloadData.length);
//0b10000000表示發送結束
send[0] = opcode | 0b10000000;
//載荷數據的長度
send[1] = payloadData.length;
payloadData.copy(send, 2);
socket.write(send);
});
}
}
});
socket.on('error', function (err) {
console.log(err);
});
socket.on('end', function () {
console.log('連接結束');
});
socket.on('close', function () {
console.log('連接關閉');
});
});
//監聽8000端口
server.listen(8000);
html:(up.html)
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>Document</title>
</head>
<body>
<script>
var ws = new WebSocket('ws://localhost:8888');
ws.onopen = function () {
console.log('連接成功');
ws.send('你好服務端');
};
ws.onmessage = function (ev) {
console.log('接收數據', ev.data);
};
ws.onclose = function () {
console.log('連接斷開');
};
</script>
</body>
</html>
8、結束