Unity 网络编程-正确收发数据流

发布时间 2023-07-20 20:27:50作者: CatSevenMillion

1.TCP数据流

  我们知道在使用Socket网络程序时,操作系统会将数据存到发送接收缓存中。程序不能直接操作它们,只能通过socket.Receive, socket.Send等方法来间接操作。

  在使用以上方法时,如果接收缓存为空,那Receive方法会阻塞。如果发送缓存满了则Send方法会阻塞。

  粘包半包现象

    如果发送端快速发送多跳信息,但是接收端没有及时的调用Receive,那数据就会在接收缓存中累计。

    粘包:假设发送队列中一开始发送 {1,2,3,4}这四个字节的数据,然后发送{5,6,7,8}。等到服务端调用Receive时只调用了一次,那么此时的接收缓存就会变成{1,2,3,4,5,6,7,8}。

    半包/分包: 假设发送{HelloWorld},但是接收队列可能要接收两次,分别接收{Hel},{loWorld}。

    由于TCP是基于流发送的,所以以上现象是很正常的现象,但是我们不想要这样,直觉告诉我们我们应该发一个包就收一个包才不会弄混淆数据。

2.解决粘包问题

  一般有三种方法可以解决该问题:长度信息法,固定长度法,结束符法。

  1.长度信息法

    长度信息法的意思是在每个数据包之前加上长度信息,每次接收数据包后先读取表示长度的字节。然后取出响应的字节,否则继续等待数据接受。

  2.固定长度法

    每次都以相同的长度发送数据。。例如发送{Hello}{World},发送长度为5。如果接收到{Hello...Wo},...Wo就会被存起来等待下次接收数据。而又因为...为填充符,所以舍弃。即只保留Wo等待下一个包再拼接在一起。

  3.结束符法

    规定一个结束字符,用来分割消息。例如$ ,发送{Hello$}{World$}。接收到$为止,有多余的数据等到下一次接收后再拼接。

实现:游戏一般会使用长度信息法用于解决该问题

  发送: 假设要发送{HelloWorld},实际发送变成{0AHelloWorld},0A表示长度10。

public void Send(string sendStr){
    // 组装协议
    byte[] bodyBytes = System.Text.Encoding.Default.GetBytes(sendStr);
    Int16 len = (Int16)bodyBytes.Length;
    byte[] lenBytes = BitConverter.GetBytes(len);
    byte[] sendBytes = lenBytes.Concat(bodyBytes).ToArray();
    // 简洁代码同步发送
    socket.Send(sendBytes)
}

  接收:接收的情况比较复杂,首先应该定义一个接收缓存区(readBuff)和缓存区有效数据长度(buffCount)。

    原因是有可能会出现粘包问题,例如接收到{HelloWo},这是有效数据长度就是7。这样我们既可以处理分包问题,用7减去长度5。或者是解决分包问题,下一次接收数据从7开始接收。

    对于缓存区的长度也有一下问题:

    1.缓冲区长度小于等于2(2字节代表长度位):消息太短就不处理,直接return。

    2.缓冲区长度大于2,但还不足以构成一条消息:如果不足以构成完整的信息,那就等待下一次接收。

    3.缓冲区大于等于一条长度:那就解析出消息,然后让后面的字节往前移动。

public void OnReceiveData(){
    //消息长度
    if(buffCount <=2) return;
    Int16 bodyLength = BitConverter.ToInt16(readBuff,0);
    //消息体
    if(buffCount <2+bodyLength) return;
    string s = System.Text.Encoding.UTF8.GetString(readBuff,2,buffCount);
    // s 消息内容
    // 更新缓冲区
    int start = 2 +bodyLength;
    int count = buffCount - start;
    Array.Copy(readBuff,start,readBuff,0,count);
    buffCount -=start;
    // 继续读取消息
    if(readBuff.length>2){
        OnReceiveData();
    }
}

3.大端小端问题

   我们在前面解决粘包分包问题,使用,在计算数据长度时我们使用的是BitConvert.ToInit16。

而.Net中该方法的底层简化为如下:

public static short ToInt16(byte[] value,int startindex){
    if(startIndex%2 ==0){
       return *((short*)pbyte);
    }else{
        if(IsLittleEndian) return (short)((*pbyte)|(*(pbyte+1)<<8));
    }
    ....    
}

  其中,IsLittleEndian代表计算机是大端编码还是小端编码。不同的编码方式也会不同。所以使用该方法计算出来的消息长度也会不同。

  解决:为了兼容所有的设备,我们一般规定写入的数字必须按照小端的模式来存储。

