Основы Protocol Buffer: Python

Базовое введение в работу с protocol buffers для программистов на Python.

Это руководство предоставляет базовое введение для программистов на Python в работу с protocol buffers. На примере создания простого приложения оно показывает, как:

  • Определять форматы сообщений в файле .proto.
  • Использовать компилятор protocol buffer.
  • Использовать Python API protocol buffer для записи и чтения сообщений.

Это не исчерпывающее руководство по использованию protocol buffers в Python. Для получения более подробной справочной информации см. Руководство по языку Protocol Buffer (proto2), Руководство по языку Protocol Buffer (proto3), Справочник по Python API, Руководство по сгенерированному коду на Python и Справочник по кодированию.

Проблемная область

Пример, который мы будем использовать, — это очень простое приложение "адресная книга", которое может читать и записывать контактные данные людей в файл и из файла. Каждый человек в адресной книге имеет имя, ID, адрес электронной почты и контактный номер телефона.

Как сериализовать и извлекать такие структурированные данные? Есть несколько способов решить эту проблему:

  • Использовать Python pickling. Это подход по умолчанию, так как он встроен в язык, но он плохо справляется с эволюцией схемы, а также не работает очень хорошо, если вам нужно делиться данными с приложениями, написанными на C++ или Java.
  • Вы можете придумать специальный способ кодирования элементов данных в единую строку — например, кодируя 4 целых числа как "12:3:-23:67". Это простой и гибкий подход, хотя он требует написания одноразового кода кодирования и разбора, и разбор накладывает небольшие затраты времени выполнения. Это лучше всего работает для кодирования очень простых данных.
  • Сериализовать данные в XML. Этот подход может быть очень привлекательным, поскольку XML (вроде как) читаем человеком и есть библиотеки привязок для множества языков. Это может быть хорошим выбором, если вы хотите делиться данными с другими приложениями/проектами. Однако XML печально известен своей прожорливостью к месту, и кодирование/декодирование может наложить огромные штрафы на производительность приложений. Кроме того, навигация по DOM-дереву XML значительно сложнее, чем навигация по простым полям в классе в обычных условиях.

Вместо этих вариантов вы можете использовать protocol buffers. Protocol buffers — это гибкое, эффективное, автоматизированное решение для решения именно этой проблемы. С protocol buffers вы пишете .proto описание структуры данных, которую хотите сохранить. На основе этого компилятор protocol buffer создает класс, который реализует автоматическое кодирование и разбор данных protocol buffer в эффективном бинарном формате. Сгенерированный класс предоставляет геттеры и сеттеры для полей, составляющих protocol buffer, и заботится о деталях чтения и записи protocol buffer как единого целого. Важно, что формат protocol buffer поддерживает идею расширения формата со временем таким образом, что код все еще может читать данные, закодированные в старом формате.

Где найти пример кода

Пример кода включен в пакет с исходным кодом, в директории "examples". Скачайте его здесь.

Определение вашего формата протокола

Чтобы создать ваше приложение "адресная книга", вам нужно начать с файла .proto. Определения в файле .proto просты: вы добавляете сообщение для каждой структуры данных, которую хотите сериализовать, затем указываете имя и тип для каждого поля в сообщении. Вот файл .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 пакеты обычно определяются структурой директорий, поэтому package, который вы определяете в вашем .proto файле, не окажет влияния на сгенерированный код. Однако вы все равно должны объявить его, чтобы избежать коллизий имен в пространстве имен Protocol Buffers, а также в неподдерживаемых языках.

Далее идут ваши определения сообщений. Сообщение — это просто агрегат, содержащий набор типизированных полей. Многие стандартные простые типы данных доступны в качестве типов полей, включая bool, int32, float, double и string. Вы также можете добавить дальнейшую структуру в ваши сообщения, используя другие типы сообщений в качестве типов полей — в приведенном выше примере сообщение Person содержит PhoneNumber сообщения, в то время как сообщение AddressBook содержит Person сообщения. Вы можете даже определять типы сообщений, вложенные внутрь других сообщений — как вы можете видеть, тип PhoneNumber определен внутри Person. Вы также можете определить типы enum если хотите, чтобы одно из ваших полей имело одно из предопределенного списка значений — здесь вы хотите указать, что номер телефона может быть одним из следующих типов телефонов: PHONE_TYPE_MOBILE, PHONE_TYPE_HOME или PHONE_TYPE_WORK.

