序列化之ProtoBuf3

簡介

  1. Protocol buffers 是一種語言無關,平臺無關,可擴展的序列化數據的格式,可用於通信協議,數據存儲等。Protocol buffers 很適合做數據存儲或 RPC 數據交換格式。可用於通訊協議、數據存儲等領域的語言無關、平臺無關、可擴展的序列化結構數據格式
  2. protocol buffers 誕生之初是爲了解決服務器端新舊協議(高低版本)兼容性問題,名字也很體貼,“協議緩衝區”。只不過後期慢慢發展成用於傳輸數據
  3. 版本:目前 protocol buffers 最新版本是 proto3,與老的版本 proto2 的 API 不完全兼容。 proto中所有結構化的數據都被稱爲 message

使用入門

安裝

  1. mac安裝,下載安裝包https://github.com/google/protobuf/releases

    1. 下載protoc-3.10.0-osx-x86_64.zip
    2. 解壓
    3. 配置環境變量
    
    export PROTOBUF=/Volumes/P/develope/protoc-3.10.0-osx-x86_64/
    export PATH=$PATH:$PROTOBUF/bin
    source ~/.zshrc
    
    

準備

  1. 生成前的目錄結構

    ├── build.gradle
    └── src
        ├── main
        │   ├── java
        │   ├── proto
        │   │   └── UserProtobuf.proto
        │   └── resources
        │       └── logback.xml
        └── test
            ├── java         
            └── resources
    
  2. Protobuf 編譯器通過描述文件(.proto文件)生成對應於語言的代碼,代碼中定義了消息類型、獲取、設置、編解碼序列化等操作。這也是爲什麼 Protobuf 支持跨語言傳輸,因爲消息所有端共用一個通用的描述文件。UserProtobuf.proto內容如下:

    //proto3語法註解:默認是proto2,這必須是文件的第一個非空的非註釋行。
    syntax = "proto3";
    //生成的包名
    option java_package = "cn.jannal.protobuf";
    //生成的java類名
    option java_outer_classname = "User";
    
    message UserProto{
        //ID
        int32 id = 1;
        //姓名
        string name = 2;
        //年齡
        int32 age = 3;
        //狀態
        int32 state = 4;
    }
    

