在java程式中使用protobuf

flydean發表於2021-08-23

簡介

Protocol Buffer是google出品的一種物件序列化的方式,它的體積小傳輸快,深得大家的喜愛。protobuf是一種平臺無關和語言無關的協議,通過protobuf的定義檔案,可以輕鬆的將其轉換成多種語言的實現,非常方便。

今天將會給大家介紹一下,protobuf的基本使用和同java結合的具體案例。

為什麼使用protobuf

我們知道資料在網路傳輸中是以二進位制進行的,一般我們使用位元組byte來表示, 一個byte是8bits,如果要在網路上中傳輸物件,一般需要將物件序列化,序列化的目的就是將物件轉換成byte陣列在網路中傳輸,當接收方接收到byte陣列之後,再對byte陣列進行反序列化,最終轉換成java中的物件。

那麼將java物件序列化可能會有如下幾種方法:

  1. 使用JDK自帶的物件序列化,但是JDK自帶的序列化本身存在一些問題,並且這種序列化手段只適合在java程式之間進行傳輸,如果是非java程式,比如PHP或者GO,那麼序列化就不通用了。

  2. 你還可以自定義序列化協議,這種方式的靈活程度比較高,但是不夠通用,並且實現起來也比較複雜,很可能出現意想不到的問題。

  3. 將資料轉換成為XML或者JSON進行傳輸。XML和JSON的好處在於他們都有可以區分物件的起始符號,通過判斷這些符號的位置就可以讀取到完整的物件。但是不管是XML還是JSON的缺點都是轉換成的資料比較大。在反序列化的時候對資源的消耗也比較多。

所以我們需要一種新的序列化的方法,這就是protobuf,它是一種靈活、高效、自動化的解決方案。

通過編寫一個.proto的資料結構定義檔案,然後呼叫protobuf的編譯器,就會生成對應的類,該類以高效的二進位制格式實現protobuf資料的自動編碼和解析。 生成的類為定義檔案中的資料欄位提供了getter和setter方法,並提供了讀寫的處理細節。 重要的是,protobuf可以向前相容,也就是說老的二進位制程式碼也可以使用最新的協議進行讀取。

定義.proto檔案

.proto檔案中定義的是你將要序列化的訊息物件。我們來一個最基本的student.proto檔案,這個檔案定義了student這個物件中最基本的屬性。

先看一個比較簡單的.proto檔案:

syntax = "proto3";

package com.flydean;

option java_multiple_files = true;
option java_package = "com.flydean.tutorial.protos";
option java_outer_classname = "StudentListProtos";

message Student {
  optional string name = 1;
  optional int32 id = 2;
  optional string email = 3;

  enum PhoneType {
    MOBILE = 0;
    HOME = 1;
  }

  message PhoneNumber {
    optional string number = 1;
    optional PhoneType type = 2;
  }

  repeated PhoneNumber phones = 4;
}

message StudentList {
  repeated Student student = 1;
}

第一行定義的是protobuf中使用的syntax協議,預設情況下是proto2,因為目前最新的協議是proto3,所以這裡我們使用proto3作為例子。

然後我們定義了所在的package,這個package是指編譯的時候生成檔案的包。這是一個名稱空間,雖然我們在後面定義了java_package,但是為了和非java語言中的協議相沖突,所以定義package還是非常有必要的。

然後是三個專門給java程式使用的option。java_multiple_files, java_package, 和 java_outer_classname.

其中java_multiple_files指編譯過後java檔案的個數,如果是true,那麼將會一個java物件一個類,如果是false,那麼定義的java物件將會被包含在同一個檔案中。

java_package指定生成的類應該使用的Java包名稱。 如果沒有明確的指定,則會使用之前定義的package的值。

java_outer_classname選項定義將表示此檔案的包裝類的類名。 如果沒有給java_outer_classname賦值,它將通過將檔名轉換為大寫駝峰來生成。 例如,預設情況下,“student.proto”將使用"Student"作為包裝類名稱。

接下來的部分是訊息的定義,對於簡單型別來說可以使用bool, int32, float, double, 和 string來定義欄位的型別。

上例中我們還使用了複雜的組合屬性,和巢狀型別。還定義了一個列舉類。

上面我們為每個屬性值分配了ID,這個ID是二進位制編碼中使用的唯一“標籤”。因為在protobuf中標記數字1-15比16以上的標記數字佔用的位元組空間要更少,因此作為一種優化,通常將1-15這些標記用於常用或重複的元素,而將標記16和更高的標記用於不太常用的可選元素。

然後再來看看欄位的修飾符,有三個修飾符分別是optional,repeated和required。

optional表示該欄位是可選的,可以設定也可以不設定,如果沒有設定,則會使使用預設值,對於簡單型別來說,我們可以自定義預設值,如果不自定義,就會使用系統的預設值。對於系統的預設值來說,數字為0,字串為空字串,布林值為false。

repeated表示該欄位是可以重複的,這種重複實際上就是一種陣列的結構。

required表示該欄位是必須的,如果該欄位沒有值,那麼該欄位將會被認為是沒有初始化,嘗試構建未初始化的訊息將丟擲 RuntimeException,解析未初始化的訊息將丟擲 IOException。

注意,在Proto3中不支援required欄位。

編譯協議檔案

定義好proto檔案之後,就可以使用protoc命令對其進行編譯了。

protoc是protobuf提供的編譯器,一般情況下,可以從github的release庫中直接下載即可。如果你不想直接下載,或者官方提供的庫中並沒有你需要的版本,則可以使用原始碼直接進行編譯。

