Unity网络开发基础——TCP与UDP(贰)
学习笔记主要来源于唐老狮的Unity课程,经过个人简单整理而成。笔记总共五个部分:
- Unity网络开发基础——基础知识(壹)
- Unity网络开发基础——TCP与UDP(贰)
- Unity网络开发基础——FTP(叁)
- Unity网络开发基础——HTTP(肆)
- Unity网络开发基础——消息处理(伍)
这个课程的大致内容如下:
- 网络通信中的一些必备理论知识(OSI模型、TCP/IP协议等)。
- 对自定义类对象进行序列化和反序列化。
- 使用Socket,进行TCP、UDP的同步异步通信。
- 处理网络通信中的分包黏包和心跳消息。
- 使用FTP和HTTP协议进行文件的上传和下载。
- 掌握Unity的WWW类和UnityWebRequest类。
- 熟练使用Protobuf,了解自定义协议生成工具的制作原理。
- 了解大小端模式,消息加密原理。
课程不包含游戏的实际游戏开发的内容,比如后端相关知识、同步模式(帧同步、状态同步)等等,只包含网络通信最基本的内容。
四、网络通信Socket——TCP
(一)TCP通信概述
1、Socket套接字
(1)Socket概述
Socket套接字是支持TCP/IP网络通信的基本操作单位,一个套接字对象包含以下关键信息:
- 本机的IP地址和端口。
- 对方主机的IP地址和端口。
- 双方通信的协议信息。
可以简单将Socket连接被视为一个数据通道,这个通道连接在客户端和服务端之间,数据的发送和接受均通过这个通道进行。
这节只会简单列举Socket的常用方法,具体使用可以看后续章节。
(2)Socket的类型
- 流套接字:主要用于实现TCP通信,提供了面向连接、可靠的、有序的、数据无差错且无重复的数据传输服务。
- 数据报套接字:主要用于实现UDP通信,提供了无连接的通信服务,数据包的长度不能大于32KB,不提供正确性检查,不保证顺序,可能出现重发、丢失等情况。
- 原始套接字:主要用于实现IP数据包通信,用于直接访问协议的较低层,常用于侦听和分析数据包。不常用,因此不深入讲解。
(3)Socket声明
- 声明:三个参数都是枚举
Socket socket = new Socket(addressFamily, socketType, protocolType);
- 常用枚举:
// AddressFamily 网络寻址,决定寻址方案
AddressFamily.InterNetwork; // IPv4寻址
AddressFamily.InterNetwork6; // IPv6寻址
// SocketType 套接字类型
SocketType.Dgram; // 支持数据报,最大长度固定的无连接、不可靠的消息(主要用于UDP通信)
SocketType.Stream; // 支持可靠、双向、基于连接的字节流(主要用于TCP通信)
// ProtocolType 协议类型,决定套接字使用的通信协议
ProtocolType.Tcp; // TCP传输控制协议
ProtocolType.Udp; // UDP用户数据报协议
- 几种搭配:
SocketType.Dgram + ProtocolType.Udp = UDP协议通信(常用)
SocketType.Stream + ProtocolType.Tcp = TCP协议通信(常用)
SocketType.Raw + ProtocolType.Icmp = Internet控制报文协议(了解)
SocketType.Raw + ProtocolType.Raw = 简单的IP包通信(了解)
- 必须掌握:
//TCP流套接字
Socket socketTcp = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
//UDP数据报套接字
Socket socketUdp = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
(4)常用属性
socket.Connected; // 套接字的连接状态(表示上一次收发消息是否成功)
socket.SocketType; // 获取套接字的类型
socket.ProtocolType; // 获取套接字的协议类型
socket.AddressFamily; // 获取套接字的寻址方案
socket.Available; // 从网络中获取准备读取的数据数据量
socket.LocalEndPoint as IPEndPoint; // 获取本机EndPoint对象
socket.RemoteEndPoint as IPEndPoint; // 获取远程EndPoint对象
(5)常用方法
- 主要用于服务端
socket.Bind(iPEndPoint); // 绑定IP和端口
socket.Listen(num); // 设置客户端连接的最大数量
socket.Accept(); // 等待客户端连入
- 主要用于客户端
socket.Connect(ipAddress, port); // 连接远程服务端
- 共同使用
socket.Send(...); // 同步发送数据
socket.Receive(...); // 同步接收数据
socket.SendAsync(...); // 异步发送数据
socket.ReceiveAsync(...); // 异步接收数据
socket.Shutdown(socketShutdown); // 关闭socket收发功能,先于Close调用
socket.Disconnect(reuseSocket); // 关闭Socket连接,参数表示是否允许重用实例
socket.Close(); // 关闭连接,并释放所有Socket关联资源
这里SocketShutdown表示如何关闭套接字,可以关闭发送功能或者接收功能,也可以都关闭。
2、TCP通信概述
- 客户端和服务端的工作流程:

