Protocol Buffer 基础教程:Python

一篇面向 Python 程序员的 protocol buffers 入门介绍。

本教程为 Python 程序员提供了 protocol buffers 的入门介绍。通过创建一个简单的示例应用程序,它将向你展示如何:

  • .proto 文件中定义消息格式。
  • 使用 Protocol Buffer 编译器。
  • 使用 Python protocol buffer API 写入和读取消息。

这不是一份在 Python 中使用 protocol buffers 的综合指南。如需更详细的参考信息,请参阅 Protocol Buffer 语言指南 (proto2)Protocol Buffer 语言指南 (proto3)Python API 参考Python 生成代码指南以及编码参考

问题领域

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

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

  • 使用 Python 的 pickling。这是默认方法,因为它内置于语言中,但它不能很好地处理 schema 演进,并且如果你需要与用 C++ 或 Java 编写的应用程序共享数据,它的效果也不是很好。
  • 你可以发明一种 ad-hoc 的方式将数据项编码为单个字符串——例如将 4 个整数编码为“12:3:-23:67”。这是一种简单而灵活的方法,尽管它需要编写一次性的编码和解析代码,并且解析会带来少量的运行时成本。这种方法最适合编码非常简单的数据。
  • 将数据序列化为 XML。这种方法可能非常吸引人,因为 XML(某种程度上)是人类可读的,并且有许多语言的绑定库。如果你想与其他应用程序/项目共享数据,这可能是一个不错的选择。然而,众所周知,XML 占用大量空间,对其进行编码/解码会给应用程序带来巨大的性能损失。此外,导航 XML DOM 树通常比导航类中的简单字段要复杂得多。

除了这些选项,你还可以使用 protocol buffers。Protocol buffers 是解决此问题的灵活、高效、自动化的解决方案。使用 protocol buffers,你可以编写一个 .proto 文件来描述你希望存储的数据结构。protocol buffer 编译器会据此创建一个类,该类实现了 protocol buffer 数据的高效二进制格式的自动编码和解析。生成的类为构成 protocol buffer 的字段提供了 getter 和 setter,并负责处理读写 protocol buffer 的细节。重要的是,protocol buffer 格式支持随时间扩展格式的概念,这样代码仍然可以读取用旧格式编码的数据。

在哪里找到示例代码

示例代码包含在源代码包的“examples”目录下。在此处下载。

定义你的协议格式

要创建你的通讯录应用程序,你需要从一个 .proto 文件开始。.proto 文件中的定义很简单:为你想要序列化的每个数据结构添加一个 message,然后为消息中的每个字段指定名称和类型。这是定义你的消息的 .proto 文件,addressbook.proto

edition = "2023";

package tutorial;

message Person {
  string name = 1;
  int32 id = 2;
  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 [default = PHONE_TYPE_HOME];
  }

  repeated PhoneNumber phones = 4;
}

message AddressBook {
  repeated Person people = 1;
}

正如你所见,语法类似于 C++ 或 Java。让我们逐一查看文件的每个部分,看看它们的作用。

.proto 文件以包声明开始,这有助于防止不同项目之间的命名冲突。在 Python 中,包通常由目录结构决定,因此你在 .proto 文件中定义的 package 不会影响生成的代码。但是,你仍然应该声明一个,以避免在 Protocol Buffers 命名空间以及非 Python 语言中发生名称冲突。

接下来,是你的消息定义。消息只是一个包含一组类型化字段的聚合体。许多标准的简单数据类型都可用作字段类型,包括 boolint32floatdoublestring。你还可以通过使用其他消息类型作为字段类型来为你的消息添加更多结构——在上面的示例中,Person 消息包含 PhoneNumber 消息,而 AddressBook 消息包含 Person 消息。你甚至可以在其他消息内部定义嵌套的消息类型——如你所见,PhoneNumber 类型是在 Person 内部定义的。如果你希望某个字段的值是预定义列表中的一个,你还可以定义 enum 类型——这里你希望指定一个电话号码可以是以下电话类型之一:PHONE_TYPE_MOBILEPHONE_TYPE_HOMEPHONE_TYPE_WORK

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

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

编译你的 Protocol Buffers

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

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

  2. 现在运行编译器,指定源目录(你的应用程序源代码所在的位置——如果不提供值,则使用当前目录)、目标目录(你希望生成代码存放的位置;通常与 $SRC_DIR 相同),以及你的 .proto 文件的路径。在这种情况下,你……

    protoc --proto_path=$SRC_DIR --python_out=$DST_DIR $SRC_DIR/addressbook.proto
    

    因为你需要 Python 类,所以你使用 --python_out 选项——其他支持的语言也有类似的选项。

    Protoc 也能使用 --pyi_out 生成 python 存根 (.pyi)。

这会在你指定的目标目录中生成 addressbook_pb2.py(或 addressbook_pb2.pyi)。

Protocol Buffer API

与生成 Java 和 C++ protocol buffer 代码不同,Python protocol buffer 编译器不会直接为你生成数据访问代码。相反(如果你查看 addressbook_pb2.py 就会发现),它会为你的所有消息、枚举和字段生成特殊的描述符,以及一些神秘的空类,每种消息类型一个。

import google3
from google.protobuf import descriptor as _descriptor
from google.protobuf import descriptor_pool as _descriptor_pool
from google.protobuf import runtime_version as _runtime_version
from google.protobuf import symbol_database as _symbol_database
from google.protobuf.internal import builder as _builder
_runtime_version.ValidateProtobufRuntimeVersion(
    _runtime_version.Domain.GOOGLE_INTERNAL,
    0,
    20240502,
    0,
    '',
    'main.proto'
)
# @@protoc_insertion_point(imports)

_sym_db = _symbol_database.Default()

DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\nmain.proto\x12\x08tutorial\"\xa3\x02\n\x06Person\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\n\n\x02id\x18\x02 \x01(\x05\x12\r\n\x05\x65mail\x18\x03 \x01(\t\x12,\n\x06phones\x18\x04 \x03(\x0b\x32\x1c.tutorial.Person.PhoneNumber\x1aX\n\x0bPhoneNumber\x12\x0e\n\x06number\x18\x01 \x01(\t\x12\x39\n\x04type\x18\x02 \x01(\x0e\x32\x1a.tutorial.Person.PhoneType:\x0fPHONE_TYPE_HOME\"h\n\tPhoneType\x12\x1a\n\x16PHONE_TYPE_UNSPECIFIED\x10\x00\x12\x15\n\x11PHONE_TYPE_MOBILE\x10\x01\x12\x13\n\x0fPHONE_TYPE_HOME\x10\x02\x12\x13\n\x0fPHONE_TYPE_WORK\x10\x03\"/\n\x0b\x41\x64\x64ressBook\x12 \n\x06people\x18\x01 \x03(\x0b\x32\x10.tutorial.Person')

_globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'google3.main_pb2', _globals)
if not _descriptor._USE_C_DESCRIPTORS:
  DESCRIPTOR._loaded_options = None
  _globals['_PERSON']._serialized_start=25
  _globals['_PERSON']._serialized_end=316
  _globals['_PERSON_PHONENUMBER']._serialized_start=122
  _globals['_PERSON_PHONENUMBER']._serialized_end=210
  _globals['_PERSON_PHONETYPE']._serialized_start=212
  _globals['_PERSON_PHONETYPE']._serialized_end=316
  _globals['_ADDRESSBOOK']._serialized_start=318
  _globals['_ADDRESSBOOK']._serialized_end=365
# @@protoc_insertion_point(module_scope)

每个类中重要的一行是 __metaclass__ = reflection.GeneratedProtocolMessageType。虽然 Python 元类的工作原理细节超出了本教程的范围,但你可以将它们看作是创建类的模板。在加载时,GeneratedProtocolMessageType 元类使用指定的描述符来创建处理每种消息类型所需的所有 Python 方法,并将它们添加到相关的类中。然后你就可以在你的代码中使用这些完全填充好的类了。

这一切的最终效果是,你可以像使用 Person 类一样使用它,就好像它将 Message 基类的每个字段都定义为常规字段一样。例如,你可以写:

import addressbook_pb2
person = addressbook_pb2.Person()
person.id = 1234
person.name = "John Doe"
person.email = "jdoe@example.com"
phone = person.phones.add()
phone.number = "555-4321"
phone.type = addressbook_pb2.Person.PHONE_TYPE_HOME

请注意,这些赋值不仅仅是向一个通用的 Python 对象添加任意新字段。如果你试图赋一个在 .proto 文件中未定义的字段,将会引发 AttributeError。如果你给一个字段赋了错误类型的值,将会引发 TypeError。此外,读取一个尚未设置值的字段会返回其默认值。

person.no_such_field = 1  # raises AttributeError
person.id = "1234"        # raises TypeError

有关协议编译器为任何特定字段定义具体生成哪些成员的更多信息,请参阅 Python 生成代码参考

枚举

枚举被元类扩展为一组具有整数值的符号常量。因此,例如,常量 addressbook_pb2.Person.PhoneType.PHONE_TYPE_WORK 的值为 2。

标准消息方法

每个消息类还包含许多其他方法,可让你检查或操作整个消息,包括:

  • IsInitialized():检查所有必填字段是否已设置。
  • __str__():返回消息的人类可读表示,对调试特别有用。(通常调用为 str(message)print message。)
  • CopyFrom(other_msg):用给定消息的值覆盖当前消息。
  • Clear():将所有元素清除回空状态。

这些方法实现了 Message 接口。有关更多信息,请参阅 Message 的完整 API 文档

解析和序列化

最后,每个 Protocol Buffer 类都有使用 Protocol Buffer 二进制格式来写入和读取你所选类型的消息的方法。这些方法包括:

  • SerializeToString():序列化消息并将其作为字符串返回。注意,字节是二进制的,而不是文本;我们只使用 str 类型作为一个方便的容器。
  • ParseFromString(data):从给定的字符串中解析消息。

这些只是解析和序列化提供的几个选项。同样,请参阅 Message API 参考以获取完整列表。

你也可以轻松地将消息序列化为 JSON 或从 JSON 反序列化。json_format 模块为此提供了辅助函数:

  • MessageToJson(message):将消息序列化为 JSON 字符串。
  • Parse(json_string, message):将 JSON 字符串解析到给定的消息中。

例如:

from google.protobuf import json_format
import addressbook_pb2

person = addressbook_pb2.Person()
person.id = 1234
person.name = "John Doe"
person.email = "jdoe@example.com"

# Serialize to JSON
json_string = json_format.MessageToJson(person)

# Parse from JSON
new_person = addressbook_pb2.Person()
json_format.Parse(json_string, new_person)

写入消息

现在让我们尝试使用你的 protocol buffer 类。你的通讯录应用程序首先需要能够将个人详细信息写入你的通讯录文件。为此,你需要创建并填充你的 protocol buffer 类的实例,然后将它们写入输出流。

这是一个程序,它从一个文件中读取一个 AddressBook,根据用户输入向其中添加一个新的 Person,然后将新的 AddressBook 写回文件。直接调用或引用协议编译器生成的代码的部分已高亮显示。

#!/usr/bin/env python3

import addressbook_pb2
import sys

# This function fills in a Person message based on user input.
def PromptForAddress(person):
  person.id = int(input("Enter person ID number: "))
  person.name = input("Enter name: ")

  email = input("Enter email address (blank for none): ")
  if email != "":
    person.email = email

  while True:
    number = input("Enter a phone number (or leave blank to finish): ")
    if number == "":
      break

    phone_number = person.phones.add()
    phone_number.number = number

    phone_type = input("Is this a mobile, home, or work phone? ")
    if phone_type == "mobile":
      phone_number.type = addressbook_pb2.Person.PhoneType.PHONE_TYPE_MOBILE
    elif phone_type == "home":
      phone_number.type = addressbook_pb2.Person.PhoneType.PHONE_TYPE_HOME
    elif phone_type == "work":
      phone_number.type = addressbook_pb2.Person.PhoneType.PHONE_TYPE_WORK
    else:
      print("Unknown phone type; leaving as default value.")

# Main procedure:  Reads the entire address book from a file,
#   adds one person based on user input, then writes it back out to the same
#   file.
if len(sys.argv) != 2:
  print("Usage:", sys.argv[0], "ADDRESS_BOOK_FILE")
  sys.exit(-1)

address_book = addressbook_pb2.AddressBook()

# Read the existing address book.
try:
  with open(sys.argv[1], "rb") as f:
    address_book.ParseFromString(f.read())
except IOError:
  print(sys.argv[1] + ": Could not open file.  Creating a new one.")

# Add an address.
PromptForAddress(address_book.people.add())

# Write the new address book back to disk.
with open(sys.argv[1], "wb") as f:
  f.write(address_book.SerializeToString())

读取消息

当然,如果不能从中获取任何信息,地址簿就没有多大用处!这个例子读取了上面例子创建的文件,并打印出其中的所有信息。

#!/usr/bin/env python3

import addressbook_pb2
import sys

# Iterates though all people in the AddressBook and prints info about them.
def ListPeople(address_book):
  for person in address_book.people:
    print("Person ID:", person.id)
    print("  Name:", person.name)
    if person.HasField('email'):
      print("  E-mail address:", person.email)

    for phone_number in person.phones:
      if phone_number.type == addressbook_pb2.Person.PhoneType.PHONE_TYPE_MOBILE:
        print("  Mobile phone #: ", end="")
      elif phone_number.type == addressbook_pb2.Person.PhoneType.PHONE_TYPE_HOME:
        print("  Home phone #: ", end="")
      elif phone_number.type == addressbook_pb2.Person.PhoneType.PHONE_TYPE_WORK:
        print("  Work phone #: ", end="")
      print(phone_number.number)

# Main procedure:  Reads the entire address book from a file and prints all
#   the information inside.
if len(sys.argv) != 2:
  print("Usage:", sys.argv[0], "ADDRESS_BOOK_FILE")
  sys.exit(-1)

address_book = addressbook_pb2.AddressBook()

# Read the existing address book.
with open(sys.argv[1], "rb") as f:
  address_book.ParseFromString(f.read())

ListPeople(address_book)

扩展 Protocol Buffer

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

  • 您*绝不能*更改任何现有字段的标签号。
  • 你*不得*添加或删除任何必需字段。
  • 你*可以*删除可选或重复字段。
  • 可以添加新的 optional 或 repeated 字段,但你必须使用新的标签号(即,在此 protocol buffer 中从未使用过的标签号,即使是被删除的字段用过的也不行)。

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

如果你遵循这些规则,旧代码将很乐意读取新消息,并简单地忽略任何新字段。对于旧代码来说,被删除的 optional 字段将只是其默认值,而被删除的 repeated 字段将为空。新代码也将透明地读取旧消息。但是,请记住,新的 optional 字段不会出现在旧消息中,因此你需要使用 HasField('field_name') 显式检查它们是否已设置,或者在你的 .proto 文件中在标签号后使用 [default = value] 提供一个合理的默认值。如果未为 optional 元素指定默认值,则使用特定于类型的默认值:对于字符串,默认值是空字符串。对于布尔值,默认值是 false。对于数字类型,默认值是零。另请注意,如果你添加了一个新的 repeated 字段,你的新代码将无法判断它是被留空(由新代码)还是根本没有设置(由旧代码),因为没有针对它的 HasField 检查。

高级用法

Protocol buffers 的用途超出了简单的访问器和序列化。请务必探索 Python API 参考,看看你还能用它们做什么。

protocol 消息类提供的一个关键特性是反射。你可以遍历消息的字段并操作它们的值,而无需针对任何特定的消息类型编写代码。使用反射的一种非常有用的方法是将 protocol 消息与其他编码(如 XML 或 JSON)相互转换(参见解析和序列化中的示例)。反射的更高级用法可能是找出同一类型的两条消息之间的差异,或者开发一种“protocol 消息的正则表达式”,你可以在其中编写匹配特定消息内容的表达式。如果你发挥想象力,可以将 Protocol Buffers 应用于比你最初想象的更广泛的问题!

反射是作为 Message 接口的一部分提供的。