生成並運行java代碼

  1. 第一種方式:直接通過命令行生成java,使用 protoc 工具可以把編寫好的 proto 文件生成不同的語言代碼。對Java來說,編譯器爲每一個消息類型生成了一個.java文件,以及一個特殊的Builder類(該類是用來創建消息類接口的)。對javaNano來說,JavaNano是專門爲資源受限系統(如Android)設計的特殊代碼生成器和運行時庫, 代碼量和運行時開銷都非常資源友好

    -I 後面是 proto 文件所在目錄
    --java_out 後面是 java 文件存放地址
    最後一行是 proto 文件名稱
      
    protoc -I=src/main/proto --java_out=src/main/java UserProtobuf.proto
    
  2. 第二種方式:通過gradle插件生成,插件地址https://github.com/google/protobuf-gradle-plugin。可以在build之前根據proto文件自動生成java文件

    buildscript {
        repositories {
            mavenLocal()
        }
        dependencies {
            classpath 'com.google.protobuf:protobuf-gradle-plugin:0.8.10'
        }
    }
    
    subprojects {
        apply plugin: 'java'
        apply plugin: 'com.google.protobuf'
    
        sourceCompatibility = 1.8
        targetCompatibility = 1.8
    
        idea {
            module {
                downloadJavadoc = true
                downloadSources = true
            }
        }
    
        dependencies {
            compile group: 'com.google.protobuf', name: 'protobuf-java', version: '3.10.0'
         }
    
    
        //==============protobuf配置================
    
        sourceSets {
            main {
                proto {
                    //默認是 'src/main/proto'
                    srcDir 'src/main/proto'
    
                }
            }
            test {
                proto {
                    // 默認 'src/test/proto'
                    srcDir 'src/test/proto'
                }
            }
        }
    
        protobuf {
            protoc {
                //從倉庫下載
                artifact = 'com.google.protobuf:protoc:3.10.0'
                //生成代碼的目錄
                generatedFilesBaseDir = "$projectDir/src"
            }
        }
        //在build之前執行proto代碼生成
        build.dependsOn(":$project.name:generateProto")
        //==============protobuf配置================
    
    }
    
    
  3. 生成後的目錄結構

    .
    ├── build.gradle
    └── src
        ├── main
        │   ├── java
        │   │   └── cn
        │   │       └── jannal
        │   │           └── protobuf
        │   │               └── User.java
        │   ├── proto
        │   │   └── UserProtobuf.proto
        │   └── resources
        │       └── logback.xml
        └── test
            ├── java
            │   └── cn
            │       └── jannal
            │           └── protobuf
            │               └── UserTest.java
            └── resources
    
    
  4. 測試程序

    public class UserTest {
        private static final Logger logger = LoggerFactory.getLogger(UserTest.class);
    
        public static void main(String[] args) throws IOException {
            User.UserProto.Builder user = User.UserProto.newBuilder();
            user.setAge(12);
            user.setId(1000);
            user.setName("jannal");
            User.UserProto userInfo = user.build();
    
            // 將數據寫到輸出流
            ByteArrayOutputStream output = new ByteArrayOutputStream();
            userInfo.writeTo(output);
            // 將數據序列化後發送
            byte[] byteArray = output.toByteArray();
            // 接收到流並讀取
            ByteArrayInputStream input = new ByteArrayInputStream(byteArray);
            // 反序列化
            userInfo = User.UserProto.parseFrom(input);
            /**
             * id: 1000
             * name: "jannal"
             * age: 12
             */
            logger.info("{}", userInfo.toString());
        }
    }
    

語法

  1. 一個proto文件可以定義一個或者多個message

    // 指定使用proto3,如果不指定的話,編譯器會使用proto2去編譯
    syntax = "proto3"; //[proto2|proto3]
    
    message SearchRequests {
        // 定義SearchRequests的成員變量,需要指定:[變量類型]、[變量名]、[變量Tag]
        string query = 1;
        int32 page_number = 2;
        int32 result_per_page = 3;
    }
    message SearchResponse {
        repeated string result = 1;
    }
    
  2. 嵌套定義

    message SearchResponse {
        message Result {
            string url = 1;
            string title = 2;
            repeated string snippets = 3;
        }
        repeated Result results = 1;
    }
    
    message SomeOtherMessage {
        //定義在message內部的message可以這樣使用
        SearchResponse.Result result = 1;
    }
    
  3. 註釋

    使用//表示註釋
    
  4. 消息由至少一個字段組合而成,類似於C語言中的結構。每個字段都有一定的格式

    限定修飾符① | 數據類型② | 字段名稱③ | = | 字段編碼值④ | [字段默認值⑤]
      
    
    
  5. 限定修飾符① 包含 required\optional\repeated

    • Required: 表示是一個必須字段,必須相對於發送方,在發送消息之前必須設置該字段的值,對於接收方,必須能夠識別該字段的意思。發送之前沒有設置required字段或者無法識別required字段都會引發編解碼異常,導致消息被丟棄。
    • Optional:表示是一個可選字段,可選對於發送方,在發送消息時,可以有選擇性的設置或者不設置該字段的值。對於接收方,如果能夠識別可選字段就進行相應的處理,如果無法識別,則忽略該字段,消息中的其它字段正常處理。因爲optional字段的特性,很多接口在升級版本中都把後來添加的字段都統一的設置爲optional字段,這樣老的版本無需升級程序也可以正常的與新的軟件進行通信,只不過新的字段無法識別而已,因爲並不是每個節點都需要新的功能,因此可以做到按需升級和平滑過渡。
    • Repeated: 表示該字段可以包含0~N個元素。其特性和optional一樣,但是每一次可以包含多個值。可以看作是在傳遞一個數組的值。

