Help us understand the problem. What is going on with this article?

Google.Protobuf.Reflectionを利用してC#でProtocol Buffersを汎用的に解析する話

この記事は DeNA Advent Calendar 2019 の12/9(月)の記事です。

はじめに

この記事ではC#でProtocol Buffers(以下、protobuf)の中身を解析していくための機能について紹介します

※本題に入るまで少し前置きがあるので、そういうのは飛ばしたいという方はこちらの本編へ直接どうぞ

経緯

普段はゲームのクライアントエンジニアとしてUnityによるゲーム開発の仕事をしている人間なのですが、とある案件でUnityでprotobufを扱いたいというニーズが発生し、C#でゴリゴリprotobufのデータを弄ることになりました

その際に調べながら実装を進めていたところ「C#でprotobufを扱う内容ってあまり深堀りされていないなぁ」と感じ、今回の記事公開に至りました

ターゲット

protobufがどういったものなのかを知っている前提で話を進めていきます

「C#(Unity)でゴリゴリにprotobufを扱いたいんじゃ〜」という人がドンピシャなメインターゲットですが、中々に稀有な存在だと思われるので、実際にそういう人ではなくても「そういうこともできるんだ」と思ってもらえたら幸いです

また、今回取り扱うprotobufはproto3のバージョンを対象としています

それぞれに関連する用語の詳細な解説は省いていきますので予めご了承ください

得られるもの

今まであまり語られることがなかったGoogle.Protobuf.Reflectionの機能について学ぶことができます

