Protocol Bufferの基礎: Python

プロトコルバッファを扱うための基本的なPythonプログラマー向けガイドです。

このチュートリアルでは、プロトコルバッファを扱うための基本的なPythonプログラマー向けガイドを提供します。簡単なサンプルアプリケーションを作成する手順を通して、以下の方法を示します。

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

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

問題領域

ここで使用する例は、人々の連絡先の詳細をファイルに読み書きできる、非常にシンプルな「アドレス帳」アプリケーションです。アドレス帳の各人物には、名前、ID、メールアドレス、連絡先の電話番号があります。

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

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

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

サンプルコードの場所

サンプルコードは、ソースコードパッケージの「examples」ディレクトリに含まれています。ここからダウンロードしてください。

プロトコルフォーマットの定義

アドレス帳アプリケーションを作成するには、.proto ファイルから始める必要があります。.proto ファイル内の定義は単純です。シリアライズしたい各データ構造に対してメッセージを追加し、メッセージ内の各フィールドに名前と型を指定します。以下は、メッセージ addressbook.proto を定義する .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内に定義されています。フィールドの1つに定義済みの値のリストのいずれかを持たせたい場合は、enum型を定義することもできます。ここでは、電話番号が次のいずれかの電話タイプ(PHONE_TYPE_MOBILEPHONE_TYPE_HOME、またはPHONE_TYPE_WORK)であることを指定したいとします。

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

.protoファイルの書き方(可能なすべてのフィールド型を含む)の完全なガイドは、Protocol Buffer言語ガイドに記載されています。ただし、クラス継承に似た機能を探さないでください。プロトコルバッファにはそのような機能はありません。

Protocol Buffer のコンパイル

` .proto ` ファイルができたら、次に必要なことは、` AddressBook ` (そして ` Person ` と ` PhoneNumber `) メッセージを読み書きするために必要なクラスを生成することです。これを行うには、` .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)が生成されます。

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メソッドを作成し、関連するクラスに追加します。その後、完全に設定されたクラスをコードで使用できます。

このすべてが最終的にもたらす効果は、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_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)

メッセージの書き込み

さて、プロトコルバッファクラスを使ってみましょう。アドレス帳アプリケーションが最初にできるべきことは、個人情報をアドレス帳ファイルに書き込むことです。これを行うには、プロトコルバッファクラスのインスタンスを作成して値を入力し、それを出力ストリームに書き込む必要があります。

以下は、ファイルから 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 の拡張

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

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

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

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

高度な使い方

プロトコルバッファには、単純なアクセサーやシリアライゼーション以外の用途もあります。それらでできることについては、ぜひPython APIリファレンスを調べてみてください。

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

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