blog

DeNAのエンジニアが考えていることや、担当しているサービスについて情報発信しています

2024.01.25 技術記事

System.Text.JsonをUnityの型に対応させるライブラリの作り方

by hayato sato

#unity #library #system-text-json

はじめに

こんにちは。ゲームサービス事業本部の横断組織に所属している 23 新卒の佐藤( @hanaaaaaachiru )です。普段は業務でゲーム基盤開発をしています。

先日 “System.Text.Json を Unity の型に対応させる社内向けライブラリ"を作成したので、その話とノウハウを紹介したいと思います。

System.Text.Json とは

System.Text.Json は Microsoft が提供する高パフォーマンスでアロケーションの少ない Json シリアライザです。UTF-8 での読み書きができることや SourceGenerator を用いた ソース生成 に対応していることが大きな特徴だと思います。

Unity 特有の制約

Unity2021.2 から SourceGenerator が利用できるようになったことに合わせて、Unity でも System.Text.Json が利用できるようになりました。しかし Unity2023.3 の公式ドキュメント には System.Text.Json v6.0.0-preview のみサポートすると記載されています。これは Unity が利用している Microsoft.CodeAnalysis.CSharp のバージョンが原因です。Microsoft.CodeAnalysis.CSharp は Roslyn の C#におけるほぼ全 API を提供するライブラリのことですね。公式ドキュメントでは SourceGenerator を利用する際は Microsoft.CodeAnalysis.CSharp v3.8 を利用しなければならないと記載されており、System.Text.Json v6.0.0-preview 以降のバージョンでは Microsoft.CodeAnalysis.CSharp v3.8 以降を利用しているため動作しません。

ただそれはあくまで公式ドキュメントにそう記述されているだけで、実は Unity が利用している Microsoft.CodeAnalysis.CSharp は Unity のバージョンによっては v3.8 よりも新しいものを利用しています。例えば Unity2022.3.10f1 が利用している Microsoft.CodeAnalysis.CSharp.dll のバージョンを確認すると、v3.11.0.0 が利用されていることが確認できました。

// MacでのMicrosoft.CodeAnalysis.CSharp へのパス
/Applications/Unity/Hub/Editor/2022.3.10f1/Unity.app/Contents/Tools/ScriptUpdater/Microsoft.CodeAnalysis.CSharp.dll

Unity と Microsoft.CodeAnalysis.CSharp のバージョンの関係性については neuecc さんが執筆された記事 に詳しく書かれているので確認してみて欲しいのですが、Unity2022.2・Unity2023.1 から v4.1.0 のコンパイラが搭載されているそうです。

現在(2024/1/11)で一番新しい System.Text.Json v8.0.1 のソース生成を行う System.Text.Json.SourceGeneration は roslyn3.11, roslyn4.0, roslyn4.4 に対応しており、Unity2022.3.10f1 で System.Text.Json v8.0.1(のソース生成)が動作することを確認できました。v8.0.1 は v6.0.0-preview と比べて、Memory<T>ReadOnlyMemory<T>に対応していたり、JsonInclude属性を用いて非パブリックメンバーをシリアライズ対象にできるなどの機能が利用できます。詳細は .NET7 の新機能 .NET8 の新機能 あたりを参照してみてください。もしそれらを使いたいようであれば、公式が対応していると述べていない点も考慮しつつ利用を考えてみると良いと思います。

カスタムコンバーターについて

カスタムコンバーター を作成することでライブラリが提供している組み込みコンバーターが対応していない独自の型をサポートすることができます。

カスタムコンバーターの作成には"基本パターン"と"ファクトリパターン"の 2 種類が存在します。

  • 基本パターン : 非ジェネリック型とクローズジェネリック型
  • ファクトリパターン : Enum 型とオープンジェネリック型

例えばUnityEngine.Vector3は非ジェネリック型なので基本パターンで対応することができます。

// JsonConverter<T>の派生クラスを定義します。Tはシリアライズ・デシリアライズされる型を指定します。
public class Vector3Converter : JsonConverter<Vector3>
{
    // JSONを読み込む処理を記述します。(デシリアライズ)
    public override Vector3 Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        // 最初が「[」でなければJsonExceptionを投げます。シリアライザーはJsonExceptionとNotSupportedExceptionを特別に処理するので注意してください。
        if (reader.TokenType != JsonTokenType.StartArray)
        {
            throw new JsonException();
        }

        // xを読み取ります。
        reader.Read();
        if (reader.TokenType != JsonTokenType.Number)
        {
            throw new JsonException();
        }
        var x = reader.GetSingle();

        // yを読み取ります。
        reader.Read();
        if (reader.TokenType != JsonTokenType.Number)
        {
            throw new JsonException();
        }
        var y = reader.GetSingle();

        // zを読み取ります。
        reader.Read();
        if (reader.TokenType != JsonTokenType.Number)
        {
            throw new JsonException();
        }
        var z = reader.GetSingle();

        // 最後が「]」でなければJsonExceptionを投げます。
        reader.Read();
        if (reader.TokenType != JsonTokenType.EndArray)
        {
            throw new JsonException();
        }

        return new Vector3(x, y, z);
    }

    // JSONとして書き込む処理を記述します。(シリアライズ)
    public override void Write(Utf8JsonWriter writer, Vector3 value, JsonSerializerOptions options)
    {
        // [x,y,z]のようにJSONに変換をします。
        writer.WriteStartArray();
        writer.WriteNumberValue(value.x);
        writer.WriteNumberValue(value.y);
        writer.WriteNumberValue(value.z);
        writer.WriteEndArray();
    }
}

詳細は 公式ドキュメント を参照してください。

ライブラリの作成

コンセプト

System.Text.Json にてシリアライズ・デシリアライズする際に メタデータ を収集する必要がありますが、リフレクション・ソース生成のどちらを利用しても UnityEngine の型に対応させることが現状できません。以下のサンプルコードは System.Text.Json v6.0.0-preview で動作させた場合の結果をコメントに載せています。

[JsonSerializable(typeof(Color))]
internal partial class UnityEngineColorSourceGenerationContext : JsonSerializerContext
{
}

public class Sample
{
    public static void SampleMethod()
    {
        var color = Color.red;

        // ----------------------
        // ----  リフレクション  ----
        // ----------------------
        // ERROR: System.Text.Json.JsonException
        var json = JsonSerializer.Serialize(color);

        // ----------------------
        // ----   ソース生成   ----
        // ----------------------
        // ERROR: System.InvalidOperationException
        var json2 = JsonSerializer.Serialize(color, UnityEngineColorSourceGenerationContext.Default.Color);

        // ERROR: System.Text.Json.JsonException
        var json3 = JsonSerializer.Serialize(color, typeof(Color), UnityEngineColorSourceGenerationContext.Default);

        // ERROR: System.InvalidOperationException
        var json4 = JsonSerializer.Serialize(color, UnityEngineColorSourceGenerationContext.Default.Options);
    }
}

作成したライブラリでは UnityEngine Unity.Matiematics の型に対応したカスタムコンバーターを作成することで UnityEngine の型に対応させました。

UnityEngine と Unity.Mathematics の型に対応するカスタムコンバーター

対応させる UnityEngine の型に関しては OSS である MessagePack for C# を参考にさせていただきました。具体的には以下の通りです。

AnimationCurve, Bounds, BoudsInt, Color32, Color, GradientAlphaKey, GradientColorKey, Gradient, GradientMode, Keyframe, LayerMask, Matrix4x4, Quaternion, RangeInt, Rect, RectInt, RectOffset, Vector2, Vector2Int, Vector3, Vector3Int, Vector4, WrapMode

また MessagePack for C# の Formatters のように マップ(辞書)ではなく配列として表現しました。JsonUtility ではマップで表現されるので汎用性・互換性の面で良いかと思いますが、Unity 上でしか基本的に扱わない型なのでパフォーマンス重視で配列を採用しました。マップに対応して欲しいという要望があれば MessagePack for C# のように 切り替えられる対応 をしようかなくらいの温度感です。

