Protobuf: 高效資料傳輸的祕密武器

2023-05-11 12:01:13

當涉及到網路通訊和資料儲存時,資料序列化一直都是一個重要的話題;特別是現在很多公司都在推行微服務,資料序列化更是重中之重,通常會選擇使用 JSON 作為資料交換格式,且 JSON 已經成為業界的主流。但是 Google 這麼大的公司使用的卻是一種被稱為 Protobuf 的資料交換格式,它是有什麼優勢嗎?這篇文章介紹 Protobuf 的相關知識。

GitHub:https://github.com/protocolbuffers/protobuf

官方檔案:https://protobuf.dev/overview/

Protobuf 介紹

Protobuf(Protocol Buffers)是由 Google 開發的一種輕量級、高效的資料交換格式,它被用於結構化資料的序列化、反序列化和傳輸。相比於 XML 和 JSON 等文字格式,Protobuf 具有更小的資料體積、更快的解析速度和更強的可延伸性。

Protobuf 的核心思想是使用協定(Protocol)來定義資料的結構和編碼方式。使用 Protobuf,可以先定義資料的結構和各欄位的型別、欄位等資訊,然後使用Protobuf提供的編譯器生成對應的程式碼用於序列化和反序列化資料。由於 Protobuf 是基於二進位制編碼的,因此可以在資料傳輸和儲存中實現更高效的資料交換,同時也可以跨語言使用。

相比於 XML 和 JSON,Protobuf 有以下幾個優勢

  • 更小的資料量:Protobuf 的二進位制編碼通常比 XML 和 JSON 小 3-10 倍,因此在網路傳輸和儲存資料時可以節省頻寬和儲存空間。

  • 更快的序列化和反序列化速度:由於 Protobuf 使用二進位制格式,所以序列化和反序列化速度比 XML 和 JSON 快得多。

  • 跨語言:Protobuf 支援多種程式語言,可以使用不同的程式語言來編寫使用者端和伺服器端。這種跨語言的特性使得 Protobuf 受到很多開發者的歡迎(JSON 也是如此)。

  • 易於維護可延伸:Protobuf 使用 .proto 檔案定義資料模型和資料格式,這種檔案比 XML 和 JSON 更容易閱讀和維護,且可以在不破壞原有協定的基礎上,輕鬆新增或刪除欄位,實現版本升級和相容性。

編寫 Protobuf

使用 Protobuf 的語言定義檔案(.proto)可以定義要傳輸的資訊的資料結構,可以包括各個欄位的名稱、型別等資訊。同時也可以相互巢狀組合,構造出更加複雜的訊息結構。

比如想要構造一個地址簿 AddressBook 資訊結構。一個 AddressBook 可以包含多個人員 Person 資訊,每個 Person 資訊可以包含 id、name、email 資訊,同時一個 Person 也可以包含多個電話號碼資訊 PhoneNumber,每個電話號碼資訊需要指定號碼種類,如手機、家庭電話、工作電話等。

如果使用 Protobuf 編寫定義檔案如下:

// 檔案:addressbook.proto
syntax = "proto3";
// 指定 protobuf 包名,防止有相同類名的 message 定義
package com.wdbyte.protobuf;
// 是否生成多個檔案
option java_multiple_files = true;
// 生成的檔案存放在哪個包下
option java_package = "com.wdbyte.tool.protos";
// 生成的類名,如果沒有指定,會根據檔名自動轉駝峰來命名
option java_outer_classname = "AddressBookProtos";

message Person {
  // =1,=2 作為序列化後的二進位制編碼中的欄位的唯一標籤,也因此,1-15 比 16 會少一個位元組,所以儘量使用 1-15 來指定常用欄位。
  optional int32 id = 1;
  optional string name = 2;
  optional string email = 3;

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

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

  repeated PhoneNumber phones = 4;
}

message AddressBook {
  repeated Person people = 1;
}

Protobuf 檔案中的語法解釋。

頭部全域性定義

  • syntax = "proto3";指定 Protobuf 版本為版本3(最新版本)
  • package com.wdbyte.protobuf;指定 Protobuf 包名,防止有相同類名的 message 定義,這個包名是生成的類中所用到的一些資訊的字首,並非類所在包。
  • option java_multiple_files = true; 是否生成多個檔案。若 false,則只會生成一個類,其他類以內部類形式提供。
  • option java_package = 生成的類所在包。
  • option java_outer_classname 生成的類名,若無,自動使用檔名進行駝峰轉換來為類命名。

訊息結構具體定義

message Person 定一個了一個 Person 類。

