google protocol buffer——protobuf的使用特性及編碼原理

tera發表於2020-08-24

這一系列文章主要是對protocol buffer這種編碼格式的使用方式、特點、使用技巧進行說明,並在原生protobuf的基礎上進行擴充套件和優化,使得它能更好地為我們服務。

 

在上一篇文章中,我們展示了protobuf在java中的基本使用方式。而本文將繼續深入探究protobuf的編碼原理。

主要分為兩個部分

第一部分是結合上一篇文章留下的幾個伏筆展示protobuf的使用特性

第二部分是分析protobuf的編碼原理,解釋特性背後的原因

 

第一部分,Protobuf使用特性

1.不同型別物件的轉換

我們先定義如下一個.proto檔案 

syntax = "proto3";

option java_package = "cn.tera.protobuf.model";
option java_outer_classname = "DifferentModels";

message Person {
  string name = 1;
  int32 id = 2;
  string email = 3;
}

message Article {
  string title = 1;
  int32 wordsCount = 2;
  string author = 3;
}

其中我們定義了2個模型,一個Person,一個Article,雖然他們的欄位名字不相同,但是型別和編號都是一致的

接著我們生成.java檔案,最終檔案結構如下圖

此時我們嘗試做如下的一個轉換

/**
 * 測試不同模型間的轉換
 * @throws Exception
 */
@Test
public void parseDifferentModelsTest() throws Exception {
    //建立一個Person物件
    DifferentModels.Person person = DifferentModels.Person.newBuilder()
            .setName("person name")
            .setId(1)
            .setEmail("tera@google.com")
            .build();
    //對person編碼
    byte[] personBytes = person.toByteArray();
    //將編碼後的資料直接merge成Article物件
    DifferentModels.Article article = DifferentModels.Article.parseFrom(personBytes);
    System.out.println("article's title:" + article.getTitle());
    System.out.println("article's wordsCount:" + article.getWordsCount());
    System.out.println("article's author:" + article.getAuthor());
}

輸出結果如下

article's title:person name
article's wordsCount:1
article's author:tera@google.com

可以看到,雖然jsonBytes是由person物件編碼得到的,但是可以用於article物件的解碼,不但不會報錯,所有的資料內容都是完整保留的

這種相容性的前提是模型中所定義的欄位型別和序號都是一一對應相同的

在平時的編碼中,我們經常會遇到從資料庫中讀取資料模型,然後將其轉換成業務模型,而很多時候,這2種模型的內容其實是完全一致的,此時我們也許就可以使用protobuf的這種特性,就可以省去很多低效的賦值程式碼

 

2.protobuf序號的重要性

在上一篇文章中,我們看到在定義.proto檔案時,欄位後面會跟著一個"= X",這裡並不是指這個欄位的值,而是表示這個欄位的“序號”,和正確地編碼與解碼息息相關,在我看來是protocol buffer的靈魂

我們定義如下的.proto檔案,這裡注意,Model1和Model2的name和id的序號有不同

syntax = "proto3";

option java_package = "cn.tera.protobuf.model";
option java_outer_classname = "TagImportance";

message Model1 {
  string name = 1;
  int32 id = 2;
  string email = 3;
}

message Model2 {
  string name = 2;
  int32 id = 1;
  string email = 3;
}

定義如下的測試方法 

/**
 * 序號的重要性測試
 *
 * @throws Exception
 */
@Test
public void tagImportanceTest() throws Exception {
    TagImportance.Model1 model1 = TagImportance.Model1.newBuilder()
            .setEmail("model1@google.com")
            .setId(1)
            .setName("model1")
            .build();
    TagImportance.Model2 model2 = TagImportance.Model2.parseFrom(model1.toByteArray());
    System.out.println("model2 email:" + model2.getEmail());
    System.out.println("model2 id:" + model2.getId());
    System.out.println("model2 name:" + model2.getName());
    System.out.println("-------model2 資料---------");
    System.out.println(model2);
}

輸出結果如下

model2 email:model1@google.com
model2 id:0
model2 name:
-------model2 資料---------
email: "model1@google.com"
1: "model1"
2: 1

可以看到,雖然Model1和Model2定義的欄位型別和名字都是相同的,然而name和id的序號顛倒了一下,導致最終model2在解析byte陣列時,無法正確將資料解析到對應的欄位上,所以輸出的id為0,而name欄位為null

不過即使欄位無法一一對應,但在輸出model2.toString()時,我們依然可以看到資料是被解析到了,只不過無法對應到具體欄位,只能用1,2來表示其欄位名 

 

3.protobuf序號對編碼結果大小的影響

protobuf的序號不僅影響編碼、解碼的正確性,一定程度上還會影響編碼結果的位元組數

我們在上面的.proto檔案中增加一個Model3,其中Model3中定義的欄位沒有變化,但是序號更改為16,17,18

