1. 程式人生 > >《Unity 3D遊戲客戶端基礎框架》 protobuf網路框架

《Unity 3D遊戲客戶端基礎框架》 protobuf網路框架

前言:

        protobuf是google的一個開源專案,主要的用途是:

1.資料儲存(序列化和反序列化),這個功能類似xml和json等;

2.製作網路通訊協議;

一、資源下載:

二、資料儲存:

        C#語言方式的導表和解析過程,在之前的篇章中已經有詳細的闡述:Unity —— protobuf 導excel表格資料,建議在看後續的操作之前先看一下這篇文件,因為後面設計到得一些操作與導表中是一致的,而且在理解了導表過程之後,能夠快速地理解協議資料序列化反序列化的過程。

三、網路協議:

1.設計思想:

        有兩個必要的資料:協議號協議型別,將這兩個資料分別儲存起來

  • 當客戶端向伺服器傳送資料時,會根據協議型別加上協議號,然後使用protobuf序列化之後再發送給伺服器;
  • 當伺服器傳送資料給客戶端時,根據協議號,用protobuf根據協議型別反序列化資料,並呼叫相應回撥方法。

        由於資料在傳輸過程中,都是以資料流的形式存在的,而進行解析時無法單從protobuf資料中得知使用哪個解析類進行資料反序列化,這就要求我們在傳輸protobuf資料的同時,攜帶一個協議號,通過協議號和協議型別(解析類)之間的對應關係來確定進行資料反序列化的解析類。

       

        此處協議號的作用就是用來確定用於解析資料的解析類,所以也可能稱之為協議型別名,可以是stringint型別的資料。

2.特點分析:

使用protobuf作為網路通訊的資料載體,具有幾個優點:

  • 通過序列化之後資料量比較小
  • 而且以key-value的方式儲存資料,這對於訊息的版本相容比較強;
  • 此外,由於protobuf提供的多語言支援,所以使用protobuf作為資料載體定製的網路協議具有很強的跨語言特性

四、樣例實現:

1.協議定義:

        在之前導表的時候,我們得到了.proto的解析類,這是protobuf提供的一種特殊的指令碼,具有格式簡單、可讀性強和方便拓展的特點,所以接下來我們就是使用proto指令碼來定義我們的協議。例如:

// 物品
message Item
{
    required int32 Type 	= 1;	//遊戲物品大類
    optional int32 SubType 	= 2;	//遊戲物品小類
    required int32 num 		= 3;	//遊戲物品數量
}

// 物品列表
message ItemList
{
    repeated Item item 	= 1;	//物品列表
}
        上述例子中,Item相當於定義了一個數據結構或者是類,而ItemList是一個列表,列表中的每個元素都是一個Item物件。注意結構關鍵詞:
  • required:必有的屬性
  • optional:可選屬性
  • repeated:陣列
        其實protobuf在這裡只是提供了一個數據載體,通過在.proto中定義資料結構之後,需要使用與導表時一樣的操作,步驟為:
  • 使用protoc.exe將.proto檔案轉化為.protodesc中間格式;
  • 使用protogen.exe將中間格式為.protodesc生成指定的高階語言類,我們在Unity中使用的是C#,所以結果是.cs類
        經過上述步驟之後,我們得到了協議型別對應的C#反序列化類,當我們收到伺服器資料時,根據協議號找到協議型別,從而使用對應的反序列化的類對資料進行反序列化,得到最終的伺服器資料內容。        在這裡,我們以登入為例,首先要清楚登入需要幾個資料,正常情況下至少包含兩個資料,即賬號和密碼,都是字串型別,即定義cs_login.proto協議指令碼,內容如下:
package cs;

message CSLoginInfo
{
	required string UserName = 1;//賬號
	required string Password = 2;//密碼
}

//傳送登入請求
message CSLoginReq
{
	required CSLoginInfo LoginInfo = 1; 
}
//登入請求回包資料
message CSLoginRes
{
	required uint32 result_code = 1; 
}
       package關鍵字後面的名稱為.proto轉為.cs之後的名稱空間namespace的值,用message可以定義類,這裡定義了一個CSLoginInfo的資料類,該類包含了賬號和密碼兩個字串型別的屬性。然後定義了兩個訊息結構:
  • CSLoginReq登入請求訊息,攜帶的資料是一個CSLoginInfo型別的物件資料;
  • CSLoginRes登入請求伺服器返回的資料型別,返回結果是一個uint32無符號的整型資料,即結果碼。
        上面定義的是協議型別,除此之外我們還需要為每一個協議型別定義一個協議號,這裡可以用一個列舉指令碼cs_enum.proto來儲存,指令碼內容為:
package cs;

