概述連接: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.靈活好控制