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 及相关类。这种方法在面对变化时非常脆弱,并且在某些情况下数据体积会非常大。此外,如果您需要与为其他平台编写的应用程序共享数据,这种方法的效果也不太好。
  • 您可以设计一种临时的编码方式,将数据项编码成单个字符串——例如,将 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。对于嵌入式消息,默认值始终是该消息的“默认实例”或“原型”,其所有字段都未设置。调用访问器获取未显式设置的字段的值时,总是返回该字段的默认值。

如果一个字段是 repeated,该字段可以重复任意次数(包括零次)。重复值的顺序将在协议缓冲区中保留。可以把重复字段看作是动态大小的数组。

您可以在 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 类

生成 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 类中,因此当您想要序列化时,通常需要一个 using 指令来引入 Google.Protobuf 命名空间。例如:

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 的定义。如果您希望新的 buffer 向后兼容,旧的 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);
    }
}