• 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算法导致到粘包和不完整数据包的问题。