Person 類中的欄位被 optional 修飾,被 optional 修飾說明欄位可以不賦值。

  • 修飾符 optional 表示可選欄位,可以不賦值。
  • 修飾符 repeated 表示資料重複多個,如陣列,如 List。
  • 修飾符 required 表示必要欄位,必須給值,否則會報錯 RuntimeException,但是在 Protobuf 版本 3 中被移除。即使在版本 2 中也應該慎用,因為一旦定義,很難更改。

欄位型別定義

修飾符後面緊跟的是欄位型別,如 int32string。常用的型別如下:

  • int32、int64、uint32、uint64:整數型別,包括有符號和無符號型別。

  • float、double:浮點數型別。

  • bool:布林型別,只有兩個值,true 和 false。

  • string:字串型別。

  • bytes:二進位制資料型別。

  • enum:列舉型別,列舉值可以是整數或字串。

  • message:訊息型別,可以巢狀其他訊息型別,類似於結構體。

欄位後面的 =1,=2 是作為序列化後的二進位制編碼中的欄位的對應標籤,因為 Protobuf 訊息在序列化後是不包含欄位資訊的,只有對應的欄位序號,所以節省了空間。也因此,1-15 比 16 會少一個位元組,所以儘量使用 1-15 來指定常用欄位。且一旦定義,不要隨意更改,否則可能會對不上序列化資訊

編譯 Protobuf

使用 Protobuf 提供的編譯器,可以將 .proto 檔案編譯成各種語言的程式碼檔案(如 Java、C++、Python 等)。

下載編譯器:https://github.com/protocolbuffers/protobuf/releases/latest

安裝完成後可以使用 protoc 命令編譯 proto 檔案,如編譯範例中的 addressbook.proto.

protoc --java_out=./java ./resources/addressbook.proto
# --java_out 指定輸出 java 格式檔案,輸出到 ./java 目錄
# ./resources/addressbook.proto 為 proto 檔案位置

生成後可以看到生產的類檔案。

./
├── java
│   └── com
│       └── wdbyte
│           └── tool
│               ├── protos
│               │   ├── AddressBook.java
│               │   ├── AddressBookOrBuilder.java
│               │   ├── AddressBookProtos.java
│               │   ├── Person.java
│               │   ├── PersonOrBuilder.java
└── resources
    ├── addressbook.proto

使用 Protobuf

使用 Java 語言操作 Protobuf,首先需要引入 Protobuf 依賴。

Maven 依賴:

<dependency>
    <groupId>com.google.protobuf</groupId>
    <artifactId>protobuf-java</artifactId>
    <version>3.22.3</version>
</dependency>

構造訊息物件

// 直接構建
PhoneNumber phoneNumber1 = PhoneNumber.newBuilder().setNumber("18388888888").setType(PhoneType.HOME).build();
Person person1 = Person.newBuilder().setId(1).setName("www.wdbyte.com").setEmail("[email protected]").addPhones(phoneNumber1).build();
AddressBook addressBook1 = AddressBook.newBuilder().addPeople(person1).build();
System.out.println(addressBook1);
System.out.println("------------------");

//  鏈式構建
AddressBook addressBook2 = AddressBook
    .newBuilder()
    .addPeople(Person.newBuilder()
                     .setId(2)
                     .setName("www.wdbyte.com")
                     .setEmail("[email protected]")
                    .addPhones(PhoneNumber.newBuilder()
                                          .setNumber("18388888888")
                                          .setType(PhoneType.HOME)
                    )
    )
    .build();
System.out.println(addressBook2);

輸出:

people {
  id: 1
  name: "www.wdbyte.com"
  email: "[email protected]"
  phones {
    number: "18388888888"
    type: HOME
  }
}

------------------
people {
  id: 2
  name: "www.wdbyte.com"
  email: "[email protected]"
  phones {
    number: "18388888888"
    type: HOME
  }
}

序列化、反序列化

序列化:將記憶體中的資料物件序列化為二進位制資料,可以用於網路傳輸或儲存等場景。

反序列化:將二進位制資料反序列化成記憶體中的資料物件,可以用於資料處理和業務邏輯。

下面演示使用 Protobuf 進行字元陣列和檔案的序列化及反序列化過程。

package com.wdbyte.tool.protos;

import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;

/**
 * 
 * @author www.wdbyte.com
 */
public class ProtobufTest2 {

    public static void main(String[] args) throws IOException {
        PhoneNumber phoneNumber1 = PhoneNumber.newBuilder().setNumber("18388888888").setType(PhoneType.HOME).build();
        Person person1 = Person.newBuilder().setId(1).setName("www.wdbyte.com").setEmail("[email protected]").addPhones(phoneNumber1).build();
        AddressBook addressBook1 = AddressBook.newBuilder().addPeople(person1).build();
      
        // 序列化成位元組陣列
        byte[] byteArray = addressBook1.toByteArray();
        // 反序列化 - 位元組陣列轉物件
        AddressBook addressBook2 = AddressBook.parseFrom(byteArray);
        System.out.println("位元組陣列反序列化:");
        System.out.println(addressBook2);

        // 序列化到檔案
        addressBook1.writeTo(new FileOutputStream("AddressBook1.txt"));
        // 讀取檔案反序列化
        AddressBook addressBook3 = AddressBook.parseFrom(new FileInputStream("AddressBook1.txt"));
        System.out.println("檔案讀取反序列化:");
        System.out.println(addressBook3);
    }
}

輸出:

位元組陣列反序列化:
people {
  id: 1
  name: "www.wdbyte.com"
  email: "[email protected]"
  phones {
    number: "18388888888"
    type: HOME
  }
}

檔案讀取反序列化:
people {
  id: 1
  name: "www.wdbyte.com"
  email: "[email protected]"
  phones {
    number: "18388888888"
    type: HOME
  }
}

Protobuf 為什麼高效

在分析 Protobuf 高效之前,我們先確認一下 Protobuf 是否真的高效,下面將 Protobuf 與 JSON 進行對比,分別對比序列化和反序列化速度以及序列化後的儲存佔用大小

測試工具:JMH,FastJSON,

測試物件:Protobuf 的 addressbook.proto,JSON 的普通 Java 類。

Maven 依賴:

<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>fastjson</artifactId>
    <version>2.0.7</version>
</dependency>
<dependency>
    <groupId>org.openjdk.jmh</groupId>
    <artifactId>jmh-core</artifactId>
    <version>1.33</version>
</dependency>
<dependency>
    <groupId>org.openjdk.jmh</groupId>
    <artifactId>jmh-generator-annprocess</artifactId>
    <version>1.33</version>
    <scope>provided</scope>
</dependency>

先編寫與addressbook.proto 結構相同的 Java 類 AddressBookJava.java.

public class AddressBookJava {
    List<PersonJava> personJavaList;

    public static class PersonJava {
        private int id;
        private String name;
        private String email;
        private PhoneNumberJava phones;
        // get...set...
    }

    public static class PhoneNumberJava {
        private String number;
        private PhoneTypeJava phoneTypeJava;
        // get....set....
    }

    public enum PhoneTypeJava {
        MOBILE, HOME, WORK;
    }

    public List<PersonJava> getPersonJavaList() {
        return personJavaList;
    }

    public void setPersonJavaList(List<PersonJava> personJavaList) {
        this.personJavaList = personJavaList;
    }
}

序列化大小對比

分別在地址簿中新增 1000 個人員資訊,輸出序列化後的陣列大小。

package com.wdbyte.tool.protos;

import java.io.IOException;
import java.util.ArrayList;

import com.alibaba.fastjson.JSON;

import com.wdbyte.tool.protos.AddressBook.Builder;
import com.wdbyte.tool.protos.AddressBookJava.PersonJava;
import com.wdbyte.tool.protos.AddressBookJava.PhoneNumberJava;
import com.wdbyte.tool.protos.AddressBookJava.PhoneTypeJava;
import com.wdbyte.tool.protos.Person.PhoneNumber;
import com.wdbyte.tool.protos.Person.PhoneType;

/**
 * @author https://www.wdbyte.com
 */
public class ProtobufTest3 {

    public static void main(String[] args) throws IOException {
        AddressBookJava addressBookJava = createAddressBookJava(1000);
        String jsonString = JSON.toJSONString(addressBookJava);
        System.out.println("json string size:" + jsonString.length());

        AddressBook addressBook = createAddressBook(1000);
        byte[] addressBookByteArray = addressBook.toByteArray();
        System.out.println("protobuf byte array size:" + addressBookByteArray.length);
    }

    public static AddressBook createAddressBook(int personCount) {
        Builder builder = AddressBook.newBuilder();
        for (int i = 0; i < personCount; i++) {
            builder.addPeople(Person.newBuilder()
                .setId(i)
                .setName("www.wdbyte.com")
                .setEmail("[email protected]")
                .addPhones(PhoneNumber.newBuilder()
                    .setNumber("18333333333")
                    .setType(PhoneType.HOME)
                )
            );
        }
        return builder.build();
    }

