《Unity3D网络游戏实战》 TCP数据流、BytArray类

一、TCP数据流

1、系统缓冲区

        当收到对端数据时,操作系统会将数据存入到Socket的接收缓冲区中。操作系统层面上的缓冲区完全由操作系统操作,程序并不能直接操作它们,只能通过socket.Receive、socket.Send等方法来间接操作。

        Socket的Receive方法只是把接收缓冲区的数据提取出来,比如调用Receive(readBuff,0,2),接收2个字节的数据到readbuff(byte数组)。当系统的接收缓冲区为空,Receive方法会被阻塞,直到里面有数据。

2、粘包半包

        如果发送端快速发送多条数据,接收端没有及时调用Receive,那么数据便会在接收端的缓冲区中累积。假如客户端先发送“1、2、3、4”四个字节的数据,紧接着又发送“5、6、7、8”四个字节的数据。等到服务端调用Receive时,服务端操作系统已经将接收到的数据全部写入缓冲区,共接收到8个数据。所以这会导致有时候将两条消息当成一条消息来处理——粘包

        发送端发送的数据还有可能被拆分,如发送“HelloWorld”,但在接收端调用Receive时,操作系统只接收到了部分数据,如“Hel”,在等待一小段时间后再次调用Receive才接收到另一部分数据“loWorld”。这会导致有时候将一条消息当成两条消息来处理!——半包

二、解决粘包问题的方法

1、长度消息法

        长度信息法是指在每个数据包前面加上长度信息。每次接收到数据后,先读取表示长度的字节,如果缓冲区的数据长度大于要取的字节数,则取出相应的字节,否则等待下一次数据接收。
        游戏程序一般会使用16位整型数或32位整型数来存放长度信息,16位整型数的取值范围是0~65535,32位整型数的取值范围是0~4294967295。对于大部分游戏,网络消息的长度很难超过65535字节,使用16位整型数来存放长度信息较合适。

16位消息长度格式

2、固定长度法

         每次都以相同的长度发送数据,假设规定每条信息的长度都为10个字符,那么发送“Hello”“Unity”两条信息可以发送成“Hello...”“Unity...”,其中的“.”表示填充字符,是为凑数,没有实际意义,只为了每次发送的数据都有固定长度。

3、结束符号法

         规定一个结束符号,作为消息间的分隔符。假设规定结束符号为“$”,那么发送“Hello”“Unity”两条信息可以发送成“Hello$”“Unity$”。接收方每次读取数据,直到“$”出现为止,并且使用“$”去分割消息。

三、大小端问题 

        常用的X86结构是小端模式,很多的ARM、DSP都为小端模式,但KEIL C51则为大端模式,有些ARM处理器还可以由硬件来选择是大端模式还是小端模式。也就是说市面上的手机有些采用大端模式,有些采用小端模式。

大端模式????

        大端模式还原这个数字的步骤是:

  • 拿到第1个字节的数据00000001,乘以进制位256(2的8次方),得到256,即第1个字节(低地址)代表了十进制数字256;
  • ·拿到第2个字节的数据00000010,它代表十进制数字2,乘以进制位1,得到2;
  • ·将前两步得到的数字相加,即256+2,得到258,还原出数字。
小端模式

        小端模式还原这个数字的步骤是:

  • ·拿到第2个字节的数据00000001,乘以进制位256(2的8次方),得到256,即第2个字节(高地址)代表了十进制数字256。
  • ·拿到第1个字节的数据00000010,它代表十进制数字2,乘以进制位1,得到2。
  • ·将前两步得到的数字相加,即256+2,得到258,还原出数字。

        为了兼容所有的机型,我们规定,写入缓冲区的数字,必须按照小端模式来存储。有两种方法可以做到大小端兼容,下面分别介绍。

1、使用Reverse()函数兼容大小端代码

        如果使用BitConverter.GetBytes将数字转换成二进制数据,转换出来的数据有可能基于大端模式,也有可能基于小端模式。因为我们规定必须使用小端编码,一个简单的办法是,判断系统是否是小端编码的系统,如果不是,就使用Reverse()方法将大端编码转换为小端编码。

2、手动还原数值

        以接收数据为例。在下面的代码中,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);
    //消息体、更新缓冲区
    //消息处理、继续读取消息
    ……
}
 

四、ByteArray类

        ByteArray是封装byte[]、readIdx和length的类,使用了长度消息法解决粘包问题、手动还原数组解决大小端问题。

using System;

