Socket 完整发送和高效接受数据流

一 、完整发送数据

Send方法会把要发送的数据存入操作系统的发送缓冲区,然后返回成功写入的字节数。这句话的另一层含义是,**对于那些没有成功发送的数据,程序需要把它们保存起来,在适当的时机再次发送。**由于在网络通畅的环境下,Send只发送部分数据的概率并不高,但是也有小概率情况发生,需要解决该问题。

1.1 不完整发送示例

以异步聊天客户端为例,假设操作系统缓冲区被设置得很小,只有8个字节,再假设网络环境很差,缓冲区的数据没能及地的发送出去。如图所示,假设客户端发送字符串“hero”,发送后,Send返回6(包含两字节的长度),数据全部存入操作系统缓冲区中。但此时网络拥堵,TCP尚未把数据发送给服务端。此时,客户端又发送了字符串“cat”,由于操作系统的发送缓冲区只剩下2字节空位,只有代表数据长度的“03”被写入缓冲区(图4-27步骤②)。此时,网络环境有所改善,TCP成功把缓冲区的数据发送给服务端,操作系统缓冲区被清空,如图4-27步骤③所示。稍后,客户端又发送了字符串“hi”,数据成功发送。

在这里插入图片描述

对于服务端而言,接收到的数据是“04hero0302hi”,第一个字符串“hero”可以被解析,但对于后续的“0302hi”,服务端会解析成一串3个字节的数据“02h”,以及不完整的长度信息“i”。“04hero”往后的数据全部无法解析,通信失败。

说人话:在操作系统缓存区很小,网络环境很差的情况下。消息体不完整无法解析“0302hi”导致通信失败

1.2 如何解决发送不完整问题

要让数据能够发送完整,需要在发送前将数据保存起来;如果发送不完整,在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);
            }
        }

代码解析:

  //定义发送缓冲区
        byte[] sendBytes = new byte[1024];
        //缓冲区偏移值
        int readIdx = 0;
        //缓冲区剩余长度
        int length = 0;

•定义发送缓冲区

•readIdx表示读取位置;length表示缓冲区中数据长度

  //点击发送按钮
        public void Send()
        {
            sendBytes = 要发送的数据;
            length = sendBytes.Length;       //数据长度
            readIdx = 0;
            socket.BeginSend(sendBytes, 0, length, 0, SendCallback, socket);
        }

•发送函数

•获得发送数据的长度

•调用开始发送方法,参数为发送缓冲区(sendBytes)、发送数据的开始索引(0)、发送数据的最大长度(length)、回调函数(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);
            }
        }

•发送回调函数

•结束发送处理。读取索引的值增加为已经发送消息的长度;发送缓存区数据长度减去已经发送消息的长度。

•继续发送。如果发送缓存区还有数据,再调用开始发送方法。

一步一步来解析上面的代码。假如要发送的数据是“08hellolpy”,在调用BeginSend时,缓冲区sendBytes的数据如图4-28所示。

图解:

在这里插入图片描述

假设Socket只发送了6个数据,即发送了“08hell”,在SendCallback中,count返回6,程序会调整readIdx和length,使缓冲区相关的数据如图4-29所示。

在这里插入图片描述

此时length>0,于是程序再次调用BeginSend,发送剩余的数据。BeginSend的参数解释如下:

        socket.BeginSend(sendBytes,       //发送缓冲区
                          readIdx,        //从索引为6的数据开始发送
                          length,         //因为缓冲区只剩下4个数据,最多发送4个数据
                          0,              //标志位,设置为0即可
                          SendCallback,   //回调函数
                          socket);        //传给回调函数的对象

如果再次调用的BeginSend能够把数据发完,那万事大吉。如果不能完整发送,第二次BeginSend的回调函数也会把剩余的数据发送出去。

缓存区中有多条数据,用读取索引(readIdx)来获得应该发送的数据的开始位置

1.3 写入队列

上面的方案解决了一半问题,因为调用BeginSend之后,可能要隔一段时间才会调用回调函数,如果玩家在SendCallback被调用之前再次点击发送按钮,按照前面的写法,会重置readIdx和length, SendCallback也就不可能正确工作了。为此我们设计了加强版的发送缓冲区,叫作写入队列(writeQueue)。

说人话:为了防止再次点击发送重置readIdx和length设计写入队列,让每条消息都有readIdx和length

