UN2L3——Socket的TCP同步通信

服务端Socket的TCP同步通信

服务端Socket的基本思路如下:

  1. 创建套接字Socket
  2. Bind​方法将套接字与本地地址绑定
  3. Listen​方法监听
  4. Accept​方法等待客户端连接
  5. 建立连接,Accept​返回新套接字
  6. 对返回的新套接字用Send​和Receive​相关方法收发数据
  7. Shutdown​方法释放连接
  8. 关闭套接字

根据以上思路,实现最基础的服务端代码

两台电脑在同一局域网下连接的实践

如果可以使用两台电脑并连接同一路由器,那么不妨将服务端代码中的IP地址改为在设备在路由器下的IP地址(在cmd使用ipconfig​可查询)
并且客户端使用服务器的IP地址,如果可行,就可以实现客户端和服务端相互连接,使用两台设备学习的体会效果更好

游戏服务器一般使用8080​作为自己的端口号

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
static void Main(string[] args)
{
//1.创建套接字Socket(TCP)
Socket socketTcp = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
//2.用Bind方法将套接字与本地地址绑定
try
{
IPEndPoint ipPoint = new IPEndPoint(IPAddress.Parse("127.0.0.1"), 8080);
socketTcp.Bind(ipPoint);
}
catch (Exception e)
{
Console.WriteLine("绑定报错" + e.Message);
return;
}
//3.用Listen方法监听
socketTcp.Listen(1024);
Console.WriteLine("服务端绑定监听结束,等待客户端连入");
//4.用Accept方法等待客户端连接
//5.建立连接,Accept返回新套接字
Socket socketClient = socketTcp.Accept();
Console.WriteLine("有客户端连入了");
//6.用Send和Receive相关方法收发数据
//发送
socketClient.Send(Encoding.UTF8.GetBytes("欢迎连入服务端"));
//接受
byte[] result = new byte[1024];
//返回值为接受到的字节数
int receiveNum = socketClient.Receive(result);
Console.WriteLine("接受到了{0}发来的消息:{1}",
socketClient.RemoteEndPoint.ToString(),
Encoding.UTF8.GetString(result, 0, receiveNum));

//7.用Shutdown方法释放连接
socketClient.Shutdown(SocketShutdown.Both);
//8.关闭套接字
socketClient.Close();

Console.WriteLine("按任意键退出");
Console.ReadKey();
}

让服务端可以连接多个客户端

以上的代码一次只能连接一个客户端,随后服务端的运行就会终止

我们可以通过开启一个新线程并使用循环来反复监听客户端的连入,同时做到不卡主线程,
监听到客户端连入就将返回的新Socket​管理起来,分别管理收发消息
同时再开启一个用于接收消息的线程,循环检测是否有新的数据,有就接收数据,做到不卡主线程
这样就可以让服务端连接多个客户端

  • isClose​:用于控制各个线程的循环终止,当在终端输入Quit​指令时就为true
  • Main​:用于初始化服务端Socket,并开启监听连入线程和接收消息线程,然后检测终端输入,输入Quit​就关闭服务器,B:​就广播消息
  • AcceptClientConnent​:单独线程,循环监听客户端的连入,监听到客户端连入就将新Socket添加到列表内管理
  • ReceiveMsg​:单独线程,循环所有列表内的客户端Socket,检测是否有要接收的数据,当检测到有数据就执行receive​方法
  • HandleMsg​:采用线程池,将接收到的数据进行处理,输出到终端上,数据的接收和处理一般是分开线程,避免处理信息卡住接收消息
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
using System.Net;
using System.Net.Sockets;
using System.Text;

