TT

namespace Framework
{
    public abstract class MsgBase
    {
        // 协议名称
        public abstract string protoName { get; }
    }
}
namespace Framework
{
    public static class NetManager
    {
    
    }
}

网络模块设计

对外接口

核心类是静态类NetManager,提供如下接口

  • Connect(IP,PORT) 连接服务器
  • Close 关闭
  • Send(MSG) 发送信息
  • Update 每帧更新,需要外部调用
  • AddMsgListener(NAME,FUNC) 添加消息事件
  • AddEventListener(EVENT,FUNC) 添加网络事件

内部设计

image-20220909170718958

网络模块分为两个部分

第一部分是框架部分 framework,包含网络管理器NetManager、为提高运行效率 使用的ByteArray缓冲、以及协议基类 MsgBase

第二部分是协议类,它定义了客户端和服务端通信的数据格式

namespace Framework
{
    public static class NetManager
    {
        private static Socket client;
        private static ByteArray readBuffer;
        private static Queue<ByteArray> writeQueue;
    }
}

网络事件

事件类型

只有三种,成功,失败,关闭

public enum NetEvent
{
    ConnectSucc,
    ConnectFail,
    Close,
}

监听列表

namespace Framework
{   
    public static class NetManager
    {
        private static Dictionary<NetEvent, Action<string>> eventListeners = new Dictionary<NetEvent, Action<string>>();

        /// <summary>
        /// 添加一个网络事件
        /// </summary>
        /// <param name="netEvent">事件类型</param>
        /// <param name="func">回调函数</param>
        public static void AddEventListener(NetEvent netEvent, Action<string> func)
        {
            if (eventListeners.ContainsKey(netEvent))
                eventListeners[netEvent] += func;
            else
                eventListeners[netEvent] = func;
        }

        /// <summary>
        /// 移出一个网络事件
        /// </summary>
        /// <param name="netEvent">事件类型</param>
        /// <param name="func">回调函数</param>
        public static void RemoveEventListener(NetEvent netEvent, Action<string> func)
        {
            if (eventListeners.ContainsKey(netEvent)) 
                eventListeners[netEvent] -= func;
            if(eventListeners[netEvent] == null)
                eventListeners.Remove(netEvent);
        }
    }
}

派发事件

namespace Framework
{   
    public static class NetManager
    {
		/// <summary>
        /// 分发事件
        /// </summary>
        /// <param name="netEvent">事件类型</param>
        /// <param name="msg">参数</param>
        private static void FireEvent(NetEvent netEvent, String msg)
        {
            if (eventListeners.ContainsKey(netEvent))
                eventListeners[netEvent]?.Invoke(msg);
        }       
    }
}

连接服务器

Connect

不使用Nagle算法,且需要阻止反复连接

namespace Framework
{   
    public static class NetManager
    {
        /// <summary>
        /// 请求连接
        /// </summary>
        /// <param name="ip"></param>
        /// <param name="port"></param>
        public static void Connect(string ip, int port)
        {
            if (client != null && client.Connected) 
            {
                Debug.LogError("已连接,禁止重复连接");
                return;
            }

            if (isConnecting)
            {
                Debug.LogError("正在连接中,请等待");
                return;
            }
            
            // Socket
            client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
            // 接收缓冲区
            readBuffer = new ByteArray();
            // 写入队列
            writeQueue = new Queue<ByteArray>();
            // No Delay
            client.NoDelay = true;
            // 正在连接
            isConnecting = true;

            client.BeginConnect(ip, port, CALLBACK, client);
        }
    }
}

Connect CallBack

需要处理3个事情

  1. 将异常尽可能的放在try-catch中
  2. 连接成功或者失败时候,派发对应的事件
  3. 重置标志位isConnecting
