IT数码 购物 网址 头条 软件 日历 阅读 图书馆
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
图片批量下载器
↓批量下载图片,美女图库↓
图片自动播放器
↓图片自动播放器↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁
 
   -> 游戏开发 -> 【学习总结】通用网络服务端框架(一) -> 正文阅读

[游戏开发]【学习总结】通用网络服务端框架(一)

以下内容摘自《Unity3D网络游戏实战》
PS

服务端架构

服务端的大致框架如下图所示
在这里插入图片描述
服务端程序的两大核心是处理客户端的消息和存储玩家数据。(也可以将一些消耗性能的计算放到服务端来进行)。“网络底层”是指处理网络连接的底层模块,它有处理粘包半包,协议解析等功能。消息处理模块属于游戏的逻辑层,比如当收到客户端的MsgMove协议的时候,服务端会在消息处理模块中记录玩家坐标,然后将MsgMove协议广播给所有的客户端。在服务端中,事件处理指的是玩家上线和下线之类的操作,这些逻辑在事件处理模块中执行。数据库底层模块则提供了保存玩家数据、读取玩家数据、注册、检验用户名和密码是否正确等功能,是服务端和数据库交互的一层封装。存储结构指定哪些数据需要保存,比如在线版记事本中需要保存文本信息,对于大部分游戏需要存储玩家的例如金币、经验、等级等信息。
从服务端的角度来看,玩家会有三个阶段:

  1. 连接阶段: 客户端调用Connect连接服务器,连接后双端即可通信。但是服务器还不知道玩家具体控制的哪个角色,于是需要客户端发送一条登录协议,协议中包含用户名、密码等信息,待检验通过后,服务端才会将网络连接与游戏角色对应起来,去数据库中获取该角色的数据才算登录成功
  2. 交互阶段: 双端互通协议,这里就是主要发生游戏逻辑处理的地方
  3. 登出阶段: 玩家下线,服务端把玩家的数据保存到数据库中。可以分为定时存储和统一下线时存储的做法

Json协议编码解码

整体上和上一篇客户端中做的相同,简单复制过来就行。需要注意的是,我们这里使用的编码解码方法由于不依靠Unity了,所以需要添加System.Web.Extensions依赖,同时其方法位于System.Web.Script.Serialization命名空间中,需要使用using引用它。同时,解码方法JavaScriptSerilaizer不是静态类,所以需要创建一个对象才能使用,其它的都和JsonUtility方法差不多

using System.Web.Script.Serialization;

//编码器
static JavaScriptSerializer Js = new JavaScriptSerializer();

string s = Js.Serialize(msgBase);

网络模块

网络模块主要分为四个部分:

  1. 处理select多路复用的网络管理器NetManager,它是服务端网络模块的核心部件
  2. 定义客户端信息的ClientState类
  3. 处理网络消息的MsgHandler类
  4. 事件处理类EventHandler

ClientState

该类是记录客户端信息的类,即每一个客户端连接会对应一个ClientState对象。ClientState含有与客户端连接的套接字socket和读缓冲区readBuff。

    class ClientState
    {
        public Socket socket;
        public ByteArray readBuff = new ByteArray();

        //玩家数据
    }

开启监听和多路复用

核心类NetManager至少包含下列成员:一个是用于监听的套接字listenfd,一个是管理客户端状态的列表clients和一个用于Select多路复用的列表checkRead。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Sockets;
using System.Reflection;
using System.Text;
using System.Threading.Tasks;

//采用Select型的客户端
namespace ConsoleApp1.net
{
    class NetManager
    {
        //监听Socket
        public static Socket listenfd;
        //客户端Socket及状态信息
        public static Dictionary<Socket, ClientState> clients = new Dictionary<Socket, ClientState>();
        //Select的检查列表
        static List<Socket> checkRead = new List<Socket>();
    }
}