變量

變量類型

  1. 變量類型

    Proto 描述 Java
    double double
    float float
    int32 使用變長編碼,對負數編碼效率低,如果你的變量可能是負數,可以使用sint32 int
    int64 使用變長編碼,對負數編碼效率低,如果你的變量可能是負數,可以使用sint64 long
    uint32 使用變長編碼 int
    uint64 使用變長編碼 long
    sint32 使用變長編碼,帶符號的int類型,對負數編碼比int32高效 int
    sint64 使用變長編碼,帶符號的int類型,對負數編碼比int64高效 long
    fixed32 4字節編碼, 如果變量經常大於2^28 的話,會比uint32高效 int
    fixed64 8字節編碼, 如果變量經常大於2^56 的話,會比uint64高效 long
    sfixed32 4字節編碼 int
    sfixed64 8字節編碼 long
    bool bool
    string 必須包含utf-8編碼或者7-bit ASCII text String
    bytes 任意的字節序列 String
  2. Any可以讓你在 proto 文件中使用未定義的類型,具體裏面保存什麼數據,是在上層業務代碼使用的時候決定的,使用 Any 必須導入 import google/protobuf/any.proto

    import "google/protobuf/any.proto";
    
    message ErrorStatus {
        string message = 1;
        repeated google.protobuf.Any details = 2;
    }
    
  3. 如果你的消息中有很多可選字段,而同一個時刻最多僅有其中的一個字段被設置的話,你可以使用oneof來強化這個特性並且節約存儲空間(設置一個oneof字段會自動清理其他的oneof字段)。

    • 反射API對oneof 字段有效.
    • oneof不支持repeated.
    
    name 和 age 都是 LoginReply 的成員,但不能給他們同時設置值
      
    message LoginReply {
        oneof test_oneof {
            string name = 3;
            string age = 4;
        }
        required string status = 1;
        required string token = 2;
    }
    
  4. Map類型:protobuf 支持定義 map 類型的成員

    1. key_type:必須是string或者int
    2. value_type:任意類型
    map<key_type, value_type> map_field = N;
    
    
    // 舉例:map<string, Project> projects = 3;
    
  5. 使用 map 要注意:

    • Map 類型不能使 repeated

    • Map 是無序的

    • 以文本格式展示時,Map 以 key 來排序

    • 如果有相同的鍵會導致解析失敗

變量默認值

  1. 當解析 message 時,如果被編碼的 message 裏沒有包含某些變量,那麼根據類型不同,他們會有不同的默認值,收到數據後反序列化後,對於標準值類型的數據,比如bool,如果它的值是 false,那麼我們無法判斷這個值是對方設置的,還是對方就沒設置這個值。
    • string:默認是空的字符串
    • byte:默認是空的bytes
    • bool:默認爲false
    • numeric:默認爲0
    • enums:定義在第一位的枚舉值,也就是0
    • messages:根據生成的不同語言有不同的表現,參考generated code guide

Tag

  1. 每一個變量在message內都需要自定義一個唯一的數字Tag,protobuf會根據Tag從數據中查找變量對應的位置,Tag一旦指定,以後更新協議的時候也不能修改,否則無法對舊版本兼容
  2. Tag的取值範圍最小是1,最大是229229-1,但 19000~19999是 protobuf 預留的,用戶不能使用
  3. 不同 Tag 會對 protobuf 編碼帶來一些影響
    • 1 ~ 15:單字節編碼,使用頻率高的變量最好設置爲1 ~ 15,這樣可以減少編碼後的數據大小,由於Tag一旦指定不能修改,所以爲了以後擴展,也記得爲未來保留一些 1 ~ 15 的 Tag
    • 16 ~ 2047:雙字節編碼

變量規則

  1. 當構建 message 的時候,build 數據的時候,會檢測設置的數據跟規則是否匹配。在 proto3 中,可以給變量指定以下兩個規則
    • singular:0或者1個,但不能多於1個
    • repeated:任意數量(包括0)
  2. 在proto2中,規則爲:
    • required:必須有一個
    • optional:0或者1個
    • repeated:任意數量(包括0)

廢棄變量

  1. 可以用 reserved 關鍵字,當一個變量不再使用的時候,我們可以把它的變量名或 Tag 用 reserved 標註,這樣,當這個 Tag 或者變量名字被重新使用的時候,編譯器會報錯

    message Foo {
        // 注意,同一個 reserved 語句不能同時包含變量名和 Tag 
        reserved 2, 15, 9 to 11;
        reserved "foo", "bar";
    }
    

枚舉

  1. 定義枚舉並使用

    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;
    }
    
    
  2. 枚舉定義在一個消息內部或消息外部都是可以的,如果枚舉是定義在 message 內部,而其他 message 又想使用,那麼可以通過MessageType.EnumType的方式引用。定義枚舉的時候,要保證第一個枚舉值必須是0,枚舉值不能重複,除非使用 option allow_alias = true 選項來開啓別名

    enum EnumAllowingAlias {
        option allow_alias = true;
        UNKNOWN = 0;
        STARTED = 1;
        RUNNING = 1;
    }
    
  3. 枚舉數常量必須在32位整數的範圍內。由於enum值在傳輸中使用不同的編碼,負值效率低下,因此不推薦使用

引用其他的proto文件

  1. import

    A ->import B
    B -> import C
    A不能使用C中的內容,A可以使用B的內容,B可以使用C的內容
    
  2. import public

    A ->import B
    B -> import C
    A可以使用C中的內容,A可以使用B的內容,B可以使用C的內容
    

packages

  1. 爲了防止不同消息之間的命名衝突,你可以對特定的.proto文件指定 package 名字。在定義消息的成員的時候,可以指定包的名字:

    package foo.bar;
    message Open { ... }
    
    message Foo {
        ...
        // 帶上包名
        foo.bar.Open open = 1;
        ...
    }
    

