unity遊戲框架學習-實現c#的網絡框架

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

相關文章
相關標籤/搜索