轉自:http://shitouer.cn/2013/04/google-protocol-buffers-tutorial/
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 | |
可以看到,文法非常類似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 | |
- -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 | |
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
從上邊兩張圖可以看到:
- 每一個字段都有JavaBean風格的getter和setter
- 對于每一個簡單類型變量,還對應都有一個has這樣的一個方法,如果該字段被指派了,則傳回true,否則,傳回false
- 對每一個變量,都有一個clear方法,用于置空字段
對于repeated字段:
repeated filed
從圖上看:
- 從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 | |
除此之外,如我們所預料,還有一個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 | |
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 | |
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 | |
至此我們已經可以使用生成類寫入和讀取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能解決遠遠超過你剛開始對他的期待。
推薦閱讀順序,希望給你帶來收獲~