// UnityEngine.Vector3に対するJsonUtilityでのシリアライズ結果
{"x":1.0,"y":2.0,"z":3.0}

// UnityEngine.Vector3に対する本ライブラリでのシリアライズ結果
[1,2,3]

また Unity.Mathematics に関しては AssemblyDefinitionFiles VersionDefines を利用した任意パッケージになっており、全ての型に対応しています。

利用方法

利用する場合には用意した JsonConverter が設定された JsonSerializerOptions を用いるか、JsonConverter を追加してくれる拡張メソッドを用います。

Color color = Color.red;

// 1. UnityEngine・Mathematicsの型を含む型をSerializeする場合は"UnityOptions.Default"を引数に渡す
string colorJson = JsonSerializer.Serialize(color, UnityOptions.Default);

// 2. UnityEngine・Mathematicsの型を含む型をDeserializeする場合は"UnityOptions.Default"を引数に渡す
Color deserializedColor = JsonSerializer.Deserialize<Color>(colorJson, UnityOptions.Default);
public class TestClass
{
    public Color Color { get; set; }
    public int2x3 Number { get; set; }
    public float FloatNumber { get; set; }
}

[JsonSerializable(typeof(TestClass))]
public partial class TestClassContext : JsonSerializerContext
{
}

// 以下利用サンプル
public static class Sample
{
    public static void SampleMethod()
    {
        var value = new TestClass
        {
            Color = Color.red,
            Number = new int2x3(0, 1, 2, 3, 4, 5),
            FloatNumber = 1f
        };

        // Unity用のJsonConverterを追加します。
        var json = JsonSerializer.Serialize(value, TestClassContext.Default.WithUnityConverters());
        var deserializedValue = JsonSerializer.Deserialize<TestClass>(json, TestClassContext.Default.WithUnityConverters());
    }
}

テストコード生成について

それぞれのカスタムコンバーターの動作を保証するためにテストコードを書きたかったのですが、如何せん数が多いのでテストコードを生成することにしました。

Unity と切り離したコンソールアプリを作成する案や T4 (Text Template Transfomration Toolkit)の実行時テンプレート を利用する案もあったのですが、Unity 上で完結した方がプロセスが簡単にできるという理由から Unity 上でテストコードを生成する方針をとりました。Unity2023.2 でも C#9.0 までしか利用できないと 公式ドキュメント に記載されているので、C#11 から利用可能になった Raw String Literal を利用することはできません。ただ実は Unity 2022.3.12 以降であれば -langVersionを指定する ことで実質 C#11 を利用することができます。しかしこちらも公式が述べているわけではありませんので注意してください。早く CoreCLR 対応 してくれることを切に願うばかりですが、Rider のリファクタリング機能で Raw String Literal を 逐語的補完 することができるので、それで無理やりバージョン問題を解決しました。

// 変換前
internal class Program
{
    public static void Main()
    {
        var x = 10;
        string code = $$$"""
-------
x is {{{x}}}.
-------
""";
    }
}
// 変換後
internal class Program
{
    public static void Main()
    {
        var x = 10;
        string code = $@"-------
x is {x}.
-------";
    }
}

さいごに

Unity 上で System.Text.Json v6.0.0-preview しか使えないことが System.Text.Json が選ばれない理由であるケースもあったかと思います。私自身触っていて JsonTypeInfo<T>の API がほとんど internal なのはちょっとキツイかなと感じました。そんな課題感を感じていたときに System.Text.Json v8.0.1 が Unity 上で動作することに気づいた時は本当ビックリです。是非お手元の環境で動作することを確認してみてください。

最後まで読んでいただき、ありがとうございます!
この記事をシェアしていただける方はこちらからお願いします。

recruit

DeNAでは、失敗を恐れず常に挑戦し続けるエンジニアを募集しています。