要想知道为什么在Tcp通讯中会存在分包粘包的现象,首先你必须先了解Tcp网络通讯的消息传播机制,而系统缓冲区将是不得不讲的一个话题,那么什么是系统缓冲区呢?其实就是接到对端信息数据的时候,操作系统会将数据存入到Socket的接收缓冲区中,而在这一段时间,系统缓冲区完全是由操作系统进行操作,程序并不能直接操作它们,只能通过Socket.Receive();Socket.Send等方法来间接进行操作。其中,Socket.Receive()方法只是把接收缓冲区的数据提取出来, 比如调用Receive(readBuff,0,2) , 接收2个字节的数据到了用户缓冲区readbuff,当系统的接收缓冲区为空, Receive方法会被阻塞, 直到里面有数据。同样地, Socket的Send方法只是把数据写入到发送缓冲区里, 具体的发送过程由操作系统负责。当操作系统的发送缓冲区满了, Send方法将会阻塞。
一般有三种方法可以解决粘包和半包问题, 分别是长度信息法、固定长度法和结束符号法。一般的游戏开发会在每个数据包前面加上长度字节, 以方便解析, 本文也将详细介绍这种方法。
长度信息法是指在每个数据包前面加上长度信息。每次接收到数据后, 先读取表示长度的字节, 如果缓冲区的数据长度大于要取的字节数, 则取出相应的字节, 否则等待下一次数据接收。假如客户端要发送“HelloWorld”,那么为了让服务端判断是不是接收到了完整的消息,通常在“HelloWorld”前面加上长度即“10HelloWorld”。加入服务端第一次Receive接收到的是“10Hello”,先读取第一个字节“10”,这时候服务端知道了完整消息的长度,而显然此时消息的长度并不能达到要求,所以服务端不进行任何处理,等待下一次接收。这样就可以保证每次接收到的消息都是完整的。
规定一个结束符号,作为消息间的分隔符假设规定结束符号为"@",那么发送" Hello" “Unity"两条信息可以发送成"Hello@“和“Unity@”接收方每次读取数据,直到”@ ” 出现为止,并且使用“ @ “ 去分割消息。比如接收方第一次读到“Hello@Un”,那它把结束符前面的Hello提取出来,作为第一条消息去处理,再把“Un"保存起来。待后续读到“ ity@". 再把“ Un"和“ ity"拼成第二条消息。
每次都以相同的长度发送数据,假设规定每条信息的长度都为10个字符,那么发送"Hello" “Unity"两条信息可以发送成“ Hello…” “Unity… “,其中的”.”表示填充字符,是为凑数,没有实际意义,只为了每次发送的数据都有固定长度,接收方每次读取10个字符,作为一条消息去处理。如果读到的字符数大于10,比如第1次读到“Hello…Un” , 那它只要把前10个字节“Hello "抽取出来,再把后面的两个字节”Un"存起来,等到再次接收数据,拼接第二条信息。
本文会展示在异步客户端上,实现带有32字节长度信息的协议,来解决粘包问题。用Vs创建控制台应用进行测试长度信息发解决分包粘包问题。
客户端代码:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading.Tasks;
namespace Socket_Package_Test
{
///
/// 客户端
///
class _Client
{
Socket socket;
int BUFFER_SIZE = 1024;//缓冲区的长度
byte[] readBuff;//接收消息的缓冲区
int nowBuffLength = 0;//缓冲区现在的字节长度
byte[] fOntBuff=new byte[sizeof(Int32)];//接收到消息数组的长度的数组
public _Client() {
readBuff = new byte[BUFFER_SIZE];
StartClient();
}
//开始启动客户端
void StartClient() {
Console.WriteLine("开始启动客户端");
socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
IPAddress ipAdr = IPAddress.Parse("127.0.0.1");
IPEndPoint end = new IPEndPoint(ipAdr, 1234);
socket.Connect(end);
try
{
socket.BeginReceive(readBuff,nowBuffLength,BUFFER_SIZE-nowBuffLength,SocketFlags.None,ReceiveCb,socket);
}
catch (Exception)
{
//throw;
}
}
///
/// 接收消息的回调函数
///
///
void ReceiveCb(IAsyncResult ar) {
Socket listen = (Socket)ar.AsyncState;
try
{
//本次接收到数据的字节长度
int count = listen.EndReceive(ar);
//现在缓存区中数据的字节长度
nowBuffLength += count;
//处理接收到的数据
HandleDate(listen);
//继续回调接收消息
listen.BeginReceive(readBuff, nowBuffLength, BUFFER_SIZE-nowBuffLength, SocketFlags.None, ReceiveCb, listen);
}
catch (Exception e)
{
// throw;
}
}
///
/// 处理接收到的数据
///
///
void HandleDate(Socket listen) {
if (nowBuffLength
return;
}
//消息头长度的数组更新
Array.Copy(readBuff,fontBuff,sizeof(Int32));
//消息头(一段消息的长度)
int receiveLength = BitConverter.ToInt32(fontBuff,0);
if (nowBuffLength
//一段话没有接收完整,等待下次一块处理
return;
}
//解析出一条消息
string str = UTF8Encoding.UTF8.GetString(readBuff,sizeof(Int32),receiveLength);
Console.WriteLine("接收到服务端的消息:"+str);
Console.WriteLine("客户端回复:");
string s = Console.ReadLine();
if (s != "")
{
//服务器发送消息
SendDate(listen, s);
}
//清除掉已经处理过的数据
int remainCount = nowBuffLength - sizeof(Int32) - receiveLength;
//把剩余没有处理的本次接收到的数据重新拷贝到缓存区
Array.Copy(readBuff,receiveLength+sizeof(Int32),readBuff,0,remainCount);
//缓冲区现在的数据长度
nowBuffLength = remainCount;
if (nowBuffLength>0)
{
HandleDate(listen);
}
}
///
/// 发送消息
///
///
///
void SendDate(Socket listen,string str) {
//消息的内容数组(消息体)
byte[] sendDate = UTF8Encoding.UTF8.GetBytes(str);
//消息体的字节长度
int dateLength = sendDate.Length;
//要发送消息的长度的数组(消息头)
byte[] length = BitConverter.GetBytes(dateLength);
//消息头和消息体进行拼接
byte[] bytes = length.Concat(sendDate).ToArray();
listen.Send(bytes);
}
}
}
服务端代码:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading.Tasks;
namespace Socket_Package_Test
{
///
/// 服务端
///
class _Socket
{
Socket socket;
int BUFFER_SIZE = 1024;//缓冲区的长度
byte[] readBuff ;//接收消息的缓冲区
int nowBuffLength=0;//缓冲区现在的字节长度
byte[] fOntBuff=new byte[sizeof(Int32)];//接收到消息数组的长度的数组
int fOntLenth= sizeof(Int32);//消息头占用的字节长度
int receiveDateLength;//接收到消息的长度
public _Socket() {
readBuff = new byte[BUFFER_SIZE];
StartServer();
}
int maxListen = 50;
///
/// 开启服务器
///
void StartServer()
{
Console.WriteLine("开始启动服务器");
socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
IPAddress ipAdr = IPAddress.Parse("127.0.0.1");
IPEndPoint end = new IPEndPoint(ipAdr, 1234);
socket.Bind(end);
socket.Listen(maxListen);
try
{
socket.BeginAccept(AcceptCb, null);
}
catch (Exception)
{
// throw;
}
}
///
/// BeginAccept的回调
///
///
void AcceptCb(IAsyncResult ar)
{
Socket listen = socket.EndAccept(ar);//直到有人连接服务器 返回连接者的Socket
Console.WriteLine(listen.RemoteEndPoint+"进入房间");
SendDate(listen,"欢迎您进入房间");
//开始接收消息
listen.BeginReceive(readBuff,nowBuffLength,BUFFER_SIZE-nowBuffLength,SocketFlags.None,ReceiveCb,listen);
socket.BeginAccept(AcceptCb, null);
}
///
/// 接收消息的回调
///
///
void ReceiveCb(IAsyncResult ar) {
Socket listen = ar.AsyncState as Socket;
try
{
//本次接收到的数据长度
int count = listen.EndReceive(ar);
//现在的缓冲区长度增加新接收的长度
nowBuffLength += count;
//处理接收的数据
HandleDate(listen);
//循环接收消息
listen.BeginReceive(readBuff, nowBuffLength, BUFFER_SIZE-nowBuffLength, SocketFlags.None, ReceiveCb, listen);
}
catch (Exception)
{
// throw;
}
}
///
/// 处理服务器接收的消息
///
void HandleDate(Socket listen) {
if (nowBuffLength<=fontLenth)//缓冲区中的数据长度小于四个字节
{
return;
}
//消息头长度的数组更新
Array.Copy(readBuff,fontBuff,fontLenth);
//消息头(一段消息的长度)
receiveDateLength = BitConverter.ToInt32(fontBuff,0);
if (nowBuffLength
return;
}
//解析出一条消息
string str = UTF8Encoding.UTF8.GetString(readBuff,sizeof(Int32),receiveDateLength);
Console.WriteLine("收到客户端消息:"+str);
Console.WriteLine("服务端回复:");
string s = Console.ReadLine();
if (s!="")
{
//服务器发送消息
SendDate(listen, s);
}
int remainCount = nowBuffLength-sizeof(Int32)-receiveDateLength;
//把剩余没有处理的本次接收到的数据重新拷贝到缓存区
Array.Copy(readBuff,sizeof(Int32)+ receiveDateLength, readBuff,0,remainCount);
//缓冲区现在的数据长度
nowBuffLength = remainCount;
if (nowBuffLength>0)//如果缓冲区还有数据
{
HandleDate(listen);
}
}
///
/// 发送消息
///
///
///
void SendDate(Socket listen, string str) {
//消息的内容数组(消息体)
byte[] Date = UTF8Encoding.UTF8.GetBytes(str);
//消息体的字节长度
int sendLength = Date.Length;
//要发送消息的长度的数组(消息头)
byte[] sendDateLength = BitConverter.GetBytes(sendLength);
//消息头和消息体进行拼接
byte[] sendBytes = sendDateLength.Concat(Date).ToArray();
//发送给客户端
listen.Send(sendBytes);
}
}
}
上面的处理方式基本上解决了分包粘包的问题,但是还是存在一些问题,例如:大端小端问题、线程冲突问题。这些问题本片文章先不进行处理。除了这些问题之外还存在一些其他的不足之处,例如:在Copy操作的时候,每次成功接收一条完整的数据后,程序会调用Array.Copy,将缓冲区的数据往前移动。但Array.Copy是个时间复杂度为o(n)的操作,假如缓冲区中的数据很多,那移动全部数据将会花费较长的时间。一个可行的办法是,使用ByteArray结构作为缓冲区,使用readldx指向的数据作为缓冲区的第一个数据,当接收完数据后,只移动readldx,时间复杂度为o(l),当然,肯定还有一些其它的问题,这里就不一一列举了。