一 、完整发送数据
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、Dequeue和First三个方法,其中: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节提供的调试方法ToString和Debug。然后添加如下的测试程序,看看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;
}
}