UObjectをJsonにシリアライズしよう
この記事はUnreal Engine 4(UE4) Advent Calendar 2019の20日目の投稿記事です。
私がマーケットプレイスに公開しているObjectDelivererというプラグインがあります。
今回の記事はこのプラグインに実装している「オブジェクトをJsonにシリアライズする機能」について書きます。
例えばこんなVariablesを定義したブループリント(Objectを継承)があるとすると、
こんな感じのJson文字列に変換されます。
{ "IntValue":0, "BoolValue":false, "StringValue":"ABC" }
Jsonは文字列で表現されるので、そのままテキストファイルに保存することもできます。 また文字列なので人間の目で見ても中身が分かりやすいです。
このJsonをオブジェクトに復元する機能も作ることで、Jsonを介してオブジェクトの保存-復元ができるようになるのです。
ObjectDelivererではこのJson文字列を通信に乗せて送受信することで、オブジェクトを別の場所(別のアプリケーション)に届けることができています。
UE4におけるJsonの取り扱い
UE4には標準でJson
とJsonUtilities
というモジュールがあります。
Jsonモジュール
Jsonの読み書きをサポートするモジュールです。ただしブループリントでは使用できずC++のみサポートされています。
こんな感じで使えます。
Jsonオブジェクト→Json文字列
// FJsonObject(Jsonデータの入れ物)を作成 TSharedPtr<FJsonObject> jsonObject = MakeShareable(new FJsonObject()); // jsonObjectにプロパティを追加 // FStringにJsonを書き込むためのWriterを作成 FString OutputString; TSharedRef<TJsonWriter<>> Writer = TJsonWriterFactory<>::Create(&OutputString); // JsonをFStringに書き込み FJsonSerializer::Serialize(jsonObject.ToSharedRef(), Writer); // OutputStringにJson文字列が入っているので何かする
Json文字列→Jsonオブジェクト
// json文字列が入っているFString FString jsonString; // FStringからJsonを読み込むためのReaderを作成 TSharedRef<TJsonReader<TCHAR>> JsonReader = TJsonReaderFactory<TCHAR>::Create(jsonString); // FJsonObject(Jsonデータの入れ物)を作成 TSharedPtr<FJsonObject> JsonObject = MakeShareable(new FJsonObject()); // Json文字列からJsonオブジェクトに読み込み FJsonSerializer::Deserialize(JsonReader, JsonObject); // jsonObjectからプロパティを取り出して何かする
Jsonオブジェクトの値のGet, Set
TSharedPtr<FJsonObject> JsonObject = MakeShareable(new FJsonObject()); // 値のセット JsonObject->SetNumberField("IntValue", 1); JsonObject->SetBoolField("BoolValue", true); JsonObject->SetStringField("StringValue", "ABCDEFG"); // 値の取得 int32 intValue = JsonObject->GetIntegerField("IntValue"); bool boolValue = JsonObject->GetBoolField("BoolValue"); FString stringValue = JsonObject->GetStringField("StringValue"); // このほかArrayやObjectプロパティもGet, Setできます
上記パターンを知っていれば、だいたいのケースで対応できると思います。
JsonUtilitiesモジュール
Jsonモジュールとは別にJsonUtilitiesモジュールというものがあります。 これはJsonモジュールを使ったサポートモジュールです。
この中にFJsonObjectConverter
というクラスがあり、名前的にはオブジェクトをJsonにできそうな気がします。
ただ中身を見ると、UStructとJSONの相互変換を提供するクラスでした。 私が実現したいのはUObjectとJSONの相互変換なので、ちょっと違いました。残念。
ちなみにこの機能を使うには以下の2つのstaticメソッドをコールすることで可能です。
- UStructToJsonObject
- JsonObjectToUStruct
UObjectとJSONの相互変換を自作する
まず自作すると書きましたが、ほぼFJsonObjectConverter
クラスの実装のままです。
FJsonObjectConverter
クラスと並べてみると分かるのですが、ほぼ一緒です。
FJsonObjectConverter
クラスの実装にUObjectのやり取り処理のみ足させていただきました。
コードは上記リンクに全部のってますが、機能を実現する上での代表的な部分のみ解説します。
UObject -> JsonObject
UObjectをJsonに変換する処理です。
まずTFieldIteratorを使ってUObjectのプロパティ(ブループリントで定義する変数も含まれる)の一覧を取得し順番に処理します。
Unreal Engineではない純粋なC++のプログラムでは、このような事(リフレクション)はできません。 (将来のC++での企画では検討されているとかの記事は見たことありますが)
UE4のC++ではリフレクションの事をプロパティシステムと呼ぶらしいです。
詳しくはここに書いてあります。 www.unrealengine.com
プロパティ情報(UProperty)が取得できると、それを使ってプロパティ名や実際のプロパティの値もとれます。
そうなるとあとは、上のほうで書いたJsonオブジェクトへのプロパティのセットと同様の方法でJsonプロパティを作ることができます。
ただしUObjectの中にUObjectのプロパティがあるような入れ子構造の場合も考える必要があるため、ここでの処理は再帰的に行われます。
TSharedPtr<FJsonObject> UObjectJsonSerializer::CreateJsonObject(const UObject* Obj) { TSharedPtr<FJsonObject> JsonObject = MakeShareable(new FJsonObject()); if (!Obj) return JsonObject; // UObjectのプロパティ一覧を順番に処理 for (TFieldIterator<UProperty> PropIt(Obj->GetClass(), EFieldIteratorFlags::IncludeSuper); PropIt; ++PropIt) { UProperty* Property = *PropIt; // プロパティ名を取得 FString PropertyName = Property->GetName(); // プロパティの値を取得 uint8* CurrentPropAddr = PropIt->ContainerPtrToValuePtr<uint8>((UObject*)Obj); FJsonObjectConverter::CustomExportCallback CustomCB; CustomCB.BindStatic(UObjectJsonSerializer::ObjectJsonCallback); // Jsonのプロパティにセットする // FJsonObjectConverter::UPropertyToJsonValueは再帰的に処理される JsonObject->SetField(PropertyName, FJsonObjectConverter::UPropertyToJsonValue(Property, CurrentPropAddr, 0, 0, &CustomCB)); } return JsonObject; }
JsonObject -> UObject
JsonからUObjectを復元する処理です。
こちらもUObjectをJsonに変換する処理と同様、UE4のプロパティシステムを使って、動的にプロパティ一覧を取得し順番に処理していきます。
UPropertyを取得し、そこからプロパティ名を取得したあとに、JsonObjectからJsonプロパティを取得してUObjectのプロパティにセットする流れです。
こちらも入れ構造に対応するために、再帰的に関数が呼ばれて行きます。
bool UObjectJsonSerializer::JsonObjectToUObject(const TSharedPtr<FJsonObject>& JsonObject, UObject* OutObject) { auto& JsonAttributes = JsonObject->Values; // 復元するUObjectのプロパティ一覧を取得 for (TFieldIterator<UProperty> PropIt(OutObject->GetClass()); PropIt; ++PropIt) { UProperty* Property = *PropIt; // JsonObjectからプロパティ名をキーにしてJsonプロパティを取得 const TSharedPtr<FJsonValue>* JsonValue = JsonAttributes.Find(Property->GetName()); if (!JsonValue) { continue; } if (JsonValue->IsValid() && !(*JsonValue)->IsNull()) { // UObjectのプロパティのポインタを取得 void* Value = Property->ContainerPtrToValuePtr<uint8>(OutObject); // Jsonプロパティから値を読み出しUObjectのプロパティにセット // JsonValueToUPropertyは再帰的に処理される if (!JsonValueToUProperty(*JsonValue, Property, Value)) { return false; } } } return true; }
いくつかの問題
ObjectDelivererでは上記ObjectJsonSerializerを使って、UObjectDeliveryBoxUsingJson内でUObjectをJsonにシリアライズしています。
この機能は自分のプロジェクトでも利用しており、一通り狙ったように動作しているのですがいくつか改善したい点がでてきました。
JsonObject -> UObject時にUObjectの型を指定する必要がある
上記JsonObject -> UObjectの処理の説明で書いたように、この時の処理はUObjectの型が事前に決まっている必要があります。
これはJsonのフォーマットを見ると分かるのですが、Jsonにはオブジェクトの型を保存しておく仕組みはありません。
そのためJsonだけ見ていても、どのUObjectに復元してよいか分からないため事前にUObjectの型を決めておく必要があるのです。
このため現在のObjectDelivererのUObjectDeliveryBoxUsingJsonを使った通信では、1つのUObjectの型のみ送受信できます。複数の型の送受信には通常の使い方では対応できません。
UObject型のプロパティをもっている場合に情報が落ちる可能性がある
これも1つ前の問題と同じく、Jsonに型情報を保存できないために発生している問題です。
例えばClassAというクラスを継承してClassBというクラスが存在する環境があるとします。
その環境でJsonに保存するUObjectにClassA型のプロパティがあります。
このClassA型のプロパティにはClassBのインスタンスを入れることができます。
この時にJsonへの保存と復元をするとClassAにはなくClassBにしかないプロパティは復元されません。。。
これはプロパティの復元時にはClassA型を示すUPropertyを使っているためです。
これは理解して使っていれば避けられますが、気づきづらいバグになってしまうかもしれません。
Jsonのプロパティ名が指定した文字列になっていないことがある
たとえば冒頭の例で示したブループリントをJsonにした時に、以下のようになってしまうことがあります。
{ "Intvalue":0, "boolValue":false, "Stringvalue":"ABC" }
一見するとちゃんとできているようにも見えますが、プロパティ名の大文字小文字が間違っています。
これはUE4のプロパティがFName型を使っているためです。
上記ページにも書かれているようにFNameは大文字小文字を区別しないため、このような現象が起きてしまいます。
Jsonへの保存と復元に両方ともObjectDelivererを使ったUE4の同一プロジェクトの場合では問題はおきませんが、他の開発環境で作られたアプリケーションとJsonのやり取りをする際には問題(プロパティ名の不一致)が起きる可能性があります。
問題の対策
実はこの記事を公開する時点で上記問題の対策を行ったアップデートを公開したかったのですが、まだ完了しておりません。。。(まだテストが終わってない)
作業は以下のブランチで行っており、実装はほぼ完了しているのでどんか感じなのかは見ることはできます。
Jsonへのシリアライズ部分はだいぶ変更を入れています。
https://github.com/ayumax/ObjectDeliverer/tree/Remodeling-ObjectJsonSerializer
行った対策は以下の2つです。
Jsonの中にUObjectの型情報をユーザー選択式で書き込む
問題の1つ目と2つ目は、Jsonの中にUObjectの型情報を書き込んでおけば解消されます。
ただしそれを行うと以前とはJsonのフォーマットが変わり互換性が失われることや、純粋にJsonの文字列数が増えるためデータサイズも大きくなり、ObjectDelivererの主目的であるデータの送受信という観点からは好ましくない場合もあります。
そこでUObjectの型毎にJsonの中に型情報を書き込むか書き込まないかを使う人が選択できる仕組みを導入します。
これにより送受信する型は1つだけで、クラスの継承を使ったUObjectのプロパティは作らないという条件なら以前と同じJsonを作成することもできます。
また実際のインスタンスの型情報を書き込む事を選択することで、UObjectの保存、復元を行ったときに確実に復元させることもできます。
具体的には作成されるJsonは以下のような違いが生まれます。
型情報を書きこまない場合(以前までの実装)
{ "IntValue":0, "BoolValue":false, "StringValue":"ABC" }
型情報を書き込む場合
例としてNewBlueprint
という名前のUObjectをシリアライズした場合
{ "Type":"NewBlueprint", "Body" : { "IntValue":0, "BoolValue" : false, "StringValue" : "ABC" } }
プロパティ名の変換関数を実装するインターフェイスの定義
FName型からFString型への変換は自動に任せると狙った文字列にならない可能性があるため、ユーザーが自分で確実に定義できるようプロパティ名変換を行うインターフェイスを追加しました。
シリアライズ対象のUObjectにこのインターフェイスを実装すると、自前のプロパティ名変換関数を使ってJsonプロパティの書き込み、読み込みを行うようになります。
インターフェイスが実装されていない場合は従来通り自動でFName -> FString変換が行われます。
この機能を使うことで、全く違う文字列でJsonプロパティを作ることもできるようになるので、今回の問題解決以外にも使い道はあると思っています。
最後に
今回はUObjectをJsonObjectにシリアライズする話を書きました。
今回記事の中に書きましたJsonUtilitiesモジュールのFJsonObjectConverterのソースはJson以外へのUObjectのシリアライズを作りたい場合や、UE4のプロパティシステムを利用する上でも参考になる実装が含まれているため是非一度見てみてください。
また私の公開していますObjectDelivererはブループリントのみでTCP/IPやUDPなどを使った通信を簡単に作ることを目指して作っています。価格Freeで公開しているのでよろしければお試しください。