Google Protocol Buffers 入門

1. 前言

這篇入門教程是基於Java語言的,這篇文章我們將會:

  1. 創建一個.proto文件,在其內定義一些PB message
  2. 使用PB編譯器
  3. 使用PB Java API 讀寫數據

這篇文章僅是入門手冊,如果想深入學習及瞭解,可以參看: Protocol Buffer Language GuideJava API ReferenceJava Generated Code Guide, 以及Encoding Reference

2. 爲什麼使用Protocol Buffers

接下來用“通訊簿”這樣一個非常簡單的應用來舉例。該應用能夠寫入並讀取“聯繫人”信息,每個聯繫人由name,ID,email address以及contact photo number組成。這些信息的最終存儲在文件中。

如何序列化並檢索這樣的結構化數據呢?有以下解決方案:

  1.  使用Java序列化(Java Serialization)。這是最直接的解決方式,因爲該方式是內置於Java語言的,但是,這種方式有許多問題(Effective Java 對此有詳細介紹),而且當有其他應用程序(比如C++ 程序及Python程序書寫的應用)與之共享數據的時候,這種方式就不能工作了。
  2. 將數據項編碼成一種特殊的字符串。例如將四個整數編碼成“12:3:-23:67”。這種方法簡單且靈活,但是卻需要編寫獨立的,只需要用一次的編碼和解碼代碼,並且解析過程需要一些運行成本。這種方式對於簡單的數據結構非常有效。
  3. 將數據序列化爲XML。這種方式非常誘人,因爲易於閱讀(某種程度上)並且有不同語言的多種解析庫。在需要與其他應用或者項目共享數據的時候,這是一種非常有效的方式。但是,XML是出了名的耗空間,在編碼解碼上會有很大的性能損耗。而且呢,操作XML DOM數非常的複雜,遠不如操作類中的字段簡單。

Protocol Buffers可以靈活,高效且自動化的解決該問題,只需要:

  1. 創建一個.proto 文件,描述希望數據存儲結構
  2. 使用PB compiler 創建一個類,該類可以高效的,以二進制方式自動編碼和解析PB數據

該生成類提供組成PB數據字段的getter和setter方法,甚至考慮瞭如何高效的讀寫PB數據。更厲害的是,PB友好的支持字段拓展,拓展後的代碼,依然能夠正確的讀取原來格式編碼的數據。

3. 定義協議格式

首先需要創建一個.proto文件。非常簡單,每一個需要序列化的數據結構,編碼一個PB message,然後爲message中的字段指明一個名字和類型即可。該“通訊簿”的.proto 文件addressbook.proto定義如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package tutorial;
 
option java_package = "com.example.tutorial";
option java_outer_classname = "AddressBookProtos";
 
message Person {
  required string name = 1;
  required int32 id = 2;
  optional string email = 3;
 
  enum PhoneType {
    MOBILE = 0;
    HOME = 1;
    WORK = 2;
  }
 
  message PhoneNumber {
    required string number = 1;
    optional PhoneType type = 2 [default = HOME];
  }
 
  repeated PhoneNumber phone = 4;
}
message AddressBook {
  repeated Person person = 1;
}