- TCP协议三次握手的体现

- TCP协议四次挥手的体现

TCP协议的三次握手和四次挥手被Socket封装在了内部,因此不需要我们进行额外处理。
(二)TCP通信——同步
1、TCP同步通信——服务端
上节讲述了服务端的工作流程,这里就来进行代码实现。由于Unity主要用于客户端,很多功能服务端是用不到的,因此新建一个C#的控制台程序进行编写即可。
我们按照流程进行编写:
// 1、创建Tcp套接字
Socket socketTcp = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
// 2、Bind绑定本地地址
try
{
IPEndPoint iPEndPoint = new IPEndPoint(IPAddress.Parse("127.0.0.1"), 8080);
socketTcp.Bind(iPEndPoint);
}
catch (Exception e)
{
Console.WriteLine("绑定失败:" + e.Message);
return;
}
// 3、Listen监听
socketTcp.Listen(1024);
// 4、Accept等待客户端连接,连接后返回一个新的用于和客户端通信的套接字(同步,等待过程会阻塞线程)
Socket socketClient = socketTcp.Accept();
// 5、Send和Receive收发消息
// 发送欢迎消息
socketClient.Send(Encoding.UTF8.GetBytes("欢迎连入服务器"));
// 接收客户端消息
byte[] receiveBuffer = new byte[1024]; // 接收缓冲区
int receiveLength = socketClient.Receive(receiveBuffer); // 返回值为实际接收的字节数
string message = Encoding.UTF8.GetString(receiveBuffer, 0, receiveLength);
Console.WriteLine($"接收到来自{socketClient.RemoteEndPoint?.ToString()}的消息:{message}");
// 6、Shutdown释放连接
socketClient.Shutdown(SocketShutdown.Both); // 发送与接收功能都关闭
// 7、关闭套接字
socketClient.Close();
2、TCP同步通信——客户端
我们按照之前讲解的客户端工作流程,在Unity中编写如下代码:
// 1、创建Tcp套接字
Socket socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
// 2、Connect连接服务器
IPEndPoint iPEndPoint = new IPEndPoint(IPAddress.Parse("127.0.0.1"), 8080);
try
{
socket.Connect(iPEndPoint);
}
catch (SocketException e)
{
if (e.ErrorCode == 10061) // ErrorCode具体含义可以查看官网,这里简单举例
Debug.LogError("服务器拒绝连接");
else
Debug.LogError($"连接服务器失败:{e.ErrorCode}");
return;
}
// 3、Send和Receive收发消息
// 接收服务器的欢迎消息
byte[] receiveBuffer = new byte[1024];
int receiveLength = socket.Receive(receiveBuffer);
Debug.Log($"收到服务器消息:{Encoding.UTF8.GetString(receiveBuffer, 0, receiveLength)}");
// 发送消息给服务器
socket.Send(Encoding.UTF8.GetBytes("你好呀,服务器"));
// 4、Shutdown释放连接
socket.Shutdown(SocketShutdown.Both);
// 5、关闭套接字
socket.Close();
此时先启动服务端,再启动客户端,就可以看到两者相互发送的消息啦!
需要注意的是上面提到的
Connect、Accept、Receive、Send都是同步方法,是会阻塞主线程的。而这里我们的实现也仅仅是一对一通信,且对话完就关闭连接了。若想实现服务器一对多,且可以相互通话,可以使用多线程相关知识来完成。这里提供简单的思路:
- 服务端:
Bind并且Listen后
- 一个线程单独
Accept,并存储连接的socket到容器中;- 一个线程单独
Receive,遍历容器的socket并分别调用他们的Receive。- 关于
Send,可以遍历容器并分别调用Send。- 客户端:
Connect后
- 一个线程单独
Receive,由于Unity多线程无法访问主线程的Unity相关对象,因此需要一个中间容器存储数据(如Queue),将Receive的内容存储在Queue中;- 关于
Send,为了避免卡住线程,可以将消息存储在Queue中。再单独开一个线程负责不停Send队列里的消息。
3、 区分消息类型
之前我们学习过,继承BaseData类,就可以实现二进制的序列化和反序列化。但是这些数据对象序列化后是长度不同的字节数组,发送给对方时,对方如何区分这些消息是什么类型?即如何选择对应的数据类反序列化它们?
其中一个方案为发送的信息添加标识,比如添加消息ID,对方解析时先解析这个标识,再根据这个消息ID来区分消息的具体类型。因此,我们可以实现一个消息类,继承BaseData,在其基础上添加消息ID的功能。这里消息ID用int表示。
- BaseMessage代码如下:
public abstract class BaseMessage : BaseData
{
/// <summary>
/// 获取消息ID
/// </summary>
/// <returns>消息ID</returns>
public abstract int GetID();
}
- 子类实现如下:只需注意在计算字节数组长度和序列化消息时加上消息ID;反序列化时不必解析消息ID,因为这是接收方在反序列化之前就做了,用于区分消息类型。
public class PlayerMessage : BaseMessage
{
public int playerID; // 玩家ID
public PlayerData playerData; // 玩家数据
public override int GetBytesLength()
{
return sizeof(int) + // 消息ID
sizeof(int) + // 玩家ID
playerData.GetBytesLength(); // 玩家数据
}
public override int Reading(byte[] bytes, int beginIndex = 0)
{
int index = beginIndex;
// 不需要解析消息ID,因为这是接收方用于区分消息类型的,在执行这一步之前已经解析出来了
playerID = ReadInt(bytes, ref index); // 读取玩家ID
playerData = ReadData<PlayerData>(bytes, ref index); // 读取玩家数据
return index - beginIndex;
}
public override byte[] Writing()
{
int index = 0;
byte[] bytes = new byte[GetBytesLength()];
WriteInt(bytes, GetID(), ref index); // 写入消息ID
WriteInt(bytes, playerID, ref index); // 写入玩家ID
WriteData(bytes, playerData, ref index); // 写入玩家数据
return bytes;
}
public override int GetID()
{
return 1001;
}
}
- 客户端接收举例:
byte[] receiveBuffer = new byte[1024];
int receiveLength = socket.Receive(receiveBuffer);
// 解析消息ID
int msgID = BitConverter.ToInt32(receiveBuffer, 0);
switch (msgID)
{
case 1001:
PlayerMessage playerMsg = new PlayerMessage();
playerMsg.Reading(receiveBuffer, 4); // 解析消息体
break;
// ...
}
当然目前这个实现不是很完美,还可以进一步封装,这里就不做演示了,主要是理解思想。
4、分包、黏包
(1)概念
分包黏包指在网络通信中由于各种因素造成的消息与消息之间出现的两种状态,分包和黏包可以同时出现。
- 分包:一个消息分成了多个消息进行发送。
- 黏包:一个消息和另一个消息黏在了一起。

