(一)需求
在游戏中我们经常能够看到玩家与玩家之间可以进行私聊,在QQ或微信中最基本的功能就是用户与用户之间的通信。抽象成计算机网络,就是两个客户端通过服务器进行私聊通信,两个客户端可以互相看到对方发送过来的信息。这种两个客户端的私聊通信是如何实现的呢?在本篇文章我们就来探讨一下。
(二)解决思路
这个需求的重点部分在于网络通信,需要我们掌握基本的计算机网络通信知识,具体到每种编程语言又有对应的API。如果把这个需求抽象到计算机网络中,我们就可以理解成两个客户端向服务器发送信息,服务器接收信息后又把信息发送给另一个客户端。这样,一个客户端就可以接收到另一个客户端发送的信息了。
(三)设计思路
服务器基于本地服务器开发,通过一个单独的C#控制台项目模拟,编程语言使用C#,客户端通过Unity3D构建GUI并编写客户端脚本。两个客户端则通过打开两个Unity3D项目的可执行文件进行模拟,客户端的GUI需要有调试面板、客户端名称下拉菜单、连接和断开连接按钮、消息显示面板、消息输入框和消息发送按钮等。
(四)代码实现
由于代码中引用了自定义的网络通信共享库NetShare,关于NetShare请阅读这篇文章。
客户端
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using UnityEngine;
using UnityEngine.UI;
/*
自定义网络通信共享库NetShare,包括通用数据包DataPacket、私聊频道服务器数据包PSDataPacket、
服务器数据包ServerDataPacket和私聊频道客户端数据包PCDataPacket等。
*/
using NetShare;
using System.Threading;
using System.Linq;//私聊频道客户端
public class PersonalChannelClient : MonoBehaviour
{public Text BaseInfo;//显示Socket连接基本信息的文本public Text EchoContents;//Socket回显信息的文本public Text ChatContents;//聊天信息的文本public Dropdown Friends;//目标客户端名称下拉菜单public Dropdown ClientMenu;//客户端名称下拉菜单public Button Connect;//连接按钮public Button DisConnect;//断开连接按钮public InputField SendInput;//聊天消息输入框public Button Send;//聊天信息发送按钮static string ipAddressStr;//IP地址字符串static int port;//端口static IPAddress iPAddress;//IP地址对象static IPEndPoint iPEndPoint;//IP端点对象string clientName, sendStr;//客户端名称和发送信息字符串Socket currentClientSocket;//当前客户端Socketbool isLockSend;//是否锁定聊天信息发送按钮byte[] buffer;//消息接收缓冲区Queue<string> echoContentQueue, chatContentQueue;//回显信息队列和聊天信息队列PCDataPacket dataPacket;//通用数据包List<string> desClientNames;//目标客户端名称合集//反映Socket是否与服务器有效连接的属性bool isConnected{get{if (currentClientSocket == null) return false;return !currentClientSocket.Poll(10, SelectMode.SelectRead) && currentClientSocket.Connected;}}void Start(){//初始化ipAddressStr = "127.0.0.1";clientName = ClientMenu.options.Count > 0 ? ClientMenu.options[0].text : "";port = 5500;iPAddress = IPAddress.Parse(ipAddressStr);iPEndPoint = new IPEndPoint(iPAddress, port);buffer = new byte[1024];echoContentQueue = new Queue<string>();chatContentQueue = new Queue<string>();desClientNames = new List<string>() { "None" };//为UI控件添加监听事件ClientMenu.onValueChanged.AddListener((index) =>{clientName = ClientMenu.options[index].text;});Connect.onClick.AddListener(() =>{Thread thread = new Thread(new ThreadStart(ConnectDeal));thread.Start();});DisConnect.onClick.AddListener(() =>{Thread thread = new Thread(new ThreadStart(DisConnectDeal));thread.Start();});Send.onClick.AddListener(() =>{sendStr = SendInput.text;Thread thread = new Thread(new ThreadStart(SendDeal));thread.Start();SendInput.text = string.Empty;});}void Update(){//不断更新Socket基本信息BaseInfo.text = $"ClientName:{clientName}" +string.Format("\nSocketHashCode:{0}", currentClientSocket == null ? "None" : currentClientSocket.GetHashCode().ToString()) +$"\nisLock:{isLockSend}" +string.Format("\nPoll:{0}", currentClientSocket == null ? "None" : (!currentClientSocket.Poll(10, SelectMode.SelectRead)).ToString()) +string.Format("\nIsConnected:{0}", currentClientSocket == null ? "False" : currentClientSocket.Connected.ToString());//更新回显信息if (echoContentQueue.Count > 0){while (echoContentQueue.Count > 0){SetEchoContents(echoContentQueue.Dequeue());}}//更新聊天信息if (chatContentQueue.Count > 0){while (chatContentQueue.Count > 0){SetChatContents(chatContentQueue.Dequeue());}}//更新目标客户端名称下拉菜单if (desClientNames?.Count > 0){Friends.AddOptions(desClientNames);desClientNames.Clear();}}//设置回显信息相关UI的内容void SetEchoContents(string text){EchoContents.text += text;}//设置聊天信息相关UI的内容void SetChatContents(string text){ChatContents.text += text;}//执行逻辑:Socket异步连接处理void ConnectDeal(){echoContentQueue.Enqueue($"\n客户端{clientName}正在请求服务器连接...");Socket clientSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);clientSocket.BeginConnect(iPEndPoint, ConnectCallback, clientSocket);}//执行逻辑:Socket异步断开连接处理void DisConnectDeal(){echoContentQueue.Enqueue($"\n客户端{clientName}正在断开与服务器的连接...");if (isConnected){currentClientSocket.Shutdown(SocketShutdown.Both);currentClientSocket.BeginDisconnect(false, DisConnectCallback, currentClientSocket);}else echoContentQueue.Enqueue($"\n客户端{clientName}未与服务器建立连接,无法进行断开连接的操作...");}//执行逻辑:Socket异步接收信息处理void ReceiveDeal(){echoContentQueue.Enqueue($"\n客户端{clientName}开始监听服务器响应...");currentClientSocket.BeginReceive(buffer, 0, buffer.Length, SocketFlags.None, ReceiveCallback, currentClientSocket);}//执行逻辑:Socket异步发送信息处理void SendDeal(){if (!isLockSend && !string.IsNullOrEmpty(sendStr)){dataPacket.mContent = sendStr;string v_desClientName = Friends.options[Friends.value].text;if (!v_desClientName.Equals("None")) dataPacket.mDestinationClientName = v_desClientName;byte[] bytes = dataPacket.ToBytes();currentClientSocket.BeginSend(bytes, 0, bytes.Length, SocketFlags.None, SendCallback, currentClientSocket);}}//执行逻辑:Socket异步连接处理回调void ConnectCallback(IAsyncResult ar){try{Socket socket = ar.AsyncState as Socket;socket.EndConnect(ar);currentClientSocket = socket;if (isConnected){dataPacket = new PCDataPacket(){mLocalEndPointStr = socket.LocalEndPoint.ToString(),mClientName = clientName,mDestinationClientName = string.Empty,mContent = $"成功与服务器建立连接!"};byte[] bytes = dataPacket.ToBytes();socket.BeginSend(bytes, 0, bytes.Length, SocketFlags.None, SendCallback, currentClientSocket);isLockSend = false;echoContentQueue.Enqueue($"\n<color=orange>客户端{clientName}与服务器连接成功!</color>");ReceiveDeal();}else echoContentQueue.Enqueue($"\n<color=red>客户端{clientName}与服务器连接失败!</color>");}catch (SocketException se){echoContentQueue.Enqueue($"\n<color=red>客户端{clientName}与服务器连接失败!</color>\n错误信息:{se.Message}");}}//执行逻辑:Socket异步断开连接处理回调void DisConnectCallback(IAsyncResult ar){try{isLockSend = true;Socket socket = ar.AsyncState as Socket;socket.EndDisconnect(ar);dataPacket = null;echoContentQueue.Enqueue($"\n<color=orange>客户端{clientName}与服务器断开连接操作成功!</color>");}catch (SocketException se){echoContentQueue.Enqueue($"\n客户端{clientName}与服务器断开连接操作失败!\n错误信息:{se.Message}");}}//执行逻辑:Socket异步发送信息处理回调void SendCallback(IAsyncResult ar){try{Socket socket = ar.AsyncState as Socket;socket.EndSend(ar);echoContentQueue.Enqueue($"\n客户端{clientName}向服务器发送了一条消息!");}catch (SocketException se){echoContentQueue.Enqueue($"\n客户端{clientName}向服务器发送信息操作失败!\n错误信息:{se.Message}");}}//执行逻辑:Socket异步接收信息处理回调void ReceiveCallback(IAsyncResult ar){try{Socket socket = ar.AsyncState as Socket;int count = socket.EndReceive(ar);DataPacket dataPacket = DataPacket.ToObject<DataPacket>(buffer.Take(count).ToArray());if (dataPacket is PSDataPacket psdp && psdp.mClientNames?.Length > 0){desClientNames.Clear();foreach (string name in psdp.mClientNames){if (Friends.options.FindIndex((od) => od.text.Equals(name)) == -1) desClientNames.Add(name);}}else if (dataPacket is ServerDataPacket sdp){string v_res = sdp.mContent;if (!string.IsNullOrEmpty(v_res)) chatContentQueue.Enqueue("\n" + v_res);}//若Socket连接有效则继续接收消息if (isConnected)socket.BeginReceive(buffer, 0, buffer.Length, SocketFlags.None, ReceiveCallback, socket);}catch (SocketException se){echoContentQueue.Enqueue($"\n客户端{clientName}接收服务器消息失败!\n错误信息:{se.Message}");}}
}
服务器
using System.Net.Sockets;
using System.Net;/*
自定义网络通信共享库NetShare,其中包括了私聊频道服务器数据包PSDataPacket、通用数据包DataPacket、
服务器数据包ServerDataPacket和私聊频道客户端数据包PCDataPacket等。*/using NetShare;namespace UnityServer
{//私聊频道服务器internal class PersonalChannelServer{private static string ipAddress = "127.0.0.1";//IP地址字符串private static int port = 5500;//端口private static int maxConnect = 20;//最大连接数private static byte[] buffer = new byte[1024];//消息缓冲区//客户端Socket合集,key为IPEndPoint字符串,value为服务器为客户端分配的Socketprivate static Dictionary<string, Socket> clients = new Dictionary<string, Socket>();private static Socket? serverSocket;//服务器Socket//客户端键值对,key为客户端名称,value为IPEndPoint字符串private static Dictionary<string, string> clientKVs = new Dictionary<string, string>();private static void Main(string[] args){Thread thread = new Thread(new ThreadStart(ServerDeal));thread.Start();Console.ReadLine();}//判断Socket是否进行有效连接private static bool IsConnected(Socket socket){if (socket == null) return false;return !socket.Poll(10, SelectMode.SelectRead) && socket.Connected;}//执行逻辑:服务器处理private static void ServerDeal(){serverSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);IPAddress v_ipAddress = IPAddress.Parse(ipAddress);serverSocket.Bind(new IPEndPoint(v_ipAddress, port));serverSocket.Listen(maxConnect);Console.WriteLine($"开启服务器[{serverSocket.LocalEndPoint}]...");serverSocket.BeginAccept(AcceptCallback, null);}//执行逻辑:Socket异步接收消息private static void ReceiveDeal(object? clientSocket){Console.WriteLine("********************");if (clientSocket == null) return;Socket? v_clientSocket = clientSocket as Socket;if (v_clientSocket == null) return;Console.WriteLine("接收到客户端的连接请求!");if (IsConnected(v_clientSocket))v_clientSocket.BeginReceive(buffer, 0, buffer.Length, SocketFlags.None, ReceiveCallback, v_clientSocket);}//添加客户端Socket到客户端Socket合集private static void AddClient(Socket clientSocket){if (clientSocket == null) return;EndPoint? endPoint = clientSocket.RemoteEndPoint;if (endPoint != null){string? v_endPointStr = endPoint.ToString();if (v_endPointStr != null) clients[v_endPointStr] = clientSocket;}}//向所有客户端发送指定信息private static void SendToAll(PSDataPacket dataPacket){if (dataPacket == null) return;byte[] bytes = dataPacket.ToBytes();foreach (Socket clientSocket in clients.Values){if (IsConnected(clientSocket)){Thread thread = new Thread(() =>{clientSocket.BeginSend(bytes, 0, bytes.Length, SocketFlags.None, SendCallback, clientSocket);});thread.Start();}}}//向指定的客户端发送服务器数据包private static void SendTo(ServerDataPacket dataPacket, Socket? destinationSocket){if (dataPacket == null || destinationSocket == null) return;byte[] bytes = dataPacket.ToBytes();if (IsConnected(destinationSocket)){Thread thread = new Thread(() =>{destinationSocket.BeginSend(bytes, 0, bytes.Length, SocketFlags.None, SendCallback, destinationSocket);});thread.Start();}}//Socket监听请求回调private static void AcceptCallback(IAsyncResult ar){try{if (serverSocket != null){Socket clientSocket = serverSocket.EndAccept(ar);AddClient(clientSocket);Thread thread = new Thread(new ParameterizedThreadStart(ReceiveDeal));thread.Start(clientSocket);serverSocket.BeginAccept(AcceptCallback, null);}}catch (SocketException se){Console.WriteLine("AcceptException:" + se.Message);}}//Socket发送信息回调private static void SendCallback(IAsyncResult ar){try{Socket? clientSocket = ar.AsyncState as Socket;if (clientSocket != null) clientSocket.EndSend(ar);}catch (SocketException se){Console.WriteLine("SendException:" + se.Message);}}//Socket接收信息回调private static void ReceiveCallback(IAsyncResult ar){try{Socket? clientSocket = ar.AsyncState as Socket;if (clientSocket != null){int bytesCount = clientSocket.EndReceive(ar);PCDataPacket? dataPacket = DataPacket.ToObject<PCDataPacket>(buffer.Take(bytesCount).ToArray());if (dataPacket != null){if (clientSocket.RemoteEndPoint != null){string? v_endPointStr = clientSocket.RemoteEndPoint.ToString();if (!string.IsNullOrEmpty(v_endPointStr)){clientKVs[dataPacket.mClientName] = v_endPointStr;SendToAll(new PSDataPacket(){mClientNames = clientKVs.Keys.ToArray()});}}string v_content = $"客户端{dataPacket.mClientName}:{dataPacket.mContent}";Socket? destinationSocket;string? endPointStr;clientKVs.TryGetValue(dataPacket.mDestinationClientName, out endPointStr);if (!string.IsNullOrEmpty(endPointStr) && clients.TryGetValue(endPointStr, out destinationSocket))SendTo(new ServerDataPacket() { mContent = v_content }, destinationSocket);}if (IsConnected(clientSocket))clientSocket.BeginReceive(buffer, 0, buffer.Length, SocketFlags.None, ReceiveCallback, clientSocket);}}catch (SocketException se){Console.WriteLine("ReceiveException:" + se.Message);}}}
}
(五)测试
测试流程大概是先启动服务器,然后启动三个客户端,三个客户端分别以A、B、C的名称作为客户端名称与服务器建立连接,连接后再由客户端A、B、C分别向服务器发送信息,通过观察三个客户端的消息面板来确定测试结果,这里之所以启动三个客户端是为了进行对比测试,以区分多客户端的同频道通信,具体测试流程请观看下列视频:
本地服务器客户端单独通信
(六)总结
在服务器端,我们通过一个C#控制台项目来模拟服务器后台,服务器与客户端具有类似的功能,同样具有发送、接收消息的功能,不同的是服务器具有监听客户端连接的功能,而客户端具有向服务器发送连接请求的功能,本质上这些都是通过Socket实现的功能,人为划分成服务器端和客户端。在客户端我们通过GUI将用户的操作进行可视化构建,实现了回显、客户端名称选择、连接、断开连接、发送和显示消息等基本交互。
为了模拟多客户端并发操作,所有功能我们都采用了异步的方式启动,对于真正的网络通信而言,这对我们来说才刚刚开始,不过通过这个案例也让我们了解了基本的网络通信流程。
如果这篇文章对你有帮助,请给作者点个赞吧!