Protocol Buffer 基础:C#
本教程为 C# 程序员提供了使用 Protocol Buffer 的基础入门,教程使用的是 proto3 版本的 Protocol Buffer 语言。通过逐步创建一个简单的示例应用程序,本教程将向您展示如何:
- 在
.proto文件中定义消息格式。 - 使用 Protocol Buffer 编译器。
- 使用 C# Protocol Buffer API 来写入和读取消息。
这不是一份在 C# 中使用 Protocol Buffer 的综合指南。如需更详细的参考信息,请参阅Protocol Buffer 语言指南、C# API 参考、C# 生成代码指南以及编码参考。
问题领域
我们将要使用的示例是一个非常简单的“地址簿”应用程序,它可以从文件中读取和写入人们的联系方式。地址簿中的每个人都有姓名、ID、电子邮件地址和联系电话号码。
你如何序列化和检索这样的结构化数据?有几种方法可以解决这个问题:
- 使用 .NET 二进制序列化,配合
System.Runtime.Serialization.Formatters.Binary.BinaryFormatter及相关类。这种方式在面对变更时非常脆弱,并且在某些情况下数据体积会非常大。此外,当您需要与其他平台编写的应用程序共享数据时,它的效果也不理想。 - 您可以发明一种特殊的方式将数据项编码为单个字符串——例如将 4 个整数编码为“12:3:-23:67”。这是一种简单而灵活的方法,但它需要编写一次性的编码和解析代码,并且解析会带来一些运行时成本。这种方法最适合编码非常简单的数据。
- 将数据序列化为 XML。这种方法可能非常有吸引力,因为 XML(某种程度上)是人类可读的,而且有许多语言的绑定库。如果您想与其他应用/项目共享数据,这可能是一个不错的选择。然而,XML 是出了名的占用空间,并且编码/解码它可能会给应用带来巨大的性能损失。此外,遍历 XML DOM 树比通常情况下遍历类中的简单字段要复杂得多。
Protocol Buffer 正是为解决这一问题而设计的灵活、高效、自动化的解决方案。使用 Protocol Buffer,您需要编写一个 .proto 文件来描述您希望存储的数据结构。Protocol Buffer 编译器会根据该文件创建一个类,该类使用高效的二进制格式实现了对 Protocol Buffer 数据的自动编码和解析。生成的类为构成 Protocol Buffer 的字段提供了 getter 和 setter,并负责处理将 Protocol Buffer 作为一个单元进行读写的细节。重要的是,Protocol Buffer 格式支持随着时间的推移扩展格式,使得代码仍然可以读取用旧格式编码的数据。
在哪里找到示例代码
我们的示例是一个命令行应用程序,用于管理一个使用 Protocol Buffer 编码的地址簿数据文件。命令 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";
接下来,是您的消息定义。消息只是一个包含一组类型化字段的聚合体。许多标准的简单数据类型都可以作为字段类型,包括 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 内部定义的。如果您希望某个字段的值是预定义列表中的一个,您还可以定义 enum 类型——在这里,您想指定一个电话号码可以是 PHONE_TYPE_MOBILE、PHONE_TYPE_HOME 或 PHONE_TYPE_WORK 中的一种。
每个元素上的 " = 1"、" = 2" 标记标识了该字段在二进制编码中使用的唯一“标签”(tag)。标签号 1-15 比较大的数字少用一个字节来编码,因此作为一种优化,您可以决定将这些标签用于常用或重复的元素,而将标签 16 及以上的数字留给不常用的可选元素。重复字段中的每个元素都需要重新编码标签号,因此重复字段特别适合进行这种优化。
如果某个字段未设置值,则会使用默认值:数字类型为零,字符串为空字符串,布尔值为 false。对于嵌入的消息,默认值始终是该消息的“默认实例”或“原型”,其所有字段都未设置。调用访问器获取未显式设置的字段值时,总是返回该字段的默认值。
如果一个字段是 repeated,该字段可以重复任意次数(包括零次)。重复值的顺序将在协议缓冲区中保留。可以把重复字段看作是动态大小的数组。
您可以在Protocol Buffer 语言指南中找到编写 .proto 文件的完整指南——包括所有可能的字段类型。但不要去寻找类似类继承的功能——Protocol Buffer 不支持这个。
编译你的 Protocol Buffers
现在您有了一个 .proto 文件,接下来需要做的就是生成读写 AddressBook(以及 Person 和 PhoneNumber)消息所需的类。为此,您需要在您的 .proto 文件上运行 Protocol Buffer 编译器 protoc:
如果你还没有安装编译器,请下载软件包并按照 README 中的说明进行操作。
现在运行编译器,指定源目录(您应用程序源代码所在的位置——如果不提供值,则使用当前目录)、目标目录(您希望生成代码存放的位置;通常与
$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类,带有Name、Id、Email和Phones属性。 - 一个
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 Buffer 的根本目的是序列化您的数据,以便在其他地方进行解析。每个生成的类都有一个 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>。这个解析器又提供了从流、字节数组和 ByteStrings 进行解析的方法。因此,要解析我们刚刚创建的文件,我们可以使用:
Person john;
using (var input = File.OpenRead("john.dat"))
{
john = Person.Parser.ParseFrom(input);
}
一个用于维护地址簿(添加新条目和列出现有条目)的完整示例程序可以在 Github 仓库中找到。
扩展 Protocol Buffer
在您发布使用 Protocol Buffer 的代码后,迟早会想要“改进”Protocol Buffer 的定义。如果您希望新的缓冲区向后兼容,并且旧的缓冲区向前兼容——您几乎肯定希望如此——那么您需要遵守一些规则。在新版本的 Protocol Buffer 中:
- 您*绝不能*更改任何现有字段的标签号。
- 您*可以*删除字段。
- 您*可以*添加新字段,但必须使用新的标签号(即,在此协议缓冲区中从未使用过的标签号,即使是被删除的字段用过的也不行)。
(这些规则有一些例外,但很少使用。)
如果您遵守这些规则,旧代码将能够愉快地读取新消息,并简单地忽略任何新字段。对于旧代码来说,被删除的单数(singular)字段将只有其默认值,而被删除的重复(repeated)字段将为空。新代码也将透明地读取旧消息。
但是,请记住,新字段在旧消息中是不存在的,因此您需要对默认值进行合理的处理。系统会使用特定类型的默认值:对于字符串,默认值是空字符串。对于布尔值,默认值是 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);
}
}