google protobuf的原理和思路提煉

tera發表於2021-06-27

之前其實已經用了5篇文章完整地分析了protobuf的原理。回過頭去看,感覺一方面篇幅過大,另一方面過於追求細節和原始碼,對protobuf的初學者並不十分友好,因此這篇文章將會站在“瞭解、使用、特性、原理、改進”的角度重新整理protobuf的相關知識,希望對大家有所幫助。

1.什麼是protobuf以及為何要使用protobuf

protocol buffer是由google推出一種資料編碼格式,不依賴平臺和語言。

和json或者xml相比,protocol buffer的解析速度更快,編碼後的位元組數更少。

2.如何使用protobuf

首先我們需要下載一個google提供的編譯器,下載地址:

https://github.com/protocolbuffers/protobuf/releases/tag/v3.12.1

選擇自己的系統下載相應的zip包.

解壓後就能看到看到一個protoc的執行檔案,即是我們所需要的編譯器。

接著我們需要定義一份BasicUsage.proto的描述檔案,其結構和我們定義普通的類十分類似。特別需要注意的是,欄位後跟著的=號不代表欄位的值,而是欄位的序號,後面會詳細解釋

syntax = "proto3";

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

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

第一行表示所使用的的語法版本,這裡選擇的是最新的proto3版本。

syntax = "proto3";

第三、四行表示最終生成的java的package名和class的類名

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

有了編譯器和.poto描述檔案,我們就可以生成java模型檔案了

-I :表示工作目錄,如果不指定,則就是當前目錄

--java_out:表示輸出.java檔案的目錄

protoc -I=/protocol_buffer/protobuf/proto --java_out=/protocol_buffer/protobuf/src/main/java/ /protocol_buffer/protobuf/proto/BasicUsage.proto

以上都是準備工作,接著我們就要進入程式碼相關部分

引入maven依賴

<!--這部分是protobuf的基本庫-->
<dependency>
    <groupId>com.google.protobuf</groupId>
    <artifactId>protobuf-java</artifactId>
    <version>3.9.1</version>
</dependency>
<!--這部分是protobuf和json相關的庫,這裡一併匯入,後面會用到-->
<dependency>
    <groupId>com.google.protobuf</groupId>
    <artifactId>protobuf-java-util</artifactId>
    <version>3.9.1</version>
</dependency>

接下去建立一個簡單的測試用例

/**
 * protobuf的基礎使用
 */
@Test
void basicUse() {
    //建立一個Person物件
    BasicUsage.Person person = BasicUsage.Person.newBuilder()
            .setId(5)
            .setName("tera")
            .setEmail("tera@google.com")
            .build();
    System.out.println("Person's name is " + person.getName());

    //編碼
    //此時我們就可以通過我們想要的方式傳遞該byte陣列了
    byte[] bytes = person.toByteArray();

    //將編碼重新轉換回Person物件
    BasicUsage.Person clone = null;
    try {
        //解碼
        clone = BasicUsage.Person.parseFrom(bytes);
        System.out.println("The clone's name is " + clone.getName());
    } catch (InvalidProtocolBufferException e) {
    }
    //引用是不同的
    System.out.println("==:" + (person == clone));
    //equals方法經過了重寫,所以equals是相同的
    System.out.println("equals:" + person.equals(clone));

    //修改clone中的值
    clone = clone.toBuilder().setName("clone").build();
    System.out.println("The clone's new name is " + clone.getName());
}

3.protobuf的特性

A.之前有提到protobuf編碼後的位元組大小要小於json格式,因此做一個簡單的對比,json結果60位元組,而protobuf是37個位元組。

/**
 * json和protobuf的編碼資料大小
 */
@Test
void codeSizeJsonVsProtobuf() throws Exception {
    //構造簡單的java模型
    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);
}

B.protobuf是一個不可完全自解析的編碼格式,也就是說,如果沒有任何其他資料的支援(例如模型的定義檔案、.proto檔案等)僅僅拿到編碼後的結果位元組,是無法還原出原始的資料的,也很難人工閱讀。