服务端开启监听的方法为StartLoop,它接收一个参数listenPort,代表正在监听的端口。经过Socket->Bind->Listen三个步骤后,服务端开启了端口监听,然后进入循环。在循环中,程序先调用ResetCheckRead重置需要传入Select的Socket列表,包括监听套接字listenfd以及每一个已经连接的客户端套接字。针对Select返回的列表,程序会遍历它,判断是有新的客户端连接还是某个客户端法发来消息,然后分别调用处理函数ReadListenfd和ReadClientfd。代码中Socket.Select的第三个参数填了1000,代表设置超过1s的超时时间。当程序执行到Socket.Select的时候,它会阻塞等待可读的连接。当1S内没有可读的消息时,它会停止阻塞,返回空的checkRead列表,程序继续执行。所以,程序在Select有可读事件和超时都会调用Timer方法,空闲时状态下每秒调用一次

        public static void StartLoop(int listenPort)
        {
            //Socket
            listenfd = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
            //Bind
            IPAddress ipAdr = IPAddress.Parse("0.0.0.0");
            IPEndPoint ipEp = new IPEndPoint(ipAdr, listenPort);
            listenfd.Bind(ipEp);
            //Listen
            listenfd.Listen(0);
            Console.WriteLine("[服务器]启动成功");
            //循环
            while (true)
            {
                ResetCheckRead();//重置CheckRead
                Socket.Select(checkRead,null,null,1000);//这个方法填充Socket监听列表
                //检查可读对象
                for(int i = checkRead.Count - 1;i>=0;i--)
                {
                    Socket s = checkRead[i];
                    if (s == listenfd)
                    {
                        ReadListenfd(s);
                    }
                    else
                    {
                        ReadClientfd(s);
                    }
                }
                //超时
                Timer();
            }
        }

        //填充checkRead列表
        public static void ResetCheckRead()
        {
            checkRead.Clear();
            checkRead.Add(listenfd);
            foreach(ClientState s in clients.Values)
            {
                checkRead.Add(s.socket);
            }
        }

处理监听消息

ReadListenfd时处理监听事件的方法,它会调用Accept接收客户端连接,然后新建一个客户端信息对象state,把它存入客户端信息列表clients。由于在访问套接字时出错、Socket已经关闭等情形下调用Accept方法会抛出异常,因此程序代码应该放在try-catch中

        //读取Listenfd
        public static void ReadListenfd(Socket listenfd)
        {
            try
            {
                Socket clientfd = listenfd.Accept();
                Console.WriteLine("Accept" + clientfd.RemoteEndPoint.ToString());
                ClientState state = new ClientState();
                state.socket = clientfd;
                clients.Add(clientfd, state);
            }
            catch(SocketException ex)
            {
                Console.WriteLine("Accept fail" + ex.ToString());
            }
        }

处理客户端消息

处理客户端消息的ReadClientfd,它会先调用clientfd.Receive接收数据,并且将数据保存在缓冲区,为了提高程序的运行效率,需要手动设置缓冲区readBuff的readIdx和writeIdx,以及手动移动缓冲区数据的checkAndMoveBytes方法。
clientfd.Receive: clientfd.Receive的第一个参数readBuff.bytes代表缓冲区的byte数据,第二个参数readBuff.readIdx代表从readIdx处开始写入接收到的数据,第三个参数readBuff.remain代表最多接收remain个字节的数据,避免缓冲区溢出。由于clientfd.Receive可能引发异常,所以要将其放到try-catch结构中,以便捕获异常。如果发生了异常,说明该连接失效,调用Close方法关闭连接。
if(count<0): 当客户端主动断开连接的时候,服务端会收到长度为0的数据,当收到长度为0的数据时,调用Close方法关闭连接
OnReceiveData: OnReceiveData会处理粘包分包问题,并且解析出协议对象

        //读取Clientfd
        public static void ReadClientfd(Socket clientfd)
        {
            ClientState state = clients[clientfd];
            ByteArray readBuff = state.readBuff;
            //接收
            int count = 0;
            //缓冲区不够,则清除,如果还不够只能返回
            //缓冲区预设长度只有1024,超过预设长度会发生错误,需要根据需要调整长度
            if(readBuff.remain<=0)
            {
                OnReceiveData(state);//处理协议
                readBuff.MoveBytes();
            }
            if (readBuff.remain <= 0)
            {
                Console.WriteLine("Receive fail,maybe msg length > buff capacity");
                Close(state);//关闭连接
            }

            try
            {
                count = clientfd.Receive(readBuff.bytes,readBuff.writeIdx,readBuff.remain,0);
            }
            catch(SocketException ex)
            {
                Console.WriteLine("Receive SocketException" + ex.ToString());
                Close(state);
                return;
            }

            //客户端关闭
            if (count <= 0)
            {
                Console.WriteLine("Scoket Close" + clientfd.RemoteEndPoint.ToString());
                Close(state);
                return;
            }

            //消息处理
            readBuff.writeIdx += count;
            //处理二进制消息
            OnReceiveData(state);
            //移动缓冲区
            readBuff.CheckAndMoveBytes();
        }

