【翻訳】Python 版 Protocol Buffer の基本【Google公式ガイド】

公開:2020/10/13
更新:2020/10/15
14 min読了の目安(約13000字TECH技術記事

Protocol Buffers とは

Protocol Buffers(以下、プロトコルバッファ)は構造化データをバイト列に変換(シリアライズ・シリアル化)するための仕組みです。構造化データとは、各値がどんな意味を持つのか整理されたデータのことです。

以下、"Protocol Buffer Basics: Python" の翻訳

翻訳元: Protocol Buffer Basics: Python

はじめに

このチュートリアルでは、プロトコルバッファを扱うための基本的な入門書を提供します。本記事の簡単なサンプルアプリケーションを作成することで、次のようなことができるようになります。

  • .proto ファイルのメッセージフォーマットを定義する
  • プロトコルバッファコンパイラを使用する
  • プロトコルバッファ API でメッセージを書き込んだり、読み込んだりする

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

なぜプロトコルバッファを使用するのか?

今回使用する例は、人の連絡先をファイルに読み書きできる、とてもシンプルな「アドレス帳」アプリです。アドレス帳の各人は、名前、ID、メールアドレス、連絡先の電話番号を持っています。

このような構造化されたデータをどのようにシリアル化して取り出すのでしょうか?

この問題を解決する方法はいくつかあります。

  • Python のピックリング(オブジェクト階層をバイトストリームに変換する処理)を使用します。これは言語に組み込まれているのでデフォルトのアプローチですが、スキーマの進化をうまく処理できませんし、C++ や Java で書かれたアプリケーションとデータを共有する必要がある場合にはあまりうまくいきません。
  • データ項目を一つの文字列にエンコードするアドホックな方法を考案することができます。これはシンプルで柔軟性の高いアプローチですが、エンコーディングと解析のコードを一度に書く必要があり、解析には少しのランタイムコストがかかります。これは非常に単純なデータのエンコーディングに最適です。
  • データを XML にシリアライズします。XML は人間が読める(ような)ものであり、多くの言語に対応したバインディングライブラリがあるので、このアプローチは非常に魅力的です。他のアプリケーションやプロジェクトとデータを共有したい場合には良い選択です。しかし、XML は悪名高いほどスペースを消費し、エンコードやデコードを行うとアプリケーションのパフォーマンスに大きなペナルティを課すことになります。また、XML DOM ツリーを移動するのは、クラス内の単純なフィールドを移動するよりもかなり複雑です。

プロトコルバッファは、この問題を解決するための柔軟で効率的な自動化されたソリューションです。

プロトコルバッファでは、保存したいデータ構造の .proto 記述を記述します。プロトコルバッファコンパイラは、プロトコルバッファのデータを効率的なバイナリ形式で自動エンコードして解析するクラスを生成します。生成されたクラスは、プロトコルバッファを構成するフィールドのゲッターとセッターを提供し、プロトコルバッファをユニットとして読み書きするための詳細な処理を行います。

重要なことは、プロトコルバッファフォーマットは、コードが古いフォーマットでエンコードされたデータを読み取ることができるように、時間の経過とともにフォーマットを拡張することをサポートしているということです。

サンプルコードの場所

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

プロトコル形式の定義

アドレス帳アプリケーションを作成するには、.proto ファイルから始める必要があります。.proto ファイルの定義は簡単です。シリアル化したいデータ構造ごとにメッセージを追加し、メッセージ内の各フィールドに名前と型を指定します。

syntax = "proto2";

package tutorial;

message Person {
  required string name = 1;
  required int32 id = 2;
  optional string email = 3;

  enum PhoneType {
    MOBILE = 0;
    HOME = 1;
    WORK = 2;
  }

  message PhoneNumber {
    required string number = 1;
    optional PhoneType type = 2 [default = HOME];
  }

  repeated PhoneNumber phones = 4;
}

message AddressBook {
  repeated Person people = 1;
}

見ての通り、構文は C++ や Java に似ています。ファイルの各部分を見て、何をするのか見てみましょう。

.proto ファイルはパッケージ宣言で始まり、異なるプロジェクト間での名前の競合を防げます。Python では、パッケージは通常ディレクトリ構造によって決定されるので、.proto ファイルで定義したパッケージは生成されるコードに影響を与えません。しかし、Python 以外の言語と同様に、プロトコルバッファの名前空間での名前の衝突を避けるためにも、パッケージを宣言する必要があります。

次に、メッセージの定義です。メッセージは、型付けされたフィールドの集合体です。bool, int32, float, double & string など、多くのデータ型がフィールド型として使用できます。また、他のメッセージ型をフィールド型として使用することで、メッセージにさらなる構造を追加することもできます。上記の例では、Person メッセージには PhoneNumber メッセージが含まれ、AddressBook メッセージには Person メッセージが含まれています。他のメッセージの中に入れ子になったメッセージ型を定義することもできます。

ご覧のように、PhoneNumber 型は Person の中に定義されています。フィールドのいずれかに定義済みの値のリストのいずれかを持たせたい場合は、列挙型を定義することもできます。

ここでは、電話番号を MOBILE、HOME、WORK のいずれかに指定します。

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

各フィールドには、以下の修飾子のいずれかをアノテーションする必要があります。

  • required: フィールドの値を指定しなければなりません。そうでない場合、メッセージは「未初期化」とみなされます。初期化されていないメッセージをシリアライズすると例外が発生します。初期化されていないメッセージのパースは失敗します。それ以外の必須フィールドはオプションフィールドと全く同じように動作します。
  • optional: フィールドは設定してもしなくてもかまいません。オプションのフィールド値が設定されていない場合は、デフォルト値が使用されます。単純な型の場合は、例の電話番号型のように、独自のデフォルト値を指定することができます。それ以外の場合は、システムのデフォルト値が使用されます。数値型の場合はゼロ、文字列の場合は空文字列、bools の場合は false です。埋め込みメッセージの場合、デフォルト値は常にメッセージの「デフォルト・インスタンス」または「プロトタイプ」であり、いずれのフィールドも設定されていません。明示的に設定されていないオプションの (または必須の) フィールドの値を取得するためにアクセサを呼び出すと、常にそのフィールドのデフォルト値が返されます。
  • repeated: フィールドは任意の回数(ゼロを含む)繰り返されます。繰り返した値の順序はプロトコルバッファに保存されます。繰り返しフィールドは動的なサイズの配列と考えてください。

フィールドを必須としてマークすることには十分注意しなければなりません。ある時点で必須フィールドの記述や送信を停止したい場合、そのフィールドをオプションフィールドに変更することは問題となります。古い読者はこのフィールドのないメッセージを不完全なものとみなし、意図せずに拒否したりドロップしたりするかもしれません。代わりに、バッファ用のアプリケーション固有のカスタムバリデーションルーチンを書くことを検討すべきです。Google のエンジニアの中には、required を使うことは良いことよりも害が大きいという結論に達した人もいます。しかし、この見解は普遍的なものではありません。

.proto ファイルの書き方については、プロトコルバッファ言語ガイドに完全なガイドがあります。ただし、クラス継承のような機能を求めてはいけません。プロトコルバッファにはそのような機能はありません。

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

.proto ができたので、次にやるべきことは、AddressBook(つまり、Person と PhoneNumber)メッセージを読み書きするために必要なクラスを生成することです。そのためには .proto 上でプロトコルバッファコンパイラ protoc を実行する必要があります。

  1. コンパイラをインストールしていない場合は、パッケージをダウンロードして README の指示に従ってください。

  2. ここでコンパイラを実行し、ソースディレクトリ(アプリケーションのソースコードがある場所。値を指定しない場合はカレントディレクトリが使用されます。)、デスティネーションディレクトリ(生成されたコードを移動させたい場所。$SRC_DIR と同じであることが多いです。)、 .proto へのパスを指定します。この場合...。

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

Python のクラスが必要なので --python_out オプションを使用します。

指定した保存先ディレクトリに addressbook_pb2.py を生成します。

プロトコルバッファ API

Java や C++ のプロトコルバッファコードを生成するときとは異なり、Python のプロトコルバッファコンパイラはデータアクセスコードを直接生成しません。その代わりに (addressbook_pb2.py を見ていただければわかると思いますが)、すべてのメッセージ、enum、フィールドのための特別な記述子と、メッセージタイプごとに1つずつの不思議な空のクラスを生成します。

class Person(message.Message):
  __metaclass__ = reflection.GeneratedProtocolMessageType

  class PhoneNumber(message.Message):
    __metaclass__ = reflection.GeneratedProtocolMessageType
    DESCRIPTOR = _PERSON_PHONENUMBER
  DESCRIPTOR = _PERSON

class AddressBook(message.Message):
  __metaclass__ = reflection.GeneratedProtocolMessageType
  DESCRIPTOR = _ADDRESSBOOK

各クラスの重要な行は __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.HOME

これらの割り当ては、一般的な Python オブジェクトに任意の新しいフィールドを追加するだけではないことに注意してください。.proto ファイルで定義されていないフィールドを代入しようとすると、AttributeError が発生します。間違った型の値にフィールドを代入すると TypeError が発生します。また、設定される前のフィールドの値を読み込むと、デフォルト値が返されます。

person.no_such_field = 1  # raises AttributeError
person.id = "1234"        # raises TypeError

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

Enums

列挙型は、メタクラスによって整数値を持つ記号定数のセットに展開されます。ですから、例えば、定数 addressbook_pb2.Person.PhoneType.WORK は値 2 を持っています。

標準的なメッセージ方法

各メッセージクラスには、メッセージ全体をチェックしたり操作したりするための他のいくつかのメソッドも含まれています。

  • IsInitialized(): 必須フィールドがすべて設定されているかどうかをチェックします。
  • __str__(): 人間が読めるメッセージの表現を返します。(通常は str(message) または print message として呼び出されます)
  • CopyFrom(other_msg): 与えられたメッセージの値でメッセージを上書きします。
  • Clear(): すべての要素を空の状態に戻してクリアします。

これらのメソッドは Message インターフェースを実装しています。詳細については、Message の完全な API ドキュメントを参照してください。

構文解析とシリアライズ

最後に、各プロトコルバッファクラスは、バッファバイナリ形式のプロトコルを使用して、選択したタイプのメッセージを書き込んだり、読み込んだりするためのメソッドを持っています。これらには以下が含まれます。

  • SerializeToString(): メッセージをシリアライズし、文字列として返します。バイトはテキストではなくバイナリであることに注意してください。
  • ParseFromString(data): 指定された文字列からメッセージを解析します。

これらは、解析およびシリアライズのために提供されているオプションのほんの一部に過ぎません。完全なリストは Message API リファレンスを参照してください。

プロトコルバッファとオブジェクト指向設計
プロトコルバッファクラスは、基本的には( C 言語の構造体のような )間抜けなデータホルダーです。生成されたクラスにリッチな動作を追加したい場合は、生成されたプロトコルバッファクラスをアプリケーション固有のクラスでラップするのが最善の方法です。プロトコルバッファをラップするのは、.proto ファイルのデザインをコントロールできない場合(例えば、別のプロジェクトのものを再利用している場合など)にも良いアイデアです。その場合、ラッパークラスを使って、アプリケーションのユニークな環境に適したインターフェースを作ることができます。データやメソッドを隠したり、便利な関数を公開したり、など。生成されたクラスを継承することで動作を追加してはいけません。これは内部メカニズムを壊すことになりますし、いずれにせよオブジェクト指向の良い習慣ではありません。

メッセージを書く

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

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

#! /usr/bin/python

import addressbook_pb2
import sys

# この関数は、ユーザ入力に基づいて Person メッセージを入力します。
def PromptForAddress(person):
  person.id = int(raw_input("Enter person ID number: "))
  person.name = raw_input("Enter name: ")

  email = raw_input("Enter email address (blank for none): ")
  if email != "":
    person.email = email

  while True:
    number = raw_input("Enter a phone number (or leave blank to finish): ")
    if number == "":
      break

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

    type = raw_input("Is this a mobile, home, or work phone? ")
    if type == "mobile":
      phone_number.type = addressbook_pb2.Person.PhoneType.MOBILE
    elif type == "home":
      phone_number.type = addressbook_pb2.Person.PhoneType.HOME
    elif type == "work":
      phone_number.type = addressbook_pb2.Person.PhoneType.WORK
    else:
      print "Unknown phone type; leaving as default value."

# メインの手続きです。ファイルからアドレス帳全体を読み込む。
# ユーザーの入力に基づいて1人を追加し、それを同じ場所に書き戻します。
if len(sys.argv) != 2:
  print "Usage:", sys.argv[0], "ADDRESS_BOOK_FILE"
  sys.exit(-1)

address_book = addressbook_pb2.AddressBook()

# 既存のアドレス帳を読む。
try:
  f = open(sys.argv[1], "rb")
  address_book.ParseFromString(f.read())
  f.close()
except IOError:
  print sys.argv[1] + ": Could not open file.  Creating a new one."

# 住所を追加します。
PromptForAddress(address_book.people.add())

# 新しいアドレス帳をディスクに書き戻します。
f = open(sys.argv[1], "wb")
f.write(address_book.SerializeToString())
f.close()

メッセージを読む

もちろん、アドレス帳は何も情報が得られなければ意味がありません。この例では、上記の例で作成したファイルを読み込んで、その中の情報をすべて印刷しています。

#! /usr/bin/python

import addressbook_pb2
import sys

# アドレス帳内のすべての人を反復処理し、それらの情報を印刷します。
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.MOBILE:
        print "  Mobile phone #: ",
      elif phone_number.type == addressbook_pb2.Person.PhoneType.HOME:
        print "  Home phone #: ",
      elif phone_number.type == addressbook_pb2.Person.PhoneType.WORK:
        print "  Work phone #: ",
      print phone_number.number

# メインの手続きです。ファイルからアドレス帳全体を読み込んで印刷する。
if len(sys.argv) != 2:
  print "Usage:", sys.argv[0], "ADDRESS_BOOK_FILE"
  sys.exit(-1)

address_book = addressbook_pb2.AddressBook()

# 既存のアドレス帳を読む。
f = open(sys.argv[1], "rb")
address_book.ParseFromString(f.read())
f.close()

ListPeople(address_book)

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

遅かれ早かれ、プロトコルバッファを使用するコードをリリースした後、間違いなくプロトコルバッファの定義を「改善」したいと思うでしょう。新しいバッファを後方互換にし、古いバッファを前方互換にしたい場合、そしてほぼ確実にそうしたい場合には、従うべきルールがいくつかあります。新しいバージョンのプロトコルバッファでは...。

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

(例外もありますが、ほとんど使われることはありません)

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

高度な使用法

プロトコルバッファは単純なアクセサやシリアライズを超えた用途を持っています。それらを使って何ができるかについては、Python API リファレンスを参照してください。

プロトコルメッセージクラスが提供する重要な機能の一つにリフレクションがあります。特定のメッセージタイプに対してコードを書くことなく、メッセージのフィールドを反復処理し、その値を操作することができます。リフレクションを使用する非常に便利な方法の一つは、プロトコルメッセージをXMLやJSONのような他のエンコーディングに変換したり、他のエンコーディングから変換したりすることです。リフレクションのより高度な使用法は、同じ型の2つのメッセージ間の違いを見つけたり、特定のメッセージの内容にマッチする式を書くことができる「プロトコルメッセージの正規表現」のようなものを開発したりすることかもしれません。想像力を働かせれば、最初に予想していたよりもはるかに広い範囲の問題にプロトコルバッファを適用することができます。

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

おわりに

Protocol Buffer Basics: Python を翻訳しました。

何か不備があったら、ご教示して下さると幸いです。