namespace Framework
{   
    public static class NetManager
    {
        /// <summary>
        /// Connect回调
        /// </summary>
        /// <param name="ar"></param>
        private static void ConnectCallback(IAsyncResult ar)
        {
            isConnecting = false;

            try
            {
                var socket = (Socket) ar.AsyncState;
                socket.EndConnect(ar);
                Debug.Log("连接成功");
                FireEvent(NetEvent.ConnectSucc, "");
            }
            catch (SocketException ex)
            {
                Debug.LogError("连接错误:" + ex);
                FireEvent(NetEvent.ConnectFail, ex.ToString());
            }
        }
    }
}

关闭连接

Close

并不能直接关闭,还需要一些额外的操作,比如未处理完的数据需要处理完

namespace Framework
{   
    public static class NetManager
    {
        
    }
}
namespace Framework
{   
    public static class NetManager
    {
        private static bool isClosing = false;
        
        /// <summary>
        /// 关闭连接
        /// </summary>
        public static void Close()
        {
            // 状态判断
            if (client == null || !client.Connected)
                return;
            if (isConnecting)
                return;

            // 还有数据在发送
            if (writeQueue.Count > 0)
            {
                isClosing = true;
            }
            // 没有数据在发送
            else
            {
                client.Close();
                FireEvent(NetEvent.Close, "");
            }
        }
    }
}

Json协议

协议类的作用

人为约定字符串作为数据发送,使用成本很高,容易出率,效率也低,所以引入第三方的协议类

JsonUtility

编码

public class MsgMove
{
    public int x = 0;
    public int y = 0;
    public int z = 0;
}

MsgMove msgMove = new MsgMove();
msgMove.x = 100;
msgMove.y = -20;
var str = JsonUtility.ToJson(msgMove); 
Debug.Log(str);

image-20220910112318036

解码

public class MsgAttack 
{
    public string desc = "127.0.0.1:6543";
}

var str = "{\"desc\":\"127.0.0.1:1289\"}";
var msgAttack = JsonUtility.FromJson(str, Type.GetType("MsgAttack"))
Debug.Log(msgAttack.desc);

或者也可以使用FromJsonOverwrite,具有一定的Debug功能

协议格式

image-20220910112907675

协议文件

添加一个协议基类,所有协议都要继承自此类

具体的协议

namespace Framework
{
    public class MsgMove : MsgBase
    {
        public override string protoName => "MsgMove";

        public int x = 0;
        public int y = 0;
        public int z = 0;
    }

    public class MsgBattle : MsgBase
    {
        public override string protoName => "MsgBattle";

        public string desc = "127.0.0.1:6543";
    }
}

协议的编码和解码

再协议基类中添加方法,进行统一的编码和解码控制

使用Type.GetType时需要加上命名空间,且后面跟着一个程序集的名称

namespace Framework
{
    public abstract class MsgBase
    {
        /// <summary>
        /// 编码
        /// </summary>
        /// <param name="msgBase">协议类</param>
        /// <returns></returns>
        public static byte[] Encode(MsgBase msgBase)
        {
            var str = JsonUtility.ToJson(msgBase);
            return System.Text.Encoding.UTF8.GetBytes(str);
        }

        /// <summary>
        /// 解码
        /// </summary>
        /// <param name="protoName">协议名称</param>
        /// <param name="bytes">协议数据</param>
        /// <param name="offset">偏移</param>
        /// <param name="count">长度</param>
        /// <returns></returns>
        public static MsgBase Decode(string protoName, byte[] bytes, int offset, int count)
        {
            var str = System.Text.Encoding.UTF8.GetString(bytes, offset, count);
            return (MsgBase) JsonUtility.FromJson(str, Type.GetType($"Framework.{protoName},Assembly-CSharp"));
        }
    }
}

编码很简单,解码如下

image-20220910114426443

协议名称的编码和解码

协议名称同样需要编码,且还需要知道协议名称的长度,并进行小端存储