关闭连接

关闭连接的Close会处理三件事情:第一是分发OnDisconnect事件,让程序可以在玩家掉线时做一些处理;第二是调用socket.Close关闭连接;其三是将客户端状态state移出clients列表

        //关闭连接
        public static void Close(ClientState state)
        {
            //事件分发
            MethodInfo mei = typeof(EventHandler).GetMethod("OnDisconnect");
            object[] ob = { state};
            mei.Invoke(null, ob);
            //关闭
            state.socket.Close();
            clients.Remove(state.socket);
        }

处理协议

处理协议方法OnReceiveData与客户端网络模块的同名方法相似。它会先判断读缓冲区的数据是否足够长,如果条件满足,它会调用MsgBase.DecodeName和MsgBase.Decode解析出协议名和协议体,最后做消息分发,即调用MsgHandler类名为protoName的方法。它会传入两个参数,第一个参数state代表该消息来自哪个客户端,第二个参数msgBase代表协议对象。处于效率考虑,OnReceiveData也需要手动设置缓冲区的readIdx等属性

        //数据处理
        public static void OnReceiveData(ClientState state)
        {
            ByteArray readBuff = state.readBuff;
            byte[] bytes = readBuff.bytes;
            int readIdx = readBuff.readIdx;
            //消息长度
            if (readBuff.length <= 2)
            {
                return;
            }
            Int16 bodyLength = (Int16)((bytes[readIdx + 1] << 8) | bytes[readIdx]);
            //消息体
            if(readBuff.length < bodyLength)
            {
                return;
            }
            readBuff.readIdx += 2;
            //解析协议名
            int nameCount = 0;
            string protoName = MsgBase.DecodeName(readBuff.bytes, readBuff.readIdx, out nameCount);
            if(protoName == "")
            {
                Console.WriteLine("OnReceiveData MsgBase.DecodeName fail");
                Close(state);
            }
            readBuff.readIdx += nameCount;
            //解析协议体
            int bodyCount = bodyLength - nameCount;
            MsgBase msgBase = MsgBase.Decode(protoName, readBuff.bytes, readBuff.readIdx, bodyCount);
            readBuff.readIdx += bodyCount;
            readBuff.CheckAndMoveBytes();
            //分发消息
            MethodInfo mi = typeof(MsgHandler).GetMethod(protoName);
            object[] o = { state, msgBase };
            Console.WriteLine("Receive" + protoName);
            if (mi != null)
            {
                mi.Invoke(null, o);
            }
            else
            {
                Console.WriteLine("OnReceiveData Invoke fail" + protoName);
            }
            //继续读取消息
            if (readBuff.length > 2) {
                OnReceiveData(state);
            }
        }

发送协议

服务端发送协议的方法和客户端大致相同,它接收两个参数:第一个参数是客户端信息对象cs,代表要将协议发哦是那个给哪个客户端;第二个参数msg代表发送的协议的对象。Send方法要先做一系列状态判断,确保客户端连接有效,然后将msg编码成Json协议格式,最后调用cs.socket.Send将字节流发送给客户端

        //发送
        public static void Send(ClientState cs,MsgBase msg)
        {
            //状态判断
            if(cs == null)
            {
                return;
            }
            if (!cs.socket.Connected)
            {
                return;
            }
            //数据编码
            byte[] nameBytes = MsgBase.EncodeName(msg);
            byte[] bodyBytes = MsgBase.Encode(msg);
            int len = nameBytes.Length + bodyBytes.Length;
            byte[] sendBytes = new byte[2 + len];
            //组装长度
            sendBytes[0] = (byte)(len%256);
            sendBytes[1] = (byte)(len / 256);
            //组装名字
            Array.Copy(nameBytes, 0, sendBytes, 2, nameBytes.Length);
            //组装消息体
            Array.Copy(bodyBytes,0,sendBytes,2+nameBytes.Length,bodyBytes.Length);
            //简化代码不设置回调
            try
            {
                cs.socket.BeginSend(sendBytes, 0, sendBytes.Length, 0, null, null);
            }
            catch(SocketException ex)
            {
                Console.WriteLine("Socket Close on BeginSend" + ex.ToString());
            }
        }

心跳机制

同客户端的心跳机制,客户端会定时向服务端发送MsgPing协议,服务端收到后需要回应MsgPong协议,并且记录当前时间。这里需要注意的是,由于服务器程序会运行很久,所以需要使用long来保存更大的数值

    public class ClientState
    {
        public Socket socket;
        public ByteArray readBuff = new ByteArray();

        //玩家数据
        public long lastPingTime = 0;
    }

采用的时间记录机制是时间戳,指的是当前时间减去1970年1月1日零点到现在的秒数

        //获取时间戳
        public static long GetTimeStamp()
        {
            TimeSpan ts = DateTime.UtcNow - new DateTime(1970, 1, 1, 0, 0, 0, 0);
            return Convert.ToInt64(ts.TotalSeconds);
        }

回应协议

这一部分代买和客户端相同

        public static void MsgPing(ClientState c, MsgBase msgBase)
        {
            Console.WriteLine("MsgPing");
            c.lastPingTime = NetManager.GetTimeStamp();
            MsgPong msgPong = new MsgPong();
            NetManager.Send(c, msgPong);
        }

超时处理

当服务端很久没有收到MsgPing时,可以认为连接已经断开。在服务端的定时事件中编写心跳机制的处理函数。CheckPing方法会遍历所有的客户端信息,然后判断连接是否超时,由于网络可能存在延迟,所以使用了相对宽松的条件,默认为120s没有收到MsgPing协议才调用NetMananger.Close断开连接

        //Ping检查
        public static void CheckPing()
        {
            //现在的时间戳
            long timeNow = NetManager.GetTimeStamp();
            //遍历,删除
            foreach(ClientState s in NetManager.clients.Values)
            {
                if(timeNow - s.lastPingTime > NetManager.pingInterval * 4)
                {
                    Console.WriteLine("Ping Close" + s.socket.RemoteEndPoint.ToString());
                    NetManager.Close(s);
                    return;
                }
            }
        }
  游戏开发 最新文章
6、英飞凌-AURIX-TC3XX: PWM实验之使用 GT
泛型自动装箱
CubeMax添加Rtthread操作系统 组件STM32F10
python多线程编程:如何优雅地关闭线程
数据类型隐式转换导致的阻塞
WebAPi实现多文件上传,并附带参数
from origin ‘null‘ has been blocked by
UE4 蓝图调用C++函数(附带项目工程)
Unity学习笔记(一)结构体的简单理解与应用
【Memory As a Programming Concept in C a
上一篇文章      下一篇文章      查看所有文章
加:2022-03-11 22:32:23  更:2022-03-11 22:33:19 
 
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁

360图书馆 购物 三丰科技 阅读网 日历 万年历 2024年11日历 -2024/11/27 14:48:30-

图片自动播放器
↓图片自动播放器↓
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
图片批量下载器
↓批量下载图片,美女图库↓
  网站联系: qq:121756557 email:121756557@qq.com  IT数码