Protocol Buffer 基础:Go

面向 Go 程序员的 Protocol Buffers 基础入门。

本教程为 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";

接下来,你有你的消息定义。消息只是一个包含一组带类型字段的聚合体。许多标准的简单数据类型可用作字段类型,包括 boolint32floatdoublestring。你还可以使用其他消息类型作为字段类型,为你的消息添加更多结构。

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_MOBILEPHONE_TYPE_HOMEPHONE_TYPE_WORK 之一。

每个元素上的“ = 1”,“ = 2”标记标识了该字段在二进制编码中使用的唯一“标签”。标签号 1-15 比更高的数字编码所需的字节少一个,因此作为一项优化,你可以决定将这些标签用于常用的或重复的元素,而将标签 16 及更高的数字留给不常用的可选元素。重复字段中的每个元素都需要重新编码标签号,因此重复字段特别适合进行此优化。

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

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

你可以在 Protocol Buffer 语言指南 中找到编写 .proto 文件(包括所有可能的字段类型)的完整指南。不过,不要寻找类似于类继承的功能——protocol buffers 没有这个功能。

编译 Protocol Buffers

现在你有了 .proto 文件,接下来你需要做的是生成读写 AddressBook(以及 PersonPhoneNumber)消息所需的类。为此,你需要在你的 .proto 文件上运行 protocol buffer 编译器 protoc

  1. 如果你还没有安装编译器,请 下载软件包 并按照 README 中的说明进行操作。

  2. 运行以下命令安装 Go protocol buffers 插件

    go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
    

    编译器插件 protoc-gen-go 将安装在 $GOBIN 中,默认是 $GOPATH/bin。它必须在你的 $PATH 中,以便 protocol compiler protoc 能找到它。

  3. 现在运行编译器,指定源目录(应用程序源代码所在的位置——如果未提供值则使用当前目录)、目标目录(希望生成代码放置的位置;通常与 $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 结构体,包含 NameIdEmailPhones 字段。
  • 一个 Person_PhoneNumber 结构体,包含 NumberType 字段。
  • 类型 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。对于数字类型,默认值为零。