(2)解决方案
分包、黏包会导致我们反序列化出现问题,解决方案就是消息长度,和上节消息类型一样,我们可以为消息添加头部,头部记录消息的长度。通过消息长度就可以区分分包和粘包。

(3)代码
① 序列化:
还是以之前的PlayerMessage为例,我们只需要添加带注释的部分即可。我们可以通过之前的GetBytesLength获取消息总长度,减去添加的头部信息长度即可获得消息体长度。
public class PlayerMessage : BaseMessage
{
public int playerID;
public PlayerData playerData;
public override int GetBytesLength()
{
return sizeof(int) +
sizeof(int) + // 消息长度
sizeof(int) +
playerData.GetBytesLength();
}
public override int Reading(byte[] bytes, int beginIndex = 0)
{
int index = beginIndex;
playerID = ReadInt(bytes, ref index);
playerData = ReadData<PlayerData>(bytes, ref index);
return index - beginIndex;
}
public override byte[] Writing()
{
int index = 0;
byte[] bytes = new byte[GetBytesLength()];
WriteInt(bytes, GetID(), ref index);
WriteInt(bytes, GetBytesLength() - 8, ref index); // 写入消息长度(总长度 - 消息ID和消息长度占用的字节数)
WriteInt(bytes, playerID, ref index);
WriteData(bytes, playerData, ref index);
return bytes;
}
public override int GetID()
{
return 1001;
}
}
② 接收方反序列化:
这里由于处理消息代码量比较多,因此Receive之后交给HandleMessage函数进行消息的具体处理:
int receiveLength = socket.Receive(receiveBuffer);
HandleReceiveMessage(receiveBuffer, receiveLength);
- 简单情况:无分包黏包。
/// <summary>
/// 处理接收的消息
/// </summary>
/// <param name="receiveBuffer">接收到的字节数组</param>
/// <param name="receiveLength">接收到的字节数</param>
private void HandleReceiveMessage(byte[] receiveBuffer, int receiveLength)
{
int index = 0;
// 解析消息ID
int msgID = BitConverter.ToInt32(receiveBuffer, index);
index += sizeof(int);
// 解析消息长度
int msgLength = BitConverter.ToInt32(receiveBuffer, index);
index += sizeof(int);
// 解析消息体
BaseMessage msg = null;
switch (msgID)
{
case 1001:
msg = new PlayerMessage();
msg.Reading(receiveBuffer, index);
break;
// ...
}
if (msg != null)
receiveMsgQueue.Enqueue(msg); // 消息存进公共队列中
index += msgLength;
}
- 复杂情况:包含分包和黏包问题。
- 对于黏包:套一层
while循环即可,解析完一次完整的消息后,继续解析黏住的剩余部分。 - 对于分包:字节数组无法完整表示消息,对于剩余部分不全的消息,需要保存下来,等到下一次收到消息时一起拼接即可。这里统一将接收的消息都放进
dataBuffer中,若该次循环处理不完,剩余的数据会留在dataBuffer中,留给下次接收消息时处理。
- 对于黏包:套一层
private byte[] dataBuffer = new byte[1024*1024]; // 存储消息数据的缓存区,可以存储上一次接收消息时剩余的字节数据
private int dataLength = 0; // 消息数据的缓存数组长度
/// <summary>
/// 处理接收的消息(包括解决分包粘包问题)
/// </summary>
/// <param name="receiveBuffer">接收到的字节数组</param>
/// <param name="receiveLength">接收到的字节数</param>
private void HandleReceiveMessage(byte[] receiveBuffer, int receiveLength)
{
int index = 0;
int msgID = 0;
int msgLength = 0;
// 将消息字节数组存到dataBuffer中
receiveBuffer.CopyTo(dataBuffer, dataLength);
dataLength += receiveLength;
while (index < dataLength)
{
msgLength = -1; // 每次循环置为-1,表示没有解析消息头,同时避免上次循环残留
// 是否能够完整解析消息头(1)
if (dataLength - index >= 8)
{
// 解析消息ID
msgID = BitConverter.ToInt32(dataBuffer, index);
index += sizeof(int);
// 解析消息长度
msgLength = BitConverter.ToInt32(dataBuffer, index);
index += sizeof(int);
}
// 是否能够完整解析消息体(2)
if (dataLength - index >= msgLength && msgLength != -1)
{
// 解析消息体
BaseMessage msg = null;
switch (msgID)
{
case 1001:
msg = new PlayerMessage();
msg.Reading(dataBuffer, index);
break;
// ...
}
if (msg != null)
receiveMsgQueue.Enqueue(msg);
index += msgLength;
// 消息处理完毕
if (index == dataLength)
{
dataLength = 0;
break;
}
}
// 处理分包,缓存剩余数据(3)
else
{
// 解析了消息头但是没有解析消息体,index回退到解析消息头前的位置
if (msgLength != -1) index -= 8;
// 保存剩余数据,即把剩余数据移到数组开头
Array.Copy(dataBuffer, index, dataBuffer, 0, dataLength - index);
dataLength = dataLength - index;
break;
}
}
}
这个代码可能比较难理解,因此我将while循环内部分了3个步骤,分别是注释后面标注的(1)、(2)、(3):
- (1):若
剩余消息长度 > 消息头长度,解析消息头。 - (2):若
剩余消息长度 > 消息体长度,解析消息体。 - (3):当
无法完整解析一段消息时,缓存剩余数据。若已经解析了消息头,则会进行回退。
这里我再附上图片,说明各种情况,结合代码会好理解些:

5、心跳消息
(1)客户端主动断开连接
目前在客户端主动退出时,服务端这边并不知道客户端那边是断开了的。那么如何解决呢?
- 客户端主动断开连接:调用
socket.Disconnect(),客户端即可主动断开连接。但服务端这边仍无法得知客户端连接状态,因此需要发送一个自定义的退出消息,通知服务端。 - 服务端处理退出消息:当接收到客户端发来的退出消息时,就知道客户端已断开连接,服务端这边也可以把与这个客户端相连的socket进行关闭释放。
上面提到了Disconnect方法和退出消息,接下来详细讲一下:
-
Disconnect方法:调用该方法后,
Connected属性会变为false。调用该方法前需要调用Shutdown方法,确保在Socket关闭前所有数据都已发送和接收完成。 -
退出消息:无消息体,只有一个ID表示这是退出消息。
public class QuitMessage : BaseMessage
{
public override int GetBytesLength()
{
return 8; // 只有消息头
}
public override int Reading(byte[] bytes, int beginIndex = 0)
{
return 0;
}
public override byte[] Writing()
{
int index = 0;
byte[] bytes = new byte[GetBytesLength()];
WriteInt(bytes, GetID(), ref index); // 消息ID
WriteInt(bytes, 0, ref index); // 消息体长度为0
return bytes;
}
public override int GetID()
{
return 1003;
}
}
(2)心跳消息
心跳消息,就是在长连接中,客户端和服务端之间定期发送的一种特殊的数据包,用于通知对方自己还在线,以确保长连接的有效性。由于定期发送的时间间隔类似心跳,因此称为心跳消息。
与上面客户端主动断开连接不同的是,有时客户端会非正常关闭连接,此时服务器无法正常收到关闭连接的消息。心跳消息就可以解决这个问题,当服务端长时间收不到客户端的心跳消息时,就认为客户端断开了连接。那么如何实现呢?
- 客户端定时发送心跳消息:每隔固定一段时间,发送一条心跳消息给服务端。
- 心跳消息和退出消息一样,没有消息体,只有ID信息,这里不再赘述。
- 定时发送也很简单,可以用协程、计时器、延迟函数等方式完成,这里不再赘述。
- 服务器端检测接收消息的时间:服务端不停检测上次收到某客户端消息的时间,如果超时则认为连接已经断开。
- 记录接收消息的时间可以使用
DateTime,比如记录当前时间经过的秒数:DateTime.Now.Ticks / TimeSpan.TicksPerSecond。
- 记录接收消息的时间可以使用
(三)TCP通信——异步
1、异步方法概述
- 同步方法:方法中逻辑执行完毕后,再继续执行后面的方法。
- 异步方法:方法中逻辑可能还没有执行完毕,就继续执行后面的内容。
异步方法实现原理:可以使用多线程,也可以使用async和await让函数分步执行。
2、异步方法——Begin/End相关方法
- IAsyncResult:异步回调函数的参数
- AsyncState:调用异步回调时传入的参数
- AsyncWaitHandle:用于同步等待
- AsyncCallback:异步回调函数,是一个无返回值的带一个参数的委托。参数就是
IAsyncResult
public delegate void AsyncCallback(IAsyncResult ar);
- Accept:用于服务端接收客户端连接
- BeginAccept:开始异步接收客户端连接,接收完成后调用回调函数。
- EndAccept:结束接收,并返回一个新的用于连接客户端的套接字。
- 在异步回调中,接受完后可以调用
BeginAccept继续接收下一个连接,需要注意的是这个不是递归,只是循环监听而已,不要搞错咯。
// BeginAccept,参数2代表传给回调函数的参数
socket.BeginAccept(AcceptCallback, socket);
// 异步回调函数,这里使用EndAccept结束接收,获取连接客户端的socket
private void AcceptCallback(IAsyncResult result)
{
try
{
Socket socket = result.AsyncState as Socket; // 获取参数
Socket clientSocket = socket.EndAccept(result);
socket.BeginAccept(AcceptCallback, socket); // 继续接收下一个客户端连接
}
catch (SocketException e)
{
Debug.LogError("Socket error: " + e.Message);
}
}
- Connect:用于客户端连接服务器
- BeginConnect:开始异步请求连接远程主机,连接完成后调用回调函数。
- EndConnect:结束待处理的异步连接请求。
// BeginConnect
socket.BeginConnect(iPEndPoint, ConnectCallback, socket);
// 异步回调函数,调用EndConnect结束连接
private void ConnectCallback(IAsyncResult result)
{
try
{
Socket socket = result.AsyncState as Socket;
socket.EndConnect(result);
}
catch (SocketException e)
{
Debug.LogError("Socket error: " + e.Message);
}
}
- Receive:用于接收消息
- BeginReceive:开始异步接收消息,接收完成后调用回调函数。参数2表示接收数据的偏移位置,参数3表示接收的字节数。
- EndReceive:结束待处理的异步读取,返回接收的字节数。
- SocketFlags:表示套接字发送和接收行为,这里填None即可。
// BeginReceive
socket.BeginReceive(receiveBuffer, 0, receiveBuffer.Length, SocketFlags.None, ReceiveCallback, socket);
// 异步回调函数,调用EndReceive结束接收
private void ReceiveCallback(IAsyncResult result)
{
try
{
Socket socket = result.AsyncState as Socket;
int receiveLength = socket.EndReceive(result);
// 消息处理...
Encoding.UTF8.GetString(receiveBuffer, 0, receiveLength);
// 继续下一次接收
socket.BeginReceive(receiveBuffer, 0, receiveBuffer.Length, SocketFlags.None, ReceiveCallback, socket);
}
catch (SocketException e)
{
Debug.LogError("Socket error: " + e.Message);
}
}
- Send:用于发送消息
- BeginSend:开始异步发送消息,发送完成后调用回调函数。参数2表示接收数据的偏移位置,参数3表示发送的字节数。
- EndSend:结束待处理的异步发送,返回发送的字节数。
// BeginSend
socket.BeginSend(bytes, 0, bytes.Length, SocketFlags.None, SendCallback, socket);
// 异步回调函数,调用EndSend结束发送
private void SendCallback(IAsyncResult result)
{
try
{
Socket socket = result.AsyncState as Socket;
socket.EndSend(result);
}
catch (SocketException e)
{
Debug.LogError("Socket error: " + e.Message);
}
}
3、异步方法——Async相关方法
- SocketAsyncEventArgs:一个事件参数,它会作为Async异步方法的传入值,我们需要通过它进行一些关键参数的赋值。
- Complete:用于异步操作完成后的事件。委托类型如下:
EventHandler<SocketAsyncEventArgs>。 - SocketError:获取或设置异步套接字操作的结果。
- AcceptSocket:获取或设置套接字,主要用于接受异步方法的连接套接字。
- SetBuffer方法:设置缓冲区数据,主要用于接收或发送数据的缓存区。
- Buffer:获取数据缓冲区。
- BytesTransferred:获取套接字操作中传输的字节数。
- Complete:用于异步操作完成后的事件。委托类型如下:
- EventHandler:用于事件通知的委托,
sender代表事件发送者,一般是调用了异步方法的对象,e表示事件数据对象。
public delegate void EventHandler<TEventArgs>(object sender, TEventArgs e);
- AcceptAsync:异步接收请求
SocketAsyncEventArgs e = new SocketAsyncEventArgs();
e.Completed += (sender, args) =>
{
if (args.SocketError == SocketError.Success)
{
Socket clientSocket = args.AcceptSocket; // 接收用于连接客户端的套接字
(sender as Socket).AcceptAsync(args); // 继续接收客户端连接
}
};
socket.AcceptAsync(e);
- ConnectAsync:异步请求连接远程主机
SocketAsyncEventArgs e = new SocketAsyncEventArgs();
e.Completed += (sender, args) =>
{
if (args.SocketError == SocketError.Success)
{
// 连接成功
}
};
socket.ConnectAsync(e);
- SendAsync:异步发送数据
SocketAsyncEventArgs e = new SocketAsyncEventArgs();
e.SetBuffer(bytes, 0, bytes.Length); // 设置发送数据
e.Completed += (sender, args) =>
{
if (args.SocketError == SocketError.Success)
{
// 发送成功
}
};
socket.SendAsync(e);
- ReceiveAsync:异步接收数据
SocketAsyncEventArgs e = new SocketAsyncEventArgs();
e.SetBuffer(receiveBuffer, 0, receiveBuffer.Length); // 设置接收数据的缓存区
e.Completed += (sender, args) =>
{
if (args.SocketError == SocketError.Success)
{
// 接收成功,可以解析数据
// Buffer代表接收的缓存区,BytesTransferred代表接收的字节数
Encoding.UTF8.GetString(args.Buffer, 0, args.BytesTransferred);
// 继续接收数据
args.SetBuffer(0, args.Buffer.Length); // 可以复用Buffer
(sender as Socket).ReceiveAsync(args);
}
};
socket.ReceiveAsync(e);
五、网络通信Socket——UDP
(一)UDP通信概述
1、客户端和服务端的工作流程
相较于TCP,UDP的流程更加简单。

