Protocol Buffer 基础:Go
本教程使用 proto3 版本的 protocol buffers 语言,为 Go 程序员提供了一份使用 protocol buffers 的基础入门介绍。通过引导您创建一个简单的示例应用程序,本教程将向您展示如何:
- 在
.proto
文件中定义消息格式。 - 使用 Protocol Buffer 编译器。
- 使用 Go protocol buffer API 写入和读取消息。
这并非一份在 Go 中使用 protocol buffers 的详尽指南。如需更详细的参考信息,请参阅Protocol Buffer 语言指南、Go API 参考、Go 生成代码指南以及编码参考。
问题领域
我们将要使用的示例是一个非常简单的“地址簿”应用程序,它可以从文件中读取和写入人们的联系方式。地址簿中的每个人都有姓名、ID、电子邮件地址和联系电话号码。
你如何序列化和检索这样的结构化数据?有几种方法可以解决这个问题:
- 使用 gobs 序列化 Go 数据结构。这在 Go 特定的环境中是一个很好的解决方案,但如果您需要与其他平台编写的应用程序共享数据,它的效果就不太好了。
- 您可以发明一种临时的方式将数据项编码为单个字符串——例如将 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 进行编码。命令 add_person_go
向数据文件添加一个新条目。命令 list_people_go
解析数据文件并将数据打印到控制台。
您可以在 GitHub 仓库的 examples 目录中找到完整的示例。
定义你的协议格式
要创建您的地址簿应用程序,您需要从一个 .proto
文件开始。.proto
文件中的定义很简单:为您想要序列化的每个数据结构添加一个 *message*,然后为消息中的每个字段指定名称和类型。在我们的示例中,定义消息的 .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 语言指南中找到编写 .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 编译器protoc
能够找到它。现在运行编译器,指定源目录(您的应用程序源代码所在的位置——如果不提供值,则使用当前目录)、目标目录(您希望生成代码存放的位置;通常与
$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
枚举中的每个值定义的值。
您可以在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
会返回以其线路格式编码的 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 的定义。如果您希望新的 buffer 向后兼容,旧的 buffer 向前兼容——而您几乎肯定希望如此——那么您需要遵守一些规则。在新版本的 protocol buffer 中:
- 您*绝不能*更改任何现有字段的标签号。
- 您*可以*删除字段。
- 您*可以*添加新字段,但必须使用新的标签号(即,在此协议缓冲区中从未使用过的标签号,即使是被删除的字段用过的也不行)。
(这些规则有一些例外,但很少使用。)
如果您遵循这些规则,旧代码将能够愉快地读取新消息,并简单地忽略任何新字段。对于旧代码来说,被删除的单数(singular)字段将只有其默认值,而被删除的重复(repeated)字段将为空。新代码也将能够透明地读取旧消息。
然而,请记住,新字段在旧消息中是不存在的,因此您需要对默认值做一些合理的处理。系统会使用特定于类型的默认值:对于字符串,默认值是空字符串。对于布尔值,默认值是 false。对于数字类型,默认值是零。