protobuf優缺點及編碼原理


什么是protobuf

protobuf(Google Protocol Buffers),官方文檔對 protobuf 的定義:protocol buffers 是一種語言無關、平台無關、可擴展的序列化結構數據的方法,可用於數據通信協議和數據存儲等,它是 Google 提供的一個具有高效協議數據交換格式工具庫,是一種靈活、高效和自動化機制的結構數據序列化方法。相比XML,有編碼后體積更小,編解碼速度更快的優勢;相比於 Json,Protobuf 有更高的轉化效率,時間效率和空間效率都是 JSON 的 3-5 倍。

Protocol buffers are Google's language-neutral, platform-neutral, extensible mechanism for serializing structured data – think XML, but smaller, faster, and simpler. You define how you want your data to be structured once, then you can use special generated source code to easily write and read your structured data to and from a variety of data streams and using a variety of languages.

數據格式

假設我們有一個 person 對象,用 JSON、XML 和 protobuf 表示下它們各是什么樣。

用 XML 格式表示如下

<person>
    <name>ivy</name>
    <age>24</age>
</person>

用 JSON 格式表示如下

{
    "name":"ivy",
    "age":24
}

用 protobuf 表示如下, 它直接用二進制來表示數據,不像上面 XML 和 JSON 格式那么直觀

[10 6 69 108 108 105 111 116 16 24]

protobuf優點

1、性能好/效率高

  • 時間開銷:XML 格式化(序列化)的開銷還好;但是 XML 解析(反序列化)的開銷就不敢恭維了。 但是 protobuf 在這個方面就進行了優化。可以使序列化和反序列化的時間開銷都減短。

  • 空間開銷:也減少了很多

2、有代碼生成機制

比如寫一個類似結構體的內容

 message testA  
 {  
    required int32 m_testA = 1;  
 }  

像寫一個這樣的結構,protobuf 可以自動生成它的 .h 文件和點 .cpp 文件。

protobuf 將對結構體 testA 的操作會封裝成一個類。

3、支持向后兼容和向前兼容

當客戶端和服務器同時使用一個協議時,客戶端在協議中增加一個字節,並不會影響客戶端的使用。

4、支持多種編程語言

在Google官方發布的源代碼中包含了

  • C++
  • C#
  • Dart
  • Go
  • Java
  • Kotlin
  • Python

protobuf缺點

1、二進制格式導致可讀性差

為了提高性能,protobuf 采用了二進制格式進行編碼。這直接導致了可讀性差,影響開發測試時候的效率。當然,在一般情況下,protobuf 非常可靠,並不會出現太大的問題。

2、缺乏自描述

一般來說,XML 是自描述的,而 protobuf 格式則不是。它是一段二進制格式的協議內容,並且不配合寫好的結構體是看不出來什么作用的。

3、通用性差

protobuf 雖然支持了大量語言的序列化和反序列化,但仍然並不是一個跨平台和語言的傳輸標准。在多平台消息傳遞中,對其他項目的兼容性並不是很好,需要做相應的適配改造工作。相比 json 和 XML,通用性還是沒那么好。

使用指南

定義消息類型

proto消息類型文件一般以 .proto 結尾,可以在一個 .proto 文件中定義一個或多個消息類型。

下面是一個搜索查詢的消息類型定義,在最開頭的 syntax 描述的是版本信息,proto 目前有兩個版本 proto2 和 proto3。

syntax="proto3" 明確的設置了語法格式為 proto3,如果不設置 syntax 即默認為 proto2。query 為要查詢的內容,page_number 表示查詢有多少頁,每頁的數量為 result_per_page 個。syntax="proto3" 必須位於 .proto 文中除去注釋和空行的第一行。

下面的消息包含3個字段 (query,page_number,result_per_page),每個字段有一個類型,字段名稱和字段編號。字段類型可以是 string、int32、enum 或者復合類型。

syntax = "proto3";

message SearchRequest {
  string query = 1;
  int32 page_number = 2;
  int32 result_per_page = 3;
}

字段編號

消息類型中的每個字段都需要定義唯一的編號,該編號會用來識別二進制數據中字段。編號在 [1,15] 范圍內可以用一個字節編碼表示。在 [16,2047] 范圍可以用兩個字節編碼表示。所以將 15 以內的編號留給頻繁出現的字段可以節省空間。編號的最小值為 1,最大值為 2^29-1=536870911。不能使用 [19000,19999] 范圍內的數字,因為該范圍內的數字被proto編譯器內部使用。同理,其他預先已經被保留的數字也不能使用。

字段規則

每個字段可以被 singular 或者 repeated 修飾。在 proto3 語法中,如果不指定修飾類型,默認值為 singular. singular: 表示被修飾的字段最多出現 1次,即出現 0次或 1次。repeated: 表示被修飾的字段可以出現任意次,包括 0次。在 proto3 語法中,repeated 修飾的字段默認采用 packed 編碼。

注釋

可以給 .proto 文件添加注釋,注釋語法與 C/C++ 風格相同,使用 // 或者 /* ... */

/* SearchRequest represents a search query, with pagination options to
 * indicate which results to include in the response. */

message SearchRequest {
  string query = 1;
  int32 page_number = 2;  // Which page number do we want?
  int32 result_per_page = 3;  // Number of results to return per page.
}

保留字段

當刪掉或者注釋掉 message 中的一個字段時,將來其他開發人員在更新 message 定義時可以重用之前的字段編號。如果他們意外載入了舊版本的 .proto 文件將會導致嚴重的問題,例如數據損壞。一種避免問題產生的方式是指定保留的字段編號和字段名稱。如果將來有人用了這些字段編號將在編譯 proto 的時候產生錯誤,顯示提醒 proto 有問題。

注意:不要對同一個字段混合使用字段名稱和字段編號。

message Foo {
  reserved 2, 15, 9 to 11;
  reserved "foo", "bar";
}

字段類型與語言類型映射

定義好的 .proto 文件可以通過生成器產生 Go 語言代碼,a.proto 文件產生的 go 文件為 a.pb.go 文件。

proto 中基本類型與 Go 語言類型映射如下表。這里只列舉了與 Go 和 C/C++ 之間類型的映射,其他語言參考 https://developers.google.com/protocol-buffers/docs/proto3

.proto Type Go Type C++ Type
double float64 double
float float32 float
int32 int32 int32
int64 int64 int64
uint32 uint32 uint32
uint64 uint64 uint64
sint32 int32 int32
sint64 int64 int64
fixed32 uint32 uint32
fixed64 uint64 uint64
sfixed32 int32 int32
sfixed64 int64 int64
bool bool bool
string string string
bytes []byte string

缺省值

.proto Type default value
string ""
bytes []byte
bool false
numeric types 0
enums first defined enum value

枚舉類型

在定義消息的時候,希望字段的值只能是預期某些值中的一個。

例如,現在為 SearchRequest 添加 corpus 字段,它的值只能是 UNIVERSAL、WEB、IMAGES、LOCAL、NEWS、PRODUCTS 和 VIDEO 中的一個。可以非常簡單的通過向消息定義中添加枚舉,並為每個可能的枚舉值添加常量來實現。

message SearchRequest {
  string query = 1;
  int32 page_number = 2;
  int32 result_per_page = 3;
  enum Corpus {
    UNIVERSAL = 0;
    WEB = 1;
    IMAGES = 2;
    LOCAL = 3;
    NEWS = 4;
    PRODUCTS = 5;
    VIDEO = 6;
  }
  Corpus corpus = 4;
}

Corpus 枚舉的第一個常量必須映射到 0,所有枚舉定義都需要包含一個常量映射到 0,並且該值為枚舉定義的第一行內容。因為這樣我們可以將 0作為枚舉的默認值,proto2 語法中首行的枚舉值總是默認值,為了兼容 0值必須作為定義的首行。

導入其他proto

在一個 .proto 文件中可以導入其他 .proto 文件,這樣就可以使用它導入的 .proto 中定義的消息類型了。

import "myproject/other_protos.proto";

默認情況下,只能使用直接導入的 .proto 文件中定義的消息。但是,有時候可能需要將 .proto 文件移動到新位置,有一種巧妙的做法是在舊位置放一個虛擬的 .proto 文件。在文件中使用 import public 語法將所有導入轉發到新位置,而不是直接移動 .proto 文件並在一次更改中更新所有調用點。任何導入包含 import public 語句中的 proto 文件的地方都可以傳遞依賴導入的公共依賴項。下面同一個例子來理解這里的內容。

在當前的文件夾下有 a.protob.proto 文件,現在在 a.proto 文件中 import 了 b.proto 文件。即在 a.proto 文件中有下面的內容

import "b.proto";

假設現在 b.proto 中的消息要放入到一個 common/com.proto 文件中,可以方便其他地方也使用,這時可以修改 b.proto 在里面 import com.proto 即可.注意要「import public」, 因為單獨的 import 只能使用 b.proto 中定義的消息,並不能使用 b.proto 中 import 的 proto 文件中的消息類型。

// b.proto文件, 將里面的消息定義移動了common/com.proto文件,
// 在里面添加下面的import語句

import public "common/com.proto"

在使用 protoc 編譯時,需要使用選項 -I 或 --proto_path 通知 protoc 去什么地方查找 import 的文件,如果不指定搜索路徑,protoc 將會在當前目錄下(調用protoc的路徑)下查找。

可以導入 proto2 版本中的消息類型到 proto3 文件中使用,也可以在 proto2 文件中導入 proto3 版本的消息類型。但是在 proto2 的枚舉類型不能直接應用到proto3的語法中。

嵌套消息

消息類型可以定義在消息類型的內部,即嵌套定義,里面下面的 Result 類型定義在 SearchResponse 的內部。不單單是一層嵌套,也可以多層嵌套。

message SearchResponse {
  message Result {
    string url = 1;
    string title = 2;
    repeated string snippets = 3;
  }
  repeated Result results = 1;
}

外面的消息類型使用其他消息內部的消息,下面的 SomeOtherMessage 類型使用到了 Result,可以使用 SearchResponse.Result。

message SomeOtherMessage {
  SearchResponse.Result result = 1;
}

未知字段

未知字段是 proto 編譯器無法識別的字段,例如當舊二進制文件解析具有新字段的新二進制文件發送的數據時,這些新字段將成為舊二進制文件中的未知的字段。在初版的 proto3 中消息解析時會丟掉未知的字段,但在 3.5 版本時,重新引入了未知字段的保留,未知字段在解析期間會保留,並包含在序列化輸出中。

編碼原理

protobuf高效的秘密在於它的編碼格式,它采用了 TLV(tag-length-value) 編碼格式。每個字段都有唯一的 tag 值,它是字段的唯一標識。length 表示 value 數據的長度,length 不是必須的,對於固定長度的 value,是沒有 length 的。value 是數據本身的內容。

對於 tag 值,它有 field_number 和 wire_type 兩部分組成。field_number 就是在前面的 message 中我們給每個字段的編號,wire_type 表示類型,是固定長度還是變長的。 wire_type 當前有0到5一共6個值,所以用3個 bit 就可以表示這6個值。tag 結構如下圖。

wire_type 值如下表, 其中3和4已經廢棄,我們只需要關心剩下的4種。對於 Varint 編碼數據,不需要存儲字節長度 length。這種情況下,TLV 編碼格式退化成 TV 編碼。對於64-bit和32-bit也不需要 length,因為type值已經表明了長度是8字節還是4字節。

Varint編碼原理

Varint 顧名思義就可變的 int,是一種變長的編碼方式。值越小的數字,使用越少的字節表示,通過減少表示數字的字節數從而進行數據壓縮。對於 int32 類型的數字,一般需要4個字節表示,但是采用 Varint 編碼,對於小於128的 int32 類型的數字,用1個字節來表示。對於很大的數字可能需要5個字節來表示,但是在大多數情況下,消息中一般不會有很大的數字,所以采用 Varint 編碼可以用更少的字節數來表示數字。Varint 是變長編碼,那它是怎么區分出各個字段的呢?也就是怎么識別出這個數字是1個字節還是2個字節,Varint 通過每個字節的最高位來識別,如果字節的最高位是1,表示后續的字節也是該數字的一部分,如果是0,表示這是最后一個字節,且剩余7位都用來表示數字。雖然這樣每個字節會浪費掉 1bit 空間,也就是 1/8=12.5% 的浪費,但是如果有很多數字不用固定的4字節,還是能節省不少空間。

下面通過一個例子來詳細學習編碼方法,現在有一個int32類型的數字65,它的Varint編碼過程如下,可以看到占用4字節的65編碼后只占用1個字節。

int32類型的數字128編碼過程如下,4字節的128編碼后只占用2個字節。

對於 Varint 解碼是上面過程的一個逆過程,也比較簡單,這里就不在舉例說明了。

Zigzag編碼

我們知道,負數的符號位為數字的最高位,它的最高位是1,所以對於負數用 Varint 編碼一定為占用5個字節。這是不划算的,明明是4字節可以搞定的,現在統統都需要5個字節。所以 protobuf 定義了 sint32 和 sint64 類型來表示負數,先采用 Zigzag 編碼,將有符號的數轉成無符號的數,在采用 Varint 編碼,從而減少編碼后字節數。

Zigzag采用無符號數來表示有符號數,使得絕對值小的數字可以采用比較少的字節來表示。在理解Zigzag編碼之前,我們先來看幾個概念。

原碼:最高位為符號位,剩余位表示絕對值 反碼:除符號位外,對原碼剩余位依次取反 補碼:對於正數,補碼為其本身,對於負數,除符號位外對原碼剩余位依次取反然后+1

下面以int32類型的數-2為例,分析它的編碼過程。如下圖所示。

總結起來,對於負數對其補碼做運算操作,對於數n,如果是 sint32 類型,則執行(n<<1)(n>>31)操作,如果是sint64則執行(n<<1)(n>>63), 通過前面的操作將一個負數變成了正數。這個過程就是 Zigzag 編碼,最后在采用 Varint 編碼。

因為 Varint 和 Zigzag 編碼可以自解析內容的長度,所以可以省略長度項。TLV 存儲簡化為了 TV 存儲,不需 length 項。

前面講解了每個字段有 tag 和 value 構成,對於 string 類型,還有 length 字段。下面來看 tag 和 value 值的計算方法。

tag

tag中存儲了字段的標識信息和數據類型信息,也就是說 tag=wire_type (字段數據類型)+ field_number (標識號)。通過 tag 可以獲取它的字段編號,對應上定義的消息字段。計算公式為tag=field_number<<3 | wire_type, 然后在對其采用 Varint 編碼。

value

value是采用Varint和Zigzag編碼后的消息字段的值。下面是各個 wire_type 對應的存儲類型一個總結。

wire_type 編碼方法 編碼長度 存儲方式 數據類型
0 Varint 變長 T-V int32 int64 uint32 uint64 bool enum
0 Zigzag+Varint 變長 T-V sint32 sint64
1 64-bit 固定8字節 T-V fixed64 sfixed64 double
2 length-delimi 變長 T-L-V string bytes packed repeated fields embedded
3 start group 已廢棄 已廢棄
4 end group 已廢棄 已廢棄
5 32-bit 固定4字節 T-V fixed32 sfixed32 float

string編碼

字段類型為 string 類型,字段值采用 UTF-8 編碼,下面是一個字符串編碼的示例,字段序列號為1,編碼的字符串內容是“China中國人”, proto 編碼之后的內容見下面的輸出。

message stringEncodeTest {
  string test = 1;
}

func stringEncodeTest(){
 vs:=&api.StringEncodeTest{
  Test:"China中國人",
 }
 data,err:=proto.Marshal(vs)
 if err!=nil{
  fmt.Println(err)
  return
 }
 fmt.Printf("%v\n",data)
}

編碼之后的二進制內容如下,第一個字節內容tag值,第二個字節內容14是 length,表示后面的字符串有14個字節。為啥是14個字節呢?“China中國人”不是8個字節嗎?因為字符串采用的是UTF-8編碼,每個中文字用3個字節編碼,所以"中國人"編碼之后占9個字節,在加上前面的China,一共是14個字節。

[10 14 67 104 105 110 97 228 184 173 229 155 189 228 186 186]

嵌套類型編碼

嵌套消息就是value又是一個字段消息,外層消息存儲采用 TLV 存儲,它的 value 又是一個 TLV 存儲。整個編碼結構如下圖所示。

帶有 packed 的 repeated 字段

repeaded 修飾的字段可以帶 packed 或者不帶。對於同一個 repeated 字段,多個字段值來說,它們的 tag 都是相同的,即數據類型和字段序號都相同。如果采用多個 TV 存儲,則存在 tag 的冗余。如果設置 packed=true 的 repeated 字段存儲方式,即相同的 tag 只存儲一次,添加 repeated 字段下所有值的長度 length,構成 TLVVV... 存儲結構,可以壓縮序列化后數據長度,節省傳輸開銷。

message repeatedEncodeTest{
   // 方式1,不帶packed
   repeated int32 cat = 1;
   // 方式2,帶packed
   repeated  int32 dog = 2 [packed=true];
}


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM