Protocol Bufferの基本:C#

プロトコルバッファを使用するためのC#プログラマー向けの基本的な入門書です。

このチュートリアルでは、プロトコルバッファの proto3 バージョン言語を使用して、プロトコルバッファを使用するためのC#プログラマー向けの基本的な入門書を提供します。簡単なサンプルアプリケーションの作成を通して、次の方法を示します。

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

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

問題領域

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

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

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

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

サンプルコードの場所

私たちの例は、プロトコルバッファを使用してエンコードされたアドレス帳データファイルを管理するためのコマンドラインアプリケーションです。コマンド AddressBook (参照: Program.cs) は、データファイルに新しいエントリを追加したり、データファイルを解析してデータをコンソールに出力したりできます。

完全な例は、GitHubリポジトリのexamplesディレクトリcsharp/src/AddressBookディレクトリにあります。

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

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

.proto ファイルはパッケージ宣言から始まります。これは、異なるプロジェクト間での名前の衝突を防ぐのに役立ちます。

syntax = "proto3";
package tutorial;

import "google/protobuf/timestamp.proto";

C#では、csharp_namespaceが指定されていない場合、生成されたクラスはpackage名に一致する名前空間に配置されます。私たちの例では、デフォルトをオーバーライドするためにcsharp_namespaceオプションが指定されているため、生成されたコードはTutorialではなくGoogle.Protobuf.Examples.AddressBookの名前空間を使用します。

option csharp_namespace = "Google.Protobuf.Examples.AddressBook";

次に、メッセージ定義があります。メッセージは、型付けされたフィールドのセットを含む単なる集約体です。` bool `、` int32 `、` float `、` double `、` string ` など、多くの標準的な単純なデータ型がフィールド型として利用可能です。他のメッセージ型をフィールド型として使用することで、メッセージにさらに構造を追加することもできます。

message Person {
  string name = 1;
  int32 id = 2;  // Unique ID number for this person.
  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;
  }

  repeated PhoneNumber phones = 4;

  google.protobuf.Timestamp last_updated = 5;
}

// Our address book file is just one of these.
message AddressBook {
  repeated Person people = 1;
}

上記の例では、` Person ` メッセージは ` PhoneNumber ` メッセージを含み、` AddressBook ` メッセージは ` Person ` メッセージを含んでいます。他のメッセージの中にネストされたメッセージ型を定義することもできます。ご覧のとおり、` PhoneNumber ` 型は ` Person ` の中に定義されています。フィールドの1つが事前に定義された値のリストのいずれかを持つようにしたい場合は、` enum ` 型を定義することもできます。ここでは、電話番号が ` PHONE_TYPE_MOBILE `、` PHONE_TYPE_HOME `、または ` PHONE_TYPE_WORK ` のいずれかであることを指定したいとします。

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

フィールド値が設定されていない場合、デフォルト値が使用されます。数値型の場合はゼロ、文字列の場合は空の文字列、ブール値の場合はfalseです。埋め込みメッセージの場合、デフォルト値は常にメッセージの「デフォルトインスタンス」または「プロトタイプ」であり、そのフィールドは何も設定されていません。明示的に設定されていないフィールドの値を取得するためにアクセサーを呼び出すと、常にそのフィールドのデフォルト値が返されます。

フィールドが repeated の場合、そのフィールドは任意の回数(ゼロを含む)繰り返すことができます。繰り返される値の順序は Protocol Buffer に保存されます。repeated フィールドは動的にサイズ変更される配列と考えてください。

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

Protocol Buffer のコンパイル

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

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

  2. 次に、コンパイラを実行し、ソースディレクトリ(アプリケーションのソースコードがある場所。値を指定しない場合は現在のディレクトリが使用されます)、出力先ディレクトリ(生成されたコードの出力先。多くの場合 `$SRC_DIR ` と同じです)、および ` .proto ` へのパスを指定します。この場合、次のように呼び出します。

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

    C#コードが必要なので、--csharp_outオプションを使用します。同様のオプションは他のサポートされている言語でも提供されています。

これにより、指定された出力ディレクトリにAddressbook.csが生成されます。このコードをコンパイルするには、Google.Protobufアセンブリへの参照を持つプロジェクトが必要です。

アドレス帳クラス

Addressbook.csを生成すると、次の5つの有用な型が得られます。

  • プロトコルバッファメッセージに関するメタデータを含む静的Addressbookクラス。
  • 読み取り専用のPeopleプロパティを持つAddressBookクラス。
  • NameIdEmailPhonesのプロパティを持つPersonクラス。
  • 静的Person.TypesクラスにネストされたPhoneNumberクラス。
  • Person.TypesにもネストされたPhoneType列挙型。

C#生成コードガイドで生成されるものの詳細について詳しく読むことができますが、ほとんどの場合、これらは完全に通常のC#型として扱うことができます。強調すべき点の1つは、繰り返しフィールドに対応するプロパティは読み取り専用であるということです。コレクションにアイテムを追加したり、コレクションからアイテムを削除したりすることはできますが、完全に別のコレクションに置き換えることはできません。繰り返しフィールドのコレクション型は常にRepeatedField<T>です。この型はList<T>に似ていますが、コレクション初期化子で使用するためのアイテムのコレクションを受け入れるAddオーバーロードなどのいくつかの便利なメソッドが追加されています。

Personのインスタンスを作成する方法の例を次に示します。

Person john = new Person
{
    Id = 1234,
    Name = "John Doe",
    Email = "jdoe@example.com",
    Phones = { new Person.Types.PhoneNumber { Number = "555-4321", Type = Person.Types.PhoneType.Home } }
};

C# 6では、using staticを使用してPerson.Typesの煩わしさを解消できることに注意してください。

// Add this to the other using directives
using static Google.Protobuf.Examples.AddressBook.Person.Types;
...
// The earlier Phones assignment can now be simplified to:
Phones = { new PhoneNumber { Number = "555-4321", Type = PhoneType.HOME } }

パースとシリアライズ

プロトコルバッファを使用する主な目的は、データをシリアル化して他の場所で解析できるようにすることです。生成されたすべてのクラスにはWriteTo(CodedOutputStream)メソッドがあり、CodedOutputStreamはプロトコルバッファランタイムライブラリのクラスです。ただし、通常は拡張メソッドのいずれかを使用して通常のSystem.IO.Streamに書き込んだり、メッセージをバイト配列またはByteStringに変換したりします。これらの拡張メッセージはGoogle.Protobuf.MessageExtensionsクラスにあるため、シリアル化したい場合は通常、Google.Protobuf名前空間のusingディレクティブが必要になります。例えば

using Google.Protobuf;
...
Person john = ...; // Code as before
using (var output = File.Create("john.dat"))
{
    john.WriteTo(output);
}

解析も簡単です。生成された各クラスには、その型のMessageParser<T>を返す静的Parserプロパティがあります。これにより、ストリーム、バイト配列、およびByteStringを解析するメソッドが提供されます。したがって、作成したファイルを解析するには、次のようにします。

Person john;
using (var input = File.OpenRead("john.dat"))
{
    john = Person.Parser.ParseFrom(input);
}

これらのメッセージを使用してアドレス帳を管理する(新しいエントリの追加と既存のエントリのリスト表示)完全なサンプルプログラムは、GitHubリポジトリで入手できます。

Protocol Buffer の拡張

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

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

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

これらのルールに従えば、古いコードは新しいメッセージを問題なく読み込み、新しいフィールドは単純に無視します。古いコードにとって、削除された単一フィールドは単にデフォルト値になり、削除された繰り返しフィールドは空になります。新しいコードも古いメッセージを透過的に読み込みます。

ただし、新しいフィールドは古いメッセージには存在しないことに注意してください。したがって、デフォルト値で何か合理的な処理を行う必要があります。型固有のデフォルト値が使用されます。文字列の場合、デフォルト値は空の文字列です。ブール型の場合、デフォルト値は false です。数値型の場合、デフォルト値はゼロです。

リフレクション

メッセージ記述子 (.proto ファイル内の情報) とメッセージのインスタンスは、リフレクション API を使用してプログラムで調べることができます。これは、異なるテキスト形式やスマートな差分ツールなどの汎用コードを作成する場合に役立ちます。生成された各クラスには静的な Descriptor プロパティがあり、任意のインスタンスの記述子は IMessage.Descriptor プロパティを使用して取得できます。これらの使用方法の簡単な例として、任意のメッセージのトップレベルフィールドを出力する短いメソッドを示します。

public void PrintMessage(IMessage message)
{
    var descriptor = message.Descriptor;
    foreach (var field in descriptor.Fields.InDeclarationOrder())
    {
        Console.WriteLine(
            "Field {0} ({1}): {2}",
            field.FieldNumber,
            field.Name,
            field.Accessor.GetValue(message);
    }
}