Python 在Python中使用Protocol Buffers基础介绍

实践环境

protoc-25.4-win64.zip

下载地址:

https://github.com/protocolbuffers/protobuf/releases

https://github.com/protocolbuffers/protobuf/releases/download/v25.4/protoc-25.4-win64.zip

protobuf 5.27.2

pip install protobuf==5.27.2

Python 3.9.13

问题域

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

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

使用Python pickle。这是默认方法,因为它内置于语言中,但它不能很好地处理模式演化,如果你需要与用C++或Java编写的应用程序共享数据,它也不能很好的工作。

你可以发明一种特殊的方法将数据项编码为单个字符串,例如将4个整数编码为“12:3:-23:67”。这是一种简单而灵活的方法,尽管它确实需要编写一次性编码和解析代码,并且解析带来的运行时成本很小。这最适合对非常简单的数据进行编码。

将数据序列化为XML。这种方法非常有吸引力,因为XML(某种程度上)是人类可读的,并且有许多语言的绑定库。如果想与其他应用程序/项目共享数据,这可能是一个不错的选择。然而,众所周知,XML是空间密集型的,对其进行编码/解码会给应用程序带来巨大的性能损失。此外访问XML DOM树访问类中的简单字段要复杂得多。

可以使用协议缓冲区(Protocol buffers)替代这些选择。协议缓冲区是解决这个问题的灵活、高效、自动化的解决方案。使用协议缓冲区 ,可以编写希望存储的数据结构的.proto描述。协议缓冲区编译器将从该文件创建一个类,该类以有效的二进制格式实现协议缓冲区数据的自动编码和解析。生成的类为构成协议缓冲区的字段提供getterssetters方法,并处理将协议缓冲区作为一个单元进行读写的细节。重要的是,协议缓冲区格式支持随着时间的推移扩展格式的想法,这样代码仍然可以读取用旧格式编码的数据。

定义协议格式(编写proto文件)

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

示例:addressbook.proto

syntax = "proto2"; // proto2指定proto buffer的版本

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; // phones字段是一个重复字段,可以包含多个电话号码。
}

message AddressBook {
  repeated Person people = 1;
}

说明:

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

接下来,是消息定义。消息只是包含一组类型字段的集合。许多标准的简单数据类型可以作为字段类型使用,包括boolint32floatdoublestring。还可以通过使用其他消息类型作为字段类型来为消息添加更多的结构 - 在上面的示例中,Person消息包含PhoneNumber消息,而AddressBook消息包含Person消息。甚至可以定义嵌套在其他消息中的消息类型-如上,PhoneNumber类型定义在Person中。如果希望其中一个字段具有预定义的值列表之一,也可以定义枚举类型 - 在这里希望指定电话号码可以是以下电话类型之一:

  • PHONE_TYPE_MOBILE
  • PHONE_TYPE_HOME
  • PHONE_TYPE_WORK

每个元素上的“=1”、“=2”标记标识该字段在二进制编码中使用的唯一“标记”,这确保了在序列化和反序列化过程中,‌每个字段可以被正确地识别和处理。‌这些数字标签在编译时被转换为命名空间和类型签名,‌从而保证了字段的唯一性。使用1-15的标记编号比使用更高的数字要少一个字节编码,因此作为优化,可以决定将这些标签用于常用或重复的元素,将16及更高标记编号的用于不太常用的可选元素。重复字段中的每个元素都需要重新编码标记号,因此重复字段特别适合此优化。

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

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

重要

required是永久的,在将字段标记为required时应非常小心。如果在某个时候希望停止编写或发送必需字段,将该字段更改为可选字段将很成问题 - 旧的读取器会认为没有此字段的消息不完整,并可能会意外地拒绝或删除它们。你应该考虑为协议缓冲区编写特定于应用程序的自定义验证例程。在Google 强烈不赞成使用required字段;在 proto2 语法中定义的大多数消息仅使用optionalrepeated。(Proto3 根本不支持required字段。)

编译协议缓冲区

现在有了.proto,接下来需要做的就是生成读写 AddressBook(以及 PersonPhoneNumber)消息所需的类。为此,需要在 .proto 上运行协议缓冲区编译器 protoc

1、下载protoc后解压,将protoc所在bin目录路径添加到系统环境变量

>protoc --version
libprotoc 25.4

2、现在运行编译器,指定源目录(应用程序源代码所在的位置 - 如果未提供值,则使用当前目录)、目标目录(希望生成的代码的存储目录;通常与 $SRC_DIR 相同)和 .proto 的路径。如下:

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

因为想要 Python 类,所以使用 --python_out 选项 - 为其他受支持的语言提供了类似的选项。

protoc还可以使用--pyi_out生成python存根(.pyi)。

这会在你指定的目标目录中生成对应的xxxx_pb2.py

实践:cmd打开控制台,进入到addressbook.proto3所在目录,然后执行以下命令

protoc --python_out=. addressbook.proto2

命令执行成功后,会再当前目录下生成与.proto2文件同名目录(例中为addressbook),目录下自动生成对应的py文件(例中为proto2_pb2.py,实践时将其拷贝到addressbook.proto2所在目录并从命名为addressbook_pb2.py

协议缓冲区 API

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

# -*- coding: utf-8 -*-
# Generated by the protocol buffer compiler.  DO NOT EDIT!
# source: addressbook.proto2
# Protobuf Python Version: 4.25.4
"""Generated protocol buffer code."""
from google.protobuf import descriptor as _descriptor
from google.protobuf import descriptor_pool as _descriptor_pool
from google.protobuf import symbol_database as _symbol_database
from google.protobuf.internal import builder as _builder
# @@protoc_insertion_point(imports)

_sym_db = _symbol_database.Default()




DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x12\x61\x64\x64ressbook.proto2\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, 'addressbook.proto2_pb2', _globals)
if _descriptor._USE_C_DESCRIPTORS == False:
  DESCRIPTOR._options = None
  _globals['_PERSON']._serialized_start=33
  _globals['_PERSON']._serialized_end=324
  _globals['_PERSON_PHONENUMBER']._serialized_start=130
  _globals['_PERSON_PHONENUMBER']._serialized_end=218
  _globals['_PERSON_PHONETYPE']._serialized_start=220
  _globals['_PERSON_PHONETYPE']._serialized_end=324
  _globals['_ADDRESSBOOK']._serialized_start=326
  _globals['_ADDRESSBOOK']._serialized_end=373
# @@protoc_insertion_point(module_scope)

每个类中的重要行是 __metaclass__ = reflection.GeneratedProtocolMessageType。可以将它们视为创建类的模板。在加载时,GeneratedProtocolMessageType 元类使用指定的描述符来创建使用每种消息类型所需的所有 Python 方法,并将它们添加到相关的类中。然后可以在代码中使用完全填充的类。

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

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。此外,在设置字段之前读取字段的值会返回默认值。

枚举

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

标准消息方法

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

  • IsInitialized(): 检查是否已设置所有必需的字段。
  • __str__():返回消息的可读表示形式,特别适用于调试。(通常这样调用 str(message)print(message)
  • CopyFrom(other_msg):使用给定消息的值覆盖消息。
  • Clear():清除所有元素,使其返回到空状态。

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

解析和序列化

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

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

这些只是用于解析和序列化的选择中的一部分。同样,请参阅Message API 参考以获取完整列表。

重要

协议缓冲区和面向对象设计 协议缓冲区类基本上是数据持有者(如 C 中的结构),不提供其他功能;它们在对象模型中不是好的首要公民。如果想为生成的类添加更丰富的行为,最好的方法是将生成的协议缓冲区类包装在特定于应用程序的类中。如果你无法控制 .proto 文件的设计(例如,如果正在复用来自另一个项目的文件),那么包装协议缓冲区也是一个好主意。在这种情况下,您可以使用包装器类来构建更适合你应用程序的独特环境的接口:隐藏一些数据和方法,公开便捷功能等。绝不应通过继承生成的类继承来向它们添加行为。这会破坏内部机制,而且无论如何也不是好的面向对象实践。

编写消息

假设希望通讯录应用程序能够做到的第一件事就是将个人详细信息写入通讯录文件。为此,需要创建和填充协议缓冲区类的实例,然后将它们写入输出流。

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

#!/usr/bin/env python3
# -*- coding:utf-8 -*-

import addressbook_pb2
import os

def PromptForAddress(person):
    '''基于用户输入填充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.')


address_book = addressbook_pb2.AddressBook()

# 读取已存在地址簿
if os.path.exists('my_addressbook.db'):
    with open('my_addressbook.db', 'rb') as f:
        address_book.ParseFromString(f.read())

# 添加一个通讯地址
PromptForAddress(address_book.people.add())

# 将通讯地址写到磁盘
with open('my_addressbook.db', 'wb') as f:
    f.write(address_book.SerializeToString())

运行程序后按提示输入内容,形如以下

Enter person ID number: 1
Enter name: shouke
Enter email address (blank for none): [email protected]
Enter a phone number (or leave blank to finish): 15813735565
Is this a mobile, home, or work phone? mobile
Enter a phone number (or leave blank to finish): 

读取消息

此示例读取上述示例创建的文件,并打印其中的所有信息

# -*- coding:utf-8 -*-

import addressbook_pb2

def ListPeople(address_book):
  '''遍历地址簿中的所有people并打印相关信息'''

  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)



address_book = addressbook_pb2.AddressBook()

# 读取已存在地址簿
with open('my_addressbook.db', 'rb') as f:
  address_book.ParseFromString(f.read())

ListPeople(address_book)

运行输出:

Person ID:  1
Name:  shouke
E-mail address:  [email protected]
Mobile phone #: 15813735565

另一个示例

例子中,定义了一个名为Device的消息,它有4个字段:namepricetypelabels

device.proto

syntax = "proto3";
message Device {
  string name = 1;
  int32 price = 2;
  string type = 3;
  map<string, string> labels = 15;
}

根据device.proto文件生成python文件

protoc --python_out=. device.proto

自动在当前目录下生成device目录及device/proto3_pb2.py文件

使用生成的py文件(拷贝上述py文件并重命名为device_pb2.py,和以下文件存放在同级目录)

my_test.py

# -*- coding:utf-8 -*-

import device_pb2

# 创建一个Person对象并设置字段值
device = device_pb2.Device()
device.name = '联想小星'
device.price =  3999
device.type = 'Notebook'
device.labels['color'] = 'red'
device.labels['outlook'] = 'fashionable'


# 序列化Person对象为二进制字符串
serialized_device = device.SerializeToString()
print(f"序列化后的数据:{serialized_device}")

# 反序列化二进制字符串为一个新的Person对象
new_device = device_pb2.Device()
new_device.ParseFromString(serialized_device)

# 输出新的Device对象的字段值
print(type(new_device.labels)) # <class 'google._upb._message.ScalarMapContainer'>
for label, value in new_device.labels.items():
    print(label, value) # 输出内容形如:color red

print(new_device.labels) # {'color': 'red', 'outlook': 'fashionable'}

print(f'反序列化后的数据:设备名称={new_device.name}, 价格={new_device.price}, 类型={new_device.type}, 标签={new_device.labels}')
# 输出:反序列化后的数据:设备名称=联想小星, 价格=3999, 类型=Notebook, 标签={'color': 'red', 'outlook': 'fashionable'}

参考链接

https://protobuf.dev/getting-started/pythontutorial/

https://protobuf.com.cn/getting-started/pythontutorial/

https://protobuf.dev/programming-guides/proto3/