Protocol Buffer 基础:C#

为 C# 程序员提供的 Protocol Buffers 入门基础介绍。

本教程为 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 及相关类。面对变化时,这最终会非常脆弱,在某些情况下数据大小开销很大。如果您需要与其他平台编写的应用程序共享数据,它也无法很好地工作。
  • 您可以发明一种临时的(ad-hoc)方式将数据项编码成单个字符串——例如将 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 文件中的定义很简单:您为每个要序列化的数据结构添加一个 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”标记标识了该字段在二进制编码中使用的唯一“标签”。标签号 1-15 比更高的数字编码所需的字节少一个,因此作为优化,您可以决定将这些标签用于常用或重复的元素,将标签 16 及以上留给不太常用的可选元素。重复字段中的每个元素都需要重新编码标签号,因此重复字段特别适合此优化。

如果字段值未设置,则使用默认值:数字类型为零,字符串为空字符串,布尔类型为 false。对于嵌入式消息,默认值始终是消息的“默认实例”或“原型”,该实例或原型没有任何字段被设置。调用 accessor 获取未明确设置的字段值始终返回该字段的默认值。

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

您可以在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 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);
    }
}