欢迎光临
我们一直在努力

Unity网络基础服务端代码(中阶)封装服务端和客户端

第一部分:ClientSocket.cs (客户端封装)

这个类代表“一个具体的连接”。服务器每接受一个客户端,就会 new 一个这个类的对象。

using System.Net.Sockets;
using System.Text;
using System.Threading;

namespace Lesson19
{
/// <summary>
/// ClientSocket 类:封装单个客户端连接的所有逻辑
/// 职责:维护自己的Socket,负责接收自己的消息,发送消息给自己,处理断开。
/// </summary>
class ClientSocket
{
// 静态变量,所有 ClientSocket 对象共享这一个计数器,用于生成唯一的 ID
// 使用 volatile 确保多线程可见性,但在自增时最好用 Interlocked (见下文分析)
private static int CLIENT_BEGIN_ID = 1;

public int clientID; // 当前客户端的唯一标识
public Socket socket; // 当前客户端对应的网络套接字

/// <summary>
/// 构造函数:当有新客户连接时调用
/// </summary>
/// <param name="socket">服务端 Accept 得到的新 Socket</param>
public ClientSocket(Socket socket)
{
// 【注意】这里在多线程下不是绝对安全的,建议改为 Interlocked.Increment
this.clientID = CLIENT_BEGIN_ID;
this.socket = socket;
CLIENT_BEGIN_ID++;
}

// 属性:快速判断连接是否还活着
public bool Connected => this.socket.Connected;

/// <summary>
/// 关闭连接:释放资源
/// </summary>
public void Close()
{
if (socket != null)
{
try
{
// 优雅地关闭双向通信
socket.Shutdown(SocketShutdown.Both);
socket.Close();
}
catch { /* 忽略关闭时的异常 */ }
socket = null;
}
}

/// <summary>
/// 发送消息:向当前客户端发送字符串
/// </summary>
public void Send(string info)
{
if (socket != null && socket.Connected)
{
try
{
// 将字符串转为字节数组发送
byte[] data = Encoding.UTF8.GetBytes(info);
socket.Send(data);
}
catch (Exception e)
{
Console.WriteLine($"客户端 {clientID} 发送失败: {e.Message}");
Close(); // 发送失败通常意味着连接已断,主动关闭
}
}
}

/// <summary>
/// 接收消息 (非阻塞轮询版)
/// 【缺陷分析】:此方法只检查一次,如果没数据就直接返回。
/// 需要外部有一个死循环不断调用它,或者改用阻塞式接收 (StartReceiveLoop)。
/// </summary>
public void Receive()
{
if (socket == null || !socket.Connected) return;

byte[] result = new byte[1024];
try
{
// Available > 0 表示缓冲区里有数据可读
if (socket.Available > 0)
{
int receiveNum = socket.Receive(result);
// 将处理逻辑丢到线程池,避免阻塞当前的接收检查线程
ThreadPool.QueueUserWorkItem(MsgHandle, Encoding.UTF8.GetString(result, 0, receiveNum));
}
}
catch (Exception e)
{
Console.WriteLine($"客户端 {clientID} 收消息出错: " + e.Message);
Close();
}
}

/// <summary>
/// 消息处理回调:在线程池中执行
/// </summary>
private void MsgHandle(Object obj)
{
string str = obj as string;
// 【修复】原代码用了两个 {0},导致消息内容没显示。这里改为 {0}端点 {1}消息
Console.WriteLine("收到客户端[{0}] (ID:{1}) 发来的消息:{2}",
this.socket.RemoteEndPoint,
this.clientID,
str);
}
}
}

 代码分析 & 改进建议
  • 职责单一:这个类只关心“自己”。它不知道还有其他客户端,也不知道服务器有多少人了。这符合高内聚原则。
  • ID 生成隐患:CLIENT_BEGIN_ID++ 不是原子操作。在高并发(瞬间大量连接)下,可能导致两个客户端拿到相同的 ID。建议:使用 this.clientID = Interlocked.Increment(ref CLIENT_BEGIN_ID); (需将初始值调整为0)。
  • 接收模式 (Receive):
    • 当前的 Receive() 是非阻塞的。它依赖外部有一个死循环不停地调用它。
    • 缺点:如果外部循环不够快,会漏消息;如果外部循环太快且没有 Sleep,会占用 100% CPU。
    • 最佳实践:通常我们会在这个类里写一个 StartReceiveLoop() 方法,内部是一个 while(true) 的死循环,直接调用阻塞式的 socket.Receive()。这样每个客户端在自己的线程里跑,不需要服务器统一轮询。

  • 📄 第二部分:ServerSocket.cs (服务端封装)

    这个类代表“服务器管理者”。它负责监听端口、管理所有 ClientSocket 对象、处理广播。

    using System.Net;
    using System.Net.Sockets;
    using System.Collections.Generic;
    using System.Threading;

    namespace Lesson19
    {
    /// <summary>
    /// ServerSocket 类:服务器核心管理类
    /// 职责:监听端口、接受新连接、维护客户端列表、广播消息。
    /// </summary>
    class ServerSocket
    {
    public Socket socket; // 监听用的主 Socket

    // 字典存储所有在线客户端:Key=ID, Value=ClientSocket对象
    // 【关键】Dictionary 不是线程安全的,多线程访问必须加锁!
    Dictionary<int, ClientSocket> clientDic = new Dictionary<int, ClientSocket>();

    static bool isClose = false; // 控制服务器停止的标志位

    /// <summary>
    /// 启动服务器
    /// </summary>
    public void Start(string ip, int port, int num)
    {
    // 1. 初始化监听 Socket
    socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
    IPEndPoint ipPoint = new IPEndPoint(IPAddress.Parse(ip), port);
    socket.Bind(ipPoint); // 绑定 IP 和端口
    socket.Listen(num); // 开始监听, backlog 为 num

    Console.WriteLine($"服务器已启动,监听 {ip}:{port}");

    // 2. 将“接受连接”任务放入线程池 (或新建独立线程)
    ThreadPool.QueueUserWorkItem(Accept);

    // 3. 将“轮询接收消息”任务放入线程池
    // 【注意】这种轮询模式效率较低,见下方分析
    ThreadPool.QueueUserWorkItem(Receive);
    }

    /// <summary>
    /// 关闭服务器
    /// </summary>
    public void Close()
    {
    isClose = true;

    // 关闭所有客户端连接
    foreach(ClientSocket client in clientDic.Values)
    {
    client.Close();
    }

    // 关闭主监听 Socket
    if(socket != null)
    {
    socket.Shutdown(SocketShutdown.Both);
    socket.Close();
    socket = null;
    }

    clientDic.Clear();
    Console.WriteLine("服务器已关闭。");
    }

    /// <summary>
    /// 接受客户端连入 (运行在后台线程)
    /// </summary>
    private void Accept(Object obj)
    {
    while (!isClose)
    {
    try
    {
    // 【阻塞】这里会卡住,直到有客户端连入
    Socket clientSocket = socket.Accept();

    // 实例化一个新的客户端对象
    ClientSocket client = new ClientSocket(clientSocket);

    // 发送欢迎语
    client.Send("欢迎连入服务器");

    // 【危险】直接 Add 到字典,没有加锁 (lock)
    // 如果此时 Receive 线程正在遍历字典,会抛出异常导致崩溃
    clientDic.Add(client.clientID, client);

    Console.WriteLine($"新客户连接:ID={client.clientID}");
    }
    catch(Exception e)
    {
    if(!isClose)
    Console.WriteLine("客户端连入报错:" + e.Message);
    }
    }
    }

    /// <summary>
    /// 轮询接收所有客户端消息 (运行在后台线程)
    /// </summary>
    private void Receive(Object obj)
    {
    while (!isClose)
    {
    // 只有当有客户端时才处理
    if (clientDic.Count > 0)
    {
    // 【危险】直接遍历字典,没有加锁
    // 如果此时 Accept 线程正在 Add 新客户端,会抛出 "Collection was modified" 异常
    foreach(ClientSocket client in clientDic.Values)
    {
    // 调用单个客户端的 Receive 方法 (非阻塞检查)
    client.Receive();
    }
    }
    // 【严重问题】这里没有 Thread.Sleep,会导致 CPU 占用率 100% (忙等待)
    }
    }

    /// <summary>
    /// 广播消息:发给所有人
    /// </summary>
    public void Broadcast(string str)
    {
    if (clientDic.Count > 0)
    {
    // 【危险】同样没有加锁
    foreach(ClientSocket client in clientDic.Values)
    {
    client.Send(str);
    }
    }
    }
    }
    }

    代码分析 & 致命隐患
  • 线程安全问题 (最严重):
    • clientDic 被三个地方同时操作:Accept (添加), Receive (遍历), Broadcast (遍历), Close (清空)。
    • C# 的 Dictionary 不是线程安全的。如果在 foreach 遍历时有另一个线程执行 Add,程序会直接崩溃 (InvalidOperationException)。
    • 修复方案:必须在操作 clientDic 前后加上 lock (lockObj) { … }。
  • CPU 爆炸 (忙等待):
    • Receive 方法中的 while(!isClose) 循环里没有 Thread.Sleep。
    • 这意味着即使没有客户端发消息,这个线程也会以每秒几千万次的速度空转检查,吃掉一个完整的 CPU 核心。
    • 修复方案:在循环末尾加 Thread.Sleep(10),或者改用每个客户端独立线程的阻塞式接收模型。
  • 架构耦合:
    • 服务器强行统一管理所有客户端的接收。如果客户端数量达到几千,这个统一的 Receive 循环会成为瓶颈,导致消息延迟。
    • 优化方向:在 Accept 成功后,直接为新的 ClientSocket 启动一个专属线程去跑接收循环,移除全局的 Receive 方法。

  • 📄 第三部分:Program.cs (主程序入口)

    这是程序的入口点,负责启动服务器并处理管理员的控制台指令。

    using System;

    namespace Lesson19
    {
    class Program
    {
    // 主入口函数
    static void Main(string[] args)
    {
    // 启动逻辑封装在 Start 方法中
    Start();
    }

    public static void Start()
    {
    // 1. 实例化服务器对象
    ServerSocket serverSocket = new ServerSocket();

    // 2. 启动服务器 (监听 127.0.0.1:8080,最大排队 10 人)
    // 注意:Start 方法内部已经开启了后台线程,所以这里不会卡住,代码会继续往下执行
    serverSocket.Start("127.0.0.1", 8080, 10);

    Console.WriteLine("服务器运行中… 输入 'B:消息' 广播,输入 'quit' 退出");

    // 3. 主循环:无限等待用户输入指令
    while (true)
    {
    string input = Console.ReadLine();

    // 指令 1: 退出
    if (input == "quit")
    {
    serverSocket.Close(); // 关闭服务器资源

    // 【重要】必须 break 跳出循环,否则程序还会卡在 ReadLine
    break;
    }
    // 指令 2: 广播 (格式 B:内容)
    else if (input.Length >= 2 && input.Substring(0, 2) == "B:")
    {
    // 截取 "B:" 后面的内容
    string msg = input.Substring(2);
    serverSocket.Broadcast(msg);
    Console.WriteLine($"[广播] 已发送:{msg}");
    }
    else
    {
    Console.WriteLine("未知指令。格式示例:B:大家好");
    }
    }

    Console.WriteLine("程序已结束。");
    }
    }
    }

    赞(0)
    未经允许不得转载:171主机测评 » Unity网络基础服务端代码(中阶)封装服务端和客户端
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址