enum EnmCmdID
{
	CS_LOGIN_REQ = 10001;//登入請求協議號
	CS_LOGIN_RES = 10002;//登入請求回包協議號
}
        使用protoc.exe和protogen.exe將這兩個protobuf指令碼得到C#類,具體步驟參考導表使用的操作,這裡我直接給出自動化導表使用的批處理檔案general_all.bat內容,具體檔案目錄可以根據自己放置情況進行調整:
::---------------------------------------------------
::第二步:把proto翻譯成protodesc
::---------------------------------------------------
call proto2cs\protoc protos\cs_login.proto --descriptor_set_out=cs_login.protodesc
call proto2cs\protoc protos\cs_enum.proto --descriptor_set_out=cs_enum.protodesc
::---------------------------------------------------
::第二步:把protodesc翻譯成cs
::---------------------------------------------------
call proto2cs\ProtoGen\protogen -i:cs_login.protodesc -o:cs_login.cs
call proto2cs\ProtoGen\protogen -i:cs_enum.protodesc -o:cs_enum.cs
::---------------------------------------------------
::第二步:把protodesc檔案刪除
::---------------------------------------------------
del *.protodesc

pause
        轉換結束後,我們的得到了兩個.cs檔案分別是:cs_enum.cs和cs_login.cs,將其放入到我們的Unity專案中,以便於接下來序列化和反序列化資料的使用。

2.協議資料構建:

        直接在專案程式碼中通過usingcs引入協議解析類的名稱空間,然後建立訊息物件,並對物件的屬性進行賦值,即可得到協議資料物件,例如登入請求物件的建立如下:

        CSLoginInfo mLoginInfo = new CSLoginInfo();
        mLoginInfo.UserName = "linshuhe";
        mLoginInfo.Password = "123456";
        CSLoginReq mReq = new CSLoginReq();
        mReq.LoginInfo = mLoginInfo;
        從上述程式碼,可以得到登入請求物件mReq,裡面包含了一個CSLoginInfo物件mLoginInfo,再次列舉物件中找到與此協議型別對應的協議號,即:EnmCmdID.CS_LOGIN_REQ

3.資料的序列化和反序列化:

        資料傳送的時候必須以資料流的形式進行,所以這裡我們需要考慮如何將要傳送的protobuf物件資料進行序列化,轉化為byte[]位元組陣列,這就需要藉助ProtoBuf庫為我們提供的Serializer類的Serialize方法來完成,而反序列化則需藉助Deserialize方法,將這兩個方法封裝到PackCodec類中:

using UnityEngine;
using System.Collections;
using System.IO;
using System;
using ProtoBuf;

/// <summary>
/// 網路協議資料打包和解包類
/// </summary>
public class PackCodec{
    /// <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;
    }

    /// <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;
    }
}
        使用方法很簡單,直接傳入一個數據物件即可得到位元組陣列:
        byte[] buf = PackCodec.Serialize(mReq);

        為了檢驗打包和解包是否匹配,我們可以直接做一次本地測試:將打包後的資料直接解包,看看資料是否與原來的一致:

using UnityEngine;
using System.Collections;
using System;
using cs;
using ProtoBuf;
using System.IO;

public class TestProtoNet : MonoBehaviour {

	// Use this for initialization
	void Start () {
        CSLoginInfo mLoginInfo = new CSLoginInfo();
        mLoginInfo.UserName = "linshuhe";
        mLoginInfo.Password = "123456";
        CSLoginReq mReq = new CSLoginReq();
        mReq.LoginInfo = mLoginInfo;

        byte[] pbdata = PackCodec.Serialize(mReq);
        CSLoginReq pReq = PackCodec.Deserialize<CSLoginReq>(pbdata);
        Debug.Log("UserName = " + pReq.LoginInfo.UserName + ", Password = " + pReq.LoginInfo.Password);
	}

    // Update is called once per frame
    void Update () {
	
	}
}

        將此指令碼綁到場景中的相機上,執行得到以下結果,則說明打包和解包完全匹配:
        

4.資料傳送和接收:

        這裡我們使用的網路通訊方式是Socket的強聯網方式,關於如何在Unity中使用Socket進行通訊,可以參考我之前的文章:Unity —— Socket通訊(C#),Unity客戶端需要複製此專案的ClientSocket.csByteBuffer.cs兩個類到當前專案中。

        此外,伺服器可以參照之前的方式搭建,唯一不同的是RecieveMessage(object clientSocket)方法解析資料的過程需要進行修改,因為需要使用protobuf-net.dll進行資料解包,所以需要參考客戶端的做法,把protobuf-net.dll複製到伺服器專案中的Protobuf_net目錄下:

        
        假如由於直接使用原始碼而不用.dll會出現不安全儲存,需要在Visual Studio中設定允許不安全程式碼,具體步驟為:在“解決方案”中選中工程,右鍵“資料”,選擇“生成”頁籤,勾選“允許不安全程式碼”:

         

          當然,解析資料所用的解析類和協議號兩個指令碼cs_login.cs和cs_enum.cs也應該新增到伺服器專案中,保證客戶端和伺服器一直,此外PackCodec.cs也需要新增到伺服器程式碼中但是要把其中的using UnityEngine給去掉防止報錯,最終伺服器目錄結構如下:

         

5.完整協議資料的封裝:

        從之前說過的設計思路分析,我們在傳送資料的時候除了要傳送關鍵的protobuf資料之外,還需要帶上兩個附件的資料:協議頭(用於進行通訊檢驗)和協議號(用於確定解析類)。假設我們的是:

       協議頭:用於表示後面資料的長度一個short型別的資料:

        /// <summary>
        /// 資料轉換,網路傳送需要兩部分資料,一是資料長度,二是主體資料
        /// </summary>
        /// <param name="message"></param>
        /// <returns></returns>
        private static byte[] WriteMessage(byte[] message)
        {
            MemoryStream ms = null;
            using (ms = new MemoryStream())
            {
                ms.Position = 0;
                BinaryWriter writer = new BinaryWriter(ms);
                ushort msglen = (ushort)message.Length;
                writer.Write(msglen);
                writer.Write(message);
                writer.Flush();
                return ms.ToArray();
            }
        }

       協議號:用於對應解析類,這裡我們使用的是int型別的資料:

        private byte[] CreateData(int typeId,IExtensible pbuf)
    {
        byte[] pbdata = PackCodec.Serialize(pbuf);
        ByteBuffer buff = new ByteBuffer();
        buff.WriteInt(typeId);
        buff.WriteBytes(pbdata);
        return buff.ToBytes();
    }
        客戶端傳送登入資料時測試指令碼TestProtoNet如下,測試需要將此指令碼繫結到當前場景的相機上:
using UnityEngine;
using System.Collections;
using System;
using cs;
using Net;
using ProtoBuf;
using System.IO;

public class TestProtoNet : MonoBehaviour {

	// Use this for initialization
	void Start () {


        CSLoginInfo mLoginInfo = new CSLoginInfo();
        mLoginInfo.UserName = "linshuhe";
        mLoginInfo.Password = "123456";
        CSLoginReq mReq = new CSLoginReq();
        mReq.LoginInfo = mLoginInfo;

        byte[] data = CreateData((int)EnmCmdID.CS_LOGIN_REQ, mReq);
        ClientSocket mSocket = new ClientSocket();
        mSocket.ConnectServer("127.0.0.1", 8088);
        mSocket.SendMessage(data);
    }

    private byte[] CreateData(int typeId,IExtensible pbuf)
    {
        byte[] pbdata = PackCodec.Serialize(pbuf);
        ByteBuffer buff = new ByteBuffer();
        buff.WriteInt(typeId);
        buff.WriteBytes(pbdata);
        return WriteMessage(buff.ToBytes());
    }

    /// <summary>
    /// 資料轉換,網路傳送需要兩部分資料,一是資料長度,二是主體資料
    /// </summary>
    /// <param name="message"></param>
    /// <returns></returns>
    private static byte[] WriteMessage(byte[] message)
    {
        MemoryStream ms = null;
        using (ms = new MemoryStream())
        {
            ms.Position = 0;
            BinaryWriter writer = new BinaryWriter(ms);
            ushort msglen = (ushort)message.Length;
            writer.Write(msglen);
            writer.Write(message);
            writer.Flush();
            return ms.ToArray();
        }
    }

    // Update is called once per frame
    void Update () {
	
	}
}
        伺服器接受資料解包過程參考打包資料的格式,在RecieveMessage(object clientSocket)中,解析資料的核心程式碼如下:
        ByteBuffer buff = new ByteBuffer(result);
        int datalength = buff.ReadShort();
        int typeId = buff.ReadInt();
        byte[] pbdata = buff.ReadBytes();
        //通過協議號判斷選擇的解析類
        if(typeId == (int)EnmCmdID.CS_LOGIN_REQ)
        {
                CSLoginReq clientReq = PackCodec.Deserialize<CSLoginReq>(pbdata);
                string user_name = clientReq.LoginInfo.UserName;
                string pass_word = clientReq.LoginInfo.Password;
                Console.WriteLine("資料內容:UserName={0},Password={1}", user_name, pass_word);
                }
        }
        上面通過typeId來找到匹配的資料解析類,協議少的時候可以使用這種簡單的使用if語句分支判斷來實現,但是假如協議型別多了,則需要進一步封裝查詢方法,常用的方法有:定義一個Dictionary<int,Type>字典來存放協議號(int)和協議型別(Type)的對應關係。

6.執行結果:

        啟動伺服器,然後執行Unity中的客戶端,得到正確的結果應該如下: