遊戲開發-協議設計-protobuf

wier發表於2017-12-20

本篇是遊戲開發系列第二篇,如若你有興趣,請持續關注,後期會持續更新。其他文章列表如下:

遊戲開發—協議設計

遊戲開發—協議-protobuf

遊戲開發-協議-protobuf原理詳解

WHAT

簡介

我們看官方文件是如此介紹的:

Protocol buffers are a language-neutral, platform-neutral extensible mechanism for serializing structured data.

Protocol buffers 是一個跨語言,跨平臺以及支援可擴充套件的序列化結構資料的格式。

簡單來說,Protocol Buffers就是一種google定義的結構化資料格式,用於資料的序列化和反序列化。由於它直接對二進位制源資料進行操作,所以它相對於xml來說,足夠的小,快以及簡單,而且又與語言、平臺無關,所以相容性也有不錯的表現。目前很適合做資料儲存或 網路通訊間的資料傳輸。

當前官方顯示的已支援的開發語言多達10種,分別有:C++、Java、Python、Objective-C、C#、JavaNano、JavaScript、Ruby、Go、PHP,基本上主流的語言都已支援。當然也有非官方(比如Lua)的支援語言,具體也是增加一個解析lib,有特殊需求的可以參考官方文件自己編寫。目前支援的語言如下(有source地址):

LanguageSource
C++ (include C++ runtime and protoc)src
Javajava
Pythonpython
Objective-Cobjectivec
C#csharp
JavaNanojavanano
JavaScriptjs
Rubyruby
Gogolang/protobuf
PHP


效能如何:

官方介紹的它效能足夠強悍,具體有多好?我們看下效能測試對比。

以上是基於Full Object Graph Serializers,包括建立物件,將物件序列化為記憶體中的位元組序列,然後再反序列化的整個過程。圖一是(序列化+反序列化)總共耗時,圖二是壓縮後的大小。我們可以看出protocolBuffer無論是序列化速度,還是資料大小,都有有明顯優勢。具體測試資料點此.

HOW

具體如何用,官方guide已經有很詳細的介紹了,我們基於官方demo對package進行一次分解,瞭解其序列化過程以及soruce結構,以便對整個機制有一個大概的瞭解(以下語言基於java)。

demo

此demo假定你已經擁有當前平臺的compiler(.proto生成目標語言程式碼的編譯器),如若沒有,請參照官網編譯C++ runtime and protoc,如若window平臺,也可以點選此處下載一個,無需自己編譯。

step1:引入maven

<dependency>
    <groupId>com.google.protobuf</groupId>
    <artifactId>protobuf-java</artifactId>
    <version>3.2.0</version>
</dependency>複製程式碼

step2:定義.proto檔案

syntax = "proto3";
package msg;

option java_package = "com.example.msg";
option java_outer_classname = "LoginMsg";

message Login {
  string useranme = 1;
  int32  pw=2;
}複製程式碼

可支援的資料型別:

官網咖

step3:compiler生產程式碼

//--java_out是目標語言程式碼目錄 緊跟著空格之後是.proto檔案目錄,生成多個可用-I
protoc --java_out=java resources/protoc/login.proto複製程式碼

最終生成的檔案以及目錄:

Reader&Writer

上述通過.proto定義生成的LoginMsg.java,已經整合了對LoginMsg的序列化和反序列化相關程式碼,我們對login這個訊息的reader和writer時只需要通過對該class進行操作即可。比如要把loginMsg寫入到流裡面傳送出去,只需要對loginMsg進行賦值然後writer,物件就被序列化為二進位制資料寫出,或者接收端讀取LoginMsg時,呼叫其ParserbyReader,就可以基於二進位制流反序列化為LoginMsg物件。

Write:

   public void write() throws Exception{
        //構建Login訊息物件
        LoginMsg.Login.Builder builder = LoginMsg.Login.newBuilder();
        builder.setUseranme("wier");
        builder.setPwd(111);

        //序列化並寫出到磁碟
        FileOutputStream output = new FileOutputStream("/Users/wier/login_msg");
        builder.build().writeTo(output);
        output.close();
    }複製程式碼

Read

    public void read() throws Exception{
        FileInputStream inputStream = new FileInputStream("/Users/wier/login_msg");
        LoginMsg.Login login = LoginMsg.Login.parseFrom(inputStream);
        System.out.print("login.username:"+login.getUseranme());
        System.out.print("login.pwd:"+login.getPwd());

    }複製程式碼

我們看到上述程式碼對訊息的read和write都很簡單,你只需要對上述的stream改造為為socket就可以基於tcp進行訊息傳輸了。

Message類結構

我們基於LoginMsg來看下整個訊息物件主要包含的資訊。

一個message類主要包含以下資訊:

Login 訊息結構物件的主體,主要儲存資料,同時繼承GeneratedMessageV3,內部封裝物件的序列化和反序列化,writeTo序列化,paser反序列化。

