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。这是默认的方法,因为它内置于语言中,但它不能很好地处理模式演变,而且如果您需要与使用 C++ 或 Java 编写的应用程序共享数据,它的效果也不佳。
  • 您可以发明一种临时的方式将数据项编码成一个字符串——例如将 4 个整数编码为“12:3:-23:67”。这是一种简单灵活的方法,尽管它确实需要编写一次性的编码和解析代码,并且解析会带来很小的运行时开销。这种方法最适合编码非常简单的数据。
  • 将数据序列化为 XML。这种方法可能非常有吸引力,因为 XML (某种程度上) 是人类可读的,并且有很多语言都有绑定库。如果您想与其他应用程序/项目共享数据,这可能是一个不错的选择。然而,XML 非常占用空间,而且编码/解码它可能会给应用程序带来巨大的性能损失。此外,遍历 XML DOM 树比通常遍历类中的简单字段要复杂得多。

除了这些选项,您还可以使用 Protocol Buffers。Protocol Buffers 是灵活、高效、自动化的解决方案,专门用于解决这个问题。使用 Protocol Buffers,您需要编写一个描述您希望存储的数据结构的 .proto 文件。然后,Protocol Buffer 编译器会创建一个类,该类实现了使用高效的二进制格式对 Protocol Buffer 数据进行自动编码和解析。生成的类为组成 Protocol Buffer 的字段提供了获取器和设置器,并负责读取和写入 Protocol Buffer 作为一个整体的细节。重要的是,Protocol Buffer 格式支持随着时间的推移扩展格式的想法,以便代码仍然可以读取使用旧格式编码的数据。

在哪里找到示例代码

示例代码包含在源代码包中,位于“examples”目录下。在这里下载。

定义您的协议格式

要创建您的地址簿应用程序,您需要从一个 .proto 文件开始。.proto 文件中的定义很简单:您为每个要序列化的数据结构添加一个 message,然后为 message 中的每个字段指定一个名称和类型。这就是定义您的 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 文件以 package 声明开头,这有助于防止不同项目之间的命名冲突。在 Python 中,包通常由目录结构确定,因此您在 .proto 文件中定义的 package 对生成的代码没有影响。但是,您仍然应该声明一个 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 方法,并将它们添加到相关的类中。然后,您可以在代码中使用这些功能完整的类。

所有这些的最终效果是,您可以像使用定义了 Message 基类的每个字段的常规字段一样使用 Person 类。例如,您可以编写:

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

有关 Protocol Buffer 编译器为特定字段定义生成哪些成员的详细信息,请参阅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 参考以获取完整列表。

写入消息

现在让我们尝试使用您的 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 的定义。如果您希望您的新缓冲区向下兼容,并且您的旧缓冲区向上兼容——您几乎肯定希望这样做——那么您需要遵循一些规则。在新版本的 Protocol Buffer 中:

  • 绝不能更改任何现有字段的标签号。
  • 绝不能添加或删除任何必需字段。
  • 可以删除可选或重复字段。
  • 可以添加新的可选或重复字段,但必须使用新的标签号(即,在这个 Protocol Buffer 中从未使用的标签号,即使是被删除的字段)。

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

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

高级用法

Protocol Buffers 的用途不仅仅是简单的访问器和序列化。请务必探索Python API 参考,看看您还可以用它们做什么。

Protocol 消息类提供的一个关键特性是反射。您可以迭代消息的字段并操作它们的值,而无需针对任何特定的消息类型编写代码。反射的一个非常有用的用途是将协议消息转换为其他编码(例如 XML 或 JSON)或从其他编码转换。反射的更高级用途可能包括查找同类型两个消息之间的差异,或者开发一种“Protocol 消息的正则表达式”,您可以在其中编写匹配特定消息内容的表达式。发挥您的想象力,将 Protocol Buffers 应用于比您最初预期更广泛的问题是可能的!

反射是 Message 接口的一部分。