Protocol Buffer 基礎:C#
本教程為 C# 程式設計師提供了使用 Protocol Buffers 的基本介紹,使用 proto3 版本的 Protocol Buffers 語言。透過建立一個簡單的示例應用程式,它將向您展示如何:
- 在
.proto檔案中定義訊息格式。 - 使用 Protocol Buffer 編譯器。
- 使用 C# Protocol Buffer API 編寫和讀取訊息。
這不是 C# 中使用 Protocol Buffers 的全面指南。有關更詳細的參考資訊,請參閱 Protocol Buffer 語言指南、C# API 參考、C# 生成程式碼指南 和 編碼參考。
問題領域
我們將要使用的示例是一個非常簡單的“地址簿”應用程式,它可以從檔案中讀取和寫入人們的聯絡方式。地址簿中的每個人都有姓名、ID、電子郵件地址和聯絡電話號碼。
你如何序列化和檢索這樣的結構化資料?有幾種方法可以解決這個問題:
- 使用 .NET 二進位制序列化,以及
System.Runtime.Serialization.Formatters.Binary.BinaryFormatter和相關類。這在面對更改時非常脆弱,在某些情況下資料大小開銷很大。如果需要與其他平臺編寫的應用程式共享資料,它的效果也不是很好。 - 您可以發明一種特殊的方式將資料項編碼為單個字串——例如將 4 個整數編碼為“12:3:-23:67”。這是一種簡單而靈活的方法,但它需要編寫一次性的編碼和解析程式碼,並且解析會帶來一些執行時成本。這種方法最適合編碼非常簡單的資料。
- 將資料序列化為 XML。這種方法可能非常有吸引力,因為 XML(某種程度上)是人類可讀的,而且有許多語言的繫結庫。如果您想與其他應用/專案共享資料,這可能是一個不錯的選擇。然而,XML 是出了名的佔用空間,並且編碼/解碼它可能會給應用帶來巨大的效能損失。此外,遍歷 XML DOM 樹比通常情況下遍歷類中的簡單欄位要複雜得多。
Protocol Buffer 正是為解決這一問題而設計的靈活、高效、自動化的解決方案。使用 Protocol Buffer,您需要編寫一個 .proto 檔案來描述您希望儲存的資料結構。Protocol Buffer 編譯器會根據該檔案建立一個類,該類使用高效的二進位制格式實現了對 Protocol Buffer 資料的自動編碼和解析。生成的類為構成 Protocol Buffer 的欄位提供了 getter 和 setter,並負責處理將 Protocol Buffer 作為一個單元進行讀寫的細節。重要的是,Protocol Buffer 格式支援隨著時間的推移擴充套件格式,使得程式碼仍然可以讀取用舊格式編碼的資料。
在哪裡找到示例程式碼
我們的示例是一個命令列應用程式,用於管理使用 Protocol Buffers 編碼的地址簿資料檔案。命令 AddressBook(參見:Program.cs)可以將新條目新增到資料檔案,或者解析資料檔案並將資料列印到控制檯。
您可以在 GitHub 倉庫的 examples 目錄 和 csharp/src/AddressBook 目錄 中找到完整的示例。
定義你的協議格式
要建立您的地址簿應用,您需要從一個 .proto 檔案開始。.proto 檔案中的定義很簡單:為您想要序列化的每個資料結構新增一個訊息(message),然後為訊息中的每個欄位指定名稱和型別。在我們的示例中,定義訊息的 .proto 檔案是 addressbook.proto。
.proto 檔案以包宣告開頭,這有助於防止不同專案之間的命名衝突。
syntax = "proto3";
package tutorial;
import "google/protobuf/timestamp.proto";
在 C# 中,如果未指定 csharp_namespace,您生成的類將放置在與 package 名稱匹配的名稱空間中。在我們的示例中,已指定 csharp_namespace 選項來覆蓋預設值,因此生成的程式碼使用 Google.Protobuf.Examples.AddressBook 名稱空間,而不是 Tutorial。
option csharp_namespace = "Google.Protobuf.Examples.AddressBook";
接下來,是您的訊息定義。訊息只是一個包含一組型別化欄位的聚合體。許多標準的簡單資料型別都可以作為欄位型別,包括 bool、int32、float、double 和 string。您還可以透過使用其他訊息型別作為欄位型別,為您的訊息新增更多結構。
message Person {
string name = 1;
int32 id = 2; // Unique ID number for this person.
string email = 3;
enum PhoneType {
PHONE_TYPE_UNSPECIFIED = 0;
PHONE_TYPE_MOBILE = 1;
PHONE_TYPE_HOME = 2;
PHONE_TYPE_WORK = 3;
}
message PhoneNumber {
string number = 1;
PhoneType type = 2;
}
repeated PhoneNumber phones = 4;
google.protobuf.Timestamp last_updated = 5;
}
// Our address book file is just one of these.
message AddressBook {
repeated Person people = 1;
}
在上面的示例中,Person 訊息包含 PhoneNumber 訊息,而 AddressBook 訊息包含 Person 訊息。您甚至可以在其他訊息內部定義訊息型別——如您所見,PhoneNumber 型別是在 Person 內部定義的。如果您希望某個欄位的值是預定義列表中的一個,您還可以定義 enum 型別——在這裡,您想指定一個電話號碼可以是 PHONE_TYPE_MOBILE、PHONE_TYPE_HOME 或 PHONE_TYPE_WORK 中的一種。
每個元素上的 " = 1"、" = 2" 標記標識了該欄位在二進位制編碼中使用的唯一“標籤”(tag)。標籤號 1-15 比較大的數字少用一個位元組來編碼,因此作為一種最佳化,您可以決定將這些標籤用於常用或重複的元素,而將標籤 16 及以上的數字留給不常用的可選元素。重複欄位中的每個元素都需要重新編碼標籤號,因此重複欄位特別適合進行這種最佳化。
如果某個欄位未設定值,則會使用預設值:數字型別為零,字串為空字串,布林值為 false。對於嵌入的訊息,預設值始終是該訊息的“預設例項”或“原型”,其所有欄位都未設定。呼叫訪問器獲取未顯式設定的欄位值時,總是返回該欄位的預設值。
如果一個欄位是 repeated,該欄位可以重複任意次數(包括零次)。重複值的順序將在協議緩衝區中保留。可以把重複欄位看作是動態大小的陣列。
您可以在Protocol Buffer 語言指南中找到編寫 .proto 檔案的完整指南——包括所有可能的欄位型別。但不要去尋找類似類繼承的功能——Protocol Buffer 不支援這個。
編譯你的 Protocol Buffers
現在您有了一個 .proto 檔案,接下來需要做的就是生成讀寫 AddressBook(以及 Person 和 PhoneNumber)訊息所需的類。為此,您需要在您的 .proto 檔案上執行 Protocol Buffer 編譯器 protoc:
如果你還沒有安裝編譯器,請下載軟體包並按照 README 中的說明進行操作。
現在執行編譯器,指定源目錄(您應用程式原始碼所在的位置——如果不提供值,則使用當前目錄)、目標目錄(您希望生成程式碼存放的位置;通常與
$SRC_DIR相同),以及您的.proto檔案的路徑。在這種情況下,您將呼叫:protoc -I=$SRC_DIR --csharp_out=$DST_DIR $SRC_DIR/addressbook.proto因為您需要 C# 程式碼,所以您使用
--csharp_out選項——其他受支援的語言也提供了類似的選項。
這將在您指定的輸出目錄中生成 Addressbook.cs。要編譯此程式碼,您需要一個引用 Google.Protobuf 程式集的專案。
地址簿類
生成 Addressbook.cs 會為您提供五個有用的型別
- 一個靜態
Addressbook類,包含有關 Protocol Buffer 訊息的元資料。 - 一個
AddressBook類,帶有一個只讀的People屬性。 - 一個
Person類,帶有Name、Id、Email和Phones屬性。 - 一個
PhoneNumber類,巢狀在靜態Person.Types類中。 - 一個
PhoneType列舉,也巢狀在Person.Types中。
您可以在 C# 生成程式碼指南 中閱讀有關具體生成內容的更多詳細資訊,但大多數情況下,您可以將這些視為完全普通的 C# 型別。需要強調的一點是,任何對應於重複欄位的屬性都是隻讀的。您可以向集合中新增或刪除專案,但不能將其替換為完全獨立的集合。重複欄位的集合型別始終是 RepeatedField<T>。此型別類似於 List<T>,但有一些額外的便捷方法,例如接受專案集合的 Add 過載,用於集合初始化器。
以下是您如何建立 Person 例項的示例
Person john = new Person
{
Id = 1234,
Name = "John Doe",
Email = "jdoe@example.com",
Phones = { new Person.Types.PhoneNumber { Number = "555-4321", Type = Person.Types.PhoneType.Home } }
};
請注意,使用 C# 6,您可以使用 using static 來消除 Person.Types 的不雅之處
// Add this to the other using directives
using static Google.Protobuf.Examples.AddressBook.Person.Types;
...
// The earlier Phones assignment can now be simplified to:
Phones = { new PhoneNumber { Number = "555-4321", Type = PhoneType.HOME } }
解析和序列化
使用 Protocol Buffer 的全部目的是序列化您的資料,以便可以在其他地方進行解析。每個生成的類都有一個 WriteTo(CodedOutputStream) 方法,其中 CodedOutputStream 是 Protocol Buffer 執行時庫中的一個類。但是,通常您會使用擴充套件方法之一寫入常規 System.IO.Stream 或將訊息轉換為位元組陣列或 ByteString。這些擴充套件訊息位於 Google.Protobuf.MessageExtensions 類中,因此當您想要序列化時,通常會需要 Google.Protobuf 名稱空間的 using 指令。例如:
using Google.Protobuf;
...
Person john = ...; // Code as before
using (var output = File.Create("john.dat"))
{
john.WriteTo(output);
}
解析也很簡單。每個生成的類都有一個靜態 Parser 屬性,它為該型別返回一個 MessageParser<T>。它又具有解析流、位元組陣列和 ByteString 的方法。因此,要解析我們剛剛建立的檔案,我們可以使用:
Person john;
using (var input = File.OpenRead("john.dat"))
{
john = Person.Parser.ParseFrom(input);
}
一個使用這些訊息來維護地址簿(新增新條目和列出現有條目)的完整示例程式可在 Github 倉庫中找到。
擴充套件 Protocol Buffer
在您釋出使用 Protocol Buffer 的程式碼後,遲早會想要“改進”Protocol Buffer 的定義。如果您希望新的緩衝區向後相容,並且舊的緩衝區向前相容——您幾乎肯定希望如此——那麼您需要遵守一些規則。在新版本的 Protocol Buffer 中:
- 您*絕不能*更改任何現有欄位的標籤號。
- 您*可以*刪除欄位。
- 您*可以*新增新欄位,但必須使用新的標籤號(即,在此協議緩衝區中從未使用過的標籤號,即使是被刪除的欄位用過的也不行)。
(這些規則有一些例外,但很少使用。)
如果您遵守這些規則,舊程式碼將能夠愉快地讀取新訊息,並簡單地忽略任何新欄位。對於舊程式碼來說,被刪除的單數(singular)欄位將只有其預設值,而被刪除的重複(repeated)欄位將為空。新程式碼也將透明地讀取舊訊息。
但是,請記住,新欄位在舊訊息中是不存在的,因此您需要對預設值進行合理的處理。系統會使用特定型別的預設值:對於字串,預設值是空字串。對於布林值,預設值是 false。對於數字型別,預設值是零。
反射
可以使用反射 API 以程式設計方式檢查訊息描述符(.proto 檔案中的資訊)和訊息例項。這在編寫通用程式碼(例如不同的文字格式或智慧差異工具)時非常有用。每個生成的類都有一個靜態 Descriptor 屬性,並且可以使用 IMessage.Descriptor 屬性檢索任何例項的描述符。作為這些如何使用的快速示例,這是一個列印任何訊息的頂級欄位的簡短方法。
public void PrintMessage(IMessage message)
{
var descriptor = message.Descriptor;
foreach (var field in descriptor.Fields.InDeclarationOrder())
{
Console.WriteLine(
"Field {0} ({1}): {2}",
field.FieldNumber,
field.Name,
field.Accessor.GetValue(message);
}
}