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,则生成的类将放置在与 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”标记标识该字段在二进制编码中使用的唯一“标签”。标签号 1-15 比更高的数字少使用一个字节进行编码,因此作为优化,您可以决定将这些标签用于常用或重复的元素,将标签 16 及更高用于不太常用的可选元素。重复字段中的每个元素都需要重新编码标签号,因此重复字段是此优化的特别好的候选者。

如果未设置字段值,则使用 默认值:数字类型为零,字符串为空字符串,布尔值为假。对于嵌入式消息,默认值始终是消息的“默认实例”或“原型”,其所有字段均未设置。调用访问器以获取尚未显式设置的字段的值始终返回该字段的默认值。

如果字段是 repeated,则可以重复该字段任意次数(包括零次)。重复值的顺序将保存在 Protocol Buffer 中。将重复字段视为动态大小的数组。

您将在 Protocol Buffer 语言指南 中找到编写 .proto 文件的完整指南,包括所有可能的字段类型。不过,不要去寻找类似于类继承的功能 - Protocol Buffers 不支持。

编译您的 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 为您提供了五种有用的类型

  • 一个包含 Protocol Buffer 消息元数据的静态 Addressbook 类。
  • 一个具有只读 People 属性的 AddressBook 类。

  • 一个具有 NameIdEmailPhones 属性的 Person 类。
  • 一个 PhoneNumber 类,嵌套在静态的 Person.Types 类中。
  • 一个 PhoneType 枚举,也嵌套在 Person.Types 中。

您可以在 C# 生成的代码指南 中了解更多关于生成内容的详细信息,但在大多数情况下,您可以将它们视为完全普通的 C# 类型。需要强调的一点是,任何与重复字段对应的属性都是只读的。您可以向集合中添加项目或从中删除项目,但不能将其替换为完全独立的集合。重复字段的集合类型始终为 RepeatedField<T>。这种类型类似于 List<T>,但有一些额外的便捷方法,例如接受项目集合的 Add 重载,用于集合初始化器。

这是一个关于如何创建 Person 实例的示例

Person john = new Person
{
    Id = 1234,
    Name = "John Doe",
    Email = "[email protected]",
    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。对于数字类型,默认值为零。

反射

可以使用反射 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);
    }
}