在这里插入图片描述

上图展示了一个包含三个缓冲区的写入队列,当玩家点击发送按钮时,数据会被写入队列的末尾,比如一开始发送“08hellolpy”,那么就在队列里添加一个缓冲区,这个缓冲区和本节前面介绍的缓冲区一样,包含一个bytes数组,以及指向缓冲区开始位置的readIdx、缓冲区剩余长度的length。Send方法会做这样的处理,示意代码如下:

代码解析:

        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还有数据){
                删除第一条数据
                取出第二条数据,如有,发送
            }
        }

说人话:

 sendBytes = 要发送的数据;
 writeQueue.Enqueue(ba);     //假设ba封装了readbuff、readIdx、length等数据

•把ba数据写入到写入(Enqueue)队列(writeQueue)的末尾。

 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还有数据){
                删除第一条数据
                取出第二条数据,如有,发送
            }
        }

发送回调函数

•获得已经发送消息的长度。

•ba为写入队列的第一个元素。ba的读取索引增加已经发送的长度。

•发送不完整,取出第一条数据再次发送。

•发送完整,但是写入队列还有数据,删除第一条数据,取出第二条数据,如有,发送。

图解:

我们以一个例子来说明这个过程。假设玩家发送的第一条数据是“08hellolpy”,调用writeQueue.Enqueue把数据写入writeQueue末尾,因为此时writeQueue为空,即写入第一条数据。此时的写入队列如图4-31所示。因为队列只有一条数据,程序会调用socket. BeginSend将第一条数据发送出去。

在这里插入图片描述

假设BeginSend的回调方法尚未返回,玩家又发送了第二条数据“02hi”,程序会把数据写入writeQueue末尾,形成图4-32所示的队列。由于此时发送队列有两条数据,不会调用BeginSend。这样做的目的是控制发送的数据,不同时发送多条数据,导致混乱。

说人话:写入队列有一个以上数据,不会直接发送,防止同时发送多条数据

在这里插入图片描述

假如第一次BeginSend的回调函数被调用,成功发送了6个数据,调整readindex和length后,写入队列,如图所示。

说人话:第一次回调函数被调用,说明第一条消息已经发送成功了

在这里插入图片描述

此时会进入“if(发送不完整)”的真分支,重新调用BeginSend发送数据,假如这次把数据都发送出去了,会进入“if(发送不完整)”的假分支,删除第一条数据。此时的写入队列只剩下第二条数据,如图所示。

说人话:在第一次发送数据回调函数还没执行的时候,再次点击发送按钮,那么此时回调函数中会检测到多条消息。这时候就会进入if(发送不完整)分支,删除第一条数据,也就是只剩下第二条数据。

在这里插入图片描述

程序会取出“02hi”这条数据,然后调用BeginSend发送出去,直到缓冲区被清空。如此往复,可以保证完整地发送数据。

以上过程涉及两个结构,**分别是封装byte[]、readIdx和length的缓冲区ByteArray,以及队列Queue。**这

1.3.1 ByteArray

由上一节可知,ByteArray是封装byte[]、readIdx和length的类,可以这样定义它(添加文件ByteArray.cs):

        using System;

        public class ByteArray  {
            //缓冲区
            public byte[] bytes;
            //读写位置
            public int readIdx = 0;
            public int writeIdx = 0;
            //数据长度
            public int length { get { return writeIdx-readIdx; }}

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

说人话:

•字节数组类成员属性有缓冲区数组、读位置索引、写位置索引。

•数据长度是一个只读的属性。

•构造函数 将字节数组作为参数设为缓冲区数组;将读位置索引设为0;将写位置索引设为参数长度。

在这里插入图片描述

1.3.2 Queue

C#提供了一种队列数据结构Queue。和List一样,它是一种容器,使用示例如下面代码所示。常用的有Enqueue、DequeueFirst三个方法,其中:Enqueue代表把元素放入到队列中,该元素会放到队列的末尾;Dequeue代表出列,队列的第一个元素被弹出来;First代表获取队列的第一个元素。

ByteArray和Queue代码示例

        //定义
        Queue<ByteArray> writeQueue = new Queue<ByteArray>();

        //点击发送按钮
        public void Send()
        {
            //拼接字节,省略组装sendBytes的代码
            byte[] sendBytes = 要发送的数据;
            ByteArray ba = new ByteArray(sendBytes);
            writeQueue.Enqueue(ba);
            //send
            if(writeQueue.Count == 1){
                socket.BeginSend(ba.bytes, ba.readIdx, ba.length,
                    0, SendCallback, socket);
            }
        }

        //Send回调
        public void SendCallback(IAsyncResult ar){
            //获取state、EndSend的处理
            Socket socket = (Socket) ar.AsyncState;
            int count = socket.EndSend(ar);
            //判断是否发送完整
            ByteArray ba = writeQueue.First();
            ba.readIdx+=count;
            if(ba.length == 0){   //发送完整
                writeQueue.Dequeue();
                ba = writeQueue.First();
            }
            if(ba ! = null){        //发送不完整,或发送完整且存在第二条数据
                socket.BeginSend(ba.bytes, ba.readIdx, ba.length,
                    0, SendCallback, socket);
            }
        }

说人话:

//定义
        Queue<ByteArray> writeQueue = new Queue<ByteArray>();

•定义且实例化一个队列(Queue)的数据结构writeQueue,元素的类型是ByteArray。

  //点击发送按钮
        public void Send()
        {
            //拼接字节,省略组装sendBytes的代码
            byte[] sendBytes = 要发送的数据;
            ByteArray ba = new ByteArray(sendBytes);
            writeQueue.Enqueue(ba);
            //send
            if(writeQueue.Count == 1){
                socket.BeginSend(ba.bytes, ba.readIdx, ba.length,
                    0, SendCallback, socket);
            }
        }

•发送数据函数

•实例化一个ByteArray类ba

•将ba元素放入writeQueue

•判断是否写入队列的长度是否为1,是则发送数据,不是则在回调中发送。

问题:为什么要进行判断长度是否为1?

回答:短时间内点击多次发送,会重置readInx和length导致通信失败,只有当写入队列中的消息为1时代表可以发送,因为只有一条数据,不存在重置readInx和length。而写入队列消息大于1的时候,不在Send方法中发送,而是在其回调函数中进行发送。也就是在回调中判断写入队列是否存在消息未发送,存在则继续发送队列中第一条消息。

 //Send回调
        public void SendCallback(IAsyncResult ar){
            //获取state、EndSend的处理
            Socket socket = (Socket) ar.AsyncState;
            int count = socket.EndSend(ar);
            //判断是否发送完整
            ByteArray ba = writeQueue.First();
            ba.readIdx+=count;
            if(ba.length == 0){   //发送完整
                writeQueue.Dequeue();
                ba = writeQueue.First();
            }
            if(ba ! = null){        //发送不完整,或发送完整且存在第二条数据
                socket.BeginSend(ba.bytes, ba.readIdx, ba.length,
                    0, SendCallback, socket);
            }
        }

•发送回调函数

•通过接口的AsyncState属性获得socket

•如果写入队列的第一个字节数组(发送缓存区)长度等于0,说明发送完整,删除该字节数组。

问题:在什么时候会改变发送缓存区的字节长度?

回答:是调用socket.BeginSend(ba.bytes, ba.readIdx, ba.length,
0, SendCallback, socket)方法的时候,会从读索引位置开始发送数据。

1.4 解决线程冲突

由异步的机制可以知道,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()也会获取到队列的第一条数据,也会把它发送出去。第二次发送的数据将会被发送两次,显然不是我们需要的。

说人话:第一条数据已经成功发送后,调用回调时会再次发送第二次发送的数据(因为ba ! = null),然后第二次发送再发送一次数据,总共发送了两次第二次发送的数据。

在这里插入图片描述

为了避免线程竞争,可以通过加锁(lock)的方式处理。当两个线程争夺一个锁的时候,一个线程等待,被阻止的那个锁变为可用。关于锁的介绍,读者可以去网上搜寻更多资料。加锁后的代码如下:

        //发送缓冲区
        Queue<ByteArray> writeQueue = new Queue<ByteArray>();

        //点击发送按钮
        public void Send()
        {
            //拼接字节,省略组装sendBytes的代码
            byte[] sendBytes = 要发送的数据;
            ByteArray ba = new ByteArray(sendBytes);
            int count = 0;
            lock(writeQueue){
                writeQueue.Enqueue(ba);
                count = writeQueue.Count;
            }
            //send
            if(count == 1){
                socket.BeginSend(sendBytes, 0, sendBytes.Length,
                    0, SendCallback, socket);
            }
            Debug.Log("[Send]" + BitConverter.ToString(sendBytes));
        }

        //Send回调
        public void SendCallback(IAsyncResult ar){

            //获取state、EndSend的处理
            Socket socket = (Socket) ar.AsyncState;
            int count = socket.EndSend(ar);

            ByteArray ba;
            lock(writeQueue){
                ba = writeQueue.First();
            }

            ba.readIdx+=count;
            if(count == ba.length){
                lock(writeQueue){
                    writeQueue.Dequeue();
                    ba = writeQueue.First();
                }
            }
            if(ba ! = null){
                socket.BeginSend(ba.bytes, ba.readIdx, ba.length,
                    0, SendCallback, socket);
            }
        }

说人话:在11行和37行加锁,两行代码不能同时运行,其中一条线程等待。

问题:加上lock代码结构发送了那些变化?

回答:

原:

writeQueue.Enqueue(ba);

现:

int count = 0;
lock(writeQueue){
                writeQueue.Enqueue(ba);
                count = writeQueue.Count;
            }

•跟踪writeQueue,锁定代码块writeQueue.Enqueue(ba);count = writeQueue.Count;

•用新声明的变量count代替writeQueue.Count,是为了锁定writeQueue.Count的获得。

原:

ByteArray ba = writeQueue.First();
ba.readIdx+=count;
if(ba.length == 0){   //发送完整
            writeQueue.Dequeue();
            ba = writeQueue.First();
        }

现:

ByteArray ba;
lock(writeQueue){
                ba = writeQueue.First();
            }
 			 ba.readIdx+=count;
            if(count == ba.length){
                lock(writeQueue){
                    writeQueue.Dequeue();
                    ba = writeQueue.First();
                }
            }  	

•跟踪writeQueue。

•锁定ba = writeQueue.First();判断count是否等于ba.length,锁定 writeQueue.Dequeue(); ba = writeQueue.First();

二、高效的接收数据

2.1 不足之处(使用读写索引代替copy)

Copy操作要做到极致,那就极致到底。回顾上节中接收数据的代码(OnReceiveData),每次成功接收一条完整的数据后,程序会调用Array.Copy,将缓冲区的数据往前移动。但Array.Copy是个时间复杂度为o(n)的操作,假如缓冲区中的数据很多,那移动全部数据将会花费较长的时间

       public void OnReceiveData(){
           ……
           //更新缓冲区
           int start = 2 + bodyLength;
           int count = buffCount - start;
           Array.Copy(readBuff, start, readBuff, 0, count);
           buffCount -= start;
           ……
       }

一个可行的办法是,使用ByteArray结构作为缓冲区,使用readIdx指向的数据作为缓冲区的第一个数据,当接收完数据后,只移动readIdx,时间复杂度为o(1)。例如客户端收到服务端发来的两条数据“03cat”和“02hi”,由于出现粘包现象,读缓冲区如图4-38所示。

在这里插入图片描述

程序解析出第一条数据“03cat”后,仅将readIdx增加5,形成上图所示的缓冲区。

后续读取数据时,只要从readIdx处开始读取即可。写入数据时,也只需在writeIdx处开始写入。当缓冲区长度不够时,再做一次Array.Copy,调整readIdx和writeIdx的值,即可以做到不断接收数据,平均复杂度只会比o(1)高一点点。

说人话:接收数据也按照发送数据,通过调整读写索引位置,实现不断接受数据。

2.2 缓冲区不够长(自动扩展)

之前定义的输入缓冲区最大长度是1024(byte[] readBuff = new byte[1024]),如果网络状况很不好,缓冲区数据一直堆积,总有一天会把缓冲区撑爆。一个解决办法是,当缓冲区长度不够时,就让它自动扩展,重新申请一个较长的bytes数组。

完整的ByteArrayByteArray作为一种通用的byte型缓冲区结构,它应该支持自动扩展,支持常用的读写操作。同时,为了做到极致的效率,ByteArray的大部分成员变量都设为public,以提供灵活性。1.构造ByteArray现在,我们在之前的基础上编写完整的ByteArray。成员函数定义和构造函数代码如下所示,ByteArray拥有两个构造函数,其中一个是ByteArray(byte[]defaultBytes),它可以实现发送缓冲区的数据构造,当使用ByteArray(byte[] defaultBytes)构造函数时,函数成员bytes、readIdx和writeIdx的值与传进来的数据长度相关。另一个构造函数是ByteArray(int size =DEFAULT_SIZE),用于初始化指定长度的bytes,如果不填写size,将会生成一个长度为1024(DEFAULT_SIZE)的byte数组,如下图所示。

成员变量capacity代表缓冲区容量,也就是bytes的长度,即与bytes.Lenght相同。成员变量initSize代表ByteArray被构造时的长度,即初始长度,后续会用到。readIdx代表可读位置,即缓冲区有效数据的起始位置,writeIdx代表可写位置,即缓冲区有效数据的末尾。成员函数remain代表缓冲区还可以容纳的字节数(。

说人话:定义ByteArray类新的构造函数,用于指定默认长度字节数组,该字节数组的缓冲区支持自动扩容。所以新增成员变量capacity和initSize以及remain

在这里插入图片描述

        using System;

        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 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;
            }

说人话:

  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 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;
            }

