1. 前言
這篇入門教程是基於Java語言的,這篇文章我們將會:
- 創建一個.proto文件,在其內定義一些PB message
- 使用PB編譯器
- 使用PB Java API 讀寫數據
這篇文章僅是入門手冊,如果想深入學習及瞭解,可以參看: Protocol Buffer Language Guide, Java API Reference, Java Generated Code Guide, 以及Encoding Reference。
2. 爲什麼使用Protocol Buffers
接下來用“通訊簿”這樣一個非常簡單的應用來舉例。該應用能夠寫入並讀取“聯繫人”信息,每個聯繫人由name,ID,email address以及contact photo number組成。這些信息的最終存儲在文件中。
如何序列化並檢索這樣的結構化數據呢?有以下解決方案:
- 使用Java序列化(Java Serialization)。這是最直接的解決方式,因爲該方式是內置於Java語言的,但是,這種方式有許多問題(Effective Java 對此有詳細介紹),而且當有其他應用程序(比如C++ 程序及Python程序書寫的應用)與之共享數據的時候,這種方式就不能工作了。
- 將數據項編碼成一種特殊的字符串。例如將四個整數編碼成“12:3:-23:67”。這種方法簡單且靈活,但是卻需要編寫獨立的,只需要用一次的編碼和解碼代碼,並且解析過程需要一些運行成本。這種方式對於簡單的數據結構非常有效。
- 將數據序列化爲XML。這種方式非常誘人,因爲易於閱讀(某種程度上)並且有不同語言的多種解析庫。在需要與其他應用或者項目共享數據的時候,這是一種非常有效的方式。但是,XML是出了名的耗空間,在編碼解碼上會有很大的性能損耗。而且呢,操作XML DOM數非常的複雜,遠不如操作類中的字段簡單。
Protocol Buffers可以靈活,高效且自動化的解決該問題,只需要:
- 創建一個.proto 文件,描述希望數據存儲結構
- 使用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)來聲明:
- required:必須賦值,不能爲空,否則該條message會被認爲是“uninitialized”。build一個“uninitialized” message會拋出一個RuntimeException異常,解析一條“uninitialized” message會拋出一條IOException異常。除此之外,“required”字段跟“optional”字段並無差別。
- optional:字段可以賦值,也可以不賦值。假如沒有賦值的話,會被賦上默認值。對於簡單類型,默認值可以自己設定,例如上例的PhoneNumber中的PhoneType字段。如果沒有自行設定,會被賦上一個系統默認值,數字類型會被賦爲0,String類型會被賦爲空字符串,bool類型會被賦爲false。對於內置的message,默認值爲該message的默認實例或者原型,即其內所有字段均爲設置。當獲取沒有顯式設置值的optional字段的值時,就會返回該字段的默認值。
- 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。接下來我們要做:
- 如果你還沒有安裝PB編譯器,到這裏現在安裝:download the package
- 安裝後,運行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方法,如圖:
從上邊兩張圖可以看到:
- 每一個字段都有JavaBean風格的getter和setter
- 對於每一個簡單類型變量,還對應都有一個has這樣的一個方法,如果該字段被賦值了,則返回true,否則,返回false
- 對每一個變量,都有一個clear方法,用於置空字段
對於repeated字段:
從圖上看:
- 從person.builder圖上看出,對於repeated字段,還有一個特殊的getter,即getPhoneCount方法,及repeated字段還有一個特殊的count方法
- 其getter和setter方法根據index獲取或設置一個數據項
- add()方法用於附加一個數據項
- 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" ) .addPhone( PhoneNumber .newBuilder() .setNumber( "1861xxxxxxx" ) .setType(PhoneType.WORK) .build() ).build(); |
5.4 標準消息方法
每一個消息類及Builder類,基本都包含一些公用方法,用來檢查和維護這個message,包括:
- isInitialized(): 檢查是否所有的required字段是否被賦值
- toString(): 返回一個便於閱讀的message表示(本來是二進制的,不可讀),尤其在debug時候比較有用
- mergeFrom(Message other): 僅builder有此方法,將其message的內容與此message合併,覆蓋簡單及重複字段
- clear(): 僅builder有此方法,清空所有的字段
5.5 解析及序列化
對於每一個PB類,均提供了讀寫二進制數據的方法:
- byte[] toByteArray();: 序列化message並且返回一個原始字節類型的字節數組
- static Person parseFrom(byte[] data);: 將給定的字節數組解析爲message
- void writeTo(OutputStream output);: 將序列化後的message寫入到輸出流
- 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又能夠向前兼容,我們必須遵守如下規則:
- 千萬不要修改現有字段後邊的數值標籤
- 千萬不要增加或者刪除required字段
- 可以刪除optional或者repeated字段
- 可以添加新的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/