粘包和拆包
TCP的粘包和拆包问题往往出现在基于TCP协议的通讯中
什么是粘包?
在学习粘包之前,先纠正一下读音,很多视频教程中将“粘”读作“nián”。经过调研,个人更倾向于读“zhān bāo”。
如果在百度百科上搜索“粘包”,对应的读音便是“zhān bāo”,语义解释为:网络技术术语。指TCP协议中,发送方发送的若干包数据到接收方接收时粘成一包,从接收缓冲区看,后一包数据的头紧接着前一包数据的尾。
TCP是面向字节流的协议,就是没有界限的一串数据,本没有“包”的概念,“粘包”和“拆包”一说是为了有助于形象地理解这两种现象。
粘包拆包发生场景
因为TCP是面向流,没有边界,而操作系统在发送TCP数据时,会通过缓冲区来进行优化,例如缓冲区为1024个字节大小。
如果一次请求发送的数据量比较小,没达到缓冲区大小,TCP则会将多个请求合并为同一个请求进行发送,这就形成了粘包问题。
如果一次请求发送的数据量比较大,超过了缓冲区大小,TCP就会将其拆分为多次发送,这就是拆包。
关于粘包和拆包可以参考下图的几种情况:
上图中演示了以下几种情况:
* 正常的理想情况,两个包恰好满足TCP缓冲区的大小或达到TCP等待时长,分别发送两个包;
* 粘包:两个包较小,间隔时间短,发生粘包,合并成一个包发送;
* 拆包:一个包过大,超过缓存区大小,拆分成两个或多个包发送;
* 拆包和粘包:Packet1过大,进行了拆包处理,而拆出去的一部分又与Packet2进行粘包处理。
演示粘包出现的情况
TCP服务器代码,只展示核心代码,向客户端连接循环发送100个数据
while (count < 100)
{
string msg = "我是一个整包";
byte[] bytes = Encoding.UTF8.GetBytes(msg);
stream.Write(bytes,0,bytes.Length);
count++;
}
在客户端只接受到了3个数据(我的测试,可能有所不同),并且数据中有许多的`?`,这些问号的出现是因为一个汉字占用3个字节,传输时被进行了错误的分割,如`1 + 2、2+1`,导致数据解析错误。
如果将上例的`msg`变量修改为更大的内容,可以看到拆包的效果
处理粘包问题
前置任务
一. 一般情况下,信息的发送都应该是有限制的,比如微信发送文件大小就有要求,我们这里举例最多允许发送`int个字节(2^32)字节`长度的数据
二. 一个汉字占用3个字节 我们将会在发送的数据包前面添加一个包头,用于标识该数据的长度,如下
byte[] data = Encoding.UTF8.GetBytes("窗前明月光,疑是地上床,举头望明月,低头思故乡。窗前明月光,疑是地上床,举头望明月,低头思故乡。窗前明月光,疑是地上床,举头望明月,低头思故乡。窗前明月光,疑是地上床,举头望明月,低头思故乡。"); // 待发送的数据
int length = data.Length; // 288
这个代码要发送的数据为288,我们发送数据包时将会这个288放在数据包的头部,因为int占用4个字节,因此该数据包应该是像下面这个样子,就像是256进制,第四位最大值位255,到256就要向第三位进1
二进制
[00000000, 00000000, 00000001, 00100001, 数据位...]
十进制
[0, 0, 1, 33, 数据位....]
三. 大端和小端
1. 小端就是低位对应低地址,高位对应高地址。
2. 大端就是低位对应高地址,高位对应低地址
3. C#模式是小端模式,也就是小值在前,大值在后,比如`[1,2,3,4]`其实是`[4,3,2,1]`
4. 也就是说,上例中我们的`288`发送过去之后其实是`[00100001, 00000001, 00000000, 00000000]`,分别是所谓的个位、十位、百位、千位,其实不是很合适,能理解其意即可
4. 转换问题,第三条中的`[0,0,1,33]`其实是`[33,1,0,0]`,满256进一的256进制,因此转换为10进制为`33 * 256 ^ 0 + 1 * 256 ^ 1 + 0 * 256 ^ 2 + 0 * 256 ^ 3`
分隔法
加分割符处理半包:让上个包的后半部分加上前半部分。多次发送过来的消息中使用分隔符分割后,第一个数据可能包含上一个包的数据,最后一个数据可能包含下一个包的数据,第一个包拼接上一个包的最后一个数据后一定是完整的数据
粘包就是将多个包进行了粘连,并且不能进行正确的分割,我们只需要将其正确分割即可,如下
// 保存上一个半包内容(没有的话,就是""空字符串)
StringBuilder lastPacket = new StringBuilder();
void OnMessage(){
// 先获取发送过来的数据
byte[] buffer = new byte[1024];
int length = stream.Read(buffer, 0, buffer.Length);
string msg = Encoding.UTF8.GetString(buffer, 0, length);
// 使用 "|" 进行拆分,约定每个包以 | 结尾
string[] msgs = msg.Split('|');
// 将第一个数据拼接到上一个半包,拼接后一定是完整的数据
lastPacket.Append(msgs[0]);
Console.WriteLine(lastPacket.ToString());
// 从第二个(索引是1)开始遍历
for (int i = 1; i < msgs.Length - 1; i++)
{
// 一定是个整包(除了第一个和最后一个)
count++;
Console.WriteLine(msgs[i]);
}
// 清空lastPacket后记录最后一个包的内容,也就是当作下一个包的开始部分
lastPacket.Clear();
lastPacket.Append(msgs[msgs.Length - 1]);
}
加入包头,进行拆包处理
发送数据时将数据的前4位当作包头,用于记录该数据包的数据长度,然后我们再从数据包中截取对应长度的数据
发送时:
// 1、将要发送的数据转换为字节数组
byte[] bodyByte = Encoding.UTF8.GetBytes(body);
// 2、创建内存流用于写入数据包
MemoryStream ms = new MemoryStream();
BinaryWriter bw = new BinaryWriter(ms);
// 3、写入包头(数据的长度,int类型,占4个字节,也就是4位)
bw.Write(bodyByte.Length);
// 4、写入包体
bw.Write(bodyByte);
// 5、发送给客户端
clientStream.Write(ms.ToArray(), 0, (int)ms.Length);
接收时:
// 省略部分代码,这块代码应该包裹在一个方法中
Task.Run(() =>
{
while (true)
{
byte[] body = new byte[1024];
// 数组总长度
int length = ns.Read(body, 0, body.Length);
if (lastPack.Count > 0)
{
// 5、如果之前有半包,则先把半包读完
lastPack.AddRange(body.Take(lagePackRemaining));
count++;
Console.WriteLine(count + ":" + Encoding.UTF8.GetString(lastPack.ToArray(), 0, lastPack.Count));
lastPack.Clear();
UnPack(body, length, lagePackRemaining);
}
else
{
// 1、第一个肯定是没有半包的
UnPack(body, length, 0);
}
}
});
/// <summary>
/// 拆包的方法
/// </summary>
/// <param name="body">字节数组</param>
/// <param name="length">字节数组中的有效位</param>
/// <param name="startIndex">从哪一位开始读取</param>
private void UnPack(byte[] body, int length, int startIndex)
{
// 2、计算单个包的体积
int singleLength = 0;
for (int i = startIndex; i < startIndex + 4; i++)
{
singleLength += body[i] * (int)Math.Pow(256, i - startIndex);
}
// 3. 解析数据
// 如果 开始位 + 单个数据长度 + 4(头位) == 数据有效位 说明这一定是个整包
if (startIndex + singleLength + 4 == length)
{
// 从开始位 + 4 读取数据长度
string msg = Encoding.UTF8.GetString(body, startIndex + 4, singleLength);
Console.WriteLine(++count + ":" + msg);
}
// 如果 开始位 + 单个数据长度 + 4(头位) < 数据有效位 说明这不只是一个包
else if (startIndex + singleLength + 4 < length)
{
// 取出整包
string msg = Encoding.UTF8.GetString(body, startIndex + 4, singleLength);
Console.WriteLine(++count + ":" + msg);
// 读取下一个包,从当前包的结束位开始读取
UnPack(body, length, startIndex + 4 + singleLength);
}
// 否则说明 开始位 + 单个数据长度 + 4(头位) > 数据有效位 说明这是一个半包
else
{
// 半包的话暂时存起来
lastPack.AddRange(body.Skip(startIndex + 4).Take(singleLength));
// 记录这个半包还差几个
lagePackRemaining = singleLength - lastPack.Count;
}
}