协议缓冲区基础知识:C#

C# 程序员使用协议缓冲区的基础介绍。

本教程为 C# 程序员提供了使用协议缓冲区的基础介绍,使用协议缓冲区语言的 proto3 版本。 通过创建一个简单的示例应用程序,它向您展示如何:

  • 在 .proto 文件中定义消息格式。
  • 使用协议缓冲区编译器。
  • 使用 C# 协议缓冲区 API 写入和读取消息。

这不是在 C# 中使用协议缓冲区的综合指南。 有关更详细的参考信息,请参阅《协议缓冲区语言指南》《C# API 参考》《C# 生成代码指南》《编码参考》

问题领域

我们要使用的示例是一个非常简单的“地址簿”应用程序,它可以将人员的联系方式读写到文件中。 地址簿中的每个人都有姓名、ID、电子邮件地址和联系电话号码。

您如何序列化和检索这样的结构化数据? 有几种方法可以解决这个问题:

  • 使用带有 System.Runtime.Serialization.Formatters.Binary.BinaryFormatter 和相关类的 .NET 二进制序列化。 面对更改时,这最终会变得非常脆弱,在某些情况下,数据大小方面会很昂贵。 如果您需要与其他平台编写的应用程序共享数据,它也不能很好地工作。
  • 您可以发明一种临时方法将数据项编码为单个字符串 – 例如将 4 个整数编码为“12:3:-23:67”。 这是一种简单而灵活的方法,尽管它确实需要编写一次性的编码和解析代码,并且解析会带来少量的运行时成本。 这最适合编码非常简单的数据。
  • 将数据序列化为 XML。 这种方法可能非常吸引人,因为 XML 是(某种程度上)人类可读的,并且有许多语言的绑定库。 如果您想与其他应用程序/项目共享数据,这可能是一个不错的选择。 但是,众所周知,XML 非常占用空间,并且对其进行编码/解码可能会给应用程序带来巨大的性能损失。 此外,导航 XML DOM 树比通常导航类中的简单字段要复杂得多。

协议缓冲区是解决这个问题的灵活、高效、自动化的解决方案。 使用协议缓冲区,您可以编写要存储的数据结构的 .proto 描述。 由此,协议缓冲区编译器创建一个类,该类使用高效的二进制格式实现协议缓冲区数据的自动编码和解析。 生成的类为构成协议缓冲区的字段提供 getter 和 setter,并负责读取和写入作为单元的协议缓冲区的详细信息。 重要的是,协议缓冲区格式支持随着时间的推移扩展格式的想法,以便代码仍然可以读取使用旧格式编码的数据。

在哪里找到示例代码

我们的示例是一个命令行应用程序,用于管理使用协议缓冲区编码的地址簿数据文件。 命令 AddressBook(参见:Program.cs)可以将新条目添加到数据文件,或解析数据文件并将数据打印到控制台。

您可以在 examples 目录csharp/src/AddressBook 目录中找到完整的示例 GitHub 存储库。

定义您的协议格式

要创建您的地址簿应用程序,您需要从 .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 及更高的标签留给不太常用的可选元素。 重复字段中的每个元素都需要重新编码标签号,因此重复字段特别适合这种优化。

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

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

您可以在《协议缓冲区语言指南》中找到编写 .proto 文件的完整指南 – 包括所有可能的字段类型。 不过,不要寻找类似于类继承的工具 – 协议缓冲区不这样做。

编译您的协议缓冲区

现在您有了 .proto 文件,接下来您需要做的是生成读取和写入 AddressBook(以及 PersonPhoneNumber)消息所需的类。 为此,您需要在您的 .proto 上运行协议缓冲区编译器 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 类。
  • 具有只读 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 = "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 } }

解析和序列化

使用协议缓冲区的全部目的是序列化您的数据,以便可以在其他地方对其进行解析。 每个生成的类都有一个 WriteTo(CodedOutputStream) 方法,其中 CodedOutputStream 是协议缓冲区运行时库中的一个类。 但是,通常您会使用扩展方法之一写入常规 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 存储库中找到。

扩展协议缓冲区

在您发布使用协议缓冲区的代码后,迟早您无疑会想要“改进”协议缓冲区的定义。 如果您希望您的新缓冲区向后兼容,并且您的旧缓冲区向前兼容 – 并且您几乎肯定希望如此 – 那么您需要遵循一些规则。 在新版本的协议缓冲区中:

  • 绝不能更改任何现有字段的标签号。
  • 您可以删除字段。
  • 您可以添加新字段,但您必须使用新的标签号(即从未在此协议缓冲区中使用过的标签号,即使是被删除的字段也不行)。

这些规则有一些例外,但很少使用。)

如果您遵循这些规则,旧代码将愉快地读取新消息,并简单地忽略任何新字段。 对于旧代码,已删除的单数字段将仅具有其默认值,而删除的重复字段将为空。 新代码也将透明地读取旧消息。

但是,请记住,新字段不会出现在旧消息中,因此您需要对默认值执行一些合理的操作。 使用特定于类型的默认值:对于字符串,默认值为空字符串。 对于布尔值,默认值为 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);
    }
}