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 序列化。这是默认方法,因为它内置于语言中,但它在处理模式演变方面表现不佳,而且如果您需要与用 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 的字段提供 getter 和 setter,并处理将 Protocol Buffer 作为一个单元读写时的细节。重要的是,Protocol Buffer 格式支持随着时间的推移扩展格式,以便代码仍然可以读取使用旧格式编码的数据。

在哪里找到示例代码

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

定义你的协议格式

要创建您的地址簿应用程序,您需要从 .proto 文件开始。.proto 文件中的定义很简单:您为要序列化的每个数据结构添加一个 消息,然后为消息中的每个字段指定名称和类型。以下是定义您的消息的 .proto 文件,addressbook.proto

syntax = "proto2";

package tutorial;

message Person {
  optional string name = 1;
  optional int32 id = 2;
  optional string email = 3;

  enum PhoneType {
    PHONE_TYPE_UNSPECIFIED = 0;
    PHONE_TYPE_MOBILE = 1;
    PHONE_TYPE_HOME = 2;
    PHONE_TYPE_WORK = 3;
  }

  message PhoneNumber {
    optional string number = 1;
    optional 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 及更高的标记用于不太常用的可选元素。重复字段中的每个元素都需要重新编码标记号,因此重复字段是这种优化的特别好的候选对象。

每个字段必须用以下修饰符之一进行注释:

  • optional:字段可能设置也可能未设置。如果未设置可选字段值,则使用默认值。对于简单类型,您可以指定您自己的默认值,就像我们在示例中为电话号码 type 所做的那样。否则,将使用系统默认值:数值类型的零、字符串的空字符串、布尔值的 false。对于嵌入式消息,默认值始终是消息的“默认实例”或“原型”,其所有字段均未设置。调用访问器以获取未显式设置的可选(或必需)字段的值始终返回该字段的默认值。
  • repeated:字段可以重复任意次数(包括零次)。重复值的顺序将在 Protocol Buffer 中保留。将重复字段视为动态大小的数组。
  • required:必须提供字段的值,否则消息将被视为“未初始化”。序列化未初始化的消息将引发异常。解析未初始化的消息将失败。除此之外,必需字段的行为与可选字段完全相同。

您可以在 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++ 协议缓冲区代码不同,Python 协议缓冲区编译器不会直接为您生成数据访问代码。相反(如果您查看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 = "[email protected]"
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 文档

解析和序列化

最后,每个协议缓冲区类都具有使用协议缓冲区二进制格式写入和读取所选类型消息的方法。这些包括

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

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

写入消息

现在让我们尝试使用您的协议缓冲区类。您的通讯录应用程序首先要能够做的事情是将个人详细信息写入通讯录文件。为此,您需要创建和填充协议缓冲区类的实例,然后将它们写入输出流。

这是一个程序,它从文件中读取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

发布使用协议缓冲区的代码后,迟早您肯定会想要“改进”协议缓冲区的定义。如果您希望新的缓冲区向后兼容,并且旧的缓冲区向前兼容——并且您几乎肯定希望这样做——那么您需要遵循一些规则。在新版本的协议缓冲区中

  • 不得更改任何现有字段的标签编号。
  • 不得添加或删除任何必填字段。
  • 可以删除可选字段或重复字段。
  • 可以添加新的可选字段或重复字段,但必须使用新的标签编号(即,在此协议缓冲区中从未使用过的标签编号,即使是已删除字段的标签编号)。

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

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

高级用法

协议缓冲区的使用范围超出了简单的访问器和序列化。请务必浏览Python API 参考,以了解您可以使用它们执行的其他操作。

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

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