Protocol Buffer の基礎: Python

Python プログラマ向けの、Protocol Buffer の基本的な使い方を紹介します。

このチュートリアルは、Python プログラマ向けに Protocol Buffer の基本的な使い方を紹介するものです。簡単なサンプルアプリケーションを作成する手順を通して、以下の方法を学びます。

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

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

問題領域

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

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

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

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

サンプルコードの場所

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

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

アドレス帳アプリケーションを作成するには、.proto ファイルから始める必要があります。.proto ファイルの定義は簡単です。シリアライズしたい各データ構造に対して message を追加し、メッセージ内の各フィールドに名前と型を指定します。以下がメッセージを定義する .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 Buffer の名前空間や Python 以外の言語での名前の衝突を避けるために、やはり宣言しておくべきです。

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

各要素の " = 1"、" = 2" というマーカーは、そのフィールドがバイナリエンコーディングで使用する一意の「タグ」を識別します。タグ番号 1-15 は、それより大きい番号よりもエンコードに 1 バイト少なく済むため、最適化として、よく使われるフィールドや repeated フィールドにこれらのタグを使用し、タグ 16 以上をあまり使われない optional フィールドに残すことを決定できます。repeated フィールドの各要素はタグ番号の再エンコードが必要なため、repeated フィールドは特にこの最適化の良い候補です。

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

Protocol Buffer のコンパイル

.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++ の Protocol Buffer コードを生成する場合と異なり、Python の Protocol Buffer コンパイラはデータアクセスコードを直接生成しません。その代わりに(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

Protocol Buffer コンパイラが特定のフィールド定義に対してどのようなメンバーを生成するかについての詳細は、Python 生成コードリファレンスを参照してください。

Enums

Enum はメタクラスによって、整数値を持つシンボリック定数のセットに展開されます。したがって、例えば定数 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)

メッセージの書き込み

それでは、Protocol Buffer クラスを使ってみましょう。アドレス帳アプリケーションが最初にできるようにしたいことは、個人の詳細をアドレス帳ファイルに書き込むことです。そのためには、Protocol Buffer クラスのインスタンスを作成してデータを入力し、それを出力ストリームに書き込む必要があります。

これは、ファイルから AddressBook を読み取り、ユーザー入力に基づいて新しい Person を1人追加し、新しい AddressBook を再びファイルに書き戻すプログラムです。Protocol Buffer コンパイラによって生成されたコードを直接呼び出したり参照したりする部分はハイライトされています。

#!/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 の拡張

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

  • 既存のフィールドのタグ番号を変更しては*いけません*。
  • required フィールドを追加または削除しては*いけません*。
  • optional または repeated フィールドを削除することは*できます*。
  • 新しい optional または repeated フィールドを追加することは 可能 ですが、新しいタグ番号(つまり、この Protocol Buffer で一度も使用されたことのないタグ番号。削除されたフィールドが使っていたものも含む)を使用しなければなりません。

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

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

高度な使い方

Protocol Buffer には、単純なアクセサやシリアライゼーションを超える用途があります。Python APIリファレンスを調べて、他に何ができるかを確認してください。

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

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