2、UDP的黏包分包问题
- 黏包
UDP作为无连接的不可靠的传输协议(适合频繁发送较小的数据包),不会对数据包进行合并发送,一端发送什么数据,直接就发出去了,UDP中不会出现自动黏包问题。
- 分包
由于UDP是不可靠的连接,因此消息传递过程中可能出现无序、丢包等情况,如果出现分包,那后果将会是灾难性的,处理消息会非常困难。
因此为了避免其分包,建议发送UDP消息时,控制消息的大小在**MTU(最大传输单元)**范围内。
如果想发送的消息真的比较大,我们可以手动分包,让每个消息不超过限制范围。同时解决UDP的丢包和无序问题,将不可靠的UDP通信实现为可靠的UDP通信。(比如在消息中加入序号、消息总包数、包ID、长度等信息,并且实现消息确认、消息重发等功能)
- MTU
MTU(Maximum Transmission Unit) 最大传输单元,用来通知对方所能接受数据服务单元的最大尺寸。
不同操作系统会提供用户一个默认值,如以太网和802.3对数据帧的长度限制分别为1500字节和1492字节 。由于UDP包本身带有一些信息,因此建议:
① 局域网环境下:1472字节以内(1500减去UDP头部28为1472)
② 互联网环境下:548字节以内(老的ISP拨号网络的标准值为576减去UDP头部28为548)
只要遵守这个规则,就不会出现自动分包的情况。
(二)UDP通信——同步
1、相关API
- 发送消息:发送buffer给目标主机,第二个参数表示目标主机位置,有多个重载。
socket.SendTo(buffer, remoteEP);
- 接收消息:接收来自remoteEP的消息,存进buffer中,返回接收的字节数,有多个重载。
socket.ReceiveFrom(buffer, ref remoteEP);
2、UDP同步通信——客户端
// 1、创建Udp套接字
Socket socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
// 2、绑定本地IP端口
IPEndPoint iPEndPoint = new IPEndPoint(IPAddress.Parse("127.0.0.1"), 8080);
socket.Bind(iPEndPoint);
// 3、SendTo和ReceiveFrom收发消息
// 发送消息给指定服务器
byte[] bytes = Encoding.UTF8.GetBytes("你好呀,UDP服务器");
IPEndPoint remoteIPEndPoint = new IPEndPoint(IPAddress.Parse("127.0.0.1"), 8081);
socket.SendTo(bytes, remoteIPEndPoint);
// 接收消息
byte[] receiveBuffer = new byte[512];
EndPoint remoteIPEndPoint2 = new IPEndPoint(IPAddress.Any, 0);
int receiveLength = socket.ReceiveFrom(receiveBuffer, ref remoteIPEndPoint2);
Debug.Log($"{(remoteIPEndPoint2 as IPEndPoint).Address}发送的消息:" +
$"{Encoding.UTF8.GetString(receiveBuffer, 0, receiveLength)}");
// 4、释放关闭
socket.Shutdown(SocketShutdown.Both);
socket.Close();
3、UDP同步通信——服务端
服务端和客户端流程一样,因此几乎没有差别:
// 1、创建Udp套接字
Socket socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
// 2、绑定本地IP端口
IPEndPoint iPEndPoint = new IPEndPoint(IPAddress.Parse("127.0.0.1"), 8081);
socket.Bind(iPEndPoint);
// 3、SendTo和ReceiveFrom收发消息
// 接收来自客户端的消息
byte[] receiveBuffer = new byte[512];
EndPoint remoteIPEndPoint = new IPEndPoint(IPAddress.Any, 0);
int receiveLength = socket.ReceiveFrom(receiveBuffer, ref remoteIPEndPoint);
Console.WriteLine($"{(remoteIPEndPoint as IPEndPoint).Address}发送的消息:" +
$"{Encoding.UTF8.GetString(receiveBuffer, 0, receiveLength)}");
// 发送消息
byte[] bytes = Encoding.UTF8.GetBytes("你好呀,UDP客户端");
socket.SendTo(bytes, remoteIPEndPoint);
// 4、释放关闭
socket.Shutdown(SocketShutdown.Both);
socket.Close();
(三)UDP通信——异步
1、异步方法概述
UDP主要用到的方法就是SendTo和ReceiveFrom,因此异步方法也会围绕这两个方法来讲。而在TCP中我们已经讲过类似的异步方法,因此这里不会再对参数过多讲解。
2、异步方法——Begin/End相关方法
- SendTo:用于发送消息
- BeginSendTo:开始异步发送消息,需多传入一个发送目标
IPEndPoint信息,发送完成后调用回调函数。 - EndSendTo:结束待处理的异步发送,返回发送的字节数。
- BeginSendTo:开始异步发送消息,需多传入一个发送目标
// BeginSendTo
socket.BeginSendTo(bytes, 0, bytes.Length, SocketFlags.None, ipEndPoint, SendToCallBack, socket);
// 异步回调函数,调用EndSendTo结束发送
private void SendToCallBack(IAsyncResult result)
{
try
{
Socket socket = result.AsyncState as Socket;
socket.EndSendTo(result);
}
catch (SocketException e)
{
Debug.LogError(e.Message);
}
}
- ReceiveFrom:用于接收消息
- BeginReceiveFrom:开始异步接收消息,接收完成后调用回调函数。
- EndReceiveFrom:结束待处理的异步读取,同时获取发送方的
IpEndPoint信息。返回接收的字节数。
// BeginReceiveFrom
socket.BeginReceiveFrom(receiveBuffer, 0, receiveBuffer.Length, SocketFlags.None, ref ipEndPoint, ReceiveFromCallback, (socket, ipEndPoint));
// 异步回调函数,调用EndReceiveFrom结束接收
private void ReceiveFromCallback(IAsyncResult result)
{
try
{
(Socket socket, EndPoint ipEndPoint) = ((Socket, EndPoint))result.AsyncState;
int receiveLength = socket.EndReceiveFrom(result, ref ipEndPoint);
// 消息处理...
Encoding.UTF8.GetString(receiveBuffer, 0, receiveLength);
// 继续接收下一次消息
socket.BeginReceiveFrom(receiveBuffer, 0, receiveBuffer.Length, SocketFlags.None, ref ipEndPoint, ReceiveFromCallback, (socket, ipEndPoint));
}
catch (SocketException e)
{
Debug.LogError(e.Message);
}
}
3、异步方法——Async相关方法
和TCP的类似, 不过需要多设置一个参数RemoteEndPoint来表示连接的远程主机。
- SendToAsync:异步发送数据
SocketAsyncEventArgs e = new SocketAsyncEventArgs();
e.SetBuffer(bytes, 0, bytes.Length);
e.RemoteEndPoint = ipEndPoint; // 服务器的IP和端口
e.Completed += (sender, args) =>
{
if (args.SocketError == SocketError.Success)
{
// 发送成功
}
};
socket.SendToAsync(e);
- ReceiveFromAsync:异步接收消息
SocketAsyncEventArgs e = new SocketAsyncEventArgs();
e.SetBuffer(receiveBuffer, 0, receiveBuffer.Length);
e.RemoteEndPoint = new IPEndPoint(IPAddress.Any, 0); // 接收用的IP和端口
e.Completed += (sender, args) =>
{
if (args.SocketError == SocketError.Success)
{
// 接收成功,可以解析数据
Encoding.UTF8.GetString(args.Buffer, 0, args.BytesTransferred);
// 继续接收下一次消息
args.SetBuffer(0, args.Buffer.Length); // 可以复用Buffer
(sender as Socket).ReceiveFromAsync(args);
}
};
socket.ReceiveFromAsync(e);