前面说道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,英文不好可参考下面的中文语法,这边不做赘述
中文语法:
大概样子如下:
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("//动态修改,不要手动修改\n");
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.灵活好控制