protoc的使用的命令如下:

protoc --experimental_allow_proto3_optional -I=$SRC_DIR --java_out=$DST_DIR $SRC_DIR/student.proto

如果編譯proto3,則需要新增--experimental_allow_proto3_optional選項。

我們執行一下上面的程式碼。會發現在com.flydean.tutorial.protos包裡面生成了5個檔案。分別是:

Student.java              
StudentList.java          
StudentListOrBuilder.java 
StudentListProtos.java    
StudentOrBuilder.java

其中StudentListOrBuilder和StudentOrBuilder是兩個介面,Student和StudentList是這兩個類的實現。

詳解生成的檔案

在proto檔案中,我們主要定義了兩個類Student和StudentList, 他們中定義了一個內部類Builder,以Student為例,看下這個兩個類的定義:

public final class Student extends
    com.google.protobuf.GeneratedMessageV3 implements
    StudentOrBuilder

  public static final class Builder extends
      com.google.protobuf.GeneratedMessageV3.Builder<Builder> implements
      com.flydean.tutorial.protos.StudentOrBuilder

可以看到他們實現的介面都是一樣的,表示他們可能提供了相同的功能。實際上Builder是對訊息的一個封裝器,所有對Student的操作都可以由Builder來完成。

對於Student中的欄位來說,Student類只有這些欄位的get方法,而Builder中同時有get和set方法。

對於Student來說,對於欄位的方法有:

// required string name = 1;
public boolean hasName();
public String getName();

// required int32 id = 2;
public boolean hasId();
public int getId();

// optional string email = 3;
public boolean hasEmail();
public String getEmail();

// repeated .tutorial.Person.PhoneNumber phones = 4;
public List<PhoneNumber> getPhonesList();
public int getPhonesCount();
public PhoneNumber getPhones(int index);

對於Builder來說,每個屬性多了兩個方法:

// required string name = 1;
public boolean hasName();
public java.lang.String getName();
public Builder setName(String value);
public Builder clearName();

// required int32 id = 2;
public boolean hasId();
public int getId();
public Builder setId(int value);
public Builder clearId();

// optional string email = 3;
public boolean hasEmail();
public String getEmail();
public Builder setEmail(String value);
public Builder clearEmail();

// repeated .tutorial.Person.PhoneNumber phones = 4;
public List<PhoneNumber> getPhonesList();
public int getPhonesCount();
public PhoneNumber getPhones(int index);
public Builder setPhones(int index, PhoneNumber value);
public Builder addPhones(PhoneNumber value);
public Builder addAllPhones(Iterable<PhoneNumber> value);
public Builder clearPhones();

多出的兩個方法是set和clear方法。clear是清空欄位的內容,讓其變回初始狀態。

我們還定義了一個列舉類PhoneType:

  public enum PhoneType
      implements com.google.protobuf.ProtocolMessageEnum

這個類的實現和普通的列舉類沒太大區別。

Builders 和 Messages

如上一節所示,Message對應的類只有get和has方法,所以它是不可以變的,訊息物件一旦被構造,就不能被修改。要構建訊息,必須首先構建一個構建器,將要設定的任何欄位設定為你選擇的值,然後呼叫構建器的 build()方法。

每次呼叫Builder的方法都會返回一個新的Builder,當然這個返回的Builder和原來的Builder是同一個,返回Builder只是為了方便進行程式碼的連寫。

下面的程式碼是如何建立一個Student例項:

        Student xiaoming =
                Student.newBuilder()
                        .setId(1234)
                        .setName("小明")
                        .setEmail("flydean@163.com")
                        .addPhones(
                                Student.PhoneNumber.newBuilder()
                                        .setNumber("010-1234567")
                                        .setType(Student.PhoneType.HOME))
                        .build();

Student中提供了一些常用的方法,如isInitialized()檢測是否所有必須的欄位都設定完畢。toString()將物件轉換成為字串。使用它的Builder還可以呼叫clear()用來清除已設定的狀態,mergeFrom(Message other)用來對物件進行合併。

序列化和反序列化

生成的物件中提供了序列化和反序列化方法,我們只需要在需要的時候對其進行呼叫即可:

  • byte[] toByteArray();: 序列化訊息並返回一個包含其原始位元組的位元組陣列。
  • static Person parseFrom(byte[] data);: 從給定的位元組陣列中解析一條訊息。
  • void writeTo(OutputStream output);: 序列化訊息並將其寫入 OutputStream.
  • static Person parseFrom(InputStream input);: 從一個訊息中讀取並解析訊息 InputStream.

通過使用上面的方法,可以很方便的將物件進行序列化和反序列化。

協議擴充套件

我們在定義好proto之後,假如後續還希望對其進行修改,那麼我們希望新的協議對歷史資料是相容的。那麼我們需要考慮下面幾點:

  1. 不能更改現有欄位的ID編號。
  2. 不能新增和刪除任何必填欄位。
  3. 可以 刪除可選或重複的欄位。
  4. 可以 新增新的可選欄位或重複欄位,但您必須使用新的ID編號。

總結

好了,protocol buf的基本用法就介紹到這裡,下一篇文章我們會更加詳細的介紹proto協議的具體內容,敬請期待。

本文的例子可以參考:learn-java-base-9-to-20

本文已收錄於 http://www.flydean.com/01-protocolbuf-guide/

最通俗的解讀,最深刻的乾貨,最簡潔的教程,眾多你不知道的小技巧等你來發現!

歡迎關注我的公眾號:「程式那些事」,懂技術,更懂你!

相關文章