可以看到,語法非常類似Java或者C++,接下來,我們一條一條來過一遍每句話的含義:

  • .proto文件以一個package聲明開始。該聲明有助於避免不同項目建設的命名衝突。Java版的PB,在沒有指明java_package的情況下,生成的類默認的package即爲此package。這裏我們生命的java_package,所以最終生成的類會位於com.example.tutorial package下。這裏需要強調一下,即使指明瞭java_package,我們建議依舊定義.proto文件的package。
  • 在package聲明之後,緊接着是專門爲java指定的兩個選項:java_package 以及 java_outer_classname。java_package我們已經說過,不再贅述。java_outer_classname爲生成類的名字,該類包含了所有在.proto中定義的類。如果該選項不顯式指明的話,會按照駝峯規則,將.proto文件的名字作爲該類名。例如“addressbook.proto”將會是“Addressbook”,“address_book.proto”即爲“AddressBook”
  • java指定選項後邊,即爲message定義。每個message是一個包含了一系列指明瞭類型的字段的集合。這裏的字段類型包含大多數的標準簡單數據類型,包括bool,int32,float,double以及string。Message中也可以定義嵌套的message,例如“Person” message 包含“PhoneNumber” message。也可以將已定義的message作爲新的數據類型,例如上例中,PhoneNumber類型在Person內部定義,但他是phone的type。在需要一個字段包含預先定義的一個列表的時候,也可以定義枚舉類型,例如“PhoneType”。
  • 我們注意到, 每一個message中的字段,都有“=1”,“=2”這樣的標記,這可不是初始化賦值,該值是message中,該字段的唯一標示符,在二進制編碼時候會用到。數字1~15的表示需求少於一個字節,所以在編碼的時候,有這樣一個優化,你可以用1~15標記最常使用或者重複字段元素(repeated elements)。用16或者更大的數字來標記不太常用的可選元素。再重複字段中,每一個元素都需重複編碼標籤數字,所以,該優化對重複字段最佳(repeat fileds)。

message的沒一個字段,都要用如下的三個修飾符(modifier)來聲明:

  1. required:必須賦值,不能爲空,否則該條message會被認爲是“uninitialized”。build一個“uninitialized” message會拋出一個RuntimeException異常,解析一條“uninitialized” message會拋出一條IOException異常。除此之外,“required”字段跟“optional”字段並無差別。
  2. optional:字段可以賦值,也可以不賦值。假如沒有賦值的話,會被賦上默認值。對於簡單類型,默認值可以自己設定,例如上例的PhoneNumber中的PhoneType字段。如果沒有自行設定,會被賦上一個系統默認值,數字類型會被賦爲0,String類型會被賦爲空字符串,bool類型會被賦爲false。對於內置的message,默認值爲該message的默認實例或者原型,即其內所有字段均爲設置。當獲取沒有顯式設置值的optional字段的值時,就會返回該字段的默認值。
  3. repeated:該字段可以重複任意次數,包括0次。重複數據的順序將會保存在protocol buffer中,將這個字段想象成一個可以自動設置size的數組就可以了。

 Notice:應該格外小心定義Required字段。當因爲某原因要把Required字段改爲Optional字段是,會有問題,老版本讀取器會認爲消息中沒有該字段不完整,可能會拒絕或者丟棄該字段(Google文檔是這麼說的,但是我試了一下,將required的改爲optional的,再用原來required時候的解析代碼去讀,如果字段賦值的話,並不會出錯,但是如果字段未賦值,會報這樣錯誤:Exception in thread "main" com.google.protobuf.InvalidProtocolBufferException: Message missing required fields:fieldname)。在設計時,儘量將這種驗證放在應用程序端的完成。Google的一些工程師對此也很困惑,他們覺得,required類型壞處大於好處,應該儘量僅適用optional或者repeated的。但也並不是所有的人都這麼想。

如果想深入學習.proto文件書寫,可以參考Protocol Buffer Language Guide。但是不要妄想會有類似於類繼承這樣的機制,Protocol Buffers不做這個...

4. 編譯Protocol Buffers

定義好.proto文件後,接下來,就是使用該文件,運行PB的編譯器protoc,編譯.proto文件,生成相關類,可以使用這些類讀寫“通訊簿”沒得message。接下來我們要做:

  1. 如果你還沒有安裝PB編譯器,到這裏現在安裝:download the package
  2. 安裝後,運行protoc,結束後會發現在項目com.example.tutorial package下,生成了AddressBookProtos.java文件:
1
2
3
protoc -I=$SRC_DIR --java_out=$DST_DIR $SRC_DIR/addressbook.proto
#for example
protoc -I=G:\workspace\protobuf\message --java_out=G:\workspace\protobuf\src\main\java G:\workspace\protobuf\messages\addressbook.proto
  • -I:指明應用程序的源碼位置,假如不賦值,則有當前路徑(說實話,該處我是直譯了,並不明白是什麼意思。我做了嘗試,該值不能爲空,如果爲空,則提示賦了一個空文件夾,如果是當前路徑,請用.代替,我用.代替,又提示不對。但是可以是任何一個路徑,都運行正確,只要不爲空);
  • --java_out:指明目的路徑,即生成代碼輸出路徑。因爲我們這裏是基於java來說的,所以這裏是--java_out,相對其他語言,設置爲相對語言即可
  • 最後一個參數即.proto文件