namespace Framework
{   
    public static class NetManager
    {
        /// <summary>
        /// 编码协议名(2字节长度+字符串)
        /// </summary>
        /// <param name="msgBase">协议</param>
        /// <returns></returns>
        public static byte[] EncodeName(MsgBase msgBase)
        {
            // 名字bytes和长度
            var nameBytes = System.Text.Encoding.UTF8.GetBytes(msgBase.protoName);
            var len = (Int16) nameBytes.Length;
            // 申请bytes数值
            var bytes = new byte[2 + len];
            // 组装2字节的长度信息(小端)
            bytes[0] = (byte) (len % 256);
            bytes[1] = (byte) (len / 256);
            // 组装名字bytes
            Array.Copy(nameBytes, 0, bytes, 2, len);
            return bytes;
        } 
    }
}

image-20220913151446475

解码,偏移量正常是协议所约定的长度(在这里是2个字节也就是2)

namespace Framework
{   
    public static class NetManager
    {
        /// <summary>
        /// 解码协议名(2字节长度+字符串)
        /// </summary>
        /// <param name="bytes">数据</param>
        /// <param name="offset">偏移</param>
        /// <param name="count">长度</param>
        /// <returns></returns>
        public static string DecodeName(byte[] bytes, int offset, out int count)
        {
            count = 0;
            // 必须大于2字节
            if (offset + 2 >= bytes.Length)
                return "";
            // 读取长度
            Int16 len = (Int16) ((bytes[offset + 1] << 8) | bytes[offset]);
            // 长度必须足够
            if (offset + 2 + len >= bytes.Length)
                return "";
            // 解析
            count = 2 + len;
            return System.Text.Encoding.UTF8.GetString(bytes, offset + 2, len);
        }
    }
}

发送数据

发送

在调用Send之后,并不会第一时间使用BeginSend发送数据,我们会判断消息队列里当前是否只有这么一条数据,如果只有当前这一条,那么说明其他数据已

经发送完毕了,那么可以立刻发送,否则说明还有其他数据正在发送中,此时需要到回调函数中处理

在回调函数中,我们会先判断当前这一条数据是否发送成功,如果发送完整了,那么会尝试获取队列中的下一条数据,如果有的话,那么会继续发送

namespace Framework
{
    public static class NetManager
    {
    	/// <summary>
        /// 发送数据
        /// </summary>
        /// <param name="msg"></param>
        public static void Send(MsgBase msg)
        {
            // 状态判断
            if (client == null || !client.Connected)
                return;
            if (isConnecting)
                return;
            if (isClosing)
                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);
            
            // 写入队列
            ByteArray ba = new ByteArray(sendBytes);
            int count = 0; //writeQueue的长度
            lock (writeQueue)
            {
                writeQueue.Enqueue(ba);
                count = writeQueue.Count;
            }

            // send
            if (count == 1)
            {
                client.BeginSend(sendBytes, 0, sendBytes.Length, 0, SendCallback, client);
            }
        }
        
        /// <summary>
        /// 回调函数
        /// </summary>
        /// <param name="ar"></param>
        private static void SendCallback(IAsyncResult ar)
        {
            // 获取state、EndSend的处理
            Socket socket = (Socket) ar.AsyncState;
            // 状态判断
            if (socket == null || !socket.Connected)
                return;
            
            // EndSend
            int count = socket.EndSend(ar);
            // 获取写入队列第一条数据 
            ByteArray ba;
            lock (writeQueue)
            {
                ba = writeQueue.First();
            }

            // 完整发送
            ba.ReadIndex += count;
            if (ba.Length == 0)
            {
                lock (writeQueue)
                {
                    writeQueue.Dequeue();
                    ba = writeQueue.First();
                }
            }

            // 继续发送
            if (ba != null)
                socket.BeginSend(ba.Bytes, ba.ReadIndex, ba.Length, 0, SendCallback, socket);
            // 正在关闭
            else if (isClosing)
                socket.Close();
        } 
    }
}

消息事件

和网络消息基本一样

namespace Framework
{   
    public static class NetManager
    {
        // 消息事件监听
        private static Dictionary<string, Action<MsgBase>> msgListeners = new Dictionary<string, Action<MsgBase>>();
        
        /// <summary>
        /// 添加消息监听
        /// </summary>
        /// <param name="msgName">监听的消息</param>
        /// <param name="listener">回调</param>
        public static void AddMsgListener(string msgName, Action<MsgBase> listener)
        {
            // 添加
            if (msgListeners.ContainsKey(msgName))
                msgListeners[msgName] += listener;
            // 新增
            else
                msgListeners[msgName] = listener;
        }
        
        /// <summary>
        /// 删除消息监听
        /// </summary>
        /// <param name="msgName"></param>
        /// <param name="listener"></param>
        public static void RemoveMsgListener(string msgName, Action<MsgBase> listener)
        {
            if (msgListeners.ContainsKey(msgName))
            {
                msgListeners[msgName] -= listener;
                if (msgListeners[msgName] == null)
                {
                    msgListeners.Remove(msgName);
                }
            }
        }

        /// <summary>
        /// 派发消息
        /// </summary>
        /// <param name="msgName"></param>
        /// <param name="msgBase"></param>
        private static void FireMsg(string msgName, MsgBase msgBase)
        {
            if (msgListeners.ContainsKey(msgName))
            {
                msgListeners[msgName](msgBase);
            }
        }
    }
}

接收数据

每帧处理定量的消息(根据配置)

消息列表

需要在Connect中初始化

namespace Framework
{
    public static class NetManager
    {
    	// 每一次Update处理的消息量
        private static readonly int MAX_MESSAGE_FIRE = 10; 
        // 消息列表
        private static List<MsgBase> msgList = new List<MsgBase>();
        // 消息列表长度
        static int msgCount = 0;
    	
        /// <summary>
        /// 请求连接
        /// </summary>
        /// <param name="ip"></param>
        /// <param name="port"></param>
        public static void Connect(string ip, int port)
        {
			...       
            // 消息列表
            msgList = new List<MsgBase>();
            // 消息列表的长度
            msgCount = 0;
        	...
        }
    }
}

Connect Callback

连接成功之后开始接受数据

namespace Framework
{
    public static class NetManager
    {
    	/// <summary>
        /// Connect回调
        /// </summary>
        /// <param name="ar"></param>
        private static void ConnectCallback(IAsyncResult ar)
        {
            ...
            try
            {
                ...
                // 开始接受数据
                socket.BeginReceive( readBuffer.Bytes, readBuffer.WriteIndex, readBuffer.Remain, 0, ReceiveCallback, socket);
            }
            catch (SocketException ex)
            {
                ...
            }
        }
    }
}

Receive Callback

当接受到的数据Count=0时,需要断开连接

namespace Framework
{
    public static class NetManager
    {
    	/// <summary>
        /// 接收数据回调
        /// </summary>
        /// <param name="ar"></param>
        private static void ReceiveCallback(IAsyncResult ar)
        {
            try
            {
                // 获取接收数据长度
                Socket socket = (Socket) ar.AsyncState;
                int count = socket.EndReceive(ar);
                if (count == 0)
                {
                    Close();
                    return;
                }
                readBuffer.WriteIndex += count;
                
                // 处理二进制消息
                OnReceiveData();
                
                // 继续接收数据
                if (readBuffer.Remain < 8)
                {
                    readBuffer.MoveBytes();
                    readBuffer.ReSize(readBuffer.Length * 2);
                }

                socket.BeginReceive(readBuffer.Bytes, readBuffer.WriteIndex, readBuffer.Remain, 0, ReceiveCallback, socket);
            }
            catch (SocketException ex)
            {
                Debug.Log("Socket Receive fail" + ex.ToString());
            }
        }
    }
}

OnReceiveData

会一次性处理完缓冲区的所有消息(子线程中)