C.通過google官網提供的編譯器生成的.java類檔案的大小十分驚人,例如本文之前的一個簡單的類定義,就會生成32kb的.java檔案。在實際專案中,遇到過1mb左右的.java檔案。

D.protobuf的編碼和解碼速度都優於json

4.protobuf的編碼原理

針對上面4個主要的特性,我們來了解一下protobuf的基本編碼原理,從而理解導致上述4個特性的原因。

A.數字編碼Varint、資料長度與資料編號

平時我們對數字的編碼其實並不會有特別的約定,一個位元組本身就能表示0~255的數字(這裡假定是無符號的)。然而當我們在網路上傳遞編碼成位元組的資料時,由於網路傳輸半包、粘包等等各種因素的存在,如何確定整個資料的長度就是最首要的任務。

因為資料大小的不確定性,我們無法約定固定的位元組表示資料長度。例如有時候資料量小於255個位元組,那麼一個位元組就能表示長度,若超過了65535,那麼就需要3個位元組才能表示資料長度了。

為了解決這個問題,採用了varint編碼方式。它約定,每個表示數字位元組的最高位若為1,則說明該數字還需要讀取下一個位元組。若位元組的最高位位0,則表示數字的位元組讀取完畢。而剩下的7位則記錄具體的數字。

例如:

0 0001000
最高位為0,因此這個位元組就表示了完整的數字,後7位 0001000 就表示數字8。

1 0001000  0 0001000
此時第一個位元組最高位是1,說明還需要讀取下一個位元組。第二個位元組的最高位是0,表示讀取結束。
去掉2個最高位後,0001000 0001000 表示數字1032

真正的varint編碼其實還需要按照小端排序,不過這裡就不細究了,瞭解最高位的作用即可。

瞭解了varint編碼方式後,我們回到protobuf,看看是如何應用的。

在前面定義模型的.proto檔案中,有看到每個欄位後面會跟著一個數字。這不表示這個欄位的預設值,而是表示這個欄位的編號。當編碼完成後的位元組中,每一個欄位的資料之前都會有幾個位元組表示該資料的編號。

例如:

對於簡單的數字

原始的json資料:{age:15},若age的編號為1,那麼就會得到如下的編碼結果
0 0001 000  00001111
這裡第一個位元組最高位為0,即第一個位元組就表示了完整的數字
第一個位元組的後3位為000,protobuf用於區別欄位的型別
中間的4位即為編號的具體值,表示1
第二個位元組就表示資料的具體值,即15

綜上所述,protobuf編碼的結果和json編碼的結果相比較

原始的json資料:{"name":"personJson","id":15,"email":"personJson@google.com"}
按照之前定義的.proto檔案,name編號為1,id編號為2,email編號為3,編碼後的結果若翻譯成可讀的文字來說如下
1personJson2153personJson@google.com
即1號欄位的值是personJson,2號欄位的值是15,3號欄位的值是personJson@google.com

和json編碼比較而言,protobuf首先就去除了{}"",等標點符號,其次完全沒有欄位名,而是僅保留了欄位的編號。因此protobuf的編碼結果會比json的編碼結果更小

B.protobuf的不完全自解析

假如我們拿到一個json格式的資料,那麼其中是包含了該資料的所有完整資訊,完全不需要依賴任何外部資訊,我稱其為完全自解析性。

而在瞭解了protobuf的基本編碼原理後,我們就會發現一個問題。編碼的結果中完全沒有欄位的名字了!因此當我們拿到一段protobuf的編碼資料後,是沒有辦法將其完整還原為原始資料的,我稱其為不完全自解析性。

C.protobuf編碼與解碼的定製性

為了解決資料的不完全自解析性,google採用的辦法就是在編譯.proto檔案生成.java檔案時,將會把很多解析資料的程式碼直接硬編碼到.java檔案中,從而補足了編碼結果中缺少的資訊。因此,生成的.java檔案的大小會大得驚人。

D.protobuf編碼與解碼的速度型