public void Send(string sendStr){
    byte[] bodyBytes = System.Text.Encoding.Default.GetBytes(sendStr);
    Int16 len = (Int16)bodyBytes.Length;
    byte[] lenBytes = BitConverter.GetBytes(len);
    // 大端小端编码
    if(!BitConverter.IsLittleEndian){
        Debug.Log("Change")
        lenBytes = lenBytes.Reverse();
    }  
    byte[] sendBytes = lenBytes.Concat(bodyBytes).ToArray();
    socket.Send(sendBytes)
}

4.发送完整数据

   在Send方法中,会把发送的数据存入操作系统的发送缓冲区,然后返回成功写入的字节数。即对于那些没有成功发送的数据,程序需要保存起来,再适当的时机再次发送。在大部分情况下,Send发送部分数据的情况并不是很多,但是以防万一,我们也需要对这种情况处理。

  为了让数据能够发送完整,需要在发送前将数据保存起来;如果发送不完整,在Send回调函数中继续发送数据。

byte[] sendBytes = new byte[2014];
// 缓冲区偏移量
int readIdx =0;
//缓冲区剩余长度
int length =0;

//点击发送数据
public void Send(){
    sendBytes = 数据
    length = sendByte.Length;
    readIdx = 0;
    socket.BeginSend(sendBytes,0,length,0,SendCallback,socket);  //
}

public void SendCallback(IAsyncResult ar){
    Socket socket = (Socket)ar.AsyncState;
    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可能就不能再继续工作。所以我们要解决这个问题就要设计一个加强版的缓冲区,叫做写入队列。(队列的写入操作是O(1),如果使用大数组实现性能也没有队列高)

  即采用一个队列的形式存放写入缓存。当回调函数返回成功时才会将一个缓存推出队列。

  数据结构定义如下:

public class ByteArray {
    public byte[] bytes;
    public int readIdx =0;
    public int writeIdx =0;
    public int length{ get{return writeIdx-readIdx; } };

    public ByteArray(byte[] defaultArray){
        bytes = defaultBytes;
        readIdx = 0;
        writeIdx = defaultArray.Length;
    }
}

  线程冲突问题:通过异步的机制我们可以知道,BeginSend和回调不在一个线程上,那就有可能会发生线程冲突的问题。要解决该问题也很简单,我们可以通过加锁(Lock)的方式解决。使用时注意把临界区设置的尽可能小,以提高性能。

5.高效的接收数据

  在之前的代码中,我们接收数据使用了Copy函数,这个函数的时间复杂度是On。加入缓冲区的数据很多,那移动全部数据会花费比较长的时间。

  可行的解决办法:使用ByteArray作为缓冲区,当读取数据结束时只用移动readIdx。当缓冲区长度不够时才会再使用Array.Copy重置readIdx和writeIdx。同时还需要为缓冲区设置自动扩展的功能,以防网络堵塞导致缓冲区满。

  为满足上述,数据结构如下:

public class ByteArray {    
// 默认大小 const int DEFAULT_SIZE = 1024; // 初始大小 int initSize = 0; // 缓冲区 public byte[] bytes; // 读写位置 public int readIdx =0; public int writeIdx =0; // 容量 private int capacity = 0; // 剩余空间 public int remain { get{ return capacity - writeIdx}} // 数据长度 public int length{ get{return writeIdx-readIdx; } }; // 构造函数 public ByteArray(int size = DEFAULT_SIZE){ bytes = new bytes[size]; capacity = size; initSize = size; readIdx = 0; writeIdx = defaultArray.Length; } // 重写 构造函数 public ByteArray(byte[] defaultBytes){ bytes = defaultBytes; capacity = defaultBytes.Length; initSize = defaultBytes.Length; readIdx = 0; writeIdx = defaultArray.Length; } // 重设尺寸 public void Resize(int size){   if(size<length) return; if(size<initSize) return;      int n =1; while(n<size) n*=2; capacity = n; byte[] newBytes = new byte[capacity]; Array.Copy(bytes,readIdx,newBytes,0,writeIdx-readIdx); bytes = newBytes; writeIdx = length; readIdx =0; } }