namespace Framework
{
    public static class NetManager
    {
    	/// <summary>
        /// 数据处理
        /// </summary>
        public static void OnReceiveData()
        {
            // 消息长度
            if (readBuffer.Length <= 2)
                return;

            // 获取消息体长度
            int readIdx = readBuffer.ReadIndex;
            byte[] bytes = readBuffer.Bytes;
            Int16 bodyLength = (Int16) ((bytes[readIdx + 1] << 8) | bytes[readIdx]);
            if (readBuffer.Length < bodyLength)
                return;
            readBuffer.ReadIndex += 2;
            
            // 解析协议名
            int nameCount = 0;
            string protoName = MsgBase.DecodeName(readBuffer.Bytes, readBuffer.ReadIndex, out nameCount);
            if (protoName == "")
            {
                Debug.Log("OnReceiveData MsgBase.DecodeName 失败");
                return;
            }

            readBuffer.ReadIndex += nameCount;
            // 解析协议体
            int bodyCount = bodyLength - nameCount;
            MsgBase msgBase = MsgBase.Decode(protoName, readBuffer.Bytes, readBuffer.ReadIndex, bodyCount);
            readBuffer.ReadIndex += bodyCount;
            readBuffer.CheckAndMoveBytes();
            // 添加到消息队列
            lock (msgList)
            {
                msgList.Add(msgBase);
            }

            msgCount++;
            // 继续读取消息
            if (readBuffer.Length > 2)
            {
                OnReceiveData();
            }
        }
    }
}

image-20220913174248854

Update

根据配置,每帧处理N条消息

lock的原因是,可能存在主线程Update的同时子线程往list里塞数据的情况

namespace Framework
{
    public static class NetManager
    {
    	/// <summary>
        /// 更新
        /// </summary>
        public static void MsgUpdate()
        {
            //初步判断,提升效率
            if (msgCount == 0)
                return;

            //重复处理消息
            for (int i = 0; i < MAX_MESSAGE_FIRE; i++)
            {
                //获取第一条消息
                MsgBase msgBase = null;
                lock (msgList)
                {
                    if (msgList.Count > 0)
                    {
                        msgBase = msgList[0];
                        msgList.RemoveAt(0);
                        msgCount--;
                    }
                }

                //分发消息
                if (msgBase != null)
                    FireMsg(msgBase.protoName, msgBase);
                //没有消息了
                else
                    break;
            }
        }
    }
}

心跳

如果客户端已经掉线了,但是服务器没有及时释放对应的Socket连接,那么会浪费很多的资源

心跳就是每隔一段时间由服务器向客户端发送信息,如果两边不能够正常通讯,那么在一定时间后,服务器需要释放资源

(客户端当然也要,但客户端一般只维护一个socket,其实没那么大的影响,而服务器会维护成千上万的连接,这时候影响就很大了)

PING和PONG

用于心跳机制的两条新的协议

namespace Framework
{
    public class SysMsg
    {
        public class MsgPing : MsgBase
        {
            public override string protoName => "MsgPing";
        }

        public class MsgPong : MsgBase
        {
            public override string protoName => "MsgPong";
        }
    }
}

成员变量

客户端需要控制发送PING协议的间隔,以及如果多久没收到回应时应该断开连接

在NetManager中添加几个字段

  • isUsingPing 是否开启心跳,因为心跳机制会导致流量和消息的增加
  • pingInterval 心跳间隔,默认为30秒
  • lastPingTime 上一次PING的时间
  • lastPongTime 上一次收到PING的时间
namespace Framework
{
    public static class NetManager
    {
    	// 是否启用心跳
        public static bool isUsePing = true;
        // 心跳间隔时间
        public static int pingInterval = 30;
        // 上一次发送PING的时间
        static float lastPingTime = 0;
        // 上一次收到PONG的时间
        static float lastPongTime = 0; 
        
