UN2L11——服务端和客户端同步通信

本章代码关键字

1
2
socket.SendTo()
socket.ReceiveFrom()

UDP同步收发消息API

  • 同步发送消息

    UDP同步通信中,一般用SendTo​​来发送消息,使用前不需要建立连接,只需要绑定好本机IP即可
    该方法有多种重载,最基础的重载参数包括:发送的字节数组、要发送的IP地址和端口号

    • 参数一:发送的字节数组(必须)
    • 参数二:偏移量,即从第几位开始发送
    • 参数三:要发送多少字节
    • 参数四:套接字标识
    • 参数五:要发送的IP地址和端口号(必须)
    1
    2
    3
    4
    5
    6
    7
    8
    //创建套接字
    Socket socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
    //绑定本机地址
    IPEndPoint ipPoint = new IPEndPoint(IPAddress.Parse("127.0.0.1"), 8080);
    socket.Bind(ipPoint);
    //发送到指定目标,指定要发送的字节数组和远程计算机的IP端口
    IPEndPoint remoteIpPoint = new IPEndPoint(IPAddress.Parse("127.0.0.1"), 8081);
    socket.SendTo(Encoding.UTF8.GetBytes("唐老狮来了"), remoteIpPoint);
  • 同步接收消息

    UDP同步通信中,一般用ReceiveFrom​​来接收消息,使用前不需要建立连接,只需要绑定好本机IP即可
    该方法有多种重载,最基础的重载参数包括:用来接收数据的字节数组、用于记录发送者IP地址和端口的EndPoint​​
    该方法会返回一共接收了多少字节

    • 参数一:用来接收数据的字节数组(必须)
    • 参数二:偏移量,即从第几位开始接收数据
    • 参数三:要接收多少字节
    • 参数四:套接字标识
    • 参数五:用于记录发送者IP地址和端口的EndPoint​​(必须)
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    //创建套接字
    Socket socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
    //绑定本机地址
    IPEndPoint ipPoint = new IPEndPoint(IPAddress.Parse("127.0.0.1"), 8081);
    socket.Bind(ipPoint);
    Console.WriteLine("服务器开启");
    //接收消息
    byte[] bytes = new byte[512];
    //这个变量主要是用来记录,谁发的消息给你,传入函数后,在内部,它会帮助我们进行赋值
    EndPoint remoteIpPoint = new IPEndPoint(IPAddress.Any, 0);
    int receiveNum = socket.ReceiveFrom(bytes, ref remoteIpPoint);
    Console.WriteLine("IP: " + (remoteIpPoint as IPEndPoint)?.Address.ToString() +
    "port: " + (remoteIpPoint as IPEndPoint)?.Port.ToString() +
    "发来了: " +
    Encoding.UTF8.GetString(bytes, 0, receiveNum));

服务端和客户端Socket的UDP同步通信

服务端和客户端的UDP通信基本思路是一致的:

  1. 创建套接字Socket
  2. Bind​方法将套接字与本地地址进行绑定
  3. ReceiveFrom​和SendTo​方法在套接字上收发消息
  4. Shutdown​方法释放连接
  5. 关闭套接字

因此它们的最基础的UDP同步通信实现也大同小异

服务端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
using System.Net;
using System.Net.Sockets;
using System.Text;

namespace TeachUdpServer
{
internal class Program
{
static void Main(string[] args)
{
//创建套接字
Socket socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
//绑定本机地址
IPEndPoint ipPoint = new IPEndPoint(IPAddress.Parse("127.0.0.1"), 8081);
socket.Bind(ipPoint);
Console.WriteLine("服务器开启");
//接收消息
byte[] bytes = new byte[512];
//这个变量主要是用来记录,谁发的消息给你,传入函数后,在内部,它会帮助我们进行赋值
EndPoint remoteIpPoint = new IPEndPoint(IPAddress.Any, 0);
int receiveNum = socket.ReceiveFrom(bytes, ref remoteIpPoint);
Console.WriteLine("IP: " + (remoteIpPoint as IPEndPoint)?.Address.ToString() +
"port: " + (remoteIpPoint as IPEndPoint)?.Port.ToString() +
"发来了: " +
Encoding.UTF8.GetString(bytes, 0, receiveNum));

//发送到指定目标,因为是先收消息,接受消息时就已经得到了客户端IP地址和端口号,因此可以直接复用
socket.SendTo(Encoding.UTF8.GetBytes("欢迎发送给服务器"), remoteIpPoint);

//释放关闭套接字
socket.Shutdown(SocketShutdown.Both);
socket.Close();
Console.ReadKey();
}
}
}

