Unity【Multiplayer 多人在线】- Socket 通用服务端框架(一)、定义套接字和多路复用

2022-08-29 16:54:24 浏览数 (1)

介绍

在阅读了罗培羽著作的Unity3D网络游戏实战一书后,博主综合自己的开发经验与考虑进行部分修改和调整,将通用的客户端网络模块和通用的服务端框架进行提取,形成专栏,介绍Socket网络编程,希望对其他人有所帮助。目录如下,链接为对应的CSDN博客地址:

一、通用服务端框架

(一)、定义套接字和多路复用

https://blog.csdn.net/qq_42139931/article/details/124051945?spm=1001.2014.3001.5501

(二)、客户端信息类和通用缓冲区结构

https://blog.csdn.net/qq_42139931/article/details/124053571?spm=1001.2014.3001.5502

(三)、Protobuf 通信协议

https://blog.csdn.net/qq_42139931/article/details/124054972?spm=1001.2014.3001.5501

(四)、数据处理和关闭连接

https://blog.csdn.net/qq_42139931/article/details/124055227?spm=1001.2014.3001.5501

(五)、Messenger 事件发布、订阅系统

https://blog.csdn.net/qq_42139931/article/details/124055392?spm=1001.2014.3001.5501

(六)、单点发送和广播数据

https://blog.csdn.net/qq_42139931/article/details/124055482?spm=1001.2014.3001.5501

(七)、时间戳和心跳机制

https://blog.csdn.net/qq_42139931/article/details/124055856?spm=1001.2014.3001.5501

二、通用客户端网络模块

(一)、Connect 连接服务端

https://blog.csdn.net/qq_42139931/article/details/124091349?spm=1001.2014.3001.5502

(二)、Receive 接收并处理数据

https://blog.csdn.net/qq_42139931/article/details/124092588?spm=1001.2014.3001.5502

(三)、Send 发送数据

https://blog.csdn.net/qq_42139931/article/details/124094323?spm=1001.2014.3001.5502

(四)、Close 关闭连接

https://blog.csdn.net/qq_42139931/article/details/124094895?spm=1001.2014.3001.5502

本篇内容:

Socket套接字的定义:

首先编写服务器初始化的方法Init,接受一个参数port,即监听的端口,在Main函数中调用Init传入端口以启动服务器。

代码语言:javascript复制
using System.Net;
using System.Net.Sockets;

namespace SK.Framework.Sockets
{
    /// <summary>
    /// 服务器
    /// </summary>
    public class Server
    {
        //定义套接字
        private static Socket socket;
        private static void Main(string[] args)
        {
            Init(8801);
        }
        //服务器初始化
        //port: 端口
        private static void Init(int port)
        {
            Console.WriteLine("服务器启动...");
            //TODO
        }
    }
}

Socket在调用Listen监听方法之前,必须先调用Bind方法,需要声明服务器的IP地址及监听的端口,如果不关心使用哪个本地端口,可以使用0作为端口号,系统将会自动分配1024到5000之间的可用端口号。Listen方法中参数backlog代表可排队等待接受的传入连接的数量,即挂起的连接队列的最大长度。

代码语言:javascript复制
using System.Net;
using System.Net.Sockets;

namespace SK.Framework.Sockets
{
    /// <summary>
    /// 服务器
    /// </summary>
    public class Server
    {
        //定义套接字
        private static Socket socket;
        private static void Main(string[] args)
        {
            Init(8801);
        }
        //服务器初始化
        //port: 端口
        private static void Init(int port)
        {
            Console.WriteLine("服务器启动...");
            //Socket Tcp协议
            socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
            //服务器IP地址
            IPAddress ipAddress = IPAddress.Parse("0.0.0.0");
            IPEndPoint ipEndPoint = new IPEndPoint(ipAddress, port);
            //Bind
            socket.Bind(ipEndPoint);
            //Listen 开启监听
            socket.Listen(100);
            //TODO
        }
    }
}
代码语言:javascript复制
Select多路复用:
代码语言:javascript复制
// 摘要:
//     Determines the status of one or more sockets.
//
// 参数:
//   checkRead:
//     An System.Collections.IList of System.Net.Sockets.Socket instances to check for
//     readability.
//
//   checkWrite:
//     An System.Collections.IList of System.Net.Sockets.Socket instances to check for
//     writability.
//
//   checkError:
//     An System.Collections.IList of System.Net.Sockets.Socket instances to check for
//     errors.
//
//   microSeconds:
//     The time-out value, in microseconds. A -1 value indicates an infinite time-out.
//
// 异常:
//   T:System.ArgumentNullException:
//     The checkRead parameter is null or empty. -and- The checkWrite parameter is null
//     or empty -and- The checkError parameter is null or empty.
//
//   T:System.Net.Sockets.SocketException:
//     An error occurred when attempting to access the socket.
//
//   T:System.ObjectDisposedException:
//     .NET 5.0 and later: One or more sockets are disposed.
public static void Select(IList? checkRead, IList? checkWrite, IList? checkError, int microSeconds)
代码语言:javascript复制
关于Select方法的官方文档链接地址:

https://docs.microsoft.com/zh-cn/dotnet/api/system.net.sockets.socket.select?view=net-6.0