•构造函数,参数为字节数组,按照该字节数组设定缓存区。

2.3 重设尺寸

在某些情况下,比如需要写入的数据量大于缓冲区剩余长度(remain)时,就需要扩大缓冲区。例如要在图4-42所示缓冲区后面添加数据“05hello”,使缓冲区数据变成“02hi05hello”。此时缓冲区只剩余6个字节,但“05hello”是7个字节,放不下。此时的做法是,重新申请一个长度合适的byte数组,然后把原byte数组的数据复制过去,再重新设置readIdx、writeIdx等数值。

在这里插入图片描述

重设尺寸的ReSize方法如下面的代码所示。它带有一个参数size,代表所需数据长度,在图4-42中,需要11个字节,即“02hi05hello”的长度。该方法会先做些判断,避免size值不合理,size值必须比现有有效数据大,不然有些数据放不下,为了避免缓冲区尺寸太小,规定byte数组必须大于初始长度。

Resize方法如下所示:

        //重设尺寸
        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;
        }

说人话:

•重设尺寸函数

•参数为重设尺寸大小,这个尺寸小于原来的长度或者小于原来的初始尺寸则无法重设。

•一个while循环,作用是让n不断翻倍直到大于重设尺寸参数。

•创建一个新的n容量的新的缓存区