public class ByteArray
{
    //默认大小
    const int DEFAULT_SIZE = 1024;
    //初始大小
    int initSize = 0;
    //缓冲区
    public byte[] bytes;
    //读写位置
    public int readIdx = 0;//表示在bytes数组中读取数据的下标
    public int writeIdx = 0;//表示在bytes数组中写入数据的下标
    //容量
    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 byte[size];
        capacity = size;
        initSize = size;
        readIdx = 0;
        writeIdx = 0;
    }

    //构造函数
    public ByteArray(byte[] defaultBytes)
    {
        bytes = defaultBytes;
        capacity = defaultBytes.Length;
        initSize = defaultBytes.Length;
        readIdx = 0;
        writeIdx = defaultBytes.Length;
    }

    //重设尺寸,剩余空间小于需要写入数据大小时调用。
    public void ReSize(int size)
    {
        if (size < length) return;
        if (size < initSize) return;
        int n = 1;
        while (n < size) n *= 2;//虽然传入的size不一定是2的指数倍,这里会找到距离size最近的2的指数倍作为重设尺寸
        capacity = n;
        byte[] newBytes = new byte[capacity];
        //Copy函数表示把bytes数组的readIdx位开始,复制到newBytes数组的0位开始,复制的长度位writeIdx - readIdx
        Array.Copy(bytes, readIdx, newBytes, 0, writeIdx - readIdx);//将原本的缓冲区copy到新的缓存区内。
        bytes = newBytes;//把新数组赋值给bytes
        //进行一系列操作之后,readIdx就又到第0位了,writeIdx就等于未读取数据的长度了
        writeIdx = length;
        readIdx = 0;
    }

    
    //在某些情形下,例如有效数据长度很小(这里设置为8),或者数据全部被读取时(readIdx==writeIdx),
    //可以将数据前移,增加remain,避免bytes数组过长。由于数据很少,程序执行的效率不会有影响。
    //检查并移动数据
    public void CheckAndMoveBytes()
    {
        //因为写入数据的时候是从writeIdx下标位置开始写的,bytes数组迟早会满,
        //然后readIdx下标前面的数据是已经读取过的了,是无效数据了,所以可以在适当的时候覆盖掉
        if (length < 8)
        {
            MoveBytes();
        }
    }

    //移动数据
    public void MoveBytes()
    {
        Array.Copy(bytes, readIdx, bytes, 0, length);
        writeIdx = length;
        readIdx = 0;
    }

    /// <summary>
    /// 写入数据
    /// </summary>
    /// <param name="bs">写入的数据数组</param>
    /// <param name="offset">从bs数组的第几位开始写入</param>
    /// <param name="count">写入的长度</param>
    /// <returns></returns>
    public int Write(byte[] bs, int offset, int count)
    {
        //如果剩余的长度不够,则需要重新设置bytes大小。
        if (remain < count)
        {
            ReSize(length + count);
        }
        //将bs数组对应的部分copy到bytes数组的writeidx下标处开始
        Array.Copy(bs, offset, bytes, writeIdx, count);
        writeIdx += count;//writeIdx后移
        return count;
    }

    /// <summary>
    /// 读取数据
    /// </summary>
    /// <param name="bs">将读取的数据写入的数组</param>
    /// <param name="offset">把数据写入到offset的第几位</param>
    /// <param name="count">需要读取数据的大小</param>
    /// <returns></returns>
    public int Read(byte[] bs, int offset, int count)
    {
        count = Math.Min(count, length);
        Array.Copy(bytes, 0, bs, offset, count);
        readIdx += count;//readIdx后移
        CheckAndMoveBytes();//检查并移动数据
        return count;
    }

    //读取Int16,本案例使用16整形来存放长度信息
    public Int16 ReadInt16()
    {
        //如果长度小于0,说明长度消息不完整,直接return
        if (length < 2) return 0;
        //通过前两位字节,获取长度信息
        Int16 ret = (Int16)((bytes[1] << 8) | bytes[0]);
        readIdx += 2;//readIdx后移两位
        CheckAndMoveBytes();//每次readIdx增加时,都可以check一下
        return ret;
    }

    //读取Int32,
    public Int32 ReadInt32()
    {
        if (length < 4) return 0;
        Int32 ret = (Int32)((bytes[3] << 24) |(bytes[2] << 16) |(bytes[1] << 8) |bytes[0]);
        readIdx += 4;
        CheckAndMoveBytes();
        return ret;
    }


    //打印缓冲区(仅为调试)
    public override string ToString()
    {
        return BitConverter.ToString(bytes, readIdx, length);
    }

    //打印调试信息(仅为调试)
    public string Debug()
    {
        return string.Format("readIdx({0}) writeIdx({1}) bytes({2})",
            readIdx,
            writeIdx,
            BitConverter.ToString(bytes, 0, bytes.Length)
        );
    }
}