Notice:此處運行完畢後,查看生成的代碼,很有可能會出現一些類沒有定義等錯誤,例如:com.google cannot be resolved to a type等。這是因爲項目中缺少protocol buffers的相應library。在Protocol Buffers的源碼包裏,你會發現java/src/main/java,將這下邊的文件拷貝到你的項目,大概可以解決問題。我只能說大概,因爲當時我在弄得時候,也是剛學,各種出錯,比較噁心。有一個簡單的方法,呵呵,對於懶漢來說。創建一個maven的java項目,在pom.xml中,添加Protocol Buffers的依賴即可解決所有問題~在pom.xml中添加如下依賴(注意版本):

1
2
3
4
5
<dependency>
    <groupId>com.google.protobuf</groupId>
    <artifactId>protobuf-java</artifactId>
    <version>2.5.0</version>
</dependency>

 5. Protocol Buffer Java API

5.1 產生的類及方法

接下來看一下PB編譯器創建了那些類以及方法。首先會發現一個.java文件,其內部定義了一個AddressBookProtos類,即我們在addressbook.proto文件java_outer_classname 指定的。該類內部有一系列內部類,對應分別是我們在addressbook.proto中定義的message。每個類內部都有相應的Builder類,我們可以用它創建類的實例。生成的類及類內部的Builder類,均自動生成了獲取message中字段的方法,不同的是,生成的類僅有getter方法,而生成類內部的Builder既有getter方法,又有setter方法。本例中Person類,其僅有getter方法,如圖所示:

 但是Person.Builder類,既有getter方法,又有setter方法,如圖:

person.builder
person.builder

從上邊兩張圖可以看到:

  1. 每一個字段都有JavaBean風格的getter和setter
  2. 對於每一個簡單類型變量,還對應都有一個has這樣的一個方法,如果該字段被賦值了,則返回true,否則,返回false
  3. 對每一個變量,都有一個clear方法,用於置空字段

對於repeated字段:

repeated filed
repeated filed

從圖上看:

  1. 從person.builder圖上看出,對於repeated字段,還有一個特殊的getter,即getPhoneCount方法,及repeated字段還有一個特殊的count方法
  2. 其getter和setter方法根據index獲取或設置一個數據項
  3. add()方法用於附加一個數據項
  4. addAll()方法來直接增加一個容器中的所有數據項

注意到一點:所有的這些方法均命名均符合駝峯規則,即使在.proto文件中是小寫的。PB compiler生成的方法及字段等都是按照駝峯規則來產生,以符合基本的Java規範,當然,其他語言也儘量如此。所以,在proto文件中,命名最好使用用“_”來分割不同小寫的單詞。

 5.2 枚舉及嵌套類

從代碼中可以發現,還產生了一個枚舉:PhoneType,該枚舉位於Person類內部:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public enum PhoneType
        implements com.google.protobuf.ProtocolMessageEnum {
      /**
       * <code>MOBILE = 0;</code>
       */
      MOBILE(0, 0),
      /**
       * <code>HOME = 1;</code>
       */
      HOME(1, 1),
      /**
       * <code>WORK = 2;</code>
       */
      WORK(2, 2),
      ;
      ...
}

除此之外,如我們所預料,還有一個Person.PhoneNumber內部類,嵌套在Person類中,可以自行看一下生成代碼,不再粘貼。

5.3 Builders vs. Messages

由PB compiler生成的消息類是不可變的。一旦一個消息對象構建出來,他就不再能夠修改,就像java中的String一樣。在構建一個message之前,首先要構建一個builder,然後使用builder的setter或者add()等方法爲所需字段賦值,之後調用builder對象的build方法。

在使用中會發現,這些構造message對象的builder的方法,都又會返回一個新的builder,事實上,該builder跟調用這個方法的builder是同一方法。這樣做的目的,僅是爲了方便而已,我們可以把所有的setter寫在一行內。

如下構造一個Person實例:

1
2
3
4
5
6
7
8
9
10
11
12
Person john = Person
        .newBuilder()
        .setId(1)
        .setName("john")
        .setEmail("[email protected]")
        .addPhone(
                PhoneNumber
                .newBuilder()
                .setNumber("1861xxxxxxx")
                .setType(PhoneType.WORK)
                .build()
        ).build();

5.4 標準消息方法

每一個消息類及Builder類,基本都包含一些公用方法,用來檢查和維護這個message,包括:

  1.  isInitialized(): 檢查是否所有的required字段是否被賦值
  2. toString(): 返回一個便於閱讀的message表示(本來是二進制的,不可讀),尤其在debug時候比較有用
  3. mergeFrom(Message other): 僅builder有此方法,將其message的內容與此message合併,覆蓋簡單及重複字段
  4. clear(): 僅builder有此方法,清空所有的字段

5.5 解析及序列化

對於每一個PB類,均提供了讀寫二進制數據的方法:

  1. byte[] toByteArray();: 序列化message並且返回一個原始字節類型的字節數組
  2. static Person parseFrom(byte[] data);: 將給定的字節數組解析爲message
  3. void writeTo(OutputStream output);: 將序列化後的message寫入到輸出流
  4. static Person parseFrom(InputStream input);: 讀入並且將輸入流解析爲一個message

這裏僅列出了幾個解析及序列化方法,完整列表,可以參見:Message API reference

6. 使用PB生成類寫入

接下來使用這些生成的PB類,初始化一些聯繫人,並將其寫入一個文件中。

下面的程序首先從一個文件中讀取一個通訊簿(AddressBook),然後添加一個新的聯繫人,再將新的通訊簿寫回到文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
package com.example.tutorial;
 
import com.example.tutorial.AddressBookProtos.AddressBook;
import com.example.tutorial.AddressBookProtos.Person;
import java.io.BufferedReader;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.InputStreamReader;
import java.io.IOException;
import java.io.PrintStream;
 
class AddPerson {
    // This function fills in a Person message based on user input.
    static Person PromptForAddress(BufferedReader stdin, PrintStream stdout)
            throws IOException {
        Person.Builder person = Person.newBuilder();
 
        stdout.print("Enter person ID: ");
        person.setId(Integer.valueOf(stdin.readLine()));
 
        stdout.print("Enter name: ");
        person.setName(stdin.readLine());
 
        stdout.print("Enter email address (blank for none): ");
        String email = stdin.readLine();
        if (email.length() > 0) {
            person.setEmail(email);
        }
 
        while (true) {
            stdout.print("Enter a phone number (or leave blank to finish): ");
            String number = stdin.readLine();
            if (number.length() == 0) {
                break;
            }
 
            Person.PhoneNumber.Builder phoneNumber = Person.PhoneNumber
                    .newBuilder().setNumber(number);
 
            stdout.print("Is this a mobile, home, or work phone? ");
            String type = stdin.readLine();
            if (type.equals("mobile")) {
                phoneNumber.setType(Person.PhoneType.MOBILE);
            } else if (type.equals("home")) {
                phoneNumber.setType(Person.PhoneType.HOME);
            } else if (type.equals("work")) {
                phoneNumber.setType(Person.PhoneType.WORK);
            } else {
                stdout.println("Unknown phone type.  Using default.");
            }
 
            person.addPhone(phoneNumber);
        }
 
        return person.build();
    }
 
    // Main function: Reads the entire address book from a file,
    // adds one person based on user input, then writes it back out to the same
    // file.
    public static void main(String[] args) throws Exception {
        if (args.length != 1) {
            System.err.println("Usage:  AddPerson ADDRESS_BOOK_FILE");
            System.exit(-1);
        }
 
        AddressBook.Builder addressBook = AddressBook.newBuilder();
 
        // Read the existing address book.
        try {
            addressBook.mergeFrom(new FileInputStream(args[0]));
        } catch (FileNotFoundException e) {
            System.out.println(args[0]
                    + ": File not found.  Creating a new file.");
        }
 
        // Add an address.
        addressBook.addPerson(PromptForAddress(new BufferedReader(
                new InputStreamReader(System.in)), System.out));
 
        // Write the new address book back to disk.
        FileOutputStream output = new FileOutputStream(args[0]);
        addressBook.build().writeTo(output);
        output.close();
    }
}

 7. 使用PB生成類讀取

運行第六部分程序,寫入幾個聯繫人到文件中,接下來,我們就要讀取聯繫人。程序入下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
package com.example.tutorial;
import java.io.FileInputStream;
 
import com.example.tutorial.AddressBookProtos.AddressBook;
import com.example.tutorial.AddressBookProtos.Person;
 
class ListPeople {
  // Iterates though all people in the AddressBook and prints info about them.
  static void Print(AddressBook addressBook) {
    for (Person person: addressBook.getPersonList()) {
      System.out.println("Person ID: " + person.getId());
      System.out.println("  Name: " + person.getName());
      if (person.hasEmail()) {
        System.out.println("  E-mail address: " + person.getEmail());
      }
 
      for (Person.PhoneNumber phoneNumber : person.getPhoneList()) {
        switch (phoneNumber.getType()) {
          case MOBILE:
            System.out.print("  Mobile phone #: ");
            break;
          case HOME:
            System.out.print("  Home phone #: ");
            break;
          case WORK:
            System.out.print("  Work phone #: ");
            break;
        }
        System.out.println(phoneNumber.getNumber());
      }
    }
  }
 
  // Main function:  Reads the entire address book from a file and prints all
  //   the information inside.
  public static void main(String[] args) throws Exception {
    if (args.length != 1) {
      System.err.println("Usage:  ListPeople ADDRESS_BOOK_FILE");
      System.exit(-1);
    }
 
    // Read the existing address book.
    AddressBook addressBook =
      AddressBook.parseFrom(new FileInputStream(args[0]));
 
    Print(addressBook);
  }
}

至此我們已經可以使用生成類寫入和讀取PB message。

8. 拓展PB

當產品發佈後,遲早有一天我們需要改善我們的PB定義。如果要做到新的PB能夠向後兼容,同時老的PB又能夠向前兼容,我們必須遵守如下規則:

  1. 千萬不要修改現有字段後邊的數值標籤
  2. 千萬不要增加或者刪除required字段
  3. 可以刪除optional或者repeated字段
  4. 可以添加新的optional或者repeated字段,但是必須使用新的數字標籤(該數字標籤必須從未在該PB中使用過,包括已經刪除字段的數字標籤)

如果違反了這些規則,會有一些相應的異常,可參見some exceptions,但是這些異常,很少很少會被用到。

遵守這些規則,老的代碼可以正確的讀取新的message,但是會忽略新的字段;對於刪掉的optional的字段,老代碼會使用他們的默認值;對於刪除的repeated字段,則把他們置爲空。

新的代碼也將能夠透明的讀取老的messages。但是必須注意,新的optional字段在老的message中是不存在的,必須顯式的使用has_方法來判斷其是否設置了,或者在.proto 文件中以[default = value]形式提供默認值。如果沒有指定默認值的話,會按照類型默認值賦值。對於string類型,默認值是空字符串。對於bool來說,默認值是false。對於數字類型,默認值是0。

9. 高級用法

Protocol Buffers的應用遠遠不止簡單的存取以及序列化。如果想了解更多用法,可以去研究Java API reference

Protocol Message Class提供了一個重要特性:反射。不需要再寫任何特殊的message類型就可以遍歷一條message的所有字段以及操作字段的值。反射的一個非常重要的應用是可以將PBmessage與其他的編碼語言進行轉化,例如與XML或者JSON之間。

反射另外一個更加高級的應用應該是兩個同一類型message的之間的不同,或者開發一種可以成爲“Protocol Buffers 正則表達式”的應用,使用它,可以編寫符合一定消息內容的表達式。

除此之外,開動腦筋,你會發現,Protocol Buffers能解決遠遠超過你剛開始對他的期待。

譯自:https://developers.google.com/protocol-buffers/docs/javatutorial

本文出自:http://shitouer.cn/2013/04/google-protocol-buffers-tutorial/


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章