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 编写的应用程序共享数据时也表现不佳。
  • 您可以发明一种特殊的方式将数据项编码为单个字符串——例如将 4 个整数编码为“12:3:-23:67”。这是一种简单而灵活的方法,但它需要编写一次性的编码和解析代码,并且解析会带来一些运行时成本。这种方法最适合编码非常简单的数据。
  • 将数据序列化为 XML。这种方法可能非常有吸引力,因为 XML(某种程度上)是人类可读的,而且有许多语言的绑定库。如果您想与其他应用/项目共享数据,这可能是一个不错的选择。然而,XML 是出了名的占用空间,并且编码/解码它可能会给应用带来巨大的性能损失。此外,遍历 XML DOM 树比通常情况下遍历类中的简单字段要复杂得多。

除了这些选项,您还可以使用 Protocol Buffer。Protocol Buffer 是解决这一问题的灵活、高效、自动化的解决方案。使用 Protocol Buffer,您需要编写一个 .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" 标记标识了该字段在二进制编码中使用的唯一“标签”(tag)。标签号 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 的定义。如果您希望新的缓冲区向后兼容,并且旧的缓冲区向前兼容——您几乎肯定希望如此——那么您需要遵守一些规则。在新版本的 Protocol Buffer 中:

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

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

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

高级用法

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

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

反射是 Message 接口的一部分。