LoginOrBuilder 用來連線Login和Builder,提供型別資訊以及對外提供field get方法。

Builder 訊息物件構建器,對外封裝field set方法。

Descriptor 訊息物件後設資料的描述資訊,一般用不到,如果你有動態解析的需求可以通過此來處理

Parser 解析器,為訊息反序列號提供服務

我們看下class的層次關係

MessageLite/Message介面是所有message的抽象介面,message可以基於Parser從位元組流資料中構建物件,也可以通過Builder建立的物件序列化後寫入位元組流資料到IO管道,MessageLite和Message內部都定義了自己的Builder類,繼承自MessageLiteOrBuilder以及MessageOrBuiler,並定義了MessageLite/Message和它們各自Builder類的共同介面。

呼叫時序

write

上面write的過程,我們可以看到,資料的封裝主要通過build來處理,GeneratedMessageV3封裝了一些基礎欄位讀取的操作,最終的欄位的寫入主要依靠CodedOutputStream來進行,CodedOutputStream封裝的所有(定義型別)欄位轉二進位制的方式,比如int,String 等,你只需基於定義欄位傳入即可。OutputStreamEncoder是CodedOutputStream是一個子類。

read

read的過程也是一個解包的過程,Parser主要來做解析管理,比如可以基於二進位制資料或者基於IO來解析,或者一些擴充套件欄位呼叫預註冊的ExtensionRegister來自己定義解析。最終的欄位讀取呼叫CodedInputStream來讀取,CodedInputStream和上面的CodedOutputStream一樣,也是基於一些定義欄位進行讀取操作,將二進位制資料轉換為指定欄位型別。訊息的建構函式有基於CodedInputStream讀取的,讀取順序基於tag來進行。具體每個field的tag是做什麼的後續講解。

message二進位制結構

通過上面的read和write過程,我們可以看到每個訊息欄位讀取的時候,都會先呼叫一次readTag或者writeTag,那麼這個tag是做什麼的,我們先看一個message的二進位制組成結構。

一個二進位制流,都是一隊有序的byte資料組成,上述圖中每個field都是有一個tag和value組成,tag等於就是這個value資訊的描述或者定義,告知解析器當前fields是什麼型別欄位,以及讀取的順序,有了這個資訊,解析器就知道一個field在流中的開始位置和結束位置,如此一個field解碼成功,並且與欄位順序無關。

tag的構成:

(fieldNumber << 3) | wireType;

為何需要fieldNumber,一個是它可以告知解析器當前field在位元組流中解析的順序,另外也可以做到對協議的擴充套件,比如你在已經用到的協議訊息中,需要增加一個欄位或者更改一個欄位,可以 fieldNumber+1,這樣即便是同樣一個訊息,無論client是否更新協議(比如依然採用old message),依然不影響server端的解析。這樣的機制,保證了即使該訊息新增了新的欄位,也不會影響舊的編/解碼程式正常工作。

Descriptor

Descriptor 是訊息物件的後設資料描述資訊,在compilerss生成訊息物件class的時候,會為每個message定義一個Descriptor靜態欄位、同時還會定義一個FieldAccessorTable靜態欄位用於使用反射讀取/設定某個欄位的值。

當然了這些在一般的序列化和反序列化的時候用不到,因為訊息的解析順序以及型別已經在生成的時候基於配置檔案生成好了,無需再來解析標籤含義。

如果你有動態解析的需求,比如:新增或者更新一個 Message 時候,不需要更程式碼,重啟程式,基於接收到 資料和配置檔案,自動建立具體的 Protobuf Message 物件,再做的反序列化。此時Descriptor對你有很大的幫助意義。我們看下Descriptor下類層結構。

最後

extensions

在protocol2期間,還支援extensions欄位定義,通過extend 用來解決訊息複用的方式,目前在protocol3已經廢棄了,採用Any來支援。

Unknown Fields

在protocol2期間,如果有無法解析的欄位(如訊息升級之後,client採用old message 傳送),預設的解決方式如下:

        default: 
        if (!parseUnknownField(input, unknownFields, extensionRegistry, tag)) {
          done = true;
        }複製程式碼
複製程式碼

如今protocol3已經對這一方案進行更新了,遇到沒有定義的欄位,直接skipField。

 default: 
       if (!input.skipField(tag)) {
           done = true;
           }
       break;複製程式碼
本節只針對protocol buffer 的是什麼,以及如何用進行了介紹,並沒有針對protocol為何會有佔用空間小,解析速度快以及相容性等優點進行梳理,如果你對這部分有興趣,請關注下一篇相關文字,我會嘗試梳理一下關於why問題。


---------------------------------------------------end---------------------------------------------------

掃描關注更多,關注個人成長和技術學習,期待用自己的一點點改變,帶給你一些啟發及感悟。



相關文章