Маркеры " = 1", " = 2" на каждом элементе идентифицируют уникальный "тег", который поле использует в бинарном кодировании. Номера тегов 1-15 требуют на один байт меньше для кодирования, чем более высокие номера, поэтому в качестве оптимизации вы можете решить использовать эти теги для часто используемых или повторяющихся элементов, оставляя теги 16 и выше для реже используемых необязательных элементов. Каждый элемент в повторяющемся поле требует перекодировки номера тега, поэтому повторяющиеся поля являются particularly good candidates (особенно хорошими кандидатами) для этой оптимизации.

Вы найдете полное руководство по написанию файлов .proto — включая все возможные типы полей — в Руководстве по языку Protocol Buffer. Однако не ищите возможности, похожие на наследование классов — protocol buffers этого не делают.

Компиляция ваших Protocol Buffers

Теперь, когда у вас есть .proto, следующее, что нужно сделать, — это сгенерировать классы, которые вам понадобятся для чтения и записи сообщений AddressBook (и, следовательно, Person и PhoneNumber). Для этого вам нужно запустить компилятор protocol buffer protoc на вашем .proto:

  1. Если вы не установили компилятор, скачайте пакет и следуйте инструкциям в README.

  2. Теперь запустите компилятор, указав исходную директорию (где находится исходный код вашего приложения — используется текущая директория, если вы не предоставили значение), целевую директорию (куда вы хотите, чтобы сгенерированный код попал; часто та же, что и $SRC_DIR) и путь к вашему .proto. В этом случае вы...:

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

    Поскольку вы хотите классы Python, вы используете опцию --python_out — похожие опции предоставляются для других поддерживаемых языков.

    Protoc также может генерировать Python stubs (заглушки) (.pyi) с помощью --pyi_out.

Это генерирует addressbook_pb2.py (или addressbook_pb2.pyi) в указанной вами целевой директории.

API Protocol Buffer

В отличие от случаев, когда вы генерируете код protocol buffer для Java и C++, компилятор Python protocol buffer не генерирует ваш код доступа к данным напрямую. Вместо этого (как вы увидите, если посмотрите на addressbook_pb2.py) он генерирует специальные дескрипторы для всех ваших сообщений, перечислений и полей, и некоторые mysteriously empty classes (таинственно пустые классы), по одному для каждого типа сообщения:

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

Обратите внимание, что эти присваивания не просто добавляют произвольные новые поля к generic Python object (общему объекту Python). Если вы попытаетесь присвоить полю значение, которое не определено в файле .proto, будет вызвано AttributeError. Если вы присвоите полю значение неправильного типа, будет вызвано TypeError. Также, чтение значения поля до того, как оно было установлено, возвращает значение по умолчанию.

person.no_such_field = 1  # вызывает AttributeError
person.id = "1234"        # вызывает TypeError

Для получения дополнительной информации о том, какие именно члены компилятор протоколов генерирует для любого конкретного определения поля, см. Справочник по сгенерированному коду на Python.

Перечисления

Перечисления расширяются метаклассом в набор символьных констант с целочисленными значениями. Так, например, константа addressbook_pb2.Person.PhoneType.PHONE_TYPE_WORK имеет значение 2.

Стандартные методы сообщений

Каждый класс сообщений также содержит ряд других методов, которые позволяют вам проверять или манипулировать всем сообщением, включая:

  • IsInitialized(): проверяет, установлены ли все обязательные поля.
  • __str__(): возвращает удобочитаемое представление сообщения, особенно полезное для отладки. (Обычно вызывается как str(message) или print message.)
  • CopyFrom(other_msg): перезаписывает сообщение значениями данного сообщения.
  • Clear(): очищает все элементы обратно в пустое состояние.

Эти методы реализуют интерфейс Message. Для получения дополнительной информации см. полную документацию API для Message.

Разбор и сериализация