    public static AddressBookJava createAddressBookJava(int personCount) {
        AddressBookJava addressBookJava = new AddressBookJava();
        addressBookJava.setPersonJavaList(new ArrayList<>());
        for (int i = 0; i < personCount; i++) {
            PersonJava personJava = new PersonJava();
            personJava.setId(i);
            personJava.setName("www.wdbyte.com");
            personJava.setEmail("[email protected]");

            PhoneNumberJava numberJava = new PhoneNumberJava();
            numberJava.setNumber("18333333333");
            numberJava.setPhoneTypeJava(PhoneTypeJava.HOME);

            personJava.setPhones(numberJava);
            addressBookJava.getPersonJavaList().add(personJava);
        }
        return addressBookJava;
    }
}

輸出:

json string size:108910
protobuf byte array size:50872

可見測試中 Protobuf 的序列化結果比 JSON 小了將近一倍左右。

序列化速度對比

使用 JMH 進行效能測試,分別測試 JSON 的序列化和反序列以及 Protobuf 的序列化和反序列化效能情況。每次測試前進行 3 次預熱,每次 3 秒。接著進行 5 次測試,每次 3 秒,收集測試情況。

package com.wdbyte.tool.protos;

import java.util.ArrayList;
import java.util.concurrent.TimeUnit;

import com.alibaba.fastjson.JSON;

import com.google.protobuf.InvalidProtocolBufferException;
import com.wdbyte.tool.protos.AddressBook.Builder;
import com.wdbyte.tool.protos.AddressBookJava.PersonJava;
import com.wdbyte.tool.protos.AddressBookJava.PhoneNumberJava;
import com.wdbyte.tool.protos.AddressBookJava.PhoneTypeJava;
import com.wdbyte.tool.protos.Person.PhoneNumber;
import com.wdbyte.tool.protos.Person.PhoneType;
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Fork;
import org.openjdk.jmh.annotations.Measurement;
import org.openjdk.jmh.annotations.Mode;
import org.openjdk.jmh.annotations.OutputTimeUnit;
import org.openjdk.jmh.annotations.Scope;
import org.openjdk.jmh.annotations.Setup;
import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.Warmup;

/**
 * @author https://www.wdbyte.com
 */
@State(Scope.Thread)
@Fork(2)
@Warmup(iterations = 3, time = 3)
@Measurement(iterations = 5, time = 3)
@BenchmarkMode(Mode.Throughput) // Throughput:吞吐量,SampleTime:取樣時間
@OutputTimeUnit(TimeUnit.MILLISECONDS)
public class ProtobufTest4 {

    private AddressBookJava addressBookJava;
    private AddressBook addressBook;

    @Setup
    public void init() {
        addressBookJava = createAddressBookJava(1000);
        addressBook = createAddressBook(1000);
    }

    @Benchmark
    public AddressBookJava testJSON() {
        // 轉 JSON
        String jsonString = JSON.toJSONString(addressBookJava);
        // JSON 轉物件
        return JSON.parseObject(jsonString, AddressBookJava.class);
    }

    @Benchmark
    public AddressBook testProtobuf() throws InvalidProtocolBufferException {
        // 轉 JSON
        byte[] addressBookByteArray = addressBook.toByteArray();
        // JSON 轉物件
        return AddressBook.parseFrom(addressBookByteArray);
    }

    public static AddressBook createAddressBook(int personCount) {
        Builder builder = AddressBook.newBuilder();
        for (int i = 0; i < personCount; i++) {
            builder.addPeople(Person.newBuilder()
                .setId(i)
                .setName("www.wdbyte.com")
                .setEmail("[email protected]")
                .addPhones(PhoneNumber.newBuilder()
                    .setNumber("18333333333")
                    .setType(PhoneType.HOME)
                )
            );
        }
        return builder.build();
    }

    public static AddressBookJava createAddressBookJava(int personCount) {
        AddressBookJava addressBookJava = new AddressBookJava();
        addressBookJava.setPersonJavaList(new ArrayList<>());
        for (int i = 0; i < personCount; i++) {
            PersonJava personJava = new PersonJava();
            personJava.setId(i);
            personJava.setName("www.wdbyte.com");
            personJava.setEmail("[email protected]");

            PhoneNumberJava numberJava = new PhoneNumberJava();
            numberJava.setNumber("18333333333");
            numberJava.setPhoneTypeJava(PhoneTypeJava.HOME);

            personJava.setPhones(numberJava);
            addressBookJava.getPersonJavaList().add(personJava);
        }
        return addressBookJava;
    }
}