•把旧的缓存区(bytes)的所有数据移动到新的缓存区(newBytes)中。

•把新缓存区设定为缓存区,将旧的缓存区的长度设为写位置索引,读位置索引为0。

问题:为什么要将将旧的缓存区的长度设为写位置索引,不可以不变吗?

回答:这是对新缓存区读写索引的初始化,因为旧的缓存区的读写索引位置有可能已经发生过改变,需要将读索引设为0,写索引设为旧缓存区数据的末尾也就是数据长度的位置。(因为需要从写的位置添加数据)。

移动数据

在某些情形下,例如有效数据长度很小(这里设置为8),或者数据全部被读取时(readIdx == writeIdx),可以将数据前移,增加remain,避免bytes数组过长。由于数据很少,程序执行的效率不会有影响。在图4-40所示的缓冲区上执行CheckAndMoveBytes后,缓冲区将变成下图所示的样式。

说人话:读写操作是通过移动读写索引实现的,不会直接删除缓存区(bytes数组)中的数据,所以数据全部被读取时,将数据前移防止bytes数组过长。有效数据很小的情况,也可以进行前移。这里的数据很少指的是移动的数据很少,而不是缓存区中的全部数据。

在这里插入图片描述

CheckAndMoveBytes的实现代码如下:

        //检查并移动数据
        public void CheckAndMoveBytes(){
            if(length < 8){
                MoveBytes();
            }
        }

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

说人话:

•检查并移动方法。

•移动数据方法。使用Array.Copy进行移动,只移动读和写之间的数据。

•参数表示将bytes数组的元素从readIdx位置,移动到该数组的0索引位置,移动元素的长度为length,length也是写索引的位置。

2.4 读写功能

接下来编写一些读写缓冲区数据的方法。

•Write

写数据的方法Write带有3个参数,该方法会把bs从offset位置开始的count个数据写入缓冲区。

Write方法会判断缓冲区是否有足够的剩余量,必要时调用ReSize方法调整byte数组长度

•Read

读方法Read也带有3个参数,它代表把缓冲区前count个数据放到bs中,数据从bs的offset位置开始放入。

Read方法会调用CheckAndMoveBytes,必要时移动数据,以增加remain。

        //写入数据
            public int Write(byte[] bs, int offset, int count){
                if(remain < count){
                    ReSize(length + count);
                }
                Array.Copy(bs, offset, bytes, writeIdx, count);
                writeIdx+=count;
                return count;
            }

            //读取数据
            public int Read(byte[] bs, int offset, int count){
                count = Math.Min(count, length);
                Array.Copy(bytes, 0, bs, offset, count);
                readIdx+=count;
                CheckAndMoveBytes();
                return count;
            }

说人话:写:判断剩余量是否重设尺寸,将bs从offset位置开始的count个数据写入缓冲区,写索引加上count,读同理,读后会调用CheckAndMoveBytes方法,该方法会在必要时前移数据,增加remain。

问题:为什么写入不需要调用CheckAndMoveBytes方法,读需要调用呢?

回答:CheckAndMoveBytes方法只在消息长度小于8或者读索引和写索引重合时会移动数据,所以只有读才有可能触发。

•ReadInt16和ReadInt32

为了方便读取数据,可以给缓冲区添加读取数值的方法ReadInt16和ReadInt32。其中ReadInt16代表读取16位int型整数,ReadInt32代表读取32位int型整数,它们的实现方式与Read方法相似。ReadInt16和ReadInt32代码如下所示,程序还使用4.4.3节介绍的方法处理大小端问题。

        //读取Int16
        public Int16 ReadInt16(){
            if(length < 2) return 0;
            Int16 ret = (Int16)((bytes[1] << 8) | bytes[0]);
            readIdx += 2;
            CheckAndMoveBytes();
            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;
        }

说人话:

   //读取Int16
        public Int16 ReadInt16(){
            if(length < 2) return 0;
            Int16 ret = (Int16)((bytes[1] << 8) | bytes[0]);
            readIdx += 2;
            CheckAndMoveBytes();
            return ret;
        }

•一个字节为8为,0到256,如果消息长度小于2,代表不到16位,所以无法转换返回0。

•Int16 ret = (Int16)((bytes[1] << 8) | bytes[0]) 表示16位整型等于第二个字节的所有二进制位左移8位(乘以2的8次方)在加上第一个字节。

•读索引加2。

•移动数据防止bytes数组过长。

问题:为什么读的索引要加2?

回答:读取数据16位也就是2个字节,所以读取之后读的索引要增加8。ReadInt16和Read不同的是,后者是通过获得读取消息的长度(Count)来增加读取的位数,前者是固定读取16位也就是长度为2字节。

2.5 测试缓冲区

最后给ByteArray添加4.5.3节提供的调试方法ToStringDebug。然后添加如下的测试程序,看看ByteArray是否能够正常运行。

        using System.Collections;
        using System.Collections.Generic;
        using UnityEngine;
        using System;

        public class TestByteArray : MonoBehaviour {

            // Use this for initialization
            void Start () {
                //[1 创建]
                ByteArray buff = new ByteArray(8);
                Debug.Log("[1 debug ]→" + buff.Debug());
                Debug.Log("[1 string]→" + buff.ToString());
                //[2 write]
                byte[] wb = new byte[]{1,2,3,4,5};
                buff.Write(wb, 0, 5);
                Debug.Log("[2 debug ]→" + buff.Debug());
                Debug.Log("[2 string]→" + buff.ToString());
                //[3 read]
                byte[] rb = new byte[4];
                buff.Read(rb, 0, 2);
                Debug.Log("[3 debug ]→" + buff.Debug());
                Debug.Log("[3 string]→" + buff.ToString());
                Debug.Log("[3 rb     ]→"+ BitConverter.ToString(rb));
                //[4 write, resize]
                wb = new byte[]{6,7,8,9,10,11};
                buff.Write(wb, 0, 6);
                Debug.Log("[4 debug ]→" + buff.Debug());
                Debug.Log("[4 string]→" + buff.ToString());
            }
        }

•测试了ByteArray的初始化以及写入数据和读数据功能。

2.6 将ByteArray应用到异步程序

现在,将ByteArray应用到异步程序,以避免Array.Copy导致的效率问题,同时避免网络环境不好的情况下缓冲区溢出。示范程序中,将异步程序的byte[] readbuff替换成了ByteArray readBuff。