Наконец, каждый класс protocol buffer имеет методы для записи и чтения сообщений вашего выбранного типа, используя бинарный формат protocol buffer. К ним относятся:

  • SerializeToString(): сериализует сообщение и возвращает его в виде строки. Обратите внимание, что байты являются бинарными, а не текстовыми; мы используем тип str только как удобный контейнер.
  • ParseFromString(data): разбирает сообщение из данной строки.

Это лишь пара из предоставленных вариантов для разбора и сериализации. Снова см. справочник по API Message для получения полного списка.

Вы также можете легко сериализовать сообщения в 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"

# Сериализовать в JSON
json_string = json_format.MessageToJson(person)

# Разобрать из JSON
new_person = addressbook_pb2.Person()
json_format.Parse(json_string, new_person)

{{% alert title="Важно" color="warning" %}} Protocol Buffers и объектно-ориентированный дизайн Классы Protocol buffer — это, по сути, держатели данных (как структуры в C), которые не предоставляют дополнительной функциональности; они не являются хорошими полноправными гражданами в объектной модели. Если вы хотите добавить более богатое поведение в сгенерированный класс, лучший способ сделать это — обернуть сгенерированный класс protocol buffer в специфичный для приложения класс. Обертывание protocol buffers также является хорошей идеей, если вы не контролируете дизайн файла .proto (если, скажем, вы используете его из другого проекта). В этом случае вы можете использовать класс-обертку для создания интерфейса, лучше подходящего для уникальной среды вашего приложения: скрывая некоторые данные и методы, предоставляя удобные функции и т.д. Вы никогда не должны добавлять поведение к сгенерированным классам, наследуясь от них. Это нарушит внутренние механизмы и, в любом случае, не является хорошей объектно-ориентированной практикой. {{% /alert %}}

Написание сообщения

Теперь давайте попробуем использовать ваши классы protocol buffer. Первое, что вы хотите, чтобы ваше приложение "адресная книга" могло делать, — это записывать личные данные в ваш файл адресной книги. Для этого вам нужно создать и заполнить экземпляры ваших классов protocol buffer, а затем записать их в выходной поток.

Вот программа, которая читает AddressBook из файла, добавляет одного нового Person в него на основе пользовательского ввода и записывает новый AddressBook обратно в файл снова. Части, которые напрямую вызывают или ссылаются на код, сгенерированный компилятором протокола, выделены.

#!/usr/bin/env python3

import addressbook_pb2
import sys

# Эта функция заполняет сообщение Person на основе пользовательского ввода.
def PromptForAddress(person):
  person.id = int(input("Введите ID человека: "))
  person.name = input("Введите имя: ")

  email = input("Введите адрес электронной почты (пусто для отсутствия): ")
  if email != "":
    person.email = email

  while True:
    number = input("Введите номер телефона (или оставьте пустым для завершения): ")
    if number == "":
      break

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

    phone_type = input("Это мобильный, домашний или рабочий телефон? ")
    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("Неизвестный тип телефона; оставляем значение по умолчанию.")

# Главная процедура: Читает всю адресную книгу из файла,
#   добавляет одного человека на основе пользовательского ввода, затем записывает её обратно в тот же
#   файл.
if len(sys.argv) != 2:
  print("Использование:", sys.argv[0], "ФАЙЛ_АДРЕСНОЙ_КНИГИ")
  sys.exit(-1)

address_book = addressbook_pb2.AddressBook()

# Читаем существующую адресную книгу.
try:
  with open(sys.argv[1], "rb") as f:
    address_book.ParseFromString(f.read())
except IOError:
  print(sys.argv[1] + ": Не удалось открыть файл. Создается новый.")

# Добавляем адрес.
PromptForAddress(address_book.people.add())

# Записываем новую адресную книгу обратно на диск.
with open(sys.argv[1], "wb") as f:
  f.write(address_book.SerializeToString())

Чтение сообщения

Конечно, адресная книга была бы не очень полезна, если бы вы не могли получить из нее никакой информации! Этот пример читает файл, созданный приведенным выше примером, и печатает всю информацию в нем.

#!/usr/bin/env python3

import addressbook_pb2
import sys

# Перебирает всех людей в AddressBook и печатает информацию о них.
def ListPeople(address_book):
  for person in address_book.people:
    print("ID человека:", person.id)
    print("  Имя:", person.name)
    if person.HasField('email'):
      print("  Адрес электронной почты:", person.email)

    for phone_number in person.phones:
      if phone_number.type == addressbook_pb2.Person.PhoneType.PHONE_TYPE_MOBILE:
        print("  Мобильный телефон #: ", end="")
      elif phone_number.type == addressbook_pb2.Person.PhoneType.PHONE_TYPE_HOME:
        print("  Домашний телефон #: ", end="")
      elif phone_number.type == addressbook_pb2.Person.PhoneType.PHONE_TYPE_WORK:
        print("  Рабочий телефон #: ", end="")
      print(phone_number.number)

# Главная процедура: Читает всю адресную книгу из файла и печатает всю
#   информацию внутри.
if len(sys.argv) != 2:
  print("Использование:", sys.argv[0], "ФАЙЛ_АДРЕСНОЙ_КНИГИ")
  sys.exit(-1)

address_book = addressbook_pb2.AddressBook()

# Читаем существующую адресную книгу.
with open(sys.argv[1], "rb") as f:
  address_book.ParseFromString(f.read())

ListPeople(address_book)

Расширение Protocol Buffer

Рано или поздно после того, как вы выпустите код, использующий ваш protocol buffer, вы несомненно захотите "улучшить" определение protocol buffer. Если вы хотите, чтобы ваши новые буферы были обратно совместимы, а ваши старые буферы были вперед совместимы — а вы почти certainly (несомненно) хотите этого — тогда есть некоторые правила, которым вы должны следовать. В новой версии protocol buffer:

  • вы не должны изменять номера тегов любых существующих полей.
  • вы не должны добавлять или удалять любые обязательные поля.
  • вы можете удалять необязательные или повторяющиеся поля.
  • вы можете добавлять новые необязательные или повторяющиеся поля, но вы должны использовать новые номера тегов (то есть номера тегов, которые никогда не использовались в этом protocol buffer, даже удаленными полями).

(Есть некоторые исключения из этих правил, но они редко используются.)

Если вы следуете этим правилам, старый код будет happily (с радостью) читать новые сообщения и просто игнорировать любые новые поля. Для старого кода, необязательные поля, которые были удалены, будут просто иметь свое значение по умолчанию, а удаленные повторяющиеся поля будут пусты. Новый код также будет прозрачно читать старые сообщения. Однако имейте в виду, что новые необязательные поля не будут присутствовать в старых сообщениях, поэтому вам нужно будет либо проверять явно, установлены ли они с помощью HasField('field_name'), либо предоставить разумное значение по умолчанию в вашем .proto файле с [default = value] после номера тега. Если значение по умолчанию не указано для необязательного элемента, используется значение по умолчанию, зависящее от типа: для строк значением по умолчанию является пустая строка. Для булевых значений значением по умолчанию является false. Для числовых типов значением по умолчанию является ноль. Также обратите внимание, что если вы добавили новое повторяющееся поле, ваш новый код не сможет определить, было ли оно оставлено пустым (новым кодом) или никогда не устанавливалось вообще (старым кодом), поскольку для него нет проверки HasField.

Продвинутое использование

Protocol buffers имеют применения, выходящие за рамки простых методов доступа и сериализации. Непременно исследуйте Справочник по Python API, чтобы увидеть, что еще вы можете сделать с ними.

Одной ключевой особенностью, предоставляемой классами сообщений протокола, является reflection (рефлексия). Вы можете перебирать поля сообщения и манипулировать их значениями без написания вашего кода против какого-либо конкретного типа сообщения. Очень полезный способ использования рефлексии — это преобразование сообщений протокола в другие кодировки и обратно, такие как XML или JSON (см. Разбор и сериализация для примера). Более продвинутое использование рефлексии может заключаться в нахождении разниц между двумя сообщениями одного типа, или в разработке своего рода "регулярных выражений для сообщений протокола", в которых вы можете писать выражения, соответствующие определенному содержимому сообщения. Если вы используете свое воображение, возможно применять Protocol Buffers к гораздо более широкому кругу проблем, чем вы могли изначально ожидать!

Рефлексия предоставляется как часть интерфейса Message.