Protocol Buffer の基本:Python

プロトコルバッファを扱うためのPythonプログラマー向けの基本的な紹介。

このチュートリアルでは、Pythonプログラマー向けにプロトコルバッファの基本的な扱い方を紹介します。簡単なサンプルアプリケーションを作成する手順を通して、次の方法を説明します。

  • .proto ファイルでメッセージ形式を定義する。
  • プロトコルバッファコンパイラを使用する。
  • Python プロトコルバッファ API を使用してメッセージを書き込み、読み込む。

これはPythonでプロトコルバッファを使用するための包括的なガイドではありません。より詳細なリファレンス情報については、Protocol Buffer 言語ガイド (proto2)Protocol Buffer 言語ガイド (proto3)Python API リファレンスPython 生成コードガイド、およびエンコーディングリファレンスを参照してください。

問題領域

ここで使用する例は、人々の連絡先情報をファイルに読み書きできる、ごくシンプルな「アドレス帳」アプリケーションです。アドレス帳の各人物は、名前、ID、メールアドレス、連絡先の電話番号を持っています。

このような構造化されたデータをどのようにシリアル化し、取得するのでしょうか?この問題を解決するには、いくつか方法があります。

  • Python のピクルス機能を使用する。これは言語に組み込まれているためデフォルトのアプローチですが、スキーマの進化にうまく対応できず、C++ や Java で書かれたアプリケーションとデータを共有する必要がある場合にもあまりうまく機能しません。
  • データ項目を単一の文字列にエンコードするアドホックな方法(例:「12:3:-23:67」のように4つの整数をエンコードする)を考案することもできます。これはシンプルで柔軟なアプローチですが、一度限りのエンコードおよびパースコードの作成が必要であり、パースにはわずかな実行時コストがかかります。これはごくシンプルなデータのエンコードに最適です。
  • データをXMLにシリアル化する。XMLは(ある程度)人間が読める形式であり、多くの言語に対応するバインディングライブラリがあるため、このアプローチは非常に魅力的です。他のアプリケーションやプロジェクトとデータを共有したい場合には良い選択肢となります。しかし、XMLはデータ容量が大きくなることで悪名高く、そのエンコード/デコードはアプリケーションに大きなパフォーマンスペナルティを課す可能性があります。また、XML DOMツリーをナビゲートすることは、通常のクラスのシンプルなフィールドをナビゲートするよりもかなり複雑です。

これらの選択肢の代わりに、プロトコルバッファを使用できます。プロトコルバッファは、この問題を正確に解決するための柔軟で効率的、自動化されたソリューションです。プロトコルバッファを使用すると、保存したいデータ構造の.proto記述を作成します。そこから、プロトコルバッファコンパイラは、効率的なバイナリ形式でプロトコルバッファデータの自動エンコーディングとパースを実装するクラスを作成します。生成されたクラスは、プロトコルバッファを構成するフィールドのゲッターとセッターを提供し、プロトコルバッファを単位として読み書きする詳細を処理します。重要なのは、プロトコルバッファ形式が、古い形式でエンコードされたデータをコードが読み取れるように、時間の経過とともに形式を拡張するという考え方をサポートしていることです。

サンプルコードの場所

サンプルコードは、ソースコードパッケージの「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では、パッケージは通常ディレクトリ構造によって決定されるため、.protoファイルで定義するpackageは生成されたコードには影響しません。しかし、Protocol Buffersの名前空間およびPython以外の言語での名前の衝突を避けるためには、依然として宣言すべきです。

次に、メッセージ定義があります。メッセージは、型付きフィールドのセットを含む単なる集約体です。boolint32floatdoublestringなど、多くの標準的な単純なデータ型がフィールド型として利用できます。また、他のメッセージ型をフィールド型として使用することで、メッセージにさらなる構造を追加することもできます。上記の例では、PersonメッセージにはPhoneNumberメッセージが含まれ、AddressBookメッセージにはPersonメッセージが含まれています。メッセージ型を他のメッセージ内にネストして定義することもできます。ご覧のとおり、PhoneNumber型はPerson内に定義されています。フィールドのいずれかに事前定義された値のリストのいずれかを持たせたい場合は、enum型を定義することもできます。ここでは、電話番号が以下の電話タイプのうちのいずれかであることを指定したいとします: PHONE_TYPE_MOBILEPHONE_TYPE_HOME、またはPHONE_TYPE_WORK

各要素の「= 1」、「= 2」のマーカーは、バイナリエンコーディングでそのフィールドが使用する一意の「タグ」を識別します。タグ番号1~15は、それよりも大きい番号よりも1バイト少なくエンコードされるため、最適化として、これらのタグをよく使われる要素や繰り返される要素に使用し、16以上のタグはあまり使われないオプションの要素に残すことができます。繰り返しフィールドの各要素はタグ番号の再エンコードを必要とするため、繰り返しフィールドはこの最適化に特に適しています。

可能なフィールド型を含む.protoファイルの作成に関する完全なガイドは、Protocol Buffer 言語ガイドにあります。ただし、クラス継承に似た機能を探すのはやめましょう。プロトコルバッファはそれを行いません。

プロトコルバッファのコンパイル

.protoファイルができたので、次に必要なのは、AddressBook(したがってPersonPhoneNumber)メッセージを読み書きするために必要なクラスを生成することです。これを行うには、.protoファイルに対してプロトコルバッファコンパイラ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)が生成されます。

プロトコルバッファAPI

JavaやC++のプロトコルバッファコードを生成する場合とは異なり、Pythonプロトコルバッファコンパイラはデータアクセスコードを直接生成しません。代わりに(addressbook_pb2.pyを見るとわかるように)、すべてのメッセージ、enum、フィールドに特殊な記述子を生成し、各メッセージタイプに対して不思議なほど空のクラスを生成します。

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 生成コードリファレンスを参照してください。

Enums

Enums はメタクラスによって、整数値を持つ一連のシンボリック定数に展開されます。したがって、たとえば定数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を1人追加し、新しい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)

プロトコルバッファの拡張

プロトコルバッファを使用するコードをリリースした後、遅かれ早かれ、プロトコルバッファの定義を「改善」したくなるでしょう。新しいバッファが後方互換性を持ち、古いバッファが前方互換性を持つことを望む場合(そして、ほとんどの場合そう望むはずです)、従うべきいくつかのルールがあります。プロトコルバッファの新しいバージョンでは、

  • 既存のフィールドのタグ番号を変更してはなりません
  • 必須フィールドを追加または削除してはなりません
  • オプションまたは繰り返しフィールドを削除できます
  • 新しいオプションまたは繰り返しフィールドを追加できますが、そのプロトコルバッファでこれまでに使用されていない(削除されたフィールドによっても使用されていない)新しいタグ番号を使用する必要があります。

(これらのルールにはいくつかの例外がありますが、めったに使用されません。)

これらのルールに従えば、古いコードは新しいメッセージを問題なく読み込み、新しいフィールドは単純に無視します。古いコードにとって、削除されたオプションフィールドは単にデフォルト値になり、削除された繰り返しフィールドは空になります。新しいコードも古いメッセージを透過的に読み込みます。ただし、新しいオプションフィールドは古いメッセージには存在しないため、has_で明示的に設定されているかどうかを確認するか、タグ番号の後に[default = value]を使用して.protoファイルで適切なデフォルト値を提供する必要があることに注意してください。オプション要素のデフォルト値が指定されていない場合、型固有のデフォルト値が使用されます。文字列の場合、デフォルト値は空の文字列です。ブール値の場合、デフォルト値はfalseです。数値型の場合、デフォルト値はゼロです。また、新しい繰り返しフィールドを追加した場合、新しいコードはそれが空にされたのか(新しいコードによって)、まったく設定されなかったのか(古いコードによって)を判断できないことに注意してください。これは、それに対してhas_フラグがないためです。

高度な使用法

プロトコルバッファには、単純なアクセサーやシリアル化を超えた用途があります。Python API リファレンスを調べて、他に何ができるかを確認してください。

プロトコルメッセージクラスが提供する主要な機能の1つはリフレクションです。特定のメッセージタイプにコードを記述することなく、メッセージのフィールドを反復処理し、その値を操作できます。リフレクションを使用する非常に便利な方法の1つは、プロトコルメッセージをXMLやJSONなどの他のエンコーディングに変換することです。リフレクションのより高度な使用法としては、同じタイプの2つのメッセージ間の違いを見つけたり、特定のメッセージコンテンツに一致する式を記述できる一種の「プロトコルメッセージの正規表現」を開発したりすることが考えられます。想像力を働かせれば、プロトコルバッファを当初予想していたよりもはるかに幅広い問題に適用することが可能です!

リフレクションはMessage インターフェースの一部として提供されます。