        public static void Connect(string ip, int port)
        {
            ...
            // 心跳
            lastPingTime = Time.time;
            lastPongTime = Time.time;   
            ...
        }
    }
}

发送PING

  1. 如果没有开启心跳机制,不发送
  2. 判断上一次PING的时间,与设置的心跳间隔,如果小于,不发送
  3. 判断上一次PONG的时间,与当前时间,如果超过关闭限制时间,则断开连接(超时时间目前设置为间隔*4)
namespace Framework
{
    public static class NetManager
    {
    	public static void Update()
        {
            MsgUpdate();
            PingUpdate();
        }
        
        /// <summary>
        /// 心跳
        /// </summary>
        private static void PingUpdate()
        {
            // 是否启用
            if (!isUsePing)
                return;

            // 发送PING
            if (Time.time - lastPingTime > pingInterval)
            {
                MsgPing msgPing = new MsgPing();
                Send(msgPing);
                lastPingTime = Time.time;
            }

            // 检测PONG时间
            if (Time.time - lastPongTime > pingInterval * 4)
                Close();
        } 
    }
}

监听PONG

在初始化时客户端需要监听服务器发回来的PONG消息,如果收到就调用OnMsgPong方法

考虑到客户端可能多次连接服务器,所以需要判断是否已经监听过了

namespace Framework
{
    public static class NetManager
    {        
        public static void Connect(string ip, int port)
        {
            ...
            //监听PONG协议
            if(!msgListeners.ContainsKey("MsgPong"))
                AddMsgListener("MsgPong", OnMsgPong);   
            ...
        }
        
        private static void OnMsgPong(MsgBase msgBase)
        {
            lastPongTime = Time.time;
        }
    }
}

测试

此时服务器是可以收到客户端发送的PING消息的

image-20220914131846307

但是目前还没有实现服务器消息转发,所以客户端收不到PONG,过一段时间后客户端就会断开连接

Protobuf协议

类似于Json的一种协议,有谷歌提供,开源,高效,目前的主流协议

官方文档

Git主页

获取Protoc

也就是Proto Compiler,用于将.Proto文件编译成目标语言文件

根据git建议,如果是CPP用户可以自己下载源码编译,如果不是CPP用于那么可以下载编译好的exe使用

下载目录

注意下面的protoc才是编译器,下载win版本的,我这里下载了32版本(最新版本还不支持Unity,后改为3.5.0版本)

image-20220914152501376

image-20220914155212182

在此处打开命令行并输入./protoc.exe -h,可以获取操作说明文档

image-20220914155247649

获取Proto

也就是Protobuffer,用于在Unity里解析我们的Proto协议

下载目录,选择C#版本(最新版本好像Unity的.net还不支持,后改为3.5.x版本)

image-20220914154007771

打开指定目录

image-20220914154105526

  1. 直接在Unity中使用源码,那么将Google.Protobuf这个文件夹丢到Unity工程里就OK
  2. 在Unity中使用Dll,那么需要用VS打开Google.Protobuf.sln,使用Release编译,在对应目录下会有一个DLL,放到Plugins中就好

简单使用

编译

新建一个.proto消息

image-20220914160118572

在此处打开cmd,使用命令行编译

./protoc.exe –proto_path=[FileName] –csharp_out=[OutPath]

具体命令为

./protoc.exe msgattack.proto –csharp_out=./

会得到一个编译好的C#文件

image-20220914160437409

使用

先把运行时文件拷贝到Unity目录

image-20220914162115540

然后把刚刚编译好的文件也拷到Unity下的一个目录

image-20220914162602572

测试

void Start()
{
    var attack = new MsgAttack();
    attack.SkillName = "龟派气功!!";

    // 编码
    var byteData = attack.ToByteArray();

    // 解码
    IMessage msg = MsgAttack.Parser.ParseFrom(byteData);
    var data = (MsgAttack) msg;
    Debug.Log(data.SkillName);
}

image-20220914163006435