JMH 吞吐量測試結果(Score 值越大吞吐量越高,效能越好):

Benchmark                    Mode  Cnt  Score   Error   Units
ProtobufTest3.testJSON      thrpt   10  1.877 ± 0.287  ops/ms
ProtobufTest3.testProtobuf  thrpt   10  2.813 ± 0.446  ops/ms

JMH 取樣時間測試結果(Score 越小,取樣時間越小,效能越好):

Benchmark                                          Mode    Cnt   Score   Error  Units
ProtobufTest3.testJSON                           sample  53028   0.565 ± 0.005  ms/op
ProtobufTest3.testProtobuf                       sample  90413   0.332 ± 0.001  ms/op

從測試結果看,不管是吞吐量測試,還是取樣時間測試,Protobuf 都優於 JSON。

為什麼高效?

Protobuf 是如何實現這種高效緊湊的資料編碼和解碼的呢?

首先,Protobuf 使用二進位制編碼,會提高效能;其次 Protobuf 在將資料轉換成二進位制時,會對欄位和型別重新編碼,減少空間佔用。它採用 TLV 格式來儲存編碼後的資料。TLV 也是就是 Tag-Length-Value ,是一種常見的編碼方式,因為資料其實都是鍵值對形式,所以在 TAG 中會儲存對應的欄位和型別資訊,Length 儲存內容的長度,Value 儲存具體的內容。

還記得上面定義結構體時每個欄位都對應一個數位嗎?如 =1,=2,=3.

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

在序列化成二進位制時候就是通過這個數位來標記對應的欄位的,二進位制中只儲存這個數位,反序列化時通過這個數位找對應的欄位。這也是上面為什麼說盡量使用 1-15 範圍內的數位,因為一旦超過 15,就需要多一個 bit 位來儲存。

那麼型別資訊呢?比如 int32 怎麼標記,因為型別個數有限,所以 Protobuf 規定了每個型別對應的二進位制編碼,比如 int32 對應二進位制 000string 對應二進位制 010,這樣就可以只用三個位元位儲存型別資訊。

這裡只是舉例描述大概思想,具體還有一些變化。

詳情可以參考官方檔案:https://protobuf.dev/programming-guides/encoding/

其次,Protobuf 還會採用一種變長編碼的方式來儲存資料。這種編碼方式能夠保證資料佔用的空間最小化,從而減少了資料傳輸和儲存的開銷。具體來說,Protobuf 會將整數和浮點數等型別變換成一個或多個位元組的形式,其中每個位元組都包含了一部分資料資訊和一部分識別符號資訊。這種編碼方式可以在資料值比較小的情況下,只使用一個位元組來儲存資料,以此來提高編碼效率。

最後,Protobuf 還可以通過採用壓縮演演算法來減少資料傳輸的大小。比如 GZIP 演演算法能夠將原始資料壓縮成更小的二進位制格式,從而在網路傳輸中能夠節省頻寬和傳輸時間。Protobuf 還提供了一些可選的壓縮演演算法,如 zlib 和 snappy,這些演演算法在不同的場景下能夠適應不同的壓縮需求。

綜上所述,Protobuf 在實現高效編碼和解碼的過程中,採用了多種優化方式,從而在實際應用中能夠有效地提升資料傳輸和處理的效率。

總結

ProtoBuf 是一種輕量、高效的資料交換格式,它具有以下優點:

  • 語言中立,可以支援多種程式語言;
  • 資料結構清晰,易於維護和擴充套件;
  • 二進位制編碼,資料體積小,傳輸效率高
  • 自動生成程式碼,開發效率高。

但是,ProtoBuf 也存在以下缺點:

  • 學習成本較高,需要掌握其語法規則和使用方法;
  • 需要先定義資料結構,然後才能對資料進行序列化和反序列化,增加了一定的開發成本;
  • 由於二進位制編碼,可讀性較差,這點不如 JSON 可以直接閱讀

總體來說,Protobuf 適合用於資料傳輸和儲存等場景,能夠提高資料傳輸效率和減少資料體積。但對於需要人類可讀的資料,或需要實時修改的資料,或者對資料的傳輸效率和體積沒那麼在意的場景,選擇更加通用的 JSON 未嘗不是一個好的選擇。

參考:https://protobuf.dev/overview/

一如既往,文章程式碼都存放在 Github.com/niumoo/javaNotes.

文章持續更新,可以微信搜一搜「 程式猿阿朗 」或存取「程式猿阿朗部落格 」第一時間閱讀。本文 Github.com/niumoo/JavaNotes 已經收錄,有很多系列文章,歡迎Star。