Protocol Buffer の基礎: Python
このチュートリアルは、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 以外の言語での名前の衝突を避けるために、やはり宣言しておくべきです。
次に、メッセージ定義があります。メッセージは、型付けされたフィールドのセットを含む単なる集合体です。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 は、それより大きい番号よりもエンコードに 1 バイト少なく済むため、最適化として、よく使われるフィールドや repeated フィールドにこれらのタグを使用し、タグ 16 以上をあまり使われない optional フィールドに残すことを決定できます。repeated フィールドの各要素はタグ番号の再エンコードが必要なため、repeated フィールドは特にこの最適化の良い候補です。
.proto
ファイルの書き方に関する完全なガイド(すべての可能なフィールド型を含む)は、Protocol Buffer 言語ガイドにあります。ただし、クラスの継承のような機能は探さないでください。Protocol Buffer はそれを行いません。
Protocol Buffer のコンパイル
.proto
ができたので、次に行う必要があるのは、AddressBook
(および Person
と PhoneNumber
)メッセージを読み書きするために必要なクラスを生成することです。そのためには、.proto
に対して Protocol Buffer コンパイラ protoc
を実行する必要があります。
コンパイラをインストールしていない場合は、パッケージをダウンロードし、README の指示に従ってください。
次に、ソースディレクトリ(アプリケーションのソースコードがある場所。値を指定しない場合はカレントディレクトリが使われます)、デスティネーションディレクトリ(生成されたコードを置きたい場所。多くの場合
$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 クラスは、基本的には追加機能を提供しないデータホルダ(C の構造体のようなもの)であり、オブジェクトモデルにおける第一級市民としては適していません。生成されたクラスにリッチな振る舞いを追加したい場合、最善の方法は、生成された Protocol Buffer クラスをアプリケーション固有のクラスでラップすることです。.proto
ファイルの設計を制御できない場合(例えば、他のプロジェクトのものを再利用している場合)も、Protocol Buffer をラップするのは良い考えです。その場合、ラッパークラスを使用して、アプリケーションのユニークな環境により適したインターフェースを作成できます。つまり、一部のデータやメソッドを隠蔽し、便利な関数を公開するなどです。生成されたクラスを継承して振る舞いを追加してはなりません。これは内部メカニズムを破壊し、いずれにせよ優れたオブジェクト指向の実践ではありません。メッセージの書き込み
それでは、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
インターフェース の一部として提供されています。