namespace TeachTcpServerExecises
{
internal class Program
{
static Socket? socket; //监听客户端连入的Socket
static List<Socket> clientSockets = new List<Socket>(); //客户端Socket列表
static bool isClose = false; //是否关闭,控制多线程运行是否结束

static void Main(string[] args)
{
//绑定服务端IP地址与端口号,设置最大监听数量
socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
IPEndPoint ipPoint = new IPEndPoint(IPAddress.Parse("192.168.1.111"), 8080);
socket.Bind(ipPoint);
socket.Listen(1024);
//开启监听连入线程和接收消息线程
Thread accpetThread = new Thread(AcceptClientConnent);
accpetThread.Start();
Thread receiveThread = new Thread(ReceiveMsg);
receiveThread.Start();

while (true)
{
string? input = Console.ReadLine();
//定义一个规则,输入Quit就关闭服务器,断开所有连接
if (input == null)
continue;
if (input == "Quit")
{
isClose = true;
for (int i = 0; i < clientSockets.Count; i++)
{
clientSockets[i].Shutdown(SocketShutdown.Both);
clientSockets[i].Close();
}
clientSockets.Clear();
break;
}
//定义一个规则,输入"B:广播消息内容"就广播消息
else if (input.Substring(0, 2) == "B:")
{
for (int i = 0;i < clientSockets.Count; i++)
{
clientSockets[i].Send(Encoding.UTF8.GetBytes(input.Substring(2)));
}
}
}
}

static void AcceptClientConnent()
{
while (!isClose)
{
if (socket == null)
continue;
Socket clientSocket = socket.Accept();
clientSockets.Add(clientSocket);
clientSocket.Send(Encoding.UTF8.GetBytes("欢迎你连入服务端"));
}
}

static void ReceiveMsg()
{
Socket clientSocket;
byte[] result = new byte[1024 * 1024];
int receiveNum;
int i = 0;
while (!isClose)
{
for (i = 0; i < clientSockets.Count; i++)
{
clientSocket = clientSockets[i];
//判断该socket是否有可以接收的消息,返回值就是字节数
if (clientSocket.Available > 0)
{
//客户端即使没有发消息过来,这句代码也会执行
receiveNum = clientSocket.Receive(result);
//如果直接在这收到消息就处理,就可能造成问题,即不能够及时的处理别人的消息
//为了不影响别人消息的处理,我们把消息处理,交给新的线程,为了节约线程相关的开销,我们使用线程池
ThreadPool.QueueUserWorkItem(HandleMsg, (clientSocket, Encoding.UTF8.GetString(result, 0, receiveNum)));
}
}
}
}

static void HandleMsg(object? obj)
{
if (obj == null)
return;
(Socket s, string str) info = ((Socket s, string str))obj;
Console.WriteLine($"收到了客户端{info.s.RemoteEndPoint}发来的消息:{info.str}");
}
}
}

使用面向对象封装Socket

我们可以通过面向对象来封装服务器Socket和连接客户端的Socket,使连接客户端Socket和服务端Socket的逻辑各自的逻辑分离开

Program.cs

  • Main​:创建并初始化服务器SocketServerSocket​,然后循环监听终端输入,输入Quit​就关闭服务端,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
internal class Program
{
static void Main(string[] args)
{
ServerSocket socket = new ServerSocket();
socket.Start("192.168.1.111", 8080, 1024);
Console.WriteLine("服务器开启成功");
while (true)
{
string? input = Console.ReadLine();
//定义一个规则 关闭服务器,断开所有连接
if (input == null)
continue;
if (input == "Quit")
{
socket.Close();
}
//定义一个规则,广播消息
else if (input.Substring(0, 2) == "B:")
{
socket.BroadCast(input.Substring(2));
}
}
}
}

ServerSocket.cs

ServerSocket​将服务端Socket常用的方法封装起来,同时管理所有的连接客户端的SocketClientSocket

  • clientDic​:用于管理各个连接客户端的Socket,可以通过ID来获取单个Socket
  • isClose​:用于控制各个线程的循环终止,在Close​方法中设置为true
  • Start​:提供给外部开启并初始化服务端Socket的方法,同时通过线程池开启监听连入和消息接收的线程
  • Close​:关闭并释放所有的Socket,关闭监听连入和消息接收的线程
  • Accept​:循环监听客户端的连入,监听到连入就实例化ClientSocket​并加入字典管理
  • Receive​:循环检测字典内的ClientSocket​是否有消息需要接收,检测到就执行ClientSocket​的接收消息方法
  • BroadCast​:让所有的ClientSocket​发送传入的广播消息
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
using System.Net;
using System.Net.Sockets;

