nodejs的TCP相關的一些筆記

他鄉踏雪發表於2022-04-16
  • TCP協議
  • 基於nodejs建立TCP服務端
  • TCP服務的事件
  • TCP報文解析與粘包解決方案

 一、TCP協議

1.1TCP協議原理部分參考: 無連線運輸的UDP、可靠資料傳輸原理、面向連線運輸的TCP

1.2圖解七層協議、TCP三次握手、TCP四次揮手:

 

 二、基於nodejs建立TCP服務端

 2.1建立nodejs的TCP服務例項(server.js):

 1 const net = require('net');
 2 //建立服務例項
 3 const server = net.createServer();
 4 const PORT = 12306;
 5 const HOST = 'localhost';
 6 //服務啟動對網路資源的監聽
 7 server.listen(PORT, HOST);
 8 //當服務啟動時觸發的事件
 9 server.on('listening', ()=>{
10     console.log(`服務已開啟在 ${HOST}: ${PORT}`);
11 });
12 //接收訊息,響應訊息
13 server.on('connection', (socker) => {
14     //通過Socket上的data事件接收訊息
15     socker.on('data', (chunk) => {//通過Socket上的writer方法回寫響應資料
16         const msg = chunk.toString();
17         console.log(msg);
18         //通過Socket上的writer方法回寫響應資料
19         socker.write('您好' + msg);
20     });
21 });
22 server.on('close', ()=>{
23     console.log('服務端關閉了');
24 });
25 server.on('error', (err) =>{
26     if(err.code === 'EADDRINUSE'){
27         console.log('地址正在被使用');
28     }else{
29         console.log(err);
30     }
31 });

2.2建立nodejs的TCP客戶端例項(client.js):

 1 const net = require('net');
 2 //建立客戶例項,並與服務端建立連線
 3 const client = net.createConnection({
 4     port:12306,
 5     host:'127.0.0.1'
 6 });
 7 //當套位元組與服務端連線成功時觸發connect事件
 8 client.on('connect', () =>{
 9     client.write('他鄉踏雪');//向服務端傳送資料
10 });
11 //使用data事件監聽服務端響應過來的資料
12 client.on('data', (chunk) => {
13     console.log(chunk.toString());
14 });
15 client.on('error', (err)=>{
16     console.log(err);
17 });
18 client.on('close', ()=>{
19     console.log('客戶端斷開連線');
20 });

然後使用nodemon工具啟動服務(如果沒有安裝nodemon工具可以使用npm以管理員身份安裝),當然也可以直接使用node指令啟動,使用nodemon的好處就是當你修改程式碼儲存後它會監聽檔案的變化自動重啟服務:

nodemon .\server.js

[nodemon] 2.0.15
[nodemon] to restart at any time, enter `rs`
[nodemon] watching path(s): *.*
[nodemon] watching extensions: js,mjs,json
[nodemon] starting `node .\server.js`
服務已開啟在 localhost: 12306

然後接著使用nodemon工具啟動客戶端程式,建立客戶端例項連線伺服器併傳送TCP訊息:

nodemon .\client.js

[nodemon] 2.0.15
[nodemon] to restart at any time, enter `rs`
[nodemon] watching path(s): *.*
[nodemon] watching extensions: js,mjs,json
[nodemon] starting `node .\client.js`
您好他鄉踏雪

服務端接收到訊息以後並在訊息前新增“您好”後返回該訊息,服務端的控制檯會先列印一下內容:

他鄉踏雪

以上就是一個簡單的TCP服務與客戶端的互動示例,除了使用nodemon啟動服務和客戶端以外,還可以使用系統的telnet工具在控制檯上測試連線服務,你的系統可能預設沒有啟動這個程式,telnet相關使用可以參考這裡:https://baijiahao.baidu.com/s?id=1723367561977342393&wfr=spider&for=pc

2.3TCP服務的事件

在前面的示例程式碼中已經有了TCP事件相關API的應用程式碼,這裡針對這些事件做一些概念性的介紹:

通過net.createServer()建立的服務,它是一個EventEmitter例項,這個示例負責啟動nodejs底層TCP模組對網路資源的監聽。在這個例項內部還會管理一個Socket例項,它是一個雙工流(關於雙工流點選參考)例項,Socket例項負責接收和響應具體的TCP資料。

net.createServer()上的事件:

listening:呼叫server.listen()繫結埠或者Domain Socket後觸發。
connection:每個客戶端套位元組連線到伺服器時觸發,其回撥函式會接收到一個Socket例項作為引數。
close:當伺服器關閉時觸發,在呼叫server.close()後,伺服器將停止接收新的套位元組連線,但保持當前存在的連線,等待所有連線斷開後,會觸發該事件。
error:當伺服器發生異常時,將會觸發該事件。

連線事件,也就是Socket例項上的事件,這個事件對應tream例項上的事件,因為Socket本身就是基於雙工流構造的。

data:當一端呼叫write()方法傳送資料時,另一端會觸發data事件,事件傳遞的資料即是write()傳送的資料。
end:當連線中的任意一段傳送了FIN資料時,將會觸發該事件。
connect:該事件用於客戶端,當套位元組與伺服器連線成功後會觸發。
drain:當任意一端呼叫write()傳送資料時,當前端會觸發該事件。
error:當異常發生時,觸發該事件。
close:當套位元組完全關閉時,觸發該事件。
timeout:當一定事件後連線不在活躍時,該事件將會觸發,通知使用者當前連線已經被閒置。

 三、TCP報文解析與粘包解決方案

由於TCP針對網路中小資料包有一定的優化策略:Nagle演算法。

如果每次傳送一個很小的資料包,比如一個位元組內容的資料包而不優化,就會導致網路中只有極少數有效資料的資料包,這會導致浪費大量的網路資源。Nagle演算法針對這種情況,要求快取區的資料達到一定資料量或者一定時間後才將其發出,所以資料包將會被Nagle演算法合併,以此來優化網路。這種優化雖然提高了網路頻寬的效率,但有的資料可能會被延遲傳送。

在Nodejs中,由於TCP預設啟動Nagle演算法,可以呼叫socket.setNoDelay(ture)去掉Nagle演算法,使得write()可以立即傳送資料到網路中。但需要注意的是,儘管在網路的一端呼叫write()會觸發另一端的data事件,但是並不是每次write()都會觸發另一端的data事件,再關閉Nagle演算法後,接收端可能會將接收到的多個小資料包合併,然後只觸發一次data事件。也就是說socket.setNoDelay(ture)只能解決一端的資料粘包問題。

使用第二節中的client.js示例程式碼來測試資料粘包問題:

//在客戶端的connect事件回撥中通過多個write()傳送資料,它可能會將多次write()寫入的資料一次發出
client.on('connect', () =>{
    client.write('他鄉踏雪');//向服務端傳送資料
    client.write('他鄉踏雪1');
    client.write('他鄉踏雪2');
    client.write('他鄉踏雪3');
});

我的測試結果是在服務端和客戶端都出現了粘包問題:

3.1解決粘包問題的簡單粗暴的方案

將多次write()傳送的資料,通過定時器延時傳送,這個延時超過Nagle演算法優化合並的時間就可以解決粘包的問題。比如上面的示例程式碼可以修改成下面這樣:

 1 let dataArr = ["他鄉踏雪","他鄉踏雪","他鄉踏雪"];
 2 //當套位元組與服務端連線成功時觸發connect事件
 3 client.on('connect', () =>{
 4     client.write('他鄉踏雪');//向服務端傳送資料
 5     for(let i = 0; i< dataArr.length; i++){
 6         (function(data, index){
 7             setTimeout(()=>{
 8                 client.write(data);
 9             },1000 * i);
10         })(dataArr[i], i);
11     }
12 });

上面這種方案會導致網路連線的資源長時間被佔用,使用者體驗上也會大打折扣,這顯然不是一個合理的方案。

3.2通過拆包封包的方式解決資料粘包的問題分析

通過前面的示例和對TCP資料傳輸機制雙工流的可以瞭解,TCP粘包的問題就是資料的可寫流因為Nagle演算法的優化,不會按照傳送端的write()的寫入對應觸發接收端的data事件,它可能導致資料傳輸出現以下兩種情況:

傳送端多次write()的資料可能被打包成一個資料包傳送到接收端。
傳送端通過write()一次寫入的資料可能因為Nagle演算法的優化被截斷到兩個資料包中。

TCP的資料傳輸雖然可能會出現以上兩種問題,但由於它是基於流的傳輸機制,那麼它的資料順序在傳輸過程中是確定的先進先出原則。所以,可以通過在每次write()在資料頭部新增一些標識,將每次write()傳輸的資料間隔開,然後在接收端基於這些間隔資料的標識將資料拆分或合併。

基於定長的訊息頭頭和不定長的訊息體,封包拆包實現資料在流中的標識:

訊息頭:也就是間隔資料的標識,採用定長的方式就可以實現有規律的獲取這些資料標識。訊息頭中包括訊息系列號、訊息長度。
訊息體:要傳輸的資料本身。

封包與拆包的工具模組具體實現(MyTransform.js):

 1 class MyTransformCode{
 2     constructor(){
 3         this.packageHeaderLen = 4;  //設定定長的訊息頭位元組長度
 4         this.serialNum = 0;         //訊息序列號
 5         this.serialLen = 2;         //訊息頭中每個資料佔用的位元組長度(序列號、訊息長度值)
 6     }
 7     //編碼
 8     encode(data, serialNum){    //data:當前write()實際要傳輸的資料; serialNum:當前訊息的編號
 9         const body = Buffer.from(data);//將要傳輸的資料轉換成二進位制
10         //01 先按照指定的長度來申請一片記憶體空間作為訊息頭header來使用
11         const headerBuf = Buffer.alloc(this.packageHeaderLen);
12         //02寫入包的頭部資料
13         headerBuf.writeInt16BE(serialNum || this.serialNum);//將當前訊息編號以16進位制寫入
14         headerBuf.writeInt16BE(body.length, this.serialLen);//將當前write()寫入的資料的二進位制長度作為訊息的長度寫入
15         if(serialNum === undefined){
16             this.serialNum ++;  //如果沒有傳入指定的序列號,表示在最佳寫入,訊息序列號+1
17         }
18         return Buffer.concat([headerBuf, body]);//將訊息頭和訊息體合併成一個Buffer返回,交給TCP傳送端
19     }
20     //解碼
21     decode(buffer){
22         const headerBuf = buffer.slice(0, this.packageHeaderLen);   //獲取訊息頭的二進位制資料
23         const bodyBuf = buffer.slice(this.packageHeaderLen);        //獲取訊息體的二進位制資料
24         return {
25             serialNum:headerBuf.readInt16BE(),
26             bodyLength:headerBuf.readInt16BE(this.serialLen),
27             body:bodyBuf.toString()
28         };
29     }
30     //獲取資料包長度的方法
31     getPackageLen(buffer){
32         if(buffer.length < this.packageHeaderLen){
33             return 0;   //當資料長度小於資料包頭部的長度時,說明它的資料是不完整的,返回0表示資料還沒有完全傳輸到接收端
34         }else{
35             return this.packageHeaderLen + buffer.readInt16BE(this.serialLen);  //資料包頭部長度+加上資料包訊息體的長度(從資料包的頭部資料中獲取),就是資料包的實際長度
36         }
37     }
38 }
39 module.exports = MyTransformCode;

測試自定義封包工具的編碼、解碼:

let tf = new MyTransformCode();
let str = "他鄉踏雪";
let buf = tf.encode(str);       //編碼
console.log(tf.decode(buf));    //解碼
console.log(tf.getPackageLen(buf)); //獲取資料包位元組長度
//測試結果
{ serialNum: 0, bodyLength: 12, body: '他鄉踏雪' }
16

3.3應用封包拆包工具MyTransform實現解決TCP的粘包問題

服務端示例程式碼:

 1 //應用封包解決TCP粘包問題服務端
 2 const net = require('net');
 3 const MyTransform = require('./myTransform.js');
 4 const server = net.createServer();  //建立服務例項
 5 let overageBuffer = null;           //快取每一次data傳輸過來不完整的資料包,等待一下次data事件觸發時與chunk合併處理
 6 let tsf = new MyTransform();
 7 server.listen('12306', 'localhost');
 8 server.on('listening',()=>{
 9     console.log('服務端執行在 localhost:12306');
10 });
11 server.on('connection', (socket)=>{
12     socket.on('data', (chunk)=>{
13         if(overageBuffer && overageBuffer.length > 0){
14             chunk = Buffer.concat([overageBuffer, chunk]);  //如果上一次data有未不完成的資料包的資料片段,合併到這次chunk前面一起處理
15         }
16         while(tsf.getPackageLen(chunk) && tsf.getPackageLen(chunk) <= chunk.length){   //如果接收到的資料中第一個資料包是完整的,進入迴圈體對資料進行拆包處理
17             let packageLen = tsf.getPackageLen(chunk);  //用於快取接收到的資料中第一個包的位元組長度
18             const packageCon = chunk.slice(0, packageLen);  //擷取接收到的資料的第一個資料包的資料
19             chunk = chunk.slice(packageLen);    //擷取除第一個資料包剩餘的資料,用於下一輪迴圈或下一次data事件處理
20             const ret = tsf.decode(packageCon); //解碼當前資料中第一個資料包
21             console.log(ret);
22             socket.write(tsf.encode(ret.body, ret.serialNum));  //講解碼的資料包再次封包傳送回客戶端
23         };
24         overageBuffer = chunk;  //快取不完整的資料包,等待下一次data事件接收到資料後一起處理
25     });
26 });

客戶端示例程式碼:

 1 //應用封包解決TCP粘包問題客戶端
 2 const net = require('net');
 3 const MyTransform = require('./myTransform.js');
 4 let overageBuffer = null;
 5 let tsf = new MyTransform();
 6 const client = net.createConnection({
 7     host:'localhost',
 8     port:12306
 9 });
10 client.write(tsf.encode("他鄉踏雪1"));
11 client.write(tsf.encode("他鄉踏雪2"));
12 client.write(tsf.encode("他鄉踏雪3"));
13 client.write(tsf.encode("他鄉踏雪4"));
14 client.on('data', (chunk)=>{
15     if(overageBuffer && overageBuffer.length > 0){
16         chunk = Buffer.concat([overageBuffer, chunk]);  ////如果上一次data有未不完成的資料包的資料片段,合併到這次chunk前面一起處理
17     }
18     while(tsf.getPackageLen(chunk) && tsf.getPackageLen(chunk) <= chunk.length){    //如果接收到的資料中第一個資料包是完整的,進入迴圈體對資料進行拆包處理
19         let packageLen = tsf.getPackageLen(chunk);  //用於快取接收到的資料中第一個包的位元組長度
20         const packageCon = chunk.slice(0, packageLen); //擷取接收到的資料的第一個資料包的資料
21         chunk = chunk.slice(packageLen);    //擷取除第一個資料包剩餘的資料,用於下一輪迴圈或下一次data事件處理
22         const ret = tsf.decode(packageCon); //解碼當前資料中第一個資料包
23         console.log(ret);
24     };
25     overageBuffer = chunk;  //快取不完整的資料包,等待下一次data事件接收到資料後一起處理
26 });

測試效果:

基於流的資料傳輸總是先進先出的佇列傳輸原則,所以每一次資料的前面固定幾個位元組的資料都是資料中的第一個包的頭部資料,所以就可以通過MyTransform工具中的getPackageLen(buffer)獲取到第一個資料包的資料長度,基於這樣一個原則就可以準確的判斷出當前的資料中是否有完整的資料包,如果有就將這個資料包拆分出來,迴圈這一操作就可以將所有資料全部完整的實現資料拆分,解決TCP的Nagle演算法導致到粘包和不完整資料包的問題。

相關文章