实现一个相对完善的UDP服务端

以上的代码一次只能发送和接受一条消息,随后服务端的运行就会终止

我们可以通过开启一个新线程并使用循环来反复监听消息接收,同时做到不卡主线程,
接收到消息就将发送消息的客户端的IP地址和端口号记录起来,做到主动对发送过消息的客户端发消息
同时记录该客户端上一次发送消息的时间,如果长时间没有收到消息,就移除这个客户端的记录
同时,还要在收消息的同时区分消息类型

注意!我们目前写的服务端只是为了学习用,实际上这样的服务端是不安全的
因为在UDP的通信模式下,只要知道了服务端的IP地址就可以向该服务端发送消息
如果有人恶意发送不按照规定序列化的消息或者恶意发送垃圾信息,就有可能会出现处理消息的问题,增加服务器压力

实际上,一般根本不会使用UDP来记录客户端消息,而是先建立一层TCP连接,在根据这个TCP连接再使用UDP进行通信

Program.cs

  • socket​:让外部可以调用唯一的服务端socket
  • Main​:用于初始化服务端Socket,绑定其IP地址,然后检测终端输入,输入B:​就广播消息
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
namespace TeachUdpServerExercises
{
internal class Program
{
public static ServerSocket? socket;

static void Main(string[] args)
{
socket = new ServerSocket();
socket.Start("127.0.0.1", 8080);
Console.WriteLine("UDP服务器启动了!");
while (true)
{
string? input = Console.ReadLine();
if (string.IsNullOrEmpty(input))
continue;
if (input.Substring(0, 2) == "B:")
{
PlayerMessage message = new PlayerMessage();
message.playerData = new PlayerData();
message.playerID = 1001;
message.playerData.name = "唐老狮的UDP服务器";
message.playerData.atk = 88;
message.playerData.lev = 66;
socket.Broadcast(message);
}
}
}
}
}

ServerSocket.cs

  • isClose​:用于控制多线程循环是否结束,让多线程在socket存在时才继续执行
  • clientDic​:以发送者的IP地址和端口号为键,记录发送过消息的客户端的字典,用于广播时主动对发送过消息的客户端发消息
  • Start​:初始化服务端,绑定IP地址和端口号,开启接收消息和检测超时的线程
  • CheckTimeOut​:检测超时的线程主函数,每隔一段时间就检测一次客户端是否超时未发消息,过久没法消息的客户端消息会被删除
  • ReceiveMessage​:接收消息的线程主函数,循环接收数据,并调用发送者对应的Client​处理数据,将新发消息的客户端记录下来
  • SendTo​:向某个IP地址和端口号发送消息
  • Broadcast​:向所有记录过的客户端发消息
  • Close​:关闭并释放Socket
  • RemoveClient​:将记录过的客户端从字典内移除出去
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
using System.Net;
using System.Net.Sockets;

