如果发送端快速发送多条数据,接收端没有及时调用Receive,那么数据便会在接收端的缓冲区中累积。如图所示,客户端先发送“1、2、3、4”四个字节的数据,紧接着又发送“5、6、7、8”四个字节的数据。等到服务端调用Rec eive时,服务端操作系统已经将接收到的数据全部写入缓冲区,共接收到8个数据。
长度信息法是指在每个数据包前面加上长度信息。每次接收到数据后,先读取表示长度的字节,如果缓冲区的数据长度大于要取的字节数,则取出相应的字节,否则等待下一次数据接收。
每次都以相同的长度发送数据,假设规定每条信息的长度都为10个字符,那么发送“Hello”“Unity”两条信息可以发送成“He llo… ”“Unity… ”,其中的“. ”表示填充字符,是为凑数,没有实际意义,只为了每次发送的数据都有固定长度。接收方每次读取10个字符,作为一条消息去处理。如果读到的字符数大于10,比如第1次读到“He llo…Un”,那它只要把前10个字节“Hello… ”抽取出来,再把后面的两个字节“Un”存起来,等到再次接收数据,拼接第二条信息。
规定一个结束符号,作为消息间的分隔符。假设规定结束符号为“ ”,那么发送“ H e l l o ”“ U n i t y ”两条信息可以发送成“ H e l l o ”,那么发送“Hello”“Unity”两条信息可以发送成“Hello ”,那么发送“Hello”“Unity”两条信息可以发送成“Hello”“ Unity ”。接收方每次读取数据,直到“ ”。接收方每次读取数据,直到“ ”。接收方每次读取数据,直到“”出现为止,并且使用“ ”去分割消息。比如接收方第一次读到“ H e l l o ”去分割消息。比如接收方第一次读到“Hel lo ”去分割消息。比如接收方第一次读到“HelloUn”,那它把结束符前面的Hello提取出来,作为第一条消息去处理,再把“Un”保存起来。待后续读到“ity$”,再把“Un”和“ity”拼成第二条消息。
由于历史遗留问题,字节顺序与存储地址顺序有两种模式
大端:低地址存放高位字节
小端:低地址存放低位字节
例如:数据0x1234,两种模式都是从低地址开始写入数据,高位指的是左边的位数,例如数字123,1是高位,3是地位
类型 | 低地址 | 高地址 |
---|---|---|
大端模式 | 0x12 | 0x34 |
小端模式 | 0x34 | 0x12 |
那么问题就是,不同编码方式下,计算方法不同,那对于不同的计算机,读取出来的数据长度有可能不同,为了统一格式,所有收发数字都采用小端模式
如果使用BitConverter.GetBytes将数字转换成二进制数据,转换出来的数据有可能基于大端模式,也有可能基于小端模式。因为我们规定必须使用小端编码,一个简单的办法是,判断系统是否是小端编码的系统,如果不是,就使用Reverse()方法将大端编码转换为小端编码。以Send为例,代码如下:
//点击发送按钮
public void Send()
{
string sendStr = InputFeld.text;
//组装协议
byte[] bodyBytes = System.Text.Encoding.Default.GetBytes(sendStr);
Int16 len = (Int16)bodyBytes.Length;
byte[] lenBytes = BitConverter.GetBytes(len);
//大小端编码
if(! BitConverter.IsLittleEndian){
Debug.Log("[Send] Reverse lenBytes");
lenBytes.Reverse();
}
//拼接字节
byte[] sendBytes = lenBytes.Concat(bodyBytes).ToArray();
socket.Send(sendBytes);
}
BitConverter.ToInt16中根据系统大小端采用不同的编码方式,如果是小端编码,返回的是(pbyte) | ((pbyte + 1) << 8),如果是大端编码,返回的是(pbyte <<8) | ((pbyte +1))。在下面的代码中,readBuff[0]代表缓冲区的第1个字节,readBuff[1]代表缓冲区的第2个字节,(readBuff[1] << 8)代表将缓冲区第2个字节的数据乘以256,中间的“|”代表逻辑与,在这里等同于相加。
public void OnReceiveData(){
//消息长度
if(buffCount <= 2)
return;
//消息长度
Int16 bodyLength = (short)((readBuff[1] << 8) | readBuff[0]);
Debug.Log("[Recv] bodyLength=" + bodyLength);
//消息体、更新缓冲区
//消息处理、继续读取消息
……
}
假设缓冲区设置得很小,并且网络出现问题,导致Send缓冲区的数据没有发出去,此时又向缓冲区写入数据,当网络恢复时,就会将两次存入的数据一起发出去。第二次写入的数据可能不完整,导致接收方无法解析
要让数据能够发送完整,需要在发送前将数据保存起来;如果发送不完整,在Send回调函数中继续发送数据
//定义发送缓冲区
byte[] sendBytes = new byte[1024];
//缓冲区偏移值
int readIdx = 0;
//缓冲区剩余长度
int length = 0;
//点击发送按钮
public void Send()
{
sendBytes = 要发送的数据;
length = sendBytes.Length; //数据长度
readIdx = 0;
socket.BeginSend(sendBytes, 0, length, 0, SendCallback, socket);
}
//Send回调
public void SendCallback(IAsyncResult ar){
//获取state
Socket socket = (Socket) ar.AsyncState;
//EndSend的处理
int count = socket.EndSend(ar);
readIdx + =count;
length -= count;
//继续发送
if(length > 0){
socket.BeginSend(sendBytes,
readIdx, length, 0, SendCallback, socket);
}
}
上面的方案解决了一半问题,因为调用BeginSend之后,可能要隔一段时间才会调用回调函数,如果玩家在SendCallback被调用之前再次点击发送按钮,按照前面的写法,会重置readIdx和length, SendCallback也就不可能正确工作了。为此我们设计了加强版的发送缓冲区,叫作写入队列(writeQueue)
public void Send() {
sendBytes = 要发送的数据;
writeQueue.Enqueue(ba); //假设ba封装了readbuff、readIdx、length等数据
if(writeQueue只有一条数据){
socket.BeginSend(参数略);
}
}
public void SendCallback(IAsyncResult ar){
count = socket.EndSend(ar);
ByteArray ba = writeQueue.First(); //ByteArray后面再介绍
ba.readIdx+=count; //length的处理略
if(发送不完整){
取出第一条数据,再次发送
}
else if(发送完整,且writeQueue还有数据){
删除第一条数据
取出第二条数据,如有,发送
}
}
每次Send数据以后将数据加入写入队列,判断写入队列只有一条数据时才发送,这样就能保证每次只处理一条数据,避免混乱。
由异步的机制可以知道,BeginSend和回调函数往往执行于不同的线程,如果多个线程同时操作writeQueue,有可能引发些问题。在下图所示的流程中,玩家连续点击两次发送按钮,假如运气特别差,第二次发送时,第一次发送的回调函数刚好被调用。如果线程1的Send刚好走到writeQueue.Enqueue(ba)这一行(t2时刻),按理说writeQueue.Count应为2,不应该进入if(writeQueue.Count == 1)的真分支去发送数据(因为此时writeQueue.Count== 2)。但假如在条件判断之前,回调线程刚好执行了writeQueue.Dequeue()(t3时刻),由于writeQueue里只有1个元素,在t4时刻主线程判断if(writeQueue.Count == 1)时,条件成立,会发送数据。但SendCallback中ba = writeQueue.First()也会获取到队列的第一条数据,也会把它发送出去。第二次发送的数据将会被发送两次,显然不是我们需要的。
在这里插入图片描述
在对写入队列进行操作的时候加一个互斥锁,能保证每次只有一个线程执行该语句
lock(writeQueue){
writeQueue.Enqueue(ba);
count = writeQueue.Count;
}
我这里只是把原书的内容进行了一个概括,方便快速查漏补缺,详细内容请去看原书和查阅相关资料
Unity3D网络游戏实战(第2版)-罗培羽