namespace TeachTcpServerExecises2
{
internal class ServerSocket
{
//服务器的Socket
public Socket? socket;
public Dictionary<int, ClientSocket> clientDic = new Dictionary<int, ClientSocket>();
private bool isClose;

public void Start(string ip, int port, int num)
{
//初始化服务器Socket,绑定IP地址和端口号,设置最大连接数量
socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
IPEndPoint ipPoint = new IPEndPoint(IPAddress.Parse(ip), port);
socket.Bind(ipPoint);
socket.Listen(num);
//通过线程池开启监听连入和消息接收的线程,并使isClose为false确保持续循环
ThreadPool.QueueUserWorkItem(Accept);
ThreadPool.QueueUserWorkItem(Receive);
isClose = false;
}

public void Close()
{
if (socket == null)
return;
foreach (var client in clientDic.Values)
{
client.Close();
}
clientDic.Clear();
socket.Shutdown(SocketShutdown.Both);
socket.Close();
socket = null;
isClose = true;
}

//接收客户端连入
private void Accept(object? obj)
{
if (socket == null)
return;
while (!isClose)
{
try
{
//连入一个客户端
Socket clientSocket = socket.Accept();
ClientSocket client = new ClientSocket(clientSocket);
client.Send("欢迎连入服务器");
lock (clientDic)
{
clientDic.Add(client.clientID, client);
}
}
catch (SocketException e)
{
Console.WriteLine($"客户端连入报错:{e.Message}");
}
}
}

//接收客户端消息
private void Receive(object? obj)
{
while (!isClose)
{
lock (clientDic)
{
if (clientDic.Count > 0)
{
foreach (ClientSocket client in clientDic.Values)
{
client.Receive();
}
}
}
}
}

//广播消息
public void BroadCast(string info)
{
lock (clientDic)
{
foreach (var client in clientDic.Values)
{
client.Send(info);
}
}
}
}
}

ClientSocket.cs

ClientSocket​将连接客户端的Socket常用的方法封装起来,并由ServerSocket​管理

  • CLIENT_BEGIN_ID​:连接客户端的Socket起始ID,每多一个ClientSocket​就累加1
  • clientID​:ClientSocket​的ID,便于从字典内获取
  • Connented​:是否连接
  • Close​:关闭并释放Socket
  • Send​:发送消息
  • Receive​:接收消息,当接收到消息就将从线程池开启一个新MsgHandle​线程去处理消息
  • MsgHandle​:处理消息方法,将接收到的消息输出到终端上
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
using System.Net.Sockets;
using System.Text;

namespace TeachTcpServerExecises2
{
internal class ClientSocket
{
private static int CLIENT_BEGIN_ID = 1;
public int clientID;
private Socket socket;

/// <summary>
/// 是否是连接状态
/// </summary>
public bool Connented => socket.Connected;

public ClientSocket(Socket socket)
{
this.clientID = CLIENT_BEGIN_ID;
this.socket = socket;
CLIENT_BEGIN_ID++;
}

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

public void Send(string message)
{
if (socket != null)
{
try
{
socket.Send(Encoding.UTF8.GetBytes(message));
}
catch (Exception e)
{
Console.WriteLine($"向客户端{socket.RemoteEndPoint}发送消息失败:{e.Message}");
Close();
}
}
}

public void Receive()
{
if (socket == null)
return;
try
{
if (socket.Available > 0)
{
byte[] result = new byte[1024 * 5];
int receiveNum = socket.Receive(result);
ThreadPool.QueueUserWorkItem(MsgHandle, Encoding.UTF8.GetString(result, 0, receiveNum));
}
}
catch (Exception e)
{
Console.WriteLine($"从客户端{socket.RemoteEndPoint}接收消息失败:{e.Message}");
Close();
}
}

private void MsgHandle(object? obj)
{
if (obj == null) return;
string? str = obj as string;
Console.WriteLine($"收到客户端{socket.RemoteEndPoint}发来的消息:{str}");
}
}
}

客户端Socket的TCP同步通信

客户端Socket的基本思路如下:

  1. 创建套接字Socket
  2. Connect​方法与服务端相连
  3. Send​和Receive​相关方法收发数据
  4. Shutdown​方法释放连接
  5. 关闭套接字

根据以上思路,实现最基础的客户端代码

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
void Start()
{
//1.创建套接字Socket
Socket socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
//2.用Connect方法与服务端相连
//确定服务端的IP和端口
IPEndPoint ipPoint = new IPEndPoint(IPAddress.Parse("127.0.0.1"), 8080);
try
{
socket.Connect(ipPoint);
}
catch (SocketException e)
{
if (e.ErrorCode == 10061)
print("服务器拒绝连接");
else
print("连接服务器失败" + e.ErrorCode);
return;
}
//3.用Send和Receive相关方法收发数据

//接收数据
byte[] receiveBytes = new byte[1024];
int receiveNum = socket.Receive(receiveBytes);
print("收到服务端发来的消息:" + Encoding.UTF8.GetString(receiveBytes, 0, receiveNum));

//发送数据
socket.Send(Encoding.UTF8.GetBytes("你好,我是唐老狮的客户端"));

//4.用Shutdown方法释放连接
socket.Shutdown(SocketShutdown.Both);
//5.关闭套接字
socket.Close();
}

