Protocol Buffer 基礎:C#

面向 C# 程式設計師的 Protocol Buffer 入門指南。

本教程為 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";

接下來,是您的訊息定義。訊息只是一個包含一組型別化欄位的聚合體。許多標準的簡單資料型別都可以作為欄位型別,包括 boolint32floatdoublestring。您還可以透過使用其他訊息型別作為欄位型別,為您的訊息新增更多結構。

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_MOBILEPHONE_TYPE_HOMEPHONE_TYPE_WORK 中的一種。

每個元素上的 " = 1"、" = 2" 標記標識了該欄位在二進位制編碼中使用的唯一“標籤”(tag)。標籤號 1-15 比較大的數字少用一個位元組來編碼,因此作為一種最佳化,您可以決定將這些標籤用於常用或重複的元素,而將標籤 16 及以上的數字留給不常用的可選元素。重複欄位中的每個元素都需要重新編碼標籤號,因此重複欄位特別適合進行這種最佳化。

如果某個欄位未設定值,則會使用預設值:數字型別為零,字串為空字串,布林值為 false。對於嵌入的訊息,預設值始終是該訊息的“預設例項”或“原型”,其所有欄位都未設定。呼叫訪問器獲取未顯式設定的欄位值時,總是返回該欄位的預設值。

如果一個欄位是 repeated,該欄位可以重複任意次數(包括零次)。重複值的順序將在協議緩衝區中保留。可以把重複欄位看作是動態大小的陣列。

您可以在Protocol Buffer 語言指南中找到編寫 .proto 檔案的完整指南——包括所有可能的欄位型別。但不要去尋找類似類繼承的功能——Protocol Buffer 不支援這個。

編譯你的 Protocol Buffers

現在您有了一個 .proto 檔案,接下來需要做的就是生成讀寫 AddressBook(以及 PersonPhoneNumber)訊息所需的類。為此,您需要在您的 .proto 檔案上執行 Protocol Buffer 編譯器 protoc

  1. 如果你還沒有安裝編譯器,請下載軟體包並按照 README 中的說明進行操作。

  2. 現在執行編譯器,指定源目錄(您應用程式原始碼所在的位置——如果不提供值,則使用當前目錄)、目標目錄(您希望生成程式碼存放的位置;通常與 $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 類,帶有 NameIdEmailPhones 屬性。
  • 一個 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);
    }
}