Protobuf -java基礎教程(譯文)
最近突然對RPC序列化感興趣,但是發現Protobuf的資料並不多,於是在官網找到了Java使用Protocol Buffer的入門指南,用蹩腳的英文翻譯了下,以饗同道。原文地址
示例開始:定義協議格式 Protocol Format
示例:一個簡單的通訊簿,.proto 檔案見addressbook.proto。
syntax = "proto2"; 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 phones = 4; } message AddressBook { repeated Person people = 1; } 複製程式碼
.proto檔案開始宣告包,以免出現命名衝突。 在 JAVA 中,該包名可以作為java中的package,除非您又專門指定了java_package ,在addressbook.proto 中我們就指定了package。
即使您指定了一個java_package ,也應該需要定義一個常規的package ,是為了在Protocol Buffers名稱空間中產生衝突。
在包定義宣告之後,還看到了兩個java規範中的可選項:java_package 、java_outer_classname 。java_package 指定了生成的java類需要放在哪個包下面,如果沒有指定這個值,則會使用package 指定的值。java_outer_classname 選項定義了類名,包含這個.proto檔案裡面的所有類,如果沒有明確指定這個值的話,將會把檔名通過駝峰大小寫命名方式作為類名, 例如 my_proto.proto 則預設會生成 MyProto 類名。
接下來,就有了訊息(message)定義。
一個訊息(message)是包含一系列型別欄位的聚合。
很多標準的簡單資料型別供欄位可用,包含:
- bool
- int32
- float
- double
- string
您還可以新增其他的結構型別為您的訊息欄位型別所用。前面的示例中,Person 訊息包含了PhoneNumber 訊息,而AddressBook 訊息包含Person 訊息。 您還可以定義列舉型別enum ,如果你想你的欄位可能的值為一個事先預定好的列表中的值,就可以使用enum 型別。 這個電話簿示例中,phone number有三種類型:MOBILE 、HOME 、WORK 。
=1、=2 表示每個元素上的標識欄位在二進位制編碼中使用的唯一“標記”。 Tag編號 1-15 編號所需的位元組比編號高的要少,所以您可以對常用的或經常重複使用的標記使用這些編號,剩下的16到更高的標記在可選元素中比較少用。 重複欄位中的每個元素都需要重新編碼標記號tag,所以重複的欄位是這種優化的最佳選擇。
每個欄位必須標註為以下幾種修飾符:
-
required:該欄位必須提供,否則認為訊息是"未初始化"的。嘗試去構建一個未初始化的訊息會丟擲一個RuntimeException 。解析一個未初始化的訊息則會丟擲一個IOException 。除此之外,required欄位的行為和optional欄位完全一樣。
-
optional:表示可選的欄位,該欄位可以設值亦可以不設值。如果一個可選欄位沒有set值,則會使用其預設值進行初始化。對於簡單型別,您可以明確指定自己預設的值,就像我們在示例中處理的一樣(phoneNumber的type欄位)。否則,系統預設值:數值型別預設為0,字元型別預設為空串,boo型別預設為false。對於嵌入的訊息,預設值總是"預設例項"或者訊息的"原型",沒有設定任何欄位。
-
repeated:該欄位可以重複任意次數使用。重複值的順序將會保留在protocol buffer中。可以將重複欄位看作動態陣列。
Required Is Forever 您需要非常謹慎地將欄位標記為required修飾。如果在某些時候,您希望停止寫或傳送一個required欄位,在將該欄位變更為optional時可能會發生問題——舊的reader認為訊息沒有這個值則會拒絕或者丟棄這個訊息。您應該為考慮為緩衝區編寫特定於應用程式地校驗例程。有一些Google工程師推測使用required 弊大於利。他們更傾向於使用opyional 和repeated 。不管怎樣,這種觀點並不普遍。
您還可以在Protocol Buffer 語言指南 中瞭解到完整地教程。 不要嘗試去尋找類似繼承的工具,protocol buffer不支援這樣做。
編譯你的Protocol Buffers
現在您有了一個.proto 檔案了,下一步要做的事,就是生成一個您將要讀、寫的AddressBook類。因此,您需要執行potocol buffer編譯器protoc 處理.proto :
- 如果您沒有安裝編譯器,下載安裝包,根據README安裝。
協議編譯安裝
Protocol 編譯器是C++編寫的。如果您使用C++,請根據C++安裝指導 安裝protoc。 對於非C++使用者,最簡單的安裝protocol編譯器的方式是從release頁下載預構建的二進位制:github.com/protocolbuf…
下載好的protoc-$VERSION-$PLATFORM.zip
。包含了二進位制protoc,還有一系列與protobuf釋出的標準的.proto檔案。
如果您還想找舊版本,可以在https://repo1.maven.org/maven2/com/google/protobuf/protoc/找到。
這些預構建的二進位制檔案只會在發行版本中提供。
Protobuf 執行時安裝
Protobuf 支援幾種不同的程式語言。針對於每一種語言,你可以參考原始碼中的各種語言的說明指導。
- 現在執行編譯器,需要指明源目錄 (應用原始碼所在-如果您沒有提供一個值的話預設使用當前目錄)、目標目錄 (您想要程式碼生成的目的目錄,通常類似於$SRC_DIR )、還有.proto 的路徑。在這種情況下,您可以如下操作:
protoc -I=$SRC_DIR --java_out=$DST_DIR $SRC_DIR/addressbook.proto
因為您想生成Java類,您看到--java_out 選項,類似的選項也提供了其他程式語言。
這將會在您指定的目標目錄生成com/example/tutorial/AddressBookProtos.java 。
Protocol Buffer API
讓我們來看看一些生成的程式碼,並檢視一些由編譯器為您建立的類與方法。如果您看一下AddressBookProtos.java 類,可以看到定義了一個叫做AddressBookProtos 的類,內嵌了您在addressbooproto 檔案中為每個訊息指定的類。每個類都擁有自身的Builder 類,您可以使用它來建立一個對應的類例項。您可以在下面的 Builders vs. Messages可以看到更多細節。
messages 和 builders 擁有針對訊息每個欄位的自動生成的訪問方法。message僅僅擁有getters方法、builders擁有getters、setters方法。這裡有一些關於Person 類的訪問方式(為了簡潔,忽略了實現):
// 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); 複製程式碼
同時,Person.Builder 內部類則擁有getters、setters:
// 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(); 複製程式碼
正如您所看到的一樣,每個欄位都有簡單的 Java Bean 風格的getter、setter 方法。 每個欄位也有其has 方法,如果設定了欄位值,則會返回true。 最後,每個欄位還有clear 方法,可以將該欄位迴歸到其空白狀態。
repeated欄位由一些額外的方法:
- getXXXCount 方法用於獲取淚飆大小;
- 增加了根據元素索引下標獲取元素的get、set方法(public PhoneNumber getPhones(int index); 和 public Builder setPhones(int index, PhoneNumber value);)。
- *add、addAll 方法追加新元素(列表)到列表中。
注意到所有這些訪問方法都使用了駝峰命名方式,即使.proto 檔案使用了小寫和下劃線。這個轉換是由protocol編譯器自動完成的,所以生成的類符合Java風格標準規範。在.proto 中您應該總是將欄位名用小寫和下劃線來命名。 可以參考風格指南獲取更多良好的.proto 命名風格。 更多關於編譯器為欄位生成的特定的詳細資訊可以參考Java生成程式碼參考指南
列舉和內部類
生成的程式碼包含了一個列舉PhoneType ,巢狀在Person 類中:
public static enum PhoneType { MOBILE(0, 0), HOME(1, 1), WORK(2, 2), ; ... } 複製程式碼
內部類Person.PhoneNumber 也生成了,正如您期望的一樣,它是作為Person 的一個內部類的。
Builders vs. Messages
protocol buffer編譯器生成的類都是不可變的。一旦一個message物件被建立後,就不能再被更改,類似於Java的String類。要構建一個message,您必須構建一個builder,並設定任何您想設定的欄位的值,再呼叫builders's 的builder 方法。(使用過lombok的朋友,這很類似其@Builder註解的用法)
您可能還注意到每個builder的方法返回的是另一個builder。返回的物件其實和您呼叫方法的builder是同一個。這種處理方式很方便,您可以將多個setter在一行程式碼中串寫。
這裡有一個示例,建立一個Person 的例項:
Person john = Person.newBuilder() .setId(1234) .setName("John Doe") .setEmail("[email protected]") .addPhones( Person.PhoneNumber.newBuilder() .setNumber("555-4321") .setType(Person.PhoneType.HOME) .build()) .build(); 複製程式碼
標準的message方法
每個message和builder類也包含一系列其他的方法,可以讓您檢查與操作message:
- isInitialized() : 檢查是否所有的required 欄位都已經set過值了/
- toString() : 返回一個可讀性良好的message表示,通常對除錯特別有用。
- mergeFrom(Message other) : (只在builder有)將other合併到該message中
- clear() : (只在builder有)清除所有的欄位,時其恢復到最初的空狀態
解析和序列化
最後,每個 proto buffer 類都有一些用於讀和寫二進位制的方法。
- byte[] toByteArray() : 序列化message,返回一個byte陣列。
- static Person parseFrom(byte[] data) : 將給定的byte陣列解析成一個message。
- void writeTo(OutputStream output) : 序列化message,並將其寫入一個輸出流。
- static Person parseFrom(InputStream input) : 讀取一個輸入流並從其解析處一個message。
這些只是為序列化和解析(反序列化)而提供的兩組操作方法。更多完整列表可以參考Message API幫助文件。
Protocol Buffers 和 面向物件Protocol buffer 類基本上是啞資料持有者(類似C中的struct);在物件模型中,它們不是一等公民。如果您想向生成類中新增更豐富的行為,最好的方式就是在一個特定於應用程式中的類中包裝protocol buffer生成的類。如果您不能控制.proto檔案的設計(例如,如果您正在重用來自另一個專案的一個檔案),那麼包裝協議緩衝區也是一個好主意。在這種情況下,您可以使用包裝器類來建立更適合您的應用程式的獨特環境的介面:隱藏一些資料和方法,公開方便的函式,等等。這將破壞內部機制,而且無論如何都不是良好的面向物件實踐。
寫一個Message
現在嘗試使用protocol buffer 類吧。第一件事,希望通訊簿可以把個人的資訊詳情寫道通訊簿檔案中。為了做到這一點,您需要建立和填入protocol buffer的類例項,並將它們寫入一個輸出流中。
這裡有一個程式,從一個檔案中讀取了一個AddressBook ,根據使用者的輸入還添加了一個新的Person 到通訊簿中,並將新的AddressBook 重新寫入檔案中。
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.addPhones(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.addPeople( 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(); } } 複製程式碼
讀一個Message
當然,如果您不能從通訊簿中就獲取任何資訊,那也就沒什麼用了。這個示例展示了讀取前面一個示例建立的檔案並輸出全部資訊:
import com.example.tutorial.AddressBookProtos.AddressBook; import com.example.tutorial.AddressBookProtos.Person; import java.io.FileInputStream; import java.io.IOException; import java.io.PrintStream; class ListPeople { // Iterates though all people in the AddressBook and prints info about them. static void Print(AddressBook addressBook) { for (Person person: addressBook.getPeopleList()) { 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.getPhonesList()) { 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); } } 複製程式碼
擴充套件 Protocol Buffer
當您使用protocol buffer釋出程式碼後,毋庸置疑,您會希望改善protocol buffer的定義。如果您想您的新buffer可以向後相容,並且舊的buffer可以向前相容——您肯定想要這個——這需要遵循一些規則。在新版本的protocol buffer中需要遵循:
- 您不能改變已經存在的欄位的tag的編號
- 您不能新增或者刪除任何required欄位
- 您可以刪除optional或者repeated欄位
- 您可以新增新的optional或者repeated欄位,但那時您必須使用新的tag編號(即該tag編號沒有在這個protocol buffer檔案中使用,或者已經被刪除了)
如果您遵循這些規則,舊程式碼可以友好地讀取新的message,並且忽略新的欄位。對於舊程式碼,那些刪除地optional欄位會使用它們預設的值,而repeated欄位則為空。 新程式碼顯然會讀取舊的message。不論如何,切記新的optional欄位在舊的message中是不會出現的,所以您需要檢查它們是否設定了值,可以使用has_ ,或者在您的.proto 檔案中在該欄位的tab編號後面使用[default = value] 為其提供一個default值。 如果optional欄位沒有指定default值,則會根據該型別自動為其賦值:string型別則賦值空串,對於布林型別則賦值為false,對於數值型別則賦值為0. 請注意,假如您添加了一個repeated欄位,您的新程式碼將無從知曉該欄位是空的(新程式碼),還是從來沒有設定過值(舊程式碼),因為它沒有has_ 方法。
高階用法
Protocol buffers 不僅僅只是提供了簡單的訪問和序列化功能。可以訪問 Java API幫助文件 .
protocol message類體哦概念股了一個關鍵的特性——反射。您可以遍歷message的所有欄位和操作它們的值而不需要編寫指定的message型別的程式碼。 反射的一個有用的使用方式是在各種編碼之間轉換協議訊息,例如XML或者JSON。 一個關於反射的更高階的用法是從兩個相同型別的訊息message中找出不同之處,或者開發出一種協議訊息的“正則表示式”,是的您可以編寫這種表示式去匹配某些message內容。 如果您充分發揮您的想象,使用protocol Buffers可以應用在更廣泛的問題上,正如您所期望的那樣。