为了提高执行效率,避免一次数据复制,socket.BeginReceive会直接操作readBuff.bytes,往缓冲区里写入数据,而不是使用ByteArray的Write方法,也意味着需要自己处理缓冲区扩展的功能。在ReceiveCallback中,由于不知道下一次接收的数据量,程序会使用“if(readBuff.remain < 8)”判断缓冲区是否有一定的剩余量,如果不够,会执行readBuff.ReSize(readBuff.length*2)让缓冲区增大。在OnReceiveData中,程序通过readBuff.ReadInt16读取消息长度,再使用readBuff.Read将缓冲区数据读取出来。

说人话:

•ByteArray避免Array.Copy导致的效率问题,同时避免网络环境不好的情况下缓冲区溢出。

•socket.BeginReceive会直接操作readBuff.bytes,往缓冲区里写入数据,而不是使用ByteArray的Write方法。为了提高执行效率,避免一次数据复制,也需要自己处理缓冲区扩展功能。

•在ReceiveCallback中,由于不知道下一次接收的数据量,程序会使用“if(readBuff.remain < 8)”判断缓冲区是否有一定的剩余量,如果不够,会执行readBuff.ReSize(readBuff.length*2)让缓冲区增大。

•在OnReceiveData中,程序通过readBuff.ReadInt16读取消息长度,再使用readBuff.Read将缓冲区数据读取出来。

        using System.Collections;
        using System.Collections.Generic;
        using UnityEngine;
        using System.Net.Sockets;
        using UnityEngine.UI;
        using System;
        using System.Linq;
        public class Echo : MonoBehaviour {

            //定义套接字
            Socket socket;
            //UGUI
            public InputField InputFeld;
            public Text text;
            //接收缓冲区
            ByteArray readBuff = new ByteArray();
            //显示文字
            string recvStr = "";

            //点击连接按钮
            public void Connection()
            {
                //Socket
                socket = new Socket(AddressFamily.InterNetwork,
                    SocketType.Stream, ProtocolType.Tcp);
                //为了精简代码:使用同步Connect
                //不考虑抛出异常
                socket.Connect("127.0.0.1", 8888);
                socket.BeginReceive( readBuff.bytes, readBuff.writeIdx,
                    readBuff.remain, 0, ReceiveCallback, socket);
            }

            //Receive回调
            public void ReceiveCallback(IAsyncResult ar){
                try {
                    Socket socket = (Socket) ar.AsyncState;
                    //获取接收数据长度
                    int count = socket.EndReceive(ar);
                    readBuff.writeIdx+=count;
                    //处理二进制消息
                    OnReceiveData();
                    //继续接收数据
                    if(readBuff.remain < 8){
                        readBuff.MoveBytes();
                        readBuff.ReSize(readBuff.length*2);
                    }
                    socket.BeginReceive( readBuff.bytes, readBuff.writeIdx,
                        readBuff.remain, 0, ReceiveCallback, socket);
                }
                catch (SocketException ex){
                    Debug.Log("Socket Receive fail" + ex.ToString());
                }
            }

            //数据处理
            public void OnReceiveData(){
                Debug.Log("[Recv 1] length =" + readBuff.length);
                Debug.Log("[Recv 2] readbuff=" + readBuff.ToString());
            if(readBuff.length <= 2)
                    return;
                //消息长度
                int readIdx = readBuff.readIdx;
                byte[] bytes =readBuff.bytes;
                Int16 bodyLength = (Int16)((bytes[readIdx+1] << 8 )| bytes[readIdx]);
                if(readBuff.length < bodyLength)
                    return;
                readBuff.readIdx+=2;
                Debug.Log("[Recv 3] bodyLength=" +bodyLength);
                //消息体
                byte[] stringByte = new byte[bodyLength];
                readBuff.Read(stringByte, 0, bodyLength);
                string s = System.Text.Encoding.UTF8.GetString(stringByte);

                Debug.Log("[Recv 4] s=" +s);
                Debug.Log("[Recv 5] readbuff=" + readBuff.ToString());
                //消息处理
                recvStr = s + "\n" + recvStr;
                //继续读取消息
                if(readBuff.length > 2){
                    OnReceiveData();
                }
            }

            //点击发送按钮
            public void Send() {
                //略
            }

            public void Update(){
                text.text = recvStr;
            }
        }
  • 43
    点赞
  • 40
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值