JSON映射

  1. Proto3 支持JSON的編碼規範,可實現protobuf與json互相轉換。

    • 如果JSON編碼的數據丟失或者其本身就是null,這個數據會在解析成protocol buffer的時候被表示成默認值。
    • 如果一個字段在協議緩衝區中具有默認值,默認情況下它將在 JSON 編碼數據中省略以節省空間
  2. 映射表

    message object {“fooBar”: v, “g”: null, …} 生成JSON對象。消息字段名映射到lowerCamelCase,成爲JSON對象鍵。如果指定了JSON_name字段選項,則指定的值將被用作密鑰。解析器接受lowerCamelCase名稱(或JSON_name選項指定的名稱)和原始的原域名稱。null是所有字段類型的接受值,並被視爲相應字段類型的默認值。
    enum string “FOO_BAR” 使用proto中指定的枚舉值的名稱。
    map<K,V> object {“k”: v, …} 所有鍵都轉換爲字符串。
    repeated V array [v, …] null被接受爲空列表[]。
    bool true, false true, false
    string string “Hello World!”
    bytes base64 string “YWJjMTIzIT8kKiYoKSctPUB+” JSON值使用標準base64編碼和paddings編碼作爲字符串編碼的數據。
    int32, fixed32, uint32 number 1, -10, 0 JSON值將是十進制數。接受數字或字符串。
    int64, fixed64, uint64 string “1”, “-10” JSON值將是十進制數。接受數字或字符串。
    float, double number 1.1, -10.0, 0, “NaN”, “Infinity” JSON值將是一個數字或特殊字符串值"NaN"、“Infinity"和”-Infinity"之一。接受數字或字符串。指數記數法也被接受。
    Any object {"@type": “url”, “f”: v, … } 如果Any包含具有特殊JSON映射的值,它將被轉換如下: {"@ type": XXX,“value”: yyy }。否則,該值將被轉換成JSON對象,並且“@ type”字段將被插入以指示實際的數據類型。
    Timestamp string “1972-01-01T10:00:20.021Z” 使用RFC 3339,其中生成的輸出總是Z歸一化的,並使用0、3、6或9個小數位數。除“Z”之外的偏移也是可以接受的。
    Duration string “1.000340012s”, “1s” 根據所需精度,生成的輸出總是包含0、3、6或9個小數位數,後跟後綴"s"。接受任何小數位數(也沒有),只要它們符合毫微秒精度,並且後綴"s"是必需的。
    Struct object { … } 任何JSON對象都可以。
    Wrapper types various types 2, “2”, “foo”, true, “true”, null, 0, … 包裝器在JSON中使用與包裝基元類型相同的表示,只是在數據轉換和傳輸期間允許並保留null。
    FieldMask string “f.fooBar,h” 請參見字段mask.proto
    ListValue array [foo, bar, …]
    Value value
    NullValue null JSON null
  3. proto3 的 JSON 實現中提供了以下 4 中 options:

    • 使用默認值發送字段:在默認情況下,默認值的字段在Proto3 JSON 輸出中被忽略。一個實現可以提供一個選項來覆蓋這個行爲,並使用它們的默認值輸出字段。
    • 忽略未知字段:默認情況下,Proto3 JSON 解析器應拒絕未知字段,但可能提供一個選項來忽略解析中的未知字段。
    • 使用 proto 字段名稱而不是 lowerCamelCase 名稱:默認情況下,proto3 JSON 的 printer 將字段名稱轉換爲 lowerCamelCase 並將其用作 JSON 名稱。實現可能會提供一個選項,將原始字段名稱用作 JSON 名稱。 Proto3 JSON 解析器需要接受轉換後的 lowerCamelCase 名稱和原始字段名稱。
    • 發送枚舉形式的枚舉值而不是字符串:在 JSON 輸出中默認使用枚舉值的名稱。可以提供一個選項來使用枚舉值的數值。
  4. protocol buffers 替換 JSON,可能是考慮到:

    • protocol buffers 相同數據,傳輸的數據量比 JSON 小,gzip 或者 7zip 壓縮以後,網絡傳輸消耗較少
    • protocol buffers 不是自我描述的,在缺少 .proto 文件以後,有一定的加密性,數據傳輸過程中都是二進制流,並不是明文。
    • protocol buffers 提供了一套工具,自動化生成代碼也非常方便
    • protocol buffers 具有向後兼容性,改變了數據結構以後,對老的版本沒有影響
    • protocol buffers 原生完美兼容 RPC 調用
  5. 如果很少用到整型數字,浮點型數字,全部都是字符串數據,那麼 JSON 和 protocol buffers 性能不會差太多

  6. 案例

    1. 加入依賴       
    compile group: 'com.google.protobuf', name: 'protobuf-java-util', version: '3.10.0'
    
    2. json測試
    public class UserTest {
        private static final Logger logger = LoggerFactory.getLogger(UserTest.class);
    
        public static void main(String[] args) throws IOException {
            User.UserProto.Builder user = User.UserProto.newBuilder();
            user.setAge(12);
            user.setId(1000);
            user.setName("jannal");
            User.UserProto userInfo = user.build();
    
            // 將數據寫到輸出流
            ByteArrayOutputStream output = new ByteArrayOutputStream();
            userInfo.writeTo(output);
            // 將數據序列化後發送
            byte[] byteArray = output.toByteArray();
            // 接收到流並讀取
            ByteArrayInputStream input = new ByteArrayInputStream(byteArray);
            // 反序列化
            userInfo = User.UserProto.parseFrom(input);
            /**
             * id: 1000
             * name: "jannal"
             * age: 12
             */
            logger.info("{}", userInfo.toString());
    
    
            //如果不使用protobuf提供的JSON API,而使用fastJson等,直接序列化Msg對象,會報錯。
            // 如果希望使用第三方的JSON API,可以重新定義一個實體類,抽取需要的字段
    
            //獲取Printer對象用於生成JSON字符串
            JsonFormat.Printer printer = JsonFormat.printer();
            //獲取parser對象用於解析JSON字符串
            JsonFormat.Parser parser = JsonFormat.parser();
            try {
                //生成JSON字符串
                String jsonStr = printer.print(userInfo);
                System.out.println(jsonStr);
                //解析JSON字符串
                //解析方法接收一個JSON字符串,並把其寫入指定的builder
                User.UserProto.Builder builder = User.UserProto.newBuilder();
                parser.merge(jsonStr, builder);
                User.UserProto userProto = builder.build();
                System.out.println(userProto);
            } catch (InvalidProtocolBufferException e) {
                e.printStackTrace();
            }
        }
    }
    
    

升級proto的原則

  1. 升級更改 proto 需要遵循以下原則

    • 不要修改任何已存在的變量的 Tag

    • 如果新增了變量,新生成的代碼依然能解析舊的數據,但新增的變量將會變成默認值。相應的,新代碼序列化的數據也能被舊的代碼解析,但舊代碼會自動忽略新增的變量。

    • 廢棄不用的變量用reserved標註

    • int32、 uint32、 int64、 uint64 和 bool 是相互兼容的,這意味你可以更改這些變量的類型而不會影響兼容性

    • sint32 和 sint64是兼容的,但跟其他類型不兼容

    • string 和 bytes 可以兼容,前提是他們都是UTF-8編碼的數據

    • fixed32 和 sfixed32是兼容的

    • fixed64 和 sfixed64是兼容的

定義RPC Service

  1. 如果要使用 RPC(遠程過程調用)系統的消息類型,可以在 .proto 文件中定義 RPC 服務接口,protocol buffer 編譯器將使用所選語言生成服務接口代碼和 stubs。所以,例如,如果你定義一個 RPC 服務,入參是 SearchRequest 返回值是 SearchResponse,你可以在你的 .proto 文件中定義它,如下所示:

    service SearchService {
      rpc Search (SearchRequest) returns (SearchResponse);
    }
    
  2. 與 protocol buffer 一起使用的最直接的 RPC 系統是gRPC:在谷歌開發的語言和平臺中立的開源 RPC 系統。gRPC 在 protocol buffer 中工作得非常好,並且允許你通過使用特殊的 protocol buffer 編譯插件,直接從 .proto 文件中生成 RPC 相關的代碼。

命名規範

  1. message 採用駝峯命名法。message 首字母大寫開頭。字段名採用下劃線分隔法命名。

    message SongServerRequest {
      required string song_name = 1;
    }
    
  2. 枚舉類型採用駝峯命名法。枚舉類型首字母大寫開頭。每個枚舉值全部大寫,並且採用下劃線分隔法命名。

    enum Foo {
      FIRST_VALUE = 0;
      SECOND_VALUE = 1;
    }
    每個枚舉值用分號結束,不是逗號。
    
  3. 服務名和方法名都採用駝峯命名法。並且首字母都大寫開頭。

    service FooService {
      rpc GetSomething(FooRequest) returns (FooResponse);
    }
    

遇到的問題

  1. 默認一個proto文件生成一個java文件,這樣如果proto比較長,java文件會比較大。在proto中添加option java_multiple_files = true的配置可以生成多個java文件

    syntax = "proto3";
    
    option java_package = "cn.jannal.protobuf";
    
    //生成的java名
    option java_outer_classname = "PersonProto";
    //生成多個java文件
    option java_multiple_files = true;
    
    message Person{
        string username = 1;
        string password = 2;
    }
    
    message Student{
        string username = 1;
        string password = 2;
    }
    
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章