Protocol Buffer 基礎:C#

C# 程式設計人員使用 Protocol Buffers 的基本簡介。

本教學為 C# 程式設計人員提供使用 Protocol Buffers 的基本簡介,使用 Protocol Buffers 語言的 proto3 版本。透過逐步建立簡單的範例應用程式,向您展示如何

  • 在 .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 buffers 是彈性、高效、自動化的解決方案,可精確地解決此問題。使用 protocol buffers,您可以編寫要儲存的資料結構的 .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 檔案中的定義很簡單:為您要序列化的每個資料結構新增一個訊息,然後為訊息中的每個欄位指定名稱和類型。在我們的範例中,定義訊息的 .proto 檔案是 addressbook.proto。

.proto 檔案以套件宣告開頭,這有助於防止不同專案之間的命名衝突。

syntax = "proto3";
package tutorial;

import "google/protobuf/timestamp.proto";

在 C# 中,如果未指定 csharp_namespace,產生的類別將會放置在與套件名稱相符的命名空間中。在我們的範例中,已指定 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 內定義的。如果您希望您的欄位之一具有預先定義的值清單之一,您也可以定義列舉類型 – 在此您想要指定電話號碼可以是 PHONE_TYPE_MOBILE、PHONE_TYPE_HOME 或 PHONE_TYPE_WORK 之一。

每個元素上的「= 1」、「= 2」標記識別欄位在二進位編碼中使用的唯一「標籤」。標籤號碼 1-15 比更高的號碼少一個位元組來編碼,因此作為最佳化,您可以決定將這些標籤用於常用的或重複的元素,將標籤 16 和更高的號碼留給較不常用的選用元素。重複欄位中的每個元素都需要重新編碼標籤號碼,因此重複欄位是此最佳化的特別好的候選者。

如果未設定欄位值,則會使用預設值:數值類型為零,字串為空字串,布林值為 false。對於嵌入式訊息,預設值始終是訊息的「預設執行個體」或「原型」,其中沒有設定任何欄位。呼叫存取子以取得未明確設定的欄位的值始終會傳回該欄位的預設值。

如果欄位是 repeated,則欄位可能會重複任意次數 (包括零次)。重複值的順序將保留在 protocol buffer 中。將 repeated 欄位視為動態大小的陣列。

您可以在《Protocol Buffer 語言指南》中找到編寫 .proto 檔案的完整指南 – 包括所有可能的欄位類型。不過,請不要尋找類似於類別繼承的功能 – protocol buffers 不執行此操作。

編譯您的 Protocol Buffers

現在您有了 .proto,接下來您需要做的是產生您需要讀取和寫入 AddressBook (以及 Person 和 PhoneNumber) 訊息的類別。若要執行此操作,您需要在您的 .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 類別

產生 Addressbook.cs 會提供您五種有用的類型

  • 一個靜態 Addressbook 類別,其中包含關於 protocol buffer 訊息的 метаdata。
  • 一個 AddressBook 類別,具有唯讀 People 屬性。
  • 一個 Person 類別,具有 Name、Id、Email 和 Phones 的屬性。
  • 一個 PhoneNumber 類別,巢狀在靜態 Person.Types 類別中。
  • 一個 PhoneType 列舉,也巢狀在 Person.Types 中。

您可以在《C# 產生程式碼指南》中閱讀有關產生內容的詳細資訊,但在大多數情況下,您可以將這些視為完全普通的 C# 類型。要強調的一點是,對應於 repeated 欄位的任何屬性都是唯讀的。您可以將項目新增至集合或從集合中移除項目,但您無法將其替換為完全不同的集合。repeated 欄位的集合類型始終是 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 buffers 的全部目的是序列化您的資料,以便可以在其他地方剖析它。每個產生的類別都有一個 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 中

  • 您絕不能變更任何現有欄位的標籤號碼。
  • 您可以刪除欄位。
  • 您可以新增新的欄位,但您必須使用新的標籤號碼 (即從未在此 protocol buffer 中使用過的標籤號碼,即使是已刪除的欄位也是如此)。

(這些規則有一些例外情況,但很少使用。)

如果您遵循這些規則,舊程式碼將很高興地讀取新訊息,並簡單地忽略任何新欄位。對於舊程式碼,已刪除的單數欄位將僅具有其預設值,而已刪除的重複欄位將為空。新程式碼也將透明地讀取舊訊息。

但是,請記住,新欄位不會出現在舊訊息中,因此您需要對預設值執行一些合理的處理。使用類型特定的預設值:對於字串,預設值是空字串。對於布林值,預設值為 false。對於數值類型,預設值為零。

反射

訊息描述器 (.proto 檔案中的資訊) 和訊息執行個體可以使用反射 API 以程式設計方式檢查。這在編寫通用程式碼 (例如不同的文字格式或智慧型差異工具) 時可能很有用。每個產生的類別都有一個靜態 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);
    }
}