Unity3D网络游戏实战|04 接口封装
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) 添加网络事件
内部设计
网络模块分为两个部分
第一部分是框架部分 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个事情
- 将异常尽可能的放在try-catch中
- 连接成功或者失败时候,派发对应的事件
- 重置标志位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);
解码
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功能
协议格式
协议文件
添加一个协议基类,所有协议都要继承自此类
具体的协议
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"));
}
}
}
编码很简单,解码如下
协议名称的编码和解码
协议名称同样需要编码,且还需要知道协议名称的长度,并进行小端存储
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;
}
}
}
解码,偏移量正常是协议所约定的长度(在这里是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();
}
}
}
}
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
- 如果没有开启心跳机制,不发送
- 判断上一次PING的时间,与设置的心跳间隔,如果小于,不发送
- 判断上一次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消息的
但是目前还没有实现服务器消息转发,所以客户端收不到PONG,过一段时间后客户端就会断开连接
Protobuf协议
类似于Json的一种协议,有谷歌提供,开源,高效,目前的主流协议
获取Protoc
也就是Proto Compiler,用于将.Proto文件编译成目标语言文件
根据git建议,如果是CPP用户可以自己下载源码编译,如果不是CPP用于那么可以下载编译好的exe使用
注意下面的protoc才是编译器,下载win版本的,我这里下载了32版本(最新版本还不支持Unity,后改为3.5.0版本)
在此处打开命令行并输入./protoc.exe -h,可以获取操作说明文档
获取Proto
也就是Protobuffer,用于在Unity里解析我们的Proto协议
下载目录,选择C#版本(最新版本好像Unity的.net还不支持,后改为3.5.x版本)
打开指定目录
- 直接在Unity中使用源码,那么将Google.Protobuf这个文件夹丢到Unity工程里就OK
- 在Unity中使用Dll,那么需要用VS打开Google.Protobuf.sln,使用Release编译,在对应目录下会有一个DLL,放到Plugins中就好
简单使用
编译
新建一个.proto消息
在此处打开cmd,使用命令行编译
./protoc.exe –proto_path=[FileName] –csharp_out=[OutPath]
具体命令为
./protoc.exe msgattack.proto –csharp_out=./
会得到一个编译好的C#文件
使用
先把运行时文件拷贝到Unity目录
然后把刚刚编译好的文件也拷到Unity下的一个目录
测试
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);
}