UnityのEditor拡張を使って直接protobufのデータを弄りたいというのが具体的な要件でしたが、Editor拡張の部分はとくに目新しいことはしていないため、今回はGoogle.Protobuf.Reflectionを使ってprotobufのデータを汎用的に読み書きする部分(非Unity依存・PureなC#コード)をメインに解説していきます

C#でprotobufを扱う最小サンプル

https://developers.google.com/protocol-buffers/docs/csharptutorial
公式のチュートリアルにサンプルコードが載っています

ロード

Person john;
// john.datというファイルをロードし
using (var input = File.OpenRead("john.dat"))
{
    // インスタンスに内容を格納する
    john = Person.Parser.ParseFrom(input);
}

セーブ

// johnという変数名のインスタンスを予め用意する
Person john = ...;
// john.datファイルを新たに作成し
using (var output = File.Create("john.dat"))
{
    // johnの内容をファイルに書き出す
    john.WriteTo(output);
}

これらは抜粋ですが、全体のコードを確認したい場合は以下のリンクから辿ることができます
https://github.com/protocolbuffers/protobuf/tree/master/csharp/src/AddressBook

このようにして各スキーマごとにprotobufのデータをロードして、プロダクトコードでゴリゴリにデータ弄ってそれをまたセーブをすればOK!

各スキーマごとにメンテナンスするのが大変!

はい、ここからが本題です

スキーマ定義が少ないうちは個別にゴリゴリ実装をしていってもそこまで問題はないのですが、ガッツリ使い込んでスキーマ定義が増えていくとそのスキーマ定義に依存した実装のメンテナンスが非常に大変になってきます

新しくスキーマの定義を増やしたり、スキーマの内容を変更したりするたびにメンテナンスをすることになるので、なるべく依存するコードは最低限に留めて汎用的に取り扱えるような実装にしたいです

今回の要件では、UnityEditor上からパラメータの調整をできるようにしたいというものがあり、スキーマごとに入力エディタを作るのは現実的ではありませんでした

Google.Protobuf.Reflection

ここで登場するのがGoogle.Protobuf.Reflectionのnamespace下にある機能です

protobufがスキーマ定義に対応して自動生成してくれるC#コードは、スキーマ定義ごとにGoogle.Protobuf.IMessageを継承したクラスになっています
(以下、スキーマ定義に対応したprotobufが自動生成するC#クラスをメッセージと呼びます)

Google.Protobuf.IMessageの定義に着目するとGoogle.Protobuf.Reflection.MessageDescriptor Descriptor { get; } というプロパティが存在します

このGoogle.Protobuf.Reflectionにある機能を使うことで、メッセージの具体的な型に依存せずにそのメッセージの中身を調べることができます
※要するにprotobufの仕様に限定させたC#のReflection機能のようなものです

これを使えばメッセージの具象型に依存した実装をある程度省略することができそうです

メッセージを解析する

ではどのようにしてGoogle.Protobuf.Reflectionの機能を使っていくのか
protobufはメッセージ単位でデータを扱うので、メッセージから掘り下げていきましょう

下記のようなスキーマ定義を例にどのような内容が取れるのかを解説していきます

SampleMessage.proto
syntax = "proto3";
package Sample;

option csharp_namespace = "Sample.Messages";

// 列挙型の宣言(外側)
enum SampleEnum {
    Hoge = 0;
    Fuga = 1;
    Piyo = 2;
}

// メッセージの宣言(外側)
message ExternalMessage {
    float float_field = 1;
}

// メッセージの宣言
message SampleMessage {
    // フィールドの定義(Scalar Value Types)
    int32 int_field = 1;
    // フィールドの定義(Enum Type)
    SampleEnum enum_field = 2;
    // フィールドの定義(Message Type)
    InternalMessage message_field = 3;
    // フィールドの定義(Repeated Type)
    repeated string repeated_field = 4;
    // フィールドの定義(Map Type)
    map<int32, string> map_field = 5;
    // Oneof
    oneof oneof_field {
        string oneof_string = 11;
        uint32 oneof_uint = 12;
        ExternalMessage oneof_message = 13;
    }
    // 列挙型の宣言(入れ子)
    enum InternalEnum {
        First = 0;
        Second = 1;
        Third = 2;
    }
    // メッセージの宣言(入れ子)
    message InternalMessage {
        bool bool_field = 1;
    }
}

MessageDescriptor

先程も紹介したように、IMessageには MessageDescriptor Descriptor { get; } というプロパティが定義されています
ここからメッセージの内容を解析していくことができます
メッセージ内に定義されている内容が整理された状態でこのMessageDescriptorに詰まっています

以下に今回のケースで利用するものを列挙します

IMessage message = new SampleMessage(); // データの用意は省略
MessageDescriptor descriptor = message.Descriptor;

// メッセージの名前 SampleMessage
descriptor.Name;

// パッケージ名を含めた名前 Sample.SampleMessage
descriptor.FullName;

// C#上でのメッセージの型情報 typeof(Sample.Messages.SampleMessage)
descriptor.ClrType;

// 親のメッセージの情報 SampleMessageは入れ子に宣言されていないのでnull
descriptor.ContainingType;

// メッセージ内に定義されているEnum全て
descriptor.EnumTypes;

// メッセージ内に定義されているメッセージ全て
descriptor.NestedTypes;

// メッセージ内に定義されているフィールド全て ※後述で解説
descriptor.Fields;
// FindFieldByName/FindFieldByNumberメソッドで任意のフィールドを取得できる
FieldDescriptor field1 = message.Descriptor.FindFieldByName("int_field");
FieldDescriptor field2 = message.Descriptor.FindFieldByNumber(2);
// InDeclarationOrder/InFieldNumberOrderメソッドで中身を列挙できる
foreach(var fieldDescriptor in descriptor.Fields.InDeclarationOrder())
foreach(var fieldDescriptor in descriptor.Fields.InFieldNumberOrder())

// メッセージ内に定義されているOneof全て ※後述で解説
descriptor.Oneofs;

Enumを解析する

Enumは宣言されている定数の情報がメインになるため、今回の要件では解析することは必須ではありません
一応どのような情報が入っているか確認しましょう

EnumDescriptor

// SampleMessage内に宣言されているInternalEnumを取り出す
IMessage message = new SampleMessage(); // データの用意は省略
EnumDescriptor descriptor = message.EnumTypes[0];

// Enumの名前 InternalEnum
descriptor.Name;

// パッケージ名を含めた名前 Sample.SampleMessage.InternalEnum
descriptor.FullName;

// C#上でのEnumの型情報 typeof(Sample.Messages.SampleMessage.Types.InternalEnum)
descriptor.ClrType;

// 親のメッセージの情報 SampleMessageの入れ子になっているのでSampleMessageのMessageDescriptorが入っている
descriptor.ContainingType;

// 定義されている定数全て(EnumValueDescriptor)
descriptor.Values;

EnumValueDescriptor

// SampleMessage.protoに宣言されているSampleEnumを取り出す
// ※SampleMessageReflectionクラスのDescriptorプロパティからSampleMessage.protoファイルのFileDescriptorが取得できる
EnumDescriptor enumDescriptor = SampleMessageReflection.Descriptor.EnumTypes[0];

// SampleEnumの2番目に定義されている定数を取り出す
EnumValueDescriptor enumValueDescriptor = enumDescriptor.Values[1];

//  定数の名前 Fuga
enumValueDescriptor.Name;

// パッケージ名を含めた名前 Sample.SampleEnum.Fuga
descriptor.FullName;

// 定数の宣言順序 1 (0 origin)
enumValueDescriptor.Index;

// 定数の値 10
enumValueDescriptor.Number;

フィールドを解析する

繰り返しになりますが、今回の要件ではメッセージの型に依存せずに中身を読み書きしたいので、実際の値が入っているフィールドの中身が最重要です
詳しく掘り下げていきましょう

まず初めに、protobufのフィールドには大きく分類すると以下の種類が存在します

  • Scalar Value Types
  • Enum
  • Message
  • Repeated
  • Map

Oneofと呼ばれるものも存在しますが、これはGoogle.Protobuf.Reflectionでは厳密にはフィールドという扱いではないので別枠で解説します
1つずつ見ていきたいところですがまずはどのフィールドでも共通の内容を見ていきます

FieldDescriptor

フィールドの情報は FieldDescriptorで知ることができます
この中身を調べることによって対象のフィールドが前述のどの種別に値するのかを判断することができます

IMessage message = new SampleMessage(); // データの用意は省略
FieldDescriptor enumField = message.Descriptor.FindFieldByName("enum_field");
FieldDescriptor messageField = message.Descriptor.FindFieldByName("message_field");
FieldDescriptor boolField = messageField.MessageType.FindFieldByName("bool_field");
FieldDescriptor repeatedField = message.Descriptor.FindFieldByName("repeated_field");
FieldDescriptor mapField = message.Descriptor.FindFieldByName("map_field");

// フィールドの名前 ※C#上のプロパティ名ではなくprotoファイル内で宣言した名前
enumField.Name;     // enum_field
messageField.Name;  // message_field
boolField.Name;     // bool_field
repeatedField.Name; // repeated_field
mapField.Name;      // map_field

// パッケージ名を含めた名前
enumField.FullName;     // Sample.SampleMessage.enum_field
messageField.FullName;  // Sample.SampleMessage.message_field
boolField.FullName;     // Sample.SampleMessage.InternalMessage.bool_field
repeatedField.FullName; // Sample.SampleMessage.repeated_field
mapField.FullName;      // Sample.SampleMessage.map_field

// フィールドの種別
enumField.FieldType;     // FieldType.Enum
messageField.FieldType;  // FieldType.Message
boolField.FieldType;     // FieldType.Bool
repeatedField.FieldType; // FieldType.String ※repeated stringで宣言されているのでString扱い
mapField.FieldType;      // FieldType.Message ※map<,>で宣言されているのでMessage扱い

// protoファイル内でフィールド宣言時に設定した数値
enumField.FieldNumber;     // 2
messageField.FieldNumber;  // 3
boolField.FieldNumber;     // 1
repeatedField.FieldNumber; // 4
mapField.FieldNumber;      // 5

// FieldTypeがMessageだった場合にMessageDescriptorが入っている
enumField.MessageType;     // null
messageField.MessageType;  // InternalMessageのMessageDescriptor
boolField.MessageType;     // null
repeatedField.MessageType; // null
mapField.MessageType;      // MapFieldEntryのMessageDescriptor ※後述で解説

// FieldTypeがEnumだった場合にEnumDescriptorが入っている
enumField.EnumType;     // SampleEnumのEnumDescriptor
messageField.EnumType;  // null
boolField.EnumType;     // null
repeatedField.EnumType; // null
mapField.EnumType;      // null

// 親のメッセージの情報
enumField.ContainingType;     // SampleMessageのMessageDescriptor
messageField.ContainingType;  // SampleMessageのMessageDescriptor
boolField.ContainingType;     // InternalMessageのMessageDescriptor(messageField.MessageTypeと同じ)
repeatedField.ContainingType; // SampleMessageのMessageDescriptor
mapField.ContainingType;      // SampleMessageのMessageDescriptor

// Oneofに含まれている場合Oneofの情報が入る ※後述で解説
enumField.ContainingOneof;     // null
messageField.ContainingOneof;  // null
boolField.ContainingOneof;     // null
repeatedField.ContainingOneof; // null
mapField.ContainingOneof;      // null

// Repeated属性がある場合にtrueになる
enumField.IsRepeated;     // false
messageField.IsRepeated;  // false
boolField.IsRepeated;     // false
repeatedField.IsRepeated; // true
mapField.IsRepeated;      // true

// Map属性がある場合にtrueになる
enumField.IsRepeated;     // false
messageField.IsRepeated;  // false
boolField.IsRepeated;     // false
repeatedField.IsRepeated; // false
mapField.IsRepeated;      // true

// 実体にアクセスするためのラッパークラス ※後述で解説
enumField.Accessor;     // SingleFieldAccessor
messageField.Accessor;  // SingleFieldAccessor
boolField.Accessor;     // SingleFieldAccessor
repeatedField.Accessor; // RepeatedFieldAccessor
mapField.Accessor;      // MapFieldAccessor

フィールドの種別を判断するために必要な情報が分かったのでそれぞれ具体的な種別ごとに掘り下げていきましょう

Scalar Value Types

Scalar Value Typesにどのようなものが含まれるのかは下記の公式ドキュメントにまとまっています
https://developers.google.com/protocol-buffers/docs/proto3#scalar

  • double
  • float
  • int32
  • int64
  • uint32
  • uint64
  • sint32
  • sint64
  • fixed32
  • fixed64
  • sfixed32
  • sfixed64
  • bool
  • string
  • bytes

また、これらは前述で紹介したFieldDescriptor.FieldTypeで判断することができます

実体にアクセスするためにはFieldDescriptor.Accessorを使います
Scalar Value TypesなFieldTypeの場合、SingleFieldAccessorが使われており、GetValue()/SetValue()メソッドで値の読み書きができます

var message = new SampleMessage { IntField = 10 };
var field = message.Descriptor.FindFieldByName("int_field");
var intFieldValue = (int)field.Accessor.GetValue(message); // 中身は10
field.Accessor.SetValue(message, 20);
message.IntField; // 中身は20

unity appendix

// 宣言などは省略しています
var fieldValue = fieldDescriptor.Accessor.GetValue(parentMessage);
switch(fieldDescriptor.FieldType)
{
    case FieldType.Float:
        fieldValue = (float) EditorGUILayout.FloatField(fieldDescriptor.Name, (float) fieldValue);
        break;
    case FieldType.Int64:
    case FieldType.SInt64:
    case FieldType.SFixed64:
        fieldValue = (long) EditorGUILayout.LongField(fieldDescriptor.Name, (long) fieldValue);
        break;
    // それ以外のFieldTypeも同様にTypeごとに実装する...
}

fieldDescriptor.Accessor.SetValue(parentMessage, fieldValue);

Enum

Enumの場合もSingleFieldAccessorが使われているため、Scalar Value Typesと同じ方法で値の読み書きができます

unity appendix

ここで取り出した値はSystem.Enum型として扱うことができるので、今回の要件のようなUnityEditorで取り扱う際には下記のようにして標準のEditor拡張コードに組み込む事ができます

var enumValue = (Enum)enumField.Accessor.GetValue(parentMessage);
var updatedValue = EditorGUILayout.EnumPopup("Enum", enumValue);
enumField.Accessor.SetValue(parentMessage, updatedValue);

Message

Messageの場合も前述の2種同様SingleFieldAccessorが使われています
IMessageを継承したメッセージクラスが格納されているため、IMeesageにキャストした上で更にDescriptorで掘り下げることによって更に中身を1つずつ操作することが可能です

var childMessage = (IMessage)messageField.Accessor.GetValue(parentMessage);
foreach(var field in childMessage.Descriptor.Fields.InDeclarationOrder())
{
    // 実際にはFieldTypeごとに処理をしていく
    var fieldValue = field.Accessor.GetValue(childMessage);
    // modify ...
    field.Accessor.SetValue(childMessage, fieldValue);
}

protobufは任意でメッセージの入れ子を連続させて定義することができる仕様になっているので汎用的に対応するためにはメッセージに対する処理は再帰的に行う必要があります

Repeated

FieldDescriptor.IsRepeatedtrueだった場合、そのフィールドはRepeated属性があることになります
Repeated属性のフィールドはRepeatedFieldAccessorが使われますが、これはSetValue()に対応していません
値を書き換えるには少し工夫が必要になります

if (repeatedField.IsRepeated)
{
    // 実際はRepeatedField<T>型になっているがIListを実装しているためキャストが可能
    var repeatedValue = (IList)repeatedField.Accessor.GetValue(parentMessage);
    // IList的な扱いができる ※ただし要素はobject型
    var element0 = repeatedValue[0];
    repeatedValue.Add(addValue);
    foreach(var element in repeatedValue)

    // 要素の型はFieldTypeで取得できる ※通常のフィールドにrepeated属性が付いているという扱い
    var elementType = repeatedField.FieldType;
    // メッセージだった場合は更にMessageTypeを掘り下げることで具体的な型が判別できる
    if (elementType == FieldType.Message)
    {
        var messageType = repeatedField.MessageType.ClrType;
    }
    // Enumの場合も同様
    if (elementType == FieldType.Enum)
    {
        var enumType = repeatedField.EnumType.ClrType;
    }

    // 要素の具体的な型が判別できるのであればRepeatedField<T>にもキャスト可能
    if (elementType == FieldType.Int32)
    {
        var repeatedIntValue = (RepeatedField<int>)repeatedField.Accessor.GetValue(parentMessage);
    }
}

このようにして取得したIListRepeatedField<T>のインスタンスに対してAdd()Remove()などをしてあげることでRepeatedなフィールドの中身も書き換えることが可能になります

unity appendix

IListを継承しているのでUnityEditorInternal.ReorderableListをそのまま使うことができます(結構便利です

var repeatedValue = (IList)fieldDescriptor.Accessor.GetValue(parentMessage);
var elementType = GetSystemType(fieldDescriptor);
var reorderableList = new ReorderableList(repeatedValue, elementType);

...

Type GetSystemType(FieldDescriptor descriptor)
{
    switch(descriptor.FieldType)
    {
        case FieldType.Message;
            return descriptor.MessageType.ClrType;
        case FieldType.Enum;
            return descriptor.EnumType.ClrType;
        case FieldType.String;
            return typeof(string);
        // ScalarValueTypesはFieldTypeに合わせて直接typeofする 
    }
}

Map

実はmap<key,value>として宣言したものもRepeatedなフィールドとして扱われます
内部的にはMapFieldEntryなメッセージ型にRepeated属性を付けているという扱いになっているのです
https://developers.google.com/protocol-buffers/docs/proto3#maps

なので、フィールドごとに処理を分岐させる場合はRepeatedなフィールドよりも前にMapかどうかを判断する必要があります

// フィールドに対する何かしらの処理を行う関数
void ProccessField(FieldDescriptor field)
{
    if (field.IsMap)
    {
        // Mapの処理
    }
    else if (field.IsRepeated)
    {
        // Repeatedの処理
    }
    else
    {
        // SingleFieldAccessorに対応した処理
    }
}

MapなフィールドにはMapFieldAccessorが使われますが、これもRepeatedFieldAccessorと同様にSetValue()に対応していません
自分で中身を掘り下げていく必要があります

if (mapField.IsMap)
{
    // 実際はMapField<TKey, TValue>型になっているがIDictionaryを実装しているためキャストが可能
    var mapValue = (IDictionary)mapValue.Accessor.GetValue(parentMessage);
    // IDictionary的な扱いができる ※ただしkeyもvalueもobject型
    mapValue.Add(addKey, addValue);
    foreach(var kvp in mapValue)

    // keyとvalueの型はMapFieldEntryからそれぞれのFieldDescriptorを掘り起こす必要がある
    var mapFieldEntry = mapField.MessageType;
    var keyField = mapFieldEntry.FindFieldByName("key");
    var valueField = mapFieldEntry.FindFieldByName("value");
    // … ここから更にFieldType, MessageType, EnumTypeを見て最終的に判断する
}

Mapの仕様としてはkeyに設定できる型がint32,int64, sint32,sint64, uint32,uint64, fixed32,fixed64, sfixed32,sfixed64, bool,stringに限定されてはいるものの、真面目にIDictinary<TKey, TValue>の形式にキャストしようとするとかなり骨が折れます

扱うデータの仕様によってはMapの解析は対象外としてしまうのもアリでしょう

Oneofを解析する

フィールドの種別の話でOneofはGoogle.Protobuf.Reflectionでは厳密にはフィールドの扱いになっていないと話しました
これは、Oneofの定義に対しては専用のOneofDescriptorが存在し、そのOneof内で宣言されているフィールドに対してそれぞれFieldDescriptorが存在しているという仕様になっているためです

IMessage message = new SampleMessage(); // データの用意は省略
// 名前を指定してDescriptorを取得する
OneofDescriptor oneofField = message.Descriptor.FindDescriptor<OneofDescriptor>("oneof_field");
// 全てのOneofを列挙する
foreatch (var oneofDescriptor in message.Descriptor.Oneofs)
{
    // そのOneofに宣言されているフィールドを列挙する
    foreach (var field in oneofDescriptor.Fields)
    {
        // フィールド個別の処理...
    }
}

OneofDescriptor

このようにOneofには専用のOneofDescriptorが用意されていますが、値を読み書きする方法は少し特殊です

C#上ではOneof内に宣言されているフィールドに対応したプロパティに対して値を設定することで内部的にOneofの仕様に沿った状態で値を更新してくれるようになっています
また、各フィールドに対応したプロパティへはFieldDescriptorからアクセス可能になっています

これにより、新しくOneofとして扱いたいフィールドに対して値を設定することでOneofとしての値も書き換わったことになります

// SampleMessage.oneof_fieldの場合
IMessage message = new SampleMessage();
OneofDescriptor oneofField = message.Descriptor.FindDescriptor<OneofDescriptor>("oneof_field");

// 現在のOneofの値を取得する
var currentOneof = oneofField.Accessor.GetCaseFieldDescriptor(message);
var currentOneofValue = currentOneof.Accsessor.GetValue(message);

// 新しくOneofの値にexternal_messageを設定する ※MessageDescriptorからもOneofに属しているフィールドが取得できる
var externalMessageField = message.Descriptor.FindFieldByName("external_message");
externalMessageField.Accessor.SetValue(message, new ExternalMessage());

注意点

OneofDescriptorの項目で紹介したコードにもある通り、Oneofの中に定義したフィールドはそのOneofを定義したメッセージのフィールドとしても扱われているため、MessageDescriptor.FieldsOneofDescriptor.Fieldsのどちらにも存在していることになります
下記のようにしてContainingOneofをチェックすることで処理の重複を防ぎましょう

// メッセージに対する何かしらの処理を行う関数
void ProcessMessage(IMessage message)
{
    // フィールドの処理
    foreach (var field in message.Descriptor.Fields.InDeclarationOrder())
    {
        if (field.ContainingOneof != null)
        {
            continue;
        }

        // Oneofに含まれない通常のフィールドの処理
    }

    // Oneofの処理
    foreach (var oneof in message.Descriptor.Oneofs)
    {
        foreach (var field in oneof.Fields)
        {
            // Oneofに含まれるフィールドの処理
        }
    }
}

さいごに

Google.Protobuf.Reflectionを使ってメッセージの基本的な中身を掘り下げるコードをズラーッと紹介しました

今回紹介した以外にもまだReservedやOptionsなど、応用的な使い方がたくさん残っていますので、今後も別途それらの機能についても掘り下げていきたいと思います

最後まで読んでいただきありがとうございました

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした