TCP粘"包"问题浅析及解决方案Golang代码实现

一、粘“包”问题简介

在socket网络编程中,都是端到端通信,客户端端口+客户端IP+服务端端口+服务端IP+传输协议就组成一个可以唯一可以明确的标识一条连接。在TCP的socket编程中,发送端和接收端也同样遵循这样的规则。

1、部分字符和乱码的可能原因

如果发送端多次发送字符串,接收端从socket读取数据放到接收数据的recv数组,由于recv数组初始化为\0,仅收到部分字符串就开始打印。该部分字符串放在recv数组中,末尾仍是以\0结尾,打印函数见到\0则默认结束打印输出,后部分数据还未读取到就出现读取字符不完整的情况。如果出现乱码,则可能是因为,定义的recv数组容量不够,接收端的数据占满recv数组之后,打印函数仍会寻找以\0为边界的字符作为结束标志,这样从内存中就会读取数据的时候越界。内存中存在的数据不一定可读,打印函数在按照字符的格式输出就会显示乱码。所以有时候在socket编程的时候,会出现读取字符串不完整或者乱码的现象。

接收双方收发数据的时候直接在这样一条连接中进行,TCP是面向字节流的协议,数据像是在管道中流动一样。在TCP看来,数据之间并没有明确的边界。

2、粘“包”的可能原因

TCP并没有包这一概念,而所谓的包可能是报文段或者,发送端一次发送的数据被误称为包。而粘包的现象主要表现在两方面:
1、发送端在发送数据的时候,为了避免频繁发送负载量极小的报文段导致的传输性价比低的问题,默认使用优化算法,在收集多个小的报文之后,在适当的条件一次发送。由于TCP发送的数据没有边界,发送方发送的数据就看起来像粘在一起形成一个包一样。
2、接收端在接受数据的时候,由于缓存的存在,并不会直接把接受的数据直接移交上层应用层。而是会考虑时间和缓存容量从缓存中读取数据,如果TCP接收数据包的缓存的速度大于应用层从缓存中读取数据包的速度,多个包就会被缓存,应用程序就有可能读取到多个首尾相接像是粘到一起的包。

3、粘“包”的发生

粘“包”问题也并不是一直都需要解决,如果发送方发送的多组数据本来就是同一块数据的不同部分,比如说一个文件被分成多个部分发送,这时则不需要处理粘包现象。当时更多的情况下,发送的多个数据包并不相关,则需要去解决粘包问题。

比如甲和乙要进行通信,甲先后给乙发送大小为200字节和100字节的数据包A和B。如果将数据包A分为a1和a2两个负载量更小的数据包,那么这两个数据包之间就不存在粘包问题,因为它们本来就属于同一组数据。但是由于是顺序发送的,a2和B就可能产生粘包问题,发送端应用层知道A和B的边界,但是对于接收端TCP接受的是字节流,所以乙的应用层并不知道要把哪些作为一个有效的数据包。

4、解决方案

所以粘包根本问题还是在于,TCP是面向字节流的,而字节流是没有边界的。因此要解决粘包问题就要发送端和接收端约定某个字节作为数据包的边界或者规定固定大小的数据作为一个包。放在了上层应用层来实现。

方案一:结束标志控制。以指定字符(串)为包的结束标志,这种协议就是接收端在字节流中每次遇到标志符号,比如"\r\n"就认为是一个包的末尾,把每次遇到"\r\n"之前的数据进行封装当做一个数据包。但是有时候发送的数据本身就携带这些标志字符,因此需要做转义,以免接收方误地当成包结束标志而错误的进行数据打包。
方案二:固定数据包长度。就是每次发送的数据包的长度固定,如果数据不够,需要用特殊填充填满数据包。如果过长,则需要分包。
方案三:包头包体格式数据(TLV:Type, Lenght, Value),也就是发送方接收方事先约定好,每个包由包头和数据负载部分组成。包头长度固定,包含数据类型和数据长度,这两个字段占用的长度固定,假设分别为4个字节,数据负载部分占用的长度由包头中数据长度字段的值决定。比如一个数据包如下,那么接收端的先接收到8个字节的数据就取出包头,从而得到数据的类型,知道数据的长度为个字节,依次从接下来的数据流中读取10个字节,就可以得到该数据包的完整内容。

Type(消息类型) Length(数据部分的字节长度) Value(Data实际的数据部分)
1 10(4+6) asdf大小

上述例子假设采用UTF-8编码,一个英文字符等于一个字节,一个中文(含繁体)等于三个字节。

无法解析?
那会不会出现个别字节的丢失,导致某些数据包的包头无法解析,从而错误封包呢?
至少在发送端和发送过程中不会,因为TCP是可靠通信,可以通过序列号和重传机制保证数据包有序并且正确的到达接收端。

二、Golang代码实现

