概述连接:http://www.javashuo.com/article/p-nggymcxb-bw.htmlhtml
前面说道Socket负责和游服的通讯,包括网络的链接、消息的接收、心跳包的发送、断线重连的监听和处理算法
那一个完整的网络模块包括几方面呢?(仅讨论客户端)json
1.创建和服务端的socket链接,实现客户端-服务端两端的接收和发送功能。c#
2.消息协议的选择,网络消息的解析能够是json、xml、protobuf,本篇使用的是protobuf缓存
3.消息缓存服务器
4.消息的监听、分发、移除网络
5.客户端身份验证,由客户端、服务端生成密钥进行验证。框架
6.心跳包的实现,主要是检测客户端的链接状况,避免浪费服务端资源socket
如上所述,一套完整的unity的socket网络通讯模块所包含的内容大概就是这些。工具
示例工程:连接: https://pan.baidu.com/s/1vJbo0ThXhShk9eJv3VNCuw 提取码: fngy 本篇文章资源链接
该工程主要是实现客户端-服务端两端的链接,以及消息的监听、派发、发送、接受等功能,心跳包未实现。
1、建立一个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("//动态修改,不要手动修改\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("}"); } }
这样协议的生成、解析都有了,剩下的就是消息的管理了
3、消息的缓存、接受、发送
客户端消息队列:总共生成四个缓存队列,两个子线程,一个用于发送消息,一个用于接收消息,主要是防止同时接受、发送多条信息,以及实现转菊花的效果(发送消息开始转菊花,服务器回包后结束菊花,防止重复发送消息)
发送代码以下:建立两个队列,一个用于存储主线程的等待发送的队列(由各模块调用),一个用于子线程向服务器发送消息(使用支线程向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); } } }
4、消息的监听、派发,业务经过这个类和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); } }
5、客户端身份验证,作完上面的步骤,你已经能够生成、解析、使用消息协议,也能够和服务端通讯了,其实通讯功能就已经作完了,可是客户端验证和心跳包又是游戏绕不过去的一个步骤,因此 咱们继续~
认证的过程大概是这样子的(以我当前的项目为例)
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的王国),而登陆服只会有一个?好吧 这个有知道的大神麻烦在评论告诉我下
6、心跳包,具体能够参考https://gameinstitute.qq.com/community/detail/101837
心跳包主要用于长链接的保活和断线处理,socket自己的断开通知不是很靠谱,有时候客户端断开网络,Socket并不能实时监测到,服务器还维持这个客户端没必要要的引用
心跳包之因此叫心跳包是由于:它像心跳同样每隔固定时间发一次,以此来告诉服务器,这个客户端还活着加了服务器的负荷
怎么发送心跳?
1:轮询机制:归纳来讲是服务端定时主动的与客户端通讯,询问当前的某种状态,客户端返回状态信息,客户端没有返回,则认为客户端已经宕机,而后服务端把这个客户端的宕机状态保存下来,若是客户端正常,那么保存正常状态。若是客户端宕机或者返回的是定义
的失效状态那么当前的客户端状态是可以及时的监控到的,若是客户端宕机以后重启了那么当服务端定时来轮询的时候,仍是能够正常的获取返回信息,把其状态从新更新。
2:心跳机制:最终获得的结果是与轮询同样的可是实现的方式有差异,心跳不是服务端主动去发信息检测客户端状态,而是在服务端保存下来全部客户端的状态信息,而后等待客户端定时来访问服务端,更新本身的当前状态,若是客户端超过指定的时间没有来更新状态,则认为客户端已经宕机。心跳比起轮询有两个优点:1.避免服务端的压力2.灵活好控制