该方法可以帮助我们实现non-block非阻塞方式,第一个参数checkRead代表需要检测可读性的Socket列表,第四个参数microSeconds代表阻塞等待的时长,单位为毫秒,例如传入1000则代表设置1秒的阻塞等待时长,当1秒内没有可读消息时,它会停止阻塞,返回空的checkRead列表,程序继续运行。

代码实现如下,其中的Client类定义了代表客户端信息的相关内容,在后续章节中进行介绍。

代码语言:javascript复制
using ProtoBuf;
using System.Net;
using System.Net.Sockets;

namespace SK.Framework.Sockets
{
    /// <summary>
    /// 服务器
    /// </summary>
    public class Server
    {
        //定义套接字
        private static Socket socket;
        //用于检测可读性的Socket列表
        private readonly static List<Socket> checkReadableList = new List<Socket>();
        //客户端Socket及客户端信息字典
        private readonly static Dictionary<Socket, Client> clients = new Dictionary<Socket, Client>();

        private static void Main(string[] args)
        {
            Init(8801);
        }
        //服务器初始化
        //port: 端口
        private static void Init(int port)
        {
            Console.WriteLine("服务器启动...");
            //Socket Tcp协议
            socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
            //服务器IP地址
            IPAddress ipAddress = IPAddress.Parse("0.0.0.0");
            IPEndPoint ipEndPoint = new IPEndPoint(ipAddress, port);
            //Bind
            socket.Bind(ipEndPoint);
            //Listen 开启监听
            socket.Listen(0);

            //循环
            while (true)
            {
                //首先重置用于检测可读性的Socket列表
                OnCheckReadableListReset();
                //使用Select检测可读 实现non-block非阻塞方式
                //arg4: 超时值 单位毫秒 此处设置1000表示 1秒内没有可读消息时停止阻塞 返回空的列表
                Socket.Select(checkReadableList, null, null, 1000);
                //遍历检查可读对象
                for (int i = 0; i < checkReadableList.Count; i  )
                {
                    Socket s = checkReadableList[i];
                    if (s == socket) OnListenEvent(s);
                    else OnClientEvent(s);
                }
            }
        }
        private static void OnCheckReadableListReset()
        {
            checkReadableList.Clear();
            //进行Select的列表包含监听套接字socket以及每个已经连接的客户端套接字
            checkReadableList.Add(socket);
            foreach (Client client in clients.Values)
            {
                checkReadableList.Add(client.socket);
            }
        }
        //监听事件
        private static void OnListenEvent(Socket s) {}
        //客户端消息事件
        private static void OnClientEvent(Socket s) {}
    }
}

其中OnListenEvent方法用于处理客户端连接的消息,代码如下:

代码语言:javascript复制
//监听事件
private static void OnListenEvent(Socket s)
{
    try
    {
        //接受客户端连接
        Socket socket = s.Accept();
        Console.WriteLine($"客户端接入: {socket.RemoteEndPoint}");
        Client client = new Client(socket);
        //加入字典
        clients.Add(socket, client);
    }
    catch (SocketException error)
    {
        Console.WriteLine($"客户端接入失败: {error}");
    }
}
代码语言:javascript复制
OnClientEvent方法用于处理客户端发送来的消息,代码如下:
代码语言:javascript复制
//客户端消息事件
private static void OnClientEvent(Socket s)
{
    //从字典中获取该客户端信息类
    Client client = clients[s];
    //该客户端的读缓冲区
    ByteArray readBuff = client.readBuff;
    //如果缓冲区剩余空间不足 清除
    if (readBuff.remain <= 0)
    {
        OnReceiveData(client);
        readBuff.MoveBytes();
    }
    //如果依然不足 接收数据失败 关闭客户端连接 返回
    //缓冲区默认大小为1024 根据最大单条数据长度进行调整
    if (readBuff.remain <= 0)
    {
        Console.WriteLine($"接收数据失败,超出缓冲区长度。{s.RemoteEndPoint}");
        //关闭客户端连接
        Close(client);
        return;
    }
    //接收数据长度
    int length = 0;
    try
    {
        length = s.Receive(readBuff.bytes, readBuff.writeIdx, readBuff.remain, 0);
    }
    catch (SocketException error)
    {
        Console.WriteLine($"接收数据失败: {error}. {s.RemoteEndPoint}");
        Close(client);
        return;
    }
    //客户端关闭
    if (length <= 0)
    {
        Close(client);
        return;
    }
    //处理数据
    readBuff.writeIdx  = length;
    OnReceiveData(client);
    //移动缓冲区
    readBuff.CheckAndMoveBytes();
}
//数据处理
private static void OnReceiveData(Client client) {}

其中的ByteArray类是用于处理数据的粘包半包问题而封装的用来操作读写缓冲区的相关内容,在后续章节中进行介绍。关于数据的粘包半包问题及处理方法在以往的文章中也有介绍,地址如下,本套框架中我们使用了长度信息法来处理粘包半包问题。

Socket TCP协议解决粘包、半包问题的三种解决方案

参考资料:《Unity3D网络游戏实战》(第2版)罗培羽 著

0 人点赞