namespace TeachUdpServerExercises
{
internal class ServerSocket
{
private Socket? socket;

private bool isClose = true;
//可以通过记录谁给我发了消息,把它的ip和端口记下来
private Dictionary<string, Client> clientDic = new Dictionary<string, Client>();

public void Start(string ip, int port)
{
IPEndPoint ipPoint = new IPEndPoint(IPAddress.Parse(ip), port);
socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);

try
{
socket.Bind(ipPoint);
isClose = false;
//消息接收的处理
ThreadPool.QueueUserWorkItem(ReceiveMessage);
//定时检测超时线程
ThreadPool.QueueUserWorkItem(CheckTimeOut);
}
catch (Exception e)
{
Console.WriteLine("Udp开启出错" + e.Message);
}
}

private void CheckTimeOut(object? obj)
{
long nowTime = 0;
List<string> delList = new List<string>();
while (true)
{
//每30秒检测一次是否移除
Thread.Sleep(30000);
//得到当前系统时间
nowTime = DateTime.Now.Ticks / TimeSpan.TicksPerSecond;
foreach (Client client in clientDic.Values)
{
//超过10秒没有收到消息的客户端消息需要被移除
if (nowTime - client.frontTime >= 10)
delList.Add(client.cliendStrID);
}
//从待删除列表中移除超时的客户端消息
for (int i = 0; i < delList.Count; i++)
{
RemoveClient(delList[i]);
}
delList.Clear();
}
}

private void ReceiveMessage(object? obj)
{
if (socket == null)
return;
byte[] bytes = new byte[512];
EndPoint ipPoint = new IPEndPoint(IPAddress.Any, 0);
//用于拼接字符串,唯一ID,它是由IP + 端口号构成的
string strID;
string ip;
int port;
while (!isClose)
{
if (socket.Available > 0)
{
lock (socket)
{
socket.ReceiveFrom(bytes, ref ipPoint);
}
//处理消息,最好不要在这里处理,而是交给客户端对象处理
//收到消息时,我们可以来判断是不是记录了这个客户端消息(ip和端口)
ip = ((IPEndPoint)ipPoint).Address.ToString();
port = ((IPEndPoint)ipPoint).Port;
strID = ip + ":" + port;
if (clientDic.ContainsKey(strID))
clientDic[strID].ReceiveMsg(bytes);
else
{
clientDic.Add(strID, new Client(ip, port));
clientDic[strID].ReceiveMsg(bytes);
}
}
}
}

//指定发送一共消息给某个目标
public void SendTo(BaseMessage message, IPEndPoint ipPoint)
{
if (socket == null)
return;
try
{
lock (socket)
socket.SendTo(message.Writeing(), ipPoint);
}
catch (SocketException e)
{
Console.WriteLine("发消息出现问题:" + e.SocketErrorCode + e.Message);
}
//如果是序列化问题
catch (Exception e)
{
Console.WriteLine("发送消息出问题(可能是序列化问题):" + e.Message);
}
}

public void Broadcast(BaseMessage message)
{
//广播消息,给谁广播
foreach (Client client in clientDic.Values)
{
SendTo(message, client.clientIPAndPort);
}
}

public void Close()
{
if (socket != null)
{
if (socket.Connected)
socket.Shutdown(SocketShutdown.Both);
socket.Close();
socket = null;
}
}

public void RemoveClient(string clientID)
{
if (clientDic.ContainsKey(clientID))
{
Console.WriteLine($"客户端{clientDic[clientID].clientIPAndPort}被移除了");
clientDic.Remove(clientID);
}
}
}
}

Client.cs

它是用于记录和服务器通信过的客户端的IP和端口

  • IPEndPoint​:通信过的客户端的IP和端口
  • cliendStrID​:由客户端的IP和端口组成的ID,也是在ServerSocket​的clientDic​内相对应的键
  • frontTime​:上一次收到消息的时间
  • ReceiveMsg​:从线程池中开启处理消息的线程,并记录这次发消息的时间
  • ReceiveHandle​:处理消息线程的主函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
using System.Net;

namespace TeachUdpServerExercises
{
//它是用于记录和服务器通信过的客户端的IP和端口
internal class Client
{
public IPEndPoint clientIPAndPort;
public string cliendStrID;
public long frontTime = -1; //上一次收到消息的时间

public Client(string ip, int port)
{
this.cliendStrID = ip + ":" + port;
clientIPAndPort = new IPEndPoint(IPAddress.Parse(ip), port);
}

public void ReceiveMsg(byte[] bytes)
{
//为了避免处理消息时,又接收到了其他消息,所以我们需要在处理之前先把消息拷贝出来
//处理消息和接收消息,用不同的容器避免出现问题
byte[] cacheBytes = new byte[512];
bytes.CopyTo(cacheBytes, 0);
frontTime = DateTime.Now.Ticks / TimeSpan.TicksPerSecond;
ThreadPool.QueueUserWorkItem(ReceiveHandle, cacheBytes);
}

//多线程处理消息
private void ReceiveHandle(object? obj)
{
try
{
//取出传进来的字节
if (obj is not byte[] bytes)
return;
int nowIndex = 0;
int msgID = BitConverter.ToInt32(bytes, nowIndex);
nowIndex += 4;
int msgLength = BitConverter.ToInt32(bytes, nowIndex);
nowIndex += 4;
switch (msgID)
{
case 1001:
PlayerMessage playerMessage = new PlayerMessage();
playerMessage.Reading(bytes, nowIndex);
Console.WriteLine(playerMessage.playerID);
Console.WriteLine(playerMessage.playerData.name);
Console.WriteLine(playerMessage.playerData.atk);
Console.WriteLine(playerMessage.playerData.lev);
break;
case 1003:
QuitMessage quitMessage = new QuitMessage();
//由于它没有消息体,所以不用反序列化
//处理退出
Program.socket?.RemoveClient(cliendStrID);
break;
}
}
catch (Exception e)
{
Console.WriteLine("处理消息时出错!:" + e.Message);
Program.socket?.RemoveClient(cliendStrID);
}

}
}
}

客户端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
using System.Net;
using System.Net.Sockets;
using System.Text;
using UnityEngine;

public class Lesson14 : MonoBehaviour
{
void Start()
{
//创建套接字
Socket socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
//绑定本机地址
IPEndPoint ipPoint = new IPEndPoint(IPAddress.Parse("127.0.0.1"), 8080);
socket.Bind(ipPoint);
//发送到指定目标,指定要发送的字节数组和远程计算机的IP端口
IPEndPoint remoteIpPoint = new IPEndPoint(IPAddress.Parse("127.0.0.1"), 8081);
socket.SendTo(Encoding.UTF8.GetBytes("唐老狮来了"), remoteIpPoint);
//接收消息
byte[] bytes = new byte[512];
//这个变量主要是用来记录,谁发的消息给你,传入函数后,在内部,它会帮助我们进行赋值
EndPoint remoteIpPoint2 = new IPEndPoint(IPAddress.Any, 0);
int receiveNum = socket.ReceiveFrom(bytes, ref remoteIpPoint2);
print("IP: " + (remoteIpPoint2 as IPEndPoint).Address.ToString() +
"port: " + (remoteIpPoint2 as IPEndPoint)?.Port.ToString() +
"发来了: " +
Encoding.UTF8.GetString(bytes, 0, receiveNum));
//释放关闭套接字
socket.Shutdown(SocketShutdown.Both);
socket.Close();
}
}

实现一个相对完善的UDP客户端

以上的代码一次只能发送和接受一条消息,随后服务端的运行就会终止

我们可以通过开启一个新线程并使用循环来反复监听消息接收,同时做到不卡主线程,
接收到消息就验证该消息是否是服务端发来的,如果不是就不处理这条消息,同时还要做到处理消息类型

  • sendMessageQueue​:用于发送消息的队列,公共容器,主线程往sendMessageQueue​放,发送线程从sendMessageQueue​取
  • receiveMessageQueue​:用于接收消息的队列,公共容器,接收线程往receiveMessageQueue​放,主线程从receiveMessageQueue​取
  • isClose​:用于控制多线程循环是否结束,让多线程在socket存在时才继续执行
  • Update​:每帧都检测receiveMessageQueue​内是否存在消息,若存在,就将消息输出到控制台和屏幕上
  • StartClient​:初始化客户端Socket,绑定本机IP和一个端口号,开始发送和接收消息的线程
  • SendMsg​:发送消息线程主函数,循环检测sendMessageQueue​内是否有要发送的消息,有就转换为字节数组发送到服务端
  • ReceiveMsg​:接收消息线程主函数,循环检测是否有消息需要接收,有就接收,验证是否是服务端的消息,将消息传入到receiveMessageQueue​内
  • Send​:将外部要发送的消息传入到sendMessageQueue​内,等待发送消息线程将消息转换为字节数组发送到服务端
  • Close​:发送一条断联消息,然后关闭并释放客户端Socket,在管理器被销毁时会调用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
using System;
using System.Collections;
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using System.Threading;
using UnityEngine;

public class UdpNetMgr : MonoBehaviour
{
private static UdpNetMgr instance;
public static UdpNetMgr Instance => instance;

private EndPoint serverIpPoint;

private Socket socket;
//客户端Socket是否关闭
private bool isClose = true;

//接收消息队列和发送消息队列,在多线程里面可以操作
private Queue<BaseMessage> sendQueue = new Queue<BaseMessage>();
private Queue<BaseMessage> receiveQueue = new Queue<BaseMessage>();

private byte[] cacheBytes = new byte[512];

private void Awake()
{
instance = this;
DontDestroyOnLoad(gameObject);
}

private void Update()
{
if (receiveQueue.Count > 0)
{
BaseMessage baseMsg = receiveQueue.Dequeue();
switch (baseMsg)
{
case PlayerMessage msg:
print(msg.playerID);
print(msg.playerData.name);
print(msg.playerData.atk);
print(msg.playerData.lev);
break;
}
}
}

private void OnDestroy()
{
Close();
}

// 启动客户端socket相关的方法
public void StartClient(string ip, int port)
{
//如果当前是开启状态就不用再开了
if (!isClose)
return;

//先记录服务器地址,一会发消息时会使用
serverIpPoint = new IPEndPoint(IPAddress.Parse(ip), port);
IPEndPoint clientIpPort = new IPEndPoint(IPAddress.Parse("127.0.0.1"), 8082);
try
{
socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
socket.Bind(clientIpPort);
isClose = false;
print("客户端网络启动");
ThreadPool.QueueUserWorkItem(ReceiveMsg);
ThreadPool.QueueUserWorkItem(SendMsg);
}
catch (System.Exception e)
{
print("启动Socket出问题:" + e.Message);
}
}

private void ReceiveMsg(object obj)
{
EndPoint tempIpPoint = new IPEndPoint(IPAddress.Any, 0);
int nowIndex;
int msgID;
int msgLength;
while (!isClose)
{
if (socket != null && socket.Available > 0)
{
try
{
socket.ReceiveFrom(cacheBytes, ref tempIpPoint);
if (!tempIpPoint.Equals(serverIpPoint)) //如果发现发消息的不是服务器,就不处理这个消息
continue;
//处理服务器发来的消息
nowIndex = 0;
msgID = BitConverter.ToInt32(cacheBytes, nowIndex); //解析ID
nowIndex += 4;
msgLength = BitConverter.ToInt32(cacheBytes, nowIndex); //解析长度
nowIndex += 4;
//解析消息体
BaseMessage msg = null;
switch (msgID)
{
case 1001:
msg = new PlayerMessage();
//反序列化消息体
msg.Reading(cacheBytes, nowIndex);
break;
}
if (msg != null)
receiveQueue.Enqueue(msg);
}
catch (SocketException e)
{
print("接收消息出错:" + e.SocketErrorCode + e.Message);
}
catch (System.Exception e)
{
print("接收消息出错(非网络原因):" + e.Message);
}
}
}
}

//发送消息
private void SendMsg(object obj)
{
while (!isClose)
{
lock (sendQueue)
{
if (socket != null && sendQueue.Count > 0)
{
try
{
socket.SendTo(sendQueue.Dequeue().Writeing(), serverIpPoint);
}
catch (SocketException e)
{
print("发送消息出错:" + e.SocketErrorCode + e.Message);
}
}
}
}
}

public void Send(BaseMessage msg)
{
lock (sendQueue)
{
sendQueue.Enqueue(msg);
}
}

//关闭socket
public void Close()
{
if (socket != null)
{
isClose = true;
QuitMessage msg = new QuitMessage();
//发送一个退出消息给服务器,让其移除记录
socket.SendTo(msg.Writeing(), serverIpPoint);
socket.Shutdown(SocketShutdown.Both);
socket.Close();
socket = null;
}
}
}