通過特性C,我們其實很容易就能推斷,一般的json檔案的編碼與解碼類庫或多或少會涉及到反射的呼叫,而protobuf採用的是硬編碼的方式,完全不需要使用反射,那麼速度自然會快上一大截。

在為何要使用protobuf中,有提到是因為編碼結果更小,編碼速度更快。而通過上述4個特性的分析,我們就能了充分了解其背後的根本原因了。

綜上所述,protobuf的設計思想是等價交換:即用編碼方和解碼方的硬碟儲存空間換取編碼結果的減小和編碼速度的加快

5.對protobuf的思路的進一步改進

在移動網際網路的使用場景下,單次請求耗時對於使用者來說是一個非常敏感的資料指標,而影響單次請求耗時的因素有很多,其中最重要的自然是服務端的資料處理能力與網路訊號的狀態。服務端的處理資料處理能力是完全在我們自己的掌控之中,可以有很多方法提高響應速度。然而使用者的網路訊號狀態是我們無法控制的,也許是3G訊號,也許是4G訊號,也許正在經過一個隧道,也許正在地下商場等等。如果我們能降低每一次網路請求的資料量,那麼也算是在我們所能掌控的範圍內去優化請求響應時長的問題了。

A.類庫大小的縮減

對於android和ios使用者來說,app的大小其實是非常重要的,因此google編譯生成後的.java檔案過大就是一個致命問題,而ios的類庫也有10mb所有。因此為了在該場景下應用protobuf,需要解決的首要問題就是類庫的大小

在前面的編碼過程中,我們瞭解到.java檔案的巨大是由於編碼結果的不可完全自解析性,從而需要硬編碼來彌補。然而無論是java還是swfit,都是強型別的語言,因此首先便考慮犧牲編碼的速度,使用反射來代替硬編碼

B.欄位預設值的優化

在移動網際網路場景下,由於APP釋出後是無法修改的,即使釋出了新版本,使用者也可以選擇不更新。因此大部分的頁面資料都是通過服務端返回的。然而很多時候,一些描述性的文字可能會保持很長時間不修改,若每次請求都返回相同的內容,其實很浪費傳輸資料的大小

a).對於固定的預設值,對於java可以採用annotation的方式,直接存放在客戶端的模型檔案中。而在傳輸過程中,只需要一個位的0和1來標記是否採用預設值即可。若為0則表示採用預設值,此時就不需要將該值放入編碼結果中,而客戶端讀取到0後就直接採用annotation中的預設值即可。

b).除了單一預設值的情況,還有可能是多種預設值。例如訂單的狀態,可能包括預訂成功、預訂失敗、預訂取消等等,如果我們採用一個int的列舉值表示,讓客戶端根據int列舉值判斷之後展示相應文案,那麼當將來需要新增一個預訂確認中的狀態時已經發布的老版本客戶端將無法處理,所以這些文案也應當是從服務端返回。

順著之前的思路,我們在定義預設值的時候可以定義多個,而在編碼的時候,除了用標記位表示是否使用預設值,再需要一個Int表示預設值的索引。

c).還有一種情況,一個字串中的大部分都是不變的,只有其中的幾個字元會根據不同的情況改變,例如“親愛的XXX使用者您好,歡迎回來”,在這種情況下,只有XXX需要根據實際情況進行替換,而其餘的字元都是可以不變的,因此在傳輸該資料的過程中,我們只需要傳遞會變化的部分,而不變的部分就存放到模型中

通過上述改進,可以使得傳輸資料的大小縮減20%~80%

6.總結

通過上面5個部分的分析,我們著重需要整理請以下思路

1.protobuf為了降低編碼結果的大小,犧牲了資料的自解析性

2.為了彌補不可自解析,進行了很多硬編碼,犧牲了資料的儲存空間

3.因為硬編碼,不需要反射了,因此加快了資料的編碼、解碼速度

綜上:protobuf的思想抽象而言,就是通過犧牲儲存空間,換來了編碼大小的減少和編碼速度的提高

而我們可以進一步發揚這種思路,犧牲編碼速度和一些些的儲存空間,進一步減小了編碼的結果

相關文章