客户端可随时收发服务端消息且不阻塞主线程

以上的代码在连接服务端时会阻塞主线程,也不能随时和服务端通信

我们可以将网络连接相关的内容封装到一个单例模式管理器内,并开放接口给外部,每帧检测是否接收到消息

  • sendMessageQueue​:用于发送消息的队列,公共容器,主线程往sendMessageQueue​放,发送线程从sendMessageQueue​取
  • receiveMessageQueue​:用于接收消息的队列,公共容器,接收线程往receiveMessageQueue​放,主线程从receiveMessageQueue​取
  • Connent​:传入服务器的IP地址与端口号,连接对应的服务器,同时通过线程池开启发送消息和接收消息的线程
  • Update​:每帧都检测receiveMessageQueue​内是否存在消息,若存在,就将消息输出到控制台和屏幕上
  • Close​:关闭并释放客户端Socket,在管理器被销毁时会调用
  • Send​:将外部要发送的消息传入到sendMessageQueue​内,等待发送消息线程将消息转换为字节数组发送到服务端
  • SendMessage​:发送消息的线程,循环检测sendMessageQueue​内是否由要发送的消息,有就转换为字节数组发送到服务端
  • ReceiveMessage​:接收消息的线程,循环检测是否有消息需要接收,有就将消息转换为字符串传入到receiveMessageQueue​内
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
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using UnityEngine;
using UnityEngine.UI;

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

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

public Text printMessage; //用于在界面上输出接收到的消息

//客户端Socket
private Socket socket = null;
//用于发送消息的队列,公共容器,主线程往sendMessageQueue放,发送线程从sendMessageQueue取
private Queue<string> sendMessageQueue = new Queue<string>();
//用于接收消息的队列,公共容器,接收线程往receiveMessageQueue放,主线程从receiveMessageQueue取
private Queue<string> receiveMessageQueue = new Queue<string>();

//用于收消息的缓存数组
private byte[] receiveBytes = new byte[1024 * 1024];
//接收到的数据字节数
private int receiveNum = 0;
//是否连接
private bool isConnented;

void Update()
{
if (receiveMessageQueue.Count > 0)
{
printMessage.text = receiveMessageQueue.Dequeue();
}
}

private void OnDestroy()
{
Close();
}

public void Connent(string ip, int port)
{
//如果是连接状态,就直接返回
if (isConnented)
return;
socket ??= new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
//确定服务器端的IP和端口
IPEndPoint ipPoint = new IPEndPoint(IPAddress.Parse(ip), port);
//用Connent方法与服务端相连
try
{
socket.Connect(ipPoint);
isConnented = true;
//开启发送和接收线程
ThreadPool.QueueUserWorkItem(SendMessage);
ThreadPool.QueueUserWorkItem(ReceiveMessage);
Send($"来自唐老狮的客户端");
}
catch (SocketException e)
{
if (e.ErrorCode == 10061)
{
printMessage.text = "服务器拒绝连接";
print("服务器拒绝连接");
}
else
{
printMessage.text = $"连接失败:{e.ErrorCode}, {e.Message}";
print($"连接失败:{e.ErrorCode}, {e.Message}");
}
}
}

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

public void Send(string info)
{
sendMessageQueue.Enqueue(info);
}

private void SendMessage(object obj)
{
while (isConnented)
{
if (sendMessageQueue.Count > 0)
{
socket.Send(Encoding.UTF8.GetBytes(sendMessageQueue.Dequeue()));
}
}
}

private void ReceiveMessage(object obj)
{
while (isConnented)
{
if (socket.Available > 0)
{
receiveNum = socket.Receive(receiveBytes);
//收到消息,解析消息为字符串,并放入公共容器内
receiveMessageQueue.Enqueue(Encoding.UTF8.GetString(receiveBytes, 0, receiveNum));
}
}
}
}