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、メールアドレス、連絡先の電話番号があります。

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

  • System.Runtime.Serialization.Formatters.Binary.BinaryFormatter と関連クラスを使用して .NET のバイナリシリアル化を使用する。これは変更に対して非常に脆弱であり、場合によってはデータサイズが大きくなります。また、他のプラットフォーム用に書かれたアプリケーションとデータを共有する必要がある場合にもうまく機能しません。
  • 4つの整数を "12:3:-23:67" のようにエンコードするなど、データ項目を単一の文字列にエンコードするアドホックな方法を考案することができます。これはシンプルで柔軟なアプローチですが、その場限りのエンコードとパースのコードを書く必要があり、パースにはわずかな実行時コストがかかります。これは非常に単純なデータをエンコードする場合に最も適しています。
  • データを 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)は、データファイルに新しいエントリを追加したり、データファイルをパースしてデータをコンソールに出力したりできます。

完全な例は、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";

次に、メッセージ定義があります。メッセージは、型付けされたフィールドのセットを含む単なる集合体です。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 の内部で定義されています。フィールドに事前定義された値のリストのいずれかを持たせたい場合は、enum 型を定義することもできます。ここでは、電話番号が PHONE_TYPE_MOBILEPHONE_TYPE_HOME、または PHONE_TYPE_WORK のいずれかであることを指定したいと考えています。

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

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

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

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

Protocol Buffer のコンパイル

.proto ファイルができたので、次に行うべきことは、AddressBook(および PersonPhoneNumber)メッセージを読み書きするために必要なクラスを生成することです。これを行うには、.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 クラス。
  • NameIdEmailPhones のプロパティを持つ Person クラス。
  • 静的な Person.Types クラス内にネストされた PhoneNumber クラス。
  • これも Person.Types にネストされた PhoneType enum。

生成される内容の詳細についてはC# 生成コードガイドで詳しく読むことができますが、ほとんどの場合、これらはごく普通の C# 型として扱うことができます。強調すべき点の1つは、repeated フィールドに対応するプロパティはすべて読み取り専用であるということです。コレクションに項目を追加したり、項目を削除したりすることはできますが、それを全く別のコレクションに置き換えることはできません。repeated フィールドのコレクション型は常に 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);
}

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

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

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

Protocol Buffer の拡張

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

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

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

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

ただし、新しいフィールドは古いメッセージには存在しないため、デフォルト値で何か合理的な処理を行う必要があることに注意してください。型固有のデフォルト値が使用されます。文字列の場合、デフォルト値は空文字列です。ブール値の場合、デフォルト値は 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);
    }
}