syntax = "proto3";

option java_package = "cn.tera.protobuf.model";
option java_outer_classname = "TagImportance";

message Model1 {
  string name = 1;
  int32 id = 2;
  string email = 3;
}

message Model2 {
  string name = 2;
  int32 id = 1;
  string email = 3;
}

message Model3 {
  string name = 16;
  int32 id = 17;
  string email = 18;
}

測試方法

/**
 * 序號對編碼大小的影響
 *
 * @throws Exception
 */
@Test
public void tagSizeInfluenceTest() throws Exception {
    TagImportance.Model1 model1 = TagImportance.Model1.newBuilder()
            .setEmail("model1@google.com")
            .setId(1)
            .setName("model1")
            .build();
    System.out.println("model1 編碼大小:" + model1.toByteArray().length);

    TagImportance.Model3 model3 = TagImportance.Model3.newBuilder()
            .setEmail("model1@google.com")
            .setId(1)
            .setName("model1")
            .build();
    System.out.println("model3 編碼大小:" + model3.toByteArray().length);
}

輸出結果如下

model1 編碼大小:29
model3 編碼大小:32

可以看到,在資料量完全相同的情況下,編號偏大的物件編碼的結果也會偏大

 

4.模型欄位資料型別相容性

在上一篇文章中我在getName()方法中提到了靈活性,接下去就展示一下該特性

我們定義如下的.proto檔案

syntax = "proto3";

option java_package = "cn.tera.protobuf.model";
option java_outer_classname = "ModelTypeCompatible";

message OldPerson {
  string name = 1;
  int32 id = 2;
  string email = 3;
}

message NewPerson {
  Name name = 1;
  int32 id = 2;
  string email = 3;
}

message Name {
  string first = 1;
  string last = 2;
  int32 usedYears = 3;
}

其中定義了2個Person物件

在OldPerson中,name是一個純String

在NewPerson中,name欄位則被定義為了一個物件

此時我們做如下的操作

/**
 * 模型欄位不同型別的相容性
 *
 * @throws Exception
 */
@Test
public void typeCompatibleTest() throws Exception {
    ModelTypeCompatible.NewPerson newPerson = ModelTypeCompatible.NewPerson.newBuilder()
            .setName(ModelTypeCompatible.Name.newBuilder()
                    .setFirst("tera")
                    .setLast("cn")
                    .setUsedYears(10)
            ).setId(5)
            .setEmail("tera@google.com")
            .build();
    ModelTypeCompatible.OldPerson oldPerson = ModelTypeCompatible.OldPerson.parseFrom(newPerson.toByteArray());
    System.out.println(oldPerson.getName());
}

輸出結果如下

teracn

可以看到,雖然NewPerson的name欄位是一個物件,但是卻可以被成功地轉換成OldPerson的String型別的name欄位,雖然其中的usedYears欄位被捨棄了

這種相容性的前提是從物件型別向String型別轉換,而反向是不可以的

 

5.protobuf與json之間的轉換和對比

json是現在應用最為廣泛的資料結構之一,因此當我們決定使用protobuf時,不可避免的問題就是它和json的相容性

因此接下去我們看下protobuf和json之間是如何轉換的

我們先構造一個簡單的java類

public class PersonJson {
    public String name;
    public int id;
    public String email;
}

重複利用前一篇文章中生成的protobuf模型BasicUsage.Person,以及前文就引入的json相關的maven,我們測試如下方法

/**
 * json和protobuf的互相轉換
 */
@Test
void jsonToProtobuf() throws Exception {
    //構造簡單的模型
    PersonJson model = new PersonJson();
    model.email = "personJson@google.com";
    model.id = 1;
    model.name = "personJson";
    String json = JSON.toJSONString(model);
    System.out.println("原始json");
    System.out.println("------------------------");
    System.out.println(json);
    System.out.println();

    //parser
    JsonFormat.Parser parser = JsonFormat.parser();
    //需要build才能轉換
    BasicUsage.Person.Builder personBuilder = BasicUsage.Person.newBuilder();
    //將json字串轉換成protobuf模型,並列印
    parser.merge(json, personBuilder);
    BasicUsage.Person person = personBuilder.build();
    //需要注意的是,protobuf的toString方法並不會自動轉換成json,而是以更簡單的方式呈現,所以一般沒法直接用
    System.out.println("protobuf內容");
    System.out.println("------------------------");
    System.out.println(person.toString());

    //修改protobuf模型中的欄位,並再轉換會json字串
    person = person.toBuilder().setName("protobuf").setId(2).build();
    String buftoJson = JsonFormat.printer().print(person);
    System.out.println("protobuf修改過資料後的json");
    System.out.println("------------------------");
    System.out.println(buftoJson);
}

輸出結果如下

原始json
------------------------
{"email":"personJson@google.com","id":1,"name":"personJson"}

protobuf內容
------------------------
name: "personJson"
id: 1
email: "personJson@google.com"

protobuf修改過資料後的json
------------------------
{
  "name": "protobuf",
  "id": 2,
  "email": "personJson@google.com"
}

可以看到json和protobuf是可以做到完全相容的互相轉換

此時我們就可以比較一下,相容的資料內容經過json和protobuf分別編碼後的資料位元組大小,我們就使用上面的資料內容,做如下的測試

/**
 * json和protobuf的編碼資料大小
 */
@Test
void codeSizeJsonVsProtobuf() throws Exception {
    //構造簡單的模型
    PersonJson model = new PersonJson();
    model.email = "personJson@google.com";
    model.id = 1;
    model.name = "personJson";
    String json = JSON.toJSONString(model);
    System.out.println("原始json");
    System.out.println("------------------------");
    System.out.println(json);
    System.out.println("json編碼後的位元組數:" + json.getBytes("utf-8").length + "\n");

    //parser
    JsonFormat.Parser parser = JsonFormat.parser();
    //需要build才能轉換
    BasicUsage.Person.Builder personBuilder = BasicUsage.Person.newBuilder();
    //將json字串轉換成protobuf模型,並列印
    parser.merge(json, personBuilder);
    BasicUsage.Person person = personBuilder.build();
    //需要注意的是,protobuf的toString方法並不會自動轉換成json,而是以更簡單的方式呈現,所以一般沒法直接用
    System.out.println("protobuf內容");
    System.out.println("------------------------");
    System.out.println(person.toString());
    System.out.println("protobuf編碼後的位元組數:" + person.toByteArray().length);
}

輸出內容如下

原始json
------------------------
{"email":"personJson@google.com","id":1,"name":"personJson"}
json編碼後的位元組數:60

protobuf內容
------------------------
name: "personJson"
id: 1
email: "personJson@google.com"

protobuf編碼後的位元組數:37

可以看到,相同的資料內容,protobuf編碼的結果是json編碼結果的60%左右(當然這個數值是會隨著資料內容的不同浮動)

 

這裡先總結一下之前的特性

1.protobuf的解碼不需要型別相同,也不需要欄位名相同

2.protobuf的解碼依賴於序號的正確性

3.protobuf中的序號大小會影響最終編碼大小

4.protobuf的物件型別可以向String型別相容

5.protobuf可以和json完全相容,且編碼大小要比json小

 

第二部分,Protobuf編碼原理

首先,我們需要了解一種最基本的編碼方式varints(原文件的單詞,沒有找到特別準確的翻譯,所以就就保留英文),這是一種用1個或多個位元組對Integer進行編碼的方法

當一個Integer採用這種方式編碼後,除了最後一個位元組,每一個位元組的最高位都是1,而最後一個位元組的最高位則是0,從而在解碼的時候可以通過判斷最高位的值來確定是否已經解碼到了最後一個位元組。

每一個位元組除了最高位的其他7個bit則用來存放數字本身的編碼

例如300,編碼後得到2個位元組,紅色表示最高位bit,藍色表示數字本身編碼

1010 1100  0000 0010

其中第一個位元組最高位bit為1,表示後面還有位元組需要一併進行解碼。第二個位元組最高位bit為0,則表示已經到達最後一個位元組了

解碼時

1.去掉2個位元組的最高位

010 1100  000 0010

2.反轉2個位元組的順序

000 0010  010 1100

3.連線2個位元組,構成了300的二進位制形式

100101100

 

接著我們來看一個實際的例子,編碼一個Person物件,只給裡面的id欄位賦值

/**
 * varint數字編碼
 */
@Test
void varintTest() {
    BasicUsage.Person person = BasicUsage.Person.newBuilder()
            .setId(91809)
            .build();
    Utility.printByte(person.toByteArray());
}

輸出的編碼結果如下

16    -95    -51    5    
00010000 10100001 11001101 00000101 

其中黃色部分即是91809的varints編碼,我們來驗證一下

紅色表示最高位,藍色表示數字本身編碼,在讀取該部分位元組的時候是一個一個讀取的

讀取到第一個位元組時,發現最高位是1,因此會繼續讀取第二個位元組,第二個位元組最高位也是1,因此繼續讀取第三個位元組,而第三個位元組最高位為0,從而結束讀取,就處理這3個位元組

10100001 11001101 00000101 

1.去掉3個位元組的最高位

0100001 1001101 0000101 

2.反轉3個位元組的順序

0000101 1001101 0100001

3.連線3個位元組,構成了91809的二進位制形式

10110011010100001

 接著我們看person編碼結果的第一個位元組

16    -95    -51    5    
00010000 10100001 11001101 00000101 

