Protocol Buffer 基础:Go
本教程为 Go 程序员提供了一个使用 Protocol Buffers 的基础入门,使用的是 proto3 版本的 Protocol Buffers 语言。通过逐步创建一个简单的示例应用程序,它向你展示了如何
- 在
.proto
文件中定义消息格式。 - 使用 protocol buffer 编译器。
- 使用 Go protocol buffer API 写入和读取消息。
这不是使用 Go 语言 Protocol Buffers 的全面指南。有关更详细的参考信息,请参阅 Protocol Buffer 语言指南、Go API 参考、Go 生成代码指南,以及 编码参考。
问题域
我们将要使用的示例是一个非常简单的“通讯录”应用程序,它可以从文件中读取和写入人们的联系方式。通讯录中的每个人都有姓名、ID、电子邮件地址和联系电话号码。
如何序列化和检索这样的结构化数据呢?有几种方法可以解决这个问题
- 使用 gobs 序列化 Go 数据结构。这在 Go 特定的环境中是一个不错的解决方案,但如果你需要与其他平台编写的应用程序共享数据,则效果不佳。
- 你可以发明一种临时(ad-hoc)方式将数据项编码成单个字符串,例如将 4 个 int 编码为“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 编码的通讯录数据文件。命令 add_person_go
将新条目添加到数据文件中。命令 list_people_go
解析数据文件并将数据打印到控制台。
你可以在 GitHub 仓库的 examples 目录 中找到完整的示例。
定义协议格式
要创建通讯录应用程序,你需要从一个 .proto
文件开始。一个 .proto
文件中的定义很简单:你为每个你想序列化的数据结构添加一个 消息,然后为消息中的每个字段指定名称和类型。在我们的示例中,定义消息的 .proto
文件是 addressbook.proto
。
.proto
文件以包声明开头,这有助于防止不同项目之间的命名冲突。
syntax = "proto3";
package tutorial;
import "google/protobuf/timestamp.proto";
go_package
选项定义了包的导入路径,该包将包含此文件生成的所有代码。Go 包名将是导入路径的最后一个路径组件。例如,我们的示例将使用包名“tutorialpb”。
option go_package = "github.com/protocolbuffers/protobuf/examples/go/tutorialpb";
接下来,你有你的消息定义。消息只是一个包含一组带类型字段的聚合体。许多标准的简单数据类型可用作字段类型,包括 bool
、int32
、float
、double
和 string
。你还可以使用其他消息类型作为字段类型,为你的消息添加更多结构。
message Person {
string name = 1;
int32 id = 2; // Unique ID number for this person.
string email = 3;
message PhoneNumber {
string number = 1;
PhoneType type = 2;
}
repeated PhoneNumber phones = 4;
google.protobuf.Timestamp last_updated = 5;
}
enum PhoneType {
PHONE_TYPE_UNSPECIFIED = 0;
PHONE_TYPE_MOBILE = 1;
PHONE_TYPE_HOME = 2;
PHONE_TYPE_WORK = 3;
}
// 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”标记标识了该字段在二进制编码中使用的唯一“标签”。标签号 1-15 比更高的数字编码所需的字节少一个,因此作为一项优化,你可以决定将这些标签用于常用的或重复的元素,而将标签 16 及更高的数字留给不常用的可选元素。重复字段中的每个元素都需要重新编码标签号,因此重复字段特别适合进行此优化。
如果字段值未设置,则使用 默认值:数字类型为零,字符串为空字符串,布尔类型为 false。对于嵌入式消息,默认值始终是消息的“默认实例”或“原型”,其字段均未设置。调用访问器获取尚未明确设置的字段值时,总是返回该字段的默认值。
如果字段是 repeated
,则该字段可以重复任意次数(包括零次)。重复值的顺序将在 protocol buffer 中保留。可以将重复字段视为动态大小的数组。
你可以在 Protocol Buffer 语言指南 中找到编写 .proto
文件(包括所有可能的字段类型)的完整指南。不过,不要寻找类似于类继承的功能——protocol buffers 没有这个功能。
编译 Protocol Buffers
现在你有了 .proto
文件,接下来你需要做的是生成读写 AddressBook
(以及 Person
和 PhoneNumber
)消息所需的类。为此,你需要在你的 .proto
文件上运行 protocol buffer 编译器 protoc
。
如果你还没有安装编译器,请 下载软件包 并按照 README 中的说明进行操作。
运行以下命令安装 Go protocol buffers 插件
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
编译器插件
protoc-gen-go
将安装在$GOBIN
中,默认是$GOPATH/bin
。它必须在你的$PATH
中,以便 protocol compilerprotoc
能找到它。现在运行编译器,指定源目录(应用程序源代码所在的位置——如果未提供值则使用当前目录)、目标目录(希望生成代码放置的位置;通常与
$SRC_DIR
相同)以及.proto
文件的路径。在这种情况下,你可以调用protoc -I=$SRC_DIR --go_out=$DST_DIR $SRC_DIR/addressbook.proto
由于你想要 Go 代码,因此使用
--go_out
选项——其他受支持的语言也提供了类似的选项。
这会在你指定的目标目录中生成 github.com/protocolbuffers/protobuf/examples/go/tutorialpb/addressbook.pb.go
。
Protocol Buffer API
生成 addressbook.pb.go
会得到以下有用的类型
- 一个
AddressBook
结构体,包含一个People
字段。 - 一个
Person
结构体,包含Name
、Id
、Email
和Phones
字段。 - 一个
Person_PhoneNumber
结构体,包含Number
和Type
字段。 - 类型
Person_PhoneType
,以及在Person.PhoneType
enum 中为每个值定义的常量。
你可以在 Go 生成代码指南 中阅读有关生成内容的详细信息,但大多数情况下,你可以将它们视为完全普通的 Go 类型。
下面是 list_people
命令的单元测试 中的一个示例,展示了如何创建一个 Person 实例
p := pb.Person{
Id: 1234,
Name: "John Doe",
Email: "jdoe@example.com",
Phones: []*pb.Person_PhoneNumber{
{Number: "555-4321", Type: pb.PhoneType_PHONE_TYPE_HOME},
},
}
写入消息
使用 protocol buffers 的全部目的是序列化你的数据,以便在其他地方进行解析。在 Go 中,你使用 proto
库的 Marshal 函数来序列化你的 protocol buffer 数据。指向 protocol buffer 消息 struct
的指针实现了 proto.Message
接口。调用 proto.Marshal
返回以 wire format 编码的 protocol buffer。例如,我们在 add_person
命令 中使用此函数。
book := &pb.AddressBook{}
// ...
// Write the new address book back to disk.
out, err := proto.Marshal(book)
if err != nil {
log.Fatalln("Failed to encode address book:", err)
}
if err := ioutil.WriteFile(fname, out, 0644); err != nil {
log.Fatalln("Failed to write address book:", err)
}
读取消息
要解析编码的消息,你使用 proto
库的 Unmarshal 函数。调用此函数将 in
中的数据解析为 protocol buffer 并将结果放入 book
中。因此,要在 list_people
命令 中解析文件,我们使用
// Read the existing address book.
in, err := ioutil.ReadFile(fname)
if err != nil {
log.Fatalln("Error reading file:", err)
}
book := &pb.AddressBook{}
if err := proto.Unmarshal(in, book); err != nil {
log.Fatalln("Failed to parse address book:", err)
}
扩展 Protocol Buffer
在发布使用 protocol buffer 的代码后,迟早会想要“改进” protocol buffer 的定义。如果你希望新缓冲区向后兼容,旧缓冲区向前兼容——而且你几乎肯定希望如此——那么你需要遵循一些规则。在新版本的 protocol buffer 中
- 你 绝不能 更改任何现有字段的标签号。
- 你 可以 删除字段。
- 你 可以 添加新字段,但必须使用全新的标签号(即从未在此 protocol buffer 中使用过的标签号,即使是被删除的字段也没有使用过)。
(这些规则 有一些例外,但很少使用。)
如果你遵循这些规则,旧代码将愉快地读取新消息并简单地忽略任何新字段。对于旧代码来说,被删除的单一字段将简单地具有其默认值,而被删除的重复字段将为空。新代码也将透明地读取旧消息。
然而,请记住新字段不会出现在旧消息中,因此你需要对默认值进行合理的处理。会使用特定类型的 默认值:对于字符串,默认值为空字符串。对于布尔类型,默认值为 false。对于数字类型,默认值为零。