基于上述方案三,代码实现采用的是发送端和接收端两方约定好数据(消息)的封包和拆包机制,那么接收方发送的时候按照消息头(消息ID或者消息类型+消息长度)和消息实体部分发送,接收方按照同样的格式读取,从消息头中读取消息类型和消息长度,然后从管道中读取消息长度的字节数。

1、数据打包接口

先定义数据打包工具的抽象接口

/*
定义一个解决TCP粘包问题的封包和拆包的模块
——针对Message进行TLV格式的封装
  ——先后Message的长度,ID和内容
——这对Message进行TLV格式的拆包
  ——先读取固定长度的head-->消息内容长度和消息的类型
  ——再根据消息的长度,进行一次读写,从conn中读取消息的内容
 */

//封包,拆包模块,直接面向TCP连接中的数据流,用于处理TCP粘包的问题

type IDataPack interface {
	// 获取包的长度
	GetHeadLen() uint32
	//封包方法
	Pack(msg IMessage) ([]byte, error)
	//拆包方法
	Unpack([]byte) (IMessage, error)
}

2、消息封装

数据封装成消息

//消息包含消息ID,消息长度,消息内容三部分
type Message struct {
	Id      uint32 //消息的ID
	DataLen uint32    // 消息长度
	Data    []byte //消息内容
}

//创建一个Message消息包
func NewMsgPackage(id uint32, data []byte) *Message{
	return &Message{
		Id: id,
		DataLen: uint32(len(data)),
		Data: data,
	}
}

//获取消息ID
func (m *Message) GetMessageID() uint32{
	return m.Id
}

//获取消息内容
func (m *Message) GetMessageData() []byte{
	return m.Data
}

//获取消息长度
func (m *Message) GetMessageLen() uint32{
	return m.DataLen
}

//设置消息相关
func (m *Message) SetMessageID(id uint32){
	m.Id = id
}

//设置消息相关
func (m *Message) SetMessageData(data []byte){
	m.Data = data
}

//设置消息长度
func (m *Message) SetMessageLen(length uint32){
	m.DataLen = length
}

3、封包拆包实现

具体的拆包和封包逻辑实现

//拆包封包的具体模块
type DataPack struct {
	dataHeadLen uint32
}

//拆包封包实例的初始化方法
func NewDataPack() *DataPack {
	return &DataPack{}
}

// 获取包的长度
func (dp *DataPack) GetHeadLen() uint32{
	//DataHeadLen:uint32(4个字节)+ID:uint32(4个字节)=8个字节
	return 8
}

//封包方法, Message结构体变成二进制序列化的格式数据
func (dp *DataPack) Pack(msg *IMessage) ([]byte, error){
	//创建一个存放bytes字节的缓冲
	dataBuff := bytes.NewBuffer([]byte{})

	//注意写入的顺序
	//将dataLen写入databuff中
	//这里涉及到一个网络传输的大小端问题,大端序,小端序
	if err := binary.Write(dataBuff, binary.LittleEndian, msg.GetMessageLen()); err !=nil{
		return nil, err
	}

	//将MessageID写入databuff中
	if err := binary.Write(dataBuff, binary.LittleEndian, msg.GetMessageID()); err !=nil{
		return nil, err
	}

	//将data写入databuff中
	if err := binary.Write(dataBuff, binary.LittleEndian, msg.GetMessageData()); err !=nil{
		return nil, err
	}

	//二进制的序列化返回
	return dataBuff.Bytes(), nil
}

//拆包方法()
func (dp *DataPack) Unpack(binaryData []byte) (*Message, error){
	//创建一个输入二进制数据的ioReader
	dataBuff := bytes.NewBuffer(binaryData)

	//接受消息,直解压head,获得datalen和id
	msg := &Message{}

	//读取dataLen
	//这里的&msg.DataLen是为了写入地址
	if err := binary.Read(dataBuff, binary.LittleEndian, &msg.DataLen); err!=nil{
		return nil, err
	}

	//这里的从dataBuff读取数据,应该是连续读,先读len,然后读id,不会重复
	//读取dataID
	if err := binary.Read(dataBuff, binary.LittleEndian, &msg.Id); err != nil{
		return nil, err
	}

	//这里还可以加一个判断datalen是否超出定义的长度的逻辑

	//只需拆包湖区msg的head,然后通过head的长度,从conn中读取一次数据
	return msg, nil
}

封包拆包的时候还涉及到大小端的问题,具体是指,一个字符需要多个字节才能表示,在内存中这些字节是按照从大到小的地址空间存储还是从小到大。发送接收双方事先约定好,否则就会不同的顺寻着对接收数据的解析顺序不同出错。还有从Socket中读取数据流的时候是按照顺序的,因此一旦读出来socket中就没了。

其他:具体的建立socket链接,创建数组接收数据就不写了= _ =...,博客仅作为学习笔记的记录,如果说的不对,及时改正,轻喷轻喷,感谢感谢

三、参考

粘包问题:详解传送门1
粘包问题:详解传送门2
大小端问题:详解传送门

原文地址:https://www.cnblogs.com/welan/p/15522972.html