zoukankan      html  css  js  c++  java
  • unity游戏框架学习-实现c#的网络框架

    概述链接:https://www.cnblogs.com/wang-jin-fu/p/10975660.html

    前面说道Socket负责和游服的通信,包括网络的连接、消息的接收、心跳包的发送、断线重连的监听和处理

    那一个完整的网络模块包括几方面呢?(仅讨论客户端)

    1.建立和服务端的socket连接,实现客户端-服务端两端的接收和发送功能。

    2.消息协议的选择,网络消息的解析可以是json、xml、protobuf,本篇使用的是protobuf

    3.消息缓存

    4.消息的监听、分发、移除

    5.客户端身份验证,由客户端、服务端生成密钥进行验证。

    6.心跳包的实现,主要是检测客户端的连接情况,避免浪费服务端资源

    如上所述,一套完整的unity的socket网络通信模块所包含的内容大概就是这些。

    示例工程:链接: https://pan.baidu.com/s/1vJbo0ThXhShk9eJv3VNCuw 提取码: fngy  本篇文章资源连接

    该工程主要是实现客户端-服务端两端的连接,以及消息的监听、派发、发送、接受等功能,心跳包未实现。

    一、创建一个socekt连接

    客户端代码如下:创建一个Socket对象,这个对象在客户端是唯一的,连接指定服务器IP和端口号

    public void Connect(string host, int port)
        {
            if (string.IsNullOrEmpty(host))
            {
                Debug.LogError("NetMgr.Connect host is null");
                return;
            }
    
            //IP验证
            IPEndPoint ipEndPoint = null;
            Regex regex = new Regex("((25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)\.){3}(25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|[1-9])");
            Match match = regex.Match(host);
            if (match.Success)
            {
                // IP
                ipEndPoint = new IPEndPoint(IPAddress.Parse(host), port);
            }
            else
            {
                // 域名
                IPAddress[] addresses = Dns.GetHostAddresses(host);
                ipEndPoint = new IPEndPoint(addresses[0], port);
            }
    
            //新建连接,连接类型
            mSocket = new Socket(ipEndPoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
            
            try
            {           
                mSocket.Connect(ipEndPoint);//链接IP和端口
            }
            catch (System.Exception e)
            {
                Debug.LogError(e.Message);
            }
        }

    服务端代码:创建一个服务器Socket对象,并绑定服务器IP地址和端口号

    public void InitSocket(string host, int port)
        {
            if (string.IsNullOrEmpty(host))
            {
                Debug.LogError("NetMgr.Connect host is null");
                return;
            }
    
            //IP验证
            IPEndPoint ipEndPoint = null;
            Regex regex = new Regex("((25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)\.){3}(25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|[1-9])");
            Match match = regex.Match(host);
            if (match.Success)
            {
                // IP
                ipEndPoint = new IPEndPoint(IPAddress.Parse(host), port);
            }
            else
            {
                // 域名
                IPAddress[] addresses = Dns.GetHostAddresses(host);
                ipEndPoint = new IPEndPoint(addresses[0], port);
            }
    
            //新建连接,连接类型
            mSocket = new Socket(ipEndPoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
            
            try
            {
                mSocket.Bind(ipEndPoint);//绑定IP和端口          
                mSocket.Listen(5);//设置监听数量   
            }
            catch (System.Exception e)
            {
                Debug.LogError(e.Message);
            }
        }

    二.protobuf协议生成、解析

    我们在存储一串数据的时候,无论这串数据里包含了哪些数据以及哪些数据类型,当我们拿到这串数据在解析的时候能够知道该怎么解析,这是定义协议格式的目标。它是协议解析的规则。

    简单的来说就是,当你传给我一串数据的时候,我是用什么样的规则知道这串数据里的内容的。JSON就制定了这么一个规则,这个规则以字符串KEY-VALUE,以及一些辅助的符号‘{’,'}','[',']'组合而成,这个规则非常通用,以至于任何人拿到任何JSON数据都能知道里面有什么数据。

    protobuf优势:这里只比较json(JSON与同是纯文本类型格式的XML相比较,JSON不需要结束标签,JSON更短,JSON解析和读写的速度更快,所以json是优于xml的)

    序列化和反序列化效率比 xml 和 json 都高,序列化的二进制文件更小(传输就更快,节省流量)适合网络传输节省io,Protobuf 数据使用二进制形式,把原来在JSON,XML里用字符串存储的数字换成用byte存储,大量减少了浪费的存储空间。与MessagePack相比,Protobuf减少了Key的存储空间,让原本用字符串来表达Key的方式换成了用整数表达,不但减少了存储空间也加快了反序列化的速度。
    Json明文,维护麻烦。
    protobuf提供的多语言支持,所以使用protobuf作为数据载体定制的网络协议具有很强的跨语言特性

    缺点:
    通用性差
    二进制存储易读性很差,除非你有 .proto 定义,否则你没法直接读出 Protobuf 的任何内容
    需要依赖于工具生成代码
    需要生成数据解析类,占用空间
    协议序号也要占空间,序号越大占空间越大,当序号小于16时无需额外增加字节就可以表示。

    1.protobuf语法:官方网站:https://developers.google.com/protocol-buffers/docs/proto3,英文不好可参考下面的中文语法,这边不做赘述

    中文语法:https://blog.csdn.net/u011518120/article/details/54604615

    大概样子如下:

    package protocol;
    
    //握手验证
    message Handshake{
        required string token= 1;
    }
    
    //玩家信息
    message PlayerInfo{
        required int32 account= 1;
        required string password= 2;    
        required string name= 3;
    }

    2.协议解析类的生成,如下图所示,双击protoToCs.bat文件就可以把proto文件夹下的.proto协议生成c#文件并存储在generate目录下,proto和生成的cs目录更改在protoToCs文件里面

    @echo off
     @rem 对该目录下每个*.prot文件做转换
     set curdir=%cd%
     set protoPath=%curdir%proto
     set generate=%curdir%generate
     echo %curdir%
     echo %protoPath%
    
     for /r %%j in (*.proto) do ( 
        echo %%j
        protogen -i:"%%j" -o:%generate%%%~nj.cs 
     )
     pause

    3.协议的解包、封包(解析类的使用),这边协议的格式是  协议数据长度+协议id+协议数据

    当要发送消息给服务端(或客户端)时,调用PackNetMsg封装成二进制流数据,接受到另一端的消息时调用UnpackNetMsg解析成对应的数据类,在分发给客户端使用

    协议封包:

    /// <summary>  
        /// 序列化  
        /// </summary>  
        /// <typeparam name="T"></typeparam>  
        /// <param name="msg"></param>  
        /// <returns></returns>  
        static public byte[] Serialize<T>(T msg)
        {
            byte[] result = null;
            if (msg != null)
            {
                using (var stream = new MemoryStream())
                {
                    Serializer.Serialize<T>(stream, msg);
                    result = stream.ToArray();
                }
            }
            return result;
        }
    
      //封包,依次写入协议数据长度、协议id、协议内容
    public static byte[] PackNetMsg(NetMsgData data) { ushort protoId = data.ProtoId; MemoryStream ms = null; using (ms = new MemoryStream()) { ms.Position = 0; BinaryWriter writer = new BinaryWriter(ms); byte[] pbdata = Serialize(data.ProtoData); ushort msglen = (ushort)pbdata.Length; writer.Write(msglen); writer.Write(protoId); writer.Write(pbdata); writer.Flush(); return ms.ToArray(); } }

    解包:

    /// <summary>  
        /// 反序列化  
        /// </summary>  
        /// <typeparam name="T"></typeparam>  
        /// <param name="message"></param>  
        /// <returns></returns>  
        static public T Deserialize<T>(byte[] message)
        {
            T result = default(T);
            if (message != null)
            {
                using (var stream = new MemoryStream(message))
                {
                    result = Serializer.Deserialize<T>(stream);
                }
            }
            return result;
        }
    
      //解包,依次写出协议数据长度、协议id、协议数据内容
    public static NetMsgData UnpackNetMsg(byte[] msgData) { MemoryStream ms = null; using (ms = new MemoryStream(msgData)) { BinaryReader reader = new BinaryReader(ms); ushort msgLen = reader.ReadUInt16(); ushort protoId = reader.ReadUInt16(); if (msgLen <= msgData.Length - 4) { IExtensible protoData = CreateProtoBuf.GetProtoData((ProtoDefine)protoId, reader.ReadBytes(msgLen)); return NetMsgDataPool.GetMsgData((ProtoDefine)protoId, protoData, msgLen); } else { Debug.LogError("协议长度错误"); } } return null; }

    然后这边会需要根据协议的id去生成对应的解析类,有两种方式,一种使用switch,一种是用反射的方式去生成,放射应该效率会高一点,本篇使用的是第一种(反射玩不转,我知道怎么根据类名生成指定的类,但是当参数是泛型是就盟了,评论如果有知道欢迎指出来,例如我知道类名xxx,我怎么调用Serializer.Deserialize<T>(stream);这个方法呢,就是我要怎么用xxx替换T呢)

    switch实现方式:

    //动态修改,不要手动修改
    
    using protocol;
    public class CreateProtoBuf
    {
      public static ProtoBuf.IExtensible GetProtoData(ProtoDefine protoId, byte[] msgData)
      {
          switch (protoId)
          {
                case ProtoDefine.Handshake:
                    return NetUtilcs.Deserialize<Handshake>(msgData);
                case ProtoDefine.ReqLogin:
                    return NetUtilcs.Deserialize<ReqLogin>(msgData);
                case ProtoDefine.ReqRegister:
                    return NetUtilcs.Deserialize<ReqRegister>(msgData);
                case ProtoDefine.RetLogin:
                    return NetUtilcs.Deserialize<RetLogin>(msgData);
                case ProtoDefine.RetRegister:
                    return NetUtilcs.Deserialize<RetRegister>(msgData);
              default:
                  return null;
          }
      }
    }

    createbuf这个类如果手撸的话,几百种协议还是很头疼的,所以我这边是写了个工具去生成这个类,模板也是可以实现这个功能的

    public static void WriteCreateBufClass()
        {
            using (StreamWriter sw = new StreamWriter(Application.dataPath + "/Scripts/Engine/Net/CreateProtoBuf.cs", false))
            {
                sw.WriteLine("//动态修改,不要手动修改
    ");
                sw.WriteLine("using protocol;");
                sw.WriteLine("public class CreateProtoBuf");
                sw.WriteLine("{");
                sw.WriteLine("  public static ProtoBuf.IExtensible GetProtoData(ProtoDefine protoId, byte[] msgData)");
                sw.WriteLine("  {");
                sw.WriteLine("      switch (protoId)");
                sw.WriteLine("      {");
    
                foreach (int value in Enum.GetValues(typeof(ProtoDefine)))
                {
                    string strName = Enum.GetName(typeof(ProtoDefine), value);//获取名称
                    sw.WriteLine(string.Format("            case ProtoDefine.{0}:", strName));
                    sw.WriteLine(string.Format("                return NetUtilcs.Deserialize<{0}>(msgData);", strName));
                }
    
                sw.WriteLine("          default:");
                sw.WriteLine("              return null;");
                sw.WriteLine("      }");
                sw.WriteLine("  }");
                sw.WriteLine("}");
            }
        }

    这样协议的生成、解析都有了,剩下的就是消息的管理了

     三、消息的缓存、接受、发送

    客户端消息队列:总共生成四个缓存队列,两个子线程,一个用于发送消息,一个用于接收消息,主要是防止同时接受、发送多条信息,以及实现转菊花的效果(发送消息开始转菊花,服务器回包后结束菊花,防止重复发送消息)

    发送代码如下:创建两个队列,一个用于存储主线程的等待发送的队列(由各模块调用),一个用于子线程向服务器发送消息(使用支线程向socket发送消息,减少主线程压力)

    void Send()
        {
            while (this.mIsRunning)
            {
                if (mSendingMsgQueue.Count == 0)
                {
                    lock (this.mSendLock)
                    {
                        while (this.mSendWaitingMsgQueue.Count == 0)
                            Monitor.Wait(this.mSendLock);
                        Queue<NetMsgData> temp = this.mSendingMsgQueue;
                        this.mSendingMsgQueue = this.mSendWaitingMsgQueue;
                        this.mSendWaitingMsgQueue = temp;
                    }                
                }
                else
                {
                    try
                    {
                        NetMsgData msg = this.mSendingMsgQueue.Dequeue();
                        byte[] data = NetUtilcs.PackNetMsg(msg);
                        mSocket.Send(data, data.Length, SocketFlags.None);
                        Debug.Log("client send: " + (ProtoDefine)msg.ProtoId);
                    }
                    catch (System.Exception e) {
                        Debug.LogError(e.Message);
                        Disconnect();
                    }
                }
            }
    
            this.mSendingMsgQueue.Clear();
            this.mSendWaitingMsgQueue.Clear();
        }
    
      //业务调用接口
    public void SendMsg(ProtoDefine protoType, IExtensible protoData) { if (!this.mIsRunning) return; lock (this.mSendLock) { mSendWaitingMsgQueue.Enqueue(NetMsgDataPool.GetMsgData(protoType, protoData)); Monitor.Pulse(this.mSendLock); } }

    数据的接受:创建两个队列,一个用于缓存子线程从服务器接受的消息,一个用于向主线程分发消息

    这边的update方法需要由主线程调用,或者使用协程也是可以实现的。

    void Receive()
        {
            byte[] data = new byte[1024];
            while (this.mIsRunning)
            {
                try
                {
                    //将收到的数据取出来
                    int len = mSocket.Receive(data);
                    NetMsgData receive = NetUtilcs.UnpackNetMsg(data);
                    Debug.Log("client receive : " + (ProtoDefine)receive.ProtoId);
    
                    lock (this.mRecvLock)
                    {
                        this.mRecvWaitingMsgQueue.Enqueue(receive);
                    }
                }
                catch (System.Exception e)
                {
                    Debug.LogError(e.Message);
                    Disconnect();
                }
                
            }
        }
    
        public void Update()
        {
            if (!this.mIsRunning) return;
    
            if (this.mRecvingMsgQueue.Count == 0)
            {
                lock (this.mRecvLock)
                {
                    if (this.mRecvWaitingMsgQueue.Count > 0)
                    {
                        Queue<NetMsgData> temp = this.mRecvingMsgQueue;
                        this.mRecvingMsgQueue = this.mRecvWaitingMsgQueue;
                        this.mRecvWaitingMsgQueue = temp;
                    }
                }
            }
            else
            {
                while (this.mRecvingMsgQueue.Count > 0)
                {
                    NetMsgData msg = this.mRecvingMsgQueue.Dequeue();
                    //发送给逻辑处理
                    NetMsg.DispatcherMsg(msg);
                }
            }
        }

    四、消息的监听、派发,业务通过这个类和socket交互

    using System;
    using System.Collections.Generic;
    using ProtoBuf;
    using protocol;
    
    public delegate void NetCallBack(IExtensible msgData);
    
    /// <summary>
    /// 业务和socket交互的中间层
    /// </summary>
    public class NetMsg
    {
        private static Dictionary<ProtoDefine, Delegate> m_EventTable = new Dictionary<ProtoDefine, Delegate>();
    
        /// <summary>
        /// 监听指定的消息协议
        /// </summary>
        /// <param name="protoType"></param> 需要监听的消息
        /// <param name="callBack"></param> 当接收到服务端的消息时,需要触发的消息
        public static void ListenerMsg(ProtoDefine protoType, NetCallBack callBack)
        {
            if (!m_EventTable.ContainsKey(protoType))
            {
                m_EventTable.Add(protoType, null);
            }
    
            m_EventTable[protoType] = (NetCallBack)m_EventTable[protoType] + callBack;
        }
    
        /// <summary>
        /// 移除监听某条消息
        /// </summary>
        /// <param name="protoType"></param>
        /// <param name="callBack"></param>
        public static void RemoveListenerMsg(ProtoDefine protoType, NetCallBack callBack)
        {
            if (m_EventTable.ContainsKey(protoType))
            {
                m_EventTable[protoType] = (NetCallBack)m_EventTable[protoType] - callBack;
    
                if (m_EventTable[protoType] == null)
                {
                    m_EventTable.Remove(protoType);
                }
            }     
        }
    
        /// <summary>
        /// 接收到服务端消息时,会调用这个接口通知监听这调协议的业务
        /// </summary>
        /// <param name="msgData"></param>
        public static void DispatcherMsg(NetMsgData msgData)
        {
            ProtoDefine protoType = (ProtoDefine)msgData.ProtoId;
            Delegate d;
            if (m_EventTable.TryGetValue(protoType, out d))
            {
                NetCallBack callBack = d as NetCallBack;
                if (callBack != null)
                {
                    callBack(msgData.ProtoData);
                }
            }
        }
    
        /// <summary>
        /// 向服务端发送消息
        /// </summary>
        /// <param name="protoType"></param>
        /// <param name="protoData"></param>
        public static void SendMsg(ProtoDefine protoType, IExtensible protoData)
        {
            SocketClint.Instance.SendMsg(protoType, protoData);
        }
    }

    五、客户端身份验证,做完上面的步骤,你已经可以生成、解析、使用消息协议,也可以和服务端通信了,其实通信功能就已经做完了,但是客户端验证和心跳包又是游戏绕不过去的一个步骤,所以  我们继续~

    认证的过程大概是这样子的(以我当前的项目为例)

    1.客户端随机生成一个密钥client_key,使用某种加密算法通过刚生成的密钥client_key将自己的client_token加密,然后将加密后的client_token和密钥发送给登录服(client_token只是一个字符串,客户端和服务端都有,这边的加密算法加密时需要一个密钥,服务端和客户端的加密算法是一样的)

    2.登录服收到客户端的消息,通过客户端发送的密钥client_key解密出客户端的client_token,通过比对这个client_token能确定是不是正确的客户端,如果是,登录服随机生成一个密钥server_key,并将使用server_key加密后的登录服server_token连同server_key发送给客户端

    3.客户端收到登录服返回的消息,通过登录服发送的密钥server_key解密出登录服的server_token,通过比对这个server_token能确定是不是正确的登录服

    4.双方身份验证后进行账号验证,客户端重新生成密钥client_key2,将自己的账号、密码、设备id等信息加密成client_info连同client_key2发送给登录服

    5.登录服接收到客户端消息后,过客户端发送的密钥client_key2解密出客户端的client_info,通过比对账号、密码信息,返回一个游服的token,并把该token同步给游服

    6.客户端通过登录服返回的游服token登录游服,关闭登录服连接

    那么为什么要有登录服呢,我个人的理解是1.登录服可以很大的分摊游服的压力,特别是开服的时候2.游戏服一般会有很多(例如slg的王国),而登录服只会有一个?好吧  这个有知道的大神麻烦在评论告诉我下

    六、心跳包,具体可以参考https://gameinstitute.qq.com/community/detail/101837

    心跳包主要用于长连接的保活和断线处理,socket本身的断开通知不是很靠谱,有时候客户端断开网络,Socket并不能实时监测到,服务器还维持这个客户端不必要的引用

    心跳包之所以叫心跳包是因为:它像心跳一样每隔固定时间发一次,以此来告诉服务器,这个客户端还活着加了服务器的负荷

    怎么发送心跳?

    1:轮询机制:概括来说是服务端定时主动的与客户端通信,询问当前的某种状态,客户端返回状态信息,客户端没有返回,则认为客户端已经宕机,然后服务端把这个客户端的宕机状态保存下来,如果客户端正常,那么保存正常状态。如果客户端宕机或者返回的是定义

    的失效状态那么当前的客户端状态是能够及时的监控到的,如果客户端宕机之后重启了那么当服务端定时来轮询的时候,还是可以正常的获取返回信息,把其状态重新更新。

    2:心跳机制:最终得到的结果是与轮询一样的但是实现的方式有差别,心跳不是服务端主动去发信息检测客户端状态,而是在服务端保存下来所有客户端的状态信息,然后等待客户端定时来访问服务端,更新自己的当前状态,如果客户端超过指定的时间没有来更新状态,则认为客户端已经宕机。
    心跳比起轮询有两个优势:1.避免服务端的压力2.灵活好控制

  • 相关阅读:
    九度OJ 1010 A+B
    九度OJ 1052 找x
    oracle数据库创建备份与恢复 脚本
    在select标签中添加a标签
    如何在select标签中使用a标签跳转页面
    网站访问不了
    js实现input的赋值
    PHP如何实现百万级数据导出
    互联网产品需求管理杂思2需求收集,互联网营销 狼人:
    做网站用UTF8还是GB2312?,互联网营销 狼人:
  • 原文地址:https://www.cnblogs.com/wang-jin-fu/p/11121654.html
Copyright © 2011-2022 走看看