這個位元組表示的是資料的序號型別,編碼方式也是varient,因此我將其分為3個部分

00010000

紅色0為最高位bit,表示是否解析到了本次varient的最後一個位元組

中間藍色的4個bit 0010表示序號,十進位制2,即id的序號

最後3個黃色底的0為該欄位的型別,000表示int32型別

此時一個最簡單的protobuf的編碼就解析完成了

 

到這裡我們先總結一下protobuf編碼的性質,將特別抽象的的內容轉換成一個我們可以直覺理解的東西

先看原始資料,如果用json表示出來就是如下形式

{
    "id": 91890
}

而protobuf編碼後的資料格式如下

00010000 10100001 11001101 00000101

其中第一個位元組表示序號和欄位型別,即序號為2,型別為int的欄位

後三個位元組表示資料的值,值為91890

這時候就會有這樣一個問題,那id這個欄位名去哪兒了?

答案就是,id的欄位名被protobuf捨棄了!

所以,protobuf最終的編碼結果是拋棄了所有的欄位名,僅僅保留了欄位的序號、型別和資料的值。

因此在第一篇文章的開頭,就提到protobuf並非是一種可以完全自解釋的編碼格式,意思就是如此。

也正因為如此,所以我也認為這個序號正是protobuf編碼的靈魂所在

 

有了這個概念之後,我們就可以解釋之前5個示例了

示例1:protobuf的解碼不需要型別相同,也不需要欄位名相同

因為protobuf編碼後的結果根本就不包含類的資訊,也不包含欄位名的資訊,因此解碼的時候自然也就不依賴於類和欄位名

 

示例2:protobuf的解碼依賴於序號的正確性

因為編碼後的結果的序號和型別是在同一個位元組中,是一一對應的關係,如果編碼的對應關係和解碼的對應關係不同,則自然編碼和解碼的過程會出問題

 

示例3:protobuf中的序號大小會影響最終編碼大小

我們前面看到序號和欄位型別的位元組結構如下,表示序號的部分是中間的4個bit,0010

00010000

而4個bit所能表示的最大數是1111,也就是15,因此當序號大於15的時候,一個位元組就不夠表達了,就需要額外一個位元組,例如序號為17,型別為int的欄位,它的序號位元組就會如下

10001000 00000001

其中黃色底的000表示型別Int,去除後,剩下的bit通過標準的varient解碼後,得到的結果就是17

因此,如果序號超過15,那麼就會多需要一個位元組來表示序號。回過頭看示例3,model3編碼結果正好比model1編碼結果多3個位元組,正是3個欄位的序號導致的

 

示例4:protobuf的物件型別可以向String型別相容

上面提到了int的型別在位元組中的bit表示是000,那麼接下去我麼可以看下其他型別對應的bit表示

TypeMeaningUsed For
0 Varint int32, int64, uint32, uint64, sint32, sint64, bool, enum
1 64-bit fixed64, sfixed64, double
2 Length-delimited string, bytes, embedded messages, packed repeated fields
3 Start group groups (deprecated)
4 End group groups (deprecated)
5 32-bit fixed32, sfixed32, float

這裡可以看到,0就是表示int32,表達方式是varient

而2則可以表示string、embedded messages等,而這裡的embedded messages對應的就是子物件

既然型別的表示是相同的,那麼在解碼的時候自然就是可以從embeedded messages向string相容

然而由於messages的結構是要比string複雜的,因此反向是無法相容的

其實這個更廣域和普世來說,總是複雜資訊可以向簡單資訊轉換,而反向一般是不可行的

 

示例5:protobuf可以和json完全相容,且編碼大小要比json小

相容性是由java類庫實現的,這個不在編碼原理的範疇內,這裡主要看下編碼大小比json小的原因

例如示例中的json

{"email":"personJson@google.com","id":1,"name":"personJson"}

json的編碼後,為了保證格式的正確和自解釋的功能,其中還包含了很多格式字元,包括{  "  ,  }等,還包括了email、id、name欄位名本身

而protobuf編碼後,則僅僅保留了序號、型別,以及欄位的值,沒有任何其他額外的符號,因此就比json節省了很多位元組數 

 

那麼protobuf的編碼原理基礎就先了解到這裡,下一篇文章將繼續解釋其他protobuf型別的編碼原理

 

最後總結下本文內容,通過5個示例展示了protobuf在使用上的一些特性,並通過基本的編碼原理解釋了特性的本質原因

特性有以下5點

1.protobuf的解碼不需要型別相同,也不需要欄位名相同

2.protobuf的解碼依賴於序號的正確性

3.protobuf中的序號大小會影響最終編碼大小

4.protobuf的物件型別可以向String型別相容

5.protobuf可以和json完全相容,且編碼大小要比json小

 

相關文章