Protocol Buffer の基本: C#

C# プログラマーが Protocol Buffers を使用するための基本的な入門書です。

このチュートリアルでは、Protocol Buffers 言語の proto3 バージョンを使用して、C# プログラマーが Protocol Buffers を使用するための基本的な入門を提供します。簡単なサンプルアプリケーションの作成を通して、以下の方法を説明します。

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

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

問題領域

ここで使用する例は、人々の連絡先の詳細をファイルとの間で読み書きできる非常にシンプルな「アドレス帳」アプリケーションです。アドレス帳の各個人は、名前、ID、メールアドレス、および連絡先の電話番号を持っています。

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

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

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

サンプルコードの場所

私たちの例は、Protocol Buffers を使用してエンコードされたアドレス帳データファイルを管理するためのコマンドラインアプリケーションです。コマンド AddressBook (Program.cs を参照: 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 が指定されていない場合、生成されたクラスはパッケージ名に一致する名前空間に配置されます。私たちの例では、csharp_namespace オプションがデフォルトをオーバーライドするように指定されているため、生成されたコードは Tutorial の代わりに Google.Protobuf.Examples.AddressBook の名前空間を使用します。

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

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

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_MOBILEPHONE_TYPE_HOME、または PHONE_TYPE_WORK のいずれかであることを指定したいと考えています。

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

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

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

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

Protocol Buffers のコンパイル

.proto ができたので、次に必要なのは、AddressBook (したがって Person と PhoneNumber) メッセージを読み書きするために必要なクラスを生成することです。これを行うには、.proto で Protocol Buffer コンパイラー 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 クラス群

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

  • Protocol Buffer メッセージに関するメタデータを含む静的な Addressbook クラス。
  • 読み取り専用の People プロパティを持つ AddressBook クラス。
  • NameIdEmail、および Phones のプロパティを持つ Person クラス。
  • 静的な Person.Types クラスにネストされた PhoneNumber クラス。
  • Person.Types にもネストされた PhoneType enum。

生成されるものの詳細については、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 } }

パースとシリアライズ

Protocol Buffers を使用する全体の目的は、データをシリアライズして、他の場所でパースできるようにすることです。生成されたすべてのクラスには WriteTo(CodedOutputStream) メソッドがあり、CodedOutputStream は Protocol Buffer ランタイムライブラリのクラスです。ただし、通常は拡張メソッドのいずれかを使用して、通常の 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 リポジトリで入手できます: in the Github repository

Protocol Buffer の拡張

Protocol Buffer を使用するコードをリリースした後、遅かれ早かれ、Protocol Buffer の定義を「改善」したくなるでしょう。新しいバッファーに下位互換性を持たせ、古いバッファーに上位互換性を持たせたい場合 (そして、ほぼ確実にそうしたいでしょう)、従う必要のあるいくつかのルールがあります。Protocol Buffer の新しいバージョンでは、

  • 既存のフィールドのタグ番号を変更してはなりません。
  • フィールドを削除してもかまいません。
  • 新しいフィールドを追加してもかまいませんが、新しいタグ番号 (つまり、削除されたフィールドを含め、この Protocol Buffer で使用されたことのないタグ番号) を使用する必要があります。

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

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

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

リフレクション

メッセージ記述子 (.proto ファイル内の情報) とメッセージのインスタンスは、リフレクション API を使用してプログラムで調べることができます。これは、別のテキスト形式やスマート diff ツールなどのジェネリックコードを作成する場合に役立ちます。生成された各クラスには静的な 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);
    }
}