JSON コントラクトをカスタマイズする

System.Text.Json ライブラリは、.NET 型ごとに JSON "コントラクト" を構築します。これは、型のシリアル化と逆シリアル化の方法を定義するものです。 コントラクトは、型のシェイプから派生します。これには、プロパティやフィールドなどの特性と、IEnumerable または IDictionary インターフェイスが実装されるかどうかが含まれます。 型は、リフレクションを使用して実行時に、またはソース ジェネレーターを使用してコンパイル時にコントラクトにマップされます。

.NET 7 以降では、これらの JSON コントラクトをカスタマイズして、型と JSON の間で変換を行う方法をより詳細に制御できます。 次の一覧は、シリアル化と逆シリアル化に対して実行できるカスタマイズの種類の例をいくつか示しています。

  • プライベート フィールドおよびプロパティをシリアル化します。
  • 1 つのプロパティに対して複数の名前をサポートします (たとえば、以前のライブラリ バージョンで別の名前が使用されていた場合)。
  • 特定の名前、型、または値を持つプロパティを無視します。
  • 明示的な null 値と、JSON ペイロードに値がない場合とを区別します。
  • DataContractAttribute などの System.Runtime.Serialization 属性をサポートします。 詳細については、「System.Runtime.Serialization 属性」を参照してください。
  • JSON にターゲット型の一部ではないプロパティが含まれている場合は、例外をスローします。 詳細については、「不足しているメンバーを処理する」を参照してください。

オプトインする方法

カスタマイズにプラグインするには、2 つの方法があります。 どちらもリゾルバーを取得する必要があります。リゾルバーの仕事は、シリアル化する必要がある各型の JsonTypeInfo インスタンスを提供することです。

  • DefaultJsonTypeInfoResolver() コンストラクターを呼び出して JsonSerializerOptions.TypeInfoResolver を取得し、その Modifiers プロパティにカスタム アクションを追加する。

    次に例を示します。

    JsonSerializerOptions options = new()
    {
        TypeInfoResolver = new DefaultJsonTypeInfoResolver
        {
            Modifiers =
            {
                MyCustomModifier1,
                MyCustomModifier2
            }
        }
    };
    

    複数の修飾子を追加すると、順番に呼び出されます。

  • IJsonTypeInfoResolver を実装するカスタム リゾルバーを記述する。

    • 型が処理されない場合は、IJsonTypeInfoResolver.GetTypeInfo によってその型に対して null が返されます。
    • カスタム リゾルバーを他のリゾルバー (既定のリゾルバーなど) と組み合わせることもできます。 リゾルバーは、型に対して null 以外の JsonTypeInfo 値が返されるまで、順番にクエリされます。

構成可能な側面

JsonTypeInfo.Kind プロパティは、コンバーターによって特定の型 (オブジェクトや配列など) をシリアル化する方法と、そのプロパティがシリアル化されるかどうかを示します。 このプロパティをクエリして、構成できる型の JSON コントラクトの側面を特定できます。 次の 4 種類があります。

JsonTypeInfo.Kind 説明
JsonTypeInfoKind.Object コンバーターにより、型が JSON オブジェクトにシリアル化され、そのプロパティが使用されます。 この種類は、ほとんどのクラス型と構造体型に使用され、最大限の柔軟性を実現できます。
JsonTypeInfoKind.Enumerable コンバーターにより、型が JSON 配列にシリアル化されます。 この種類は、List<T> や配列などの型に使用されます。
JsonTypeInfoKind.Dictionary コンバーターにより、型が JSON オブジェクトにシリアル化されます。 この種類は、Dictionary<K, V> などの型に使用されます。
JsonTypeInfoKind.None コンバーターにより、型のシリアル化方法や、使用する JsonTypeInfo プロパティは指定されません。 この種類は、System.Objectintstring などの型、およびカスタム コンバーターを使用するすべての型に使用されます。

修飾子

修飾子は、Action<JsonTypeInfo> (コントラクトの現在の状態を引数として取得し、コントラクトに変更を加える JsonTypeInfo パラメーターを持つメソッド) です。 たとえば、指定した JsonTypeInfo で事前設定済みのプロパティを反復処理して目的のプロパティを検索し、その JsonPropertyInfo.Get プロパティ (シリアル化の場合) または JsonPropertyInfo.Set プロパティ (逆シリアル化の場合) を変更できます。 または、JsonTypeInfo.CreateJsonPropertyInfo(Type, String) を使用して新しいプロパティを構築し、JsonTypeInfo.Properties コレクションに追加することもできます。

次の表は、実行できる変更とその実現方法を示しています。

変更 該当する JsonTypeInfo.Kind 実行方法
プロパティの値をカスタマイズする JsonTypeInfoKind.Object プロパティの JsonPropertyInfo.Get デリゲート (シリアル化の場合) または JsonPropertyInfo.Set デリゲート (逆シリアル化の場合) を変更します。 プロパティの値をインクリメントする
プロパティを追加または削除する JsonTypeInfoKind.Object JsonTypeInfo.Properties リストの項目を追加または削除します。 プライベート フィールドをシリアル化する
プロパティを条件付きでシリアル化する JsonTypeInfoKind.Object プロパティの JsonPropertyInfo.ShouldSerialize 述語を変更します。 特定の型のプロパティを無視する
特定の型の番号処理をカスタマイズする JsonTypeInfoKind.None 型の JsonTypeInfo.NumberHandling 値を変更します。 int 値を文字列にすることを許可する

例: プロパティの値をインクリメントする

次の例では、JsonPropertyInfo.Set デリゲートを変更することで、逆シリアル化時に修飾子によって特定のプロパティの値がインクリメントされます。 この例では、修飾子を定義するだけでなく、値をインクリメントする必要があるプロパティの特定に使用する新しい属性も導入しています。 これは、"プロパティのカスタマイズ" の例です。

using System.Text.Json;
using System.Text.Json.Serialization.Metadata;

namespace Serialization
{
    // Custom attribute to annotate the property
    // we want to be incremented.
    [AttributeUsage(AttributeTargets.Property)]
    class SerializationCountAttribute : Attribute
    {
    }

    // Example type to serialize and deserialize.
    class Product
    {
        public string Name { get; set; } = "";
        [SerializationCount]
        public int RoundTrips { get; set; }
    }

    public class SerializationCountExample
    {
        // Custom modifier that increments the value
        // of a specific property on deserialization.
        static void IncrementCounterModifier(JsonTypeInfo typeInfo)
        {
            foreach (JsonPropertyInfo propertyInfo in typeInfo.Properties)
            {
                if (propertyInfo.PropertyType != typeof(int))
                    continue;

                object[] serializationCountAttributes = propertyInfo.AttributeProvider?.GetCustomAttributes(typeof(SerializationCountAttribute), true) ?? Array.Empty<object>();
                SerializationCountAttribute? attribute = serializationCountAttributes.Length == 1 ? (SerializationCountAttribute)serializationCountAttributes[0] : null;

                if (attribute != null)
                {
                    Action<object, object?>? setProperty = propertyInfo.Set;
                    if (setProperty is not null)
                    {
                        propertyInfo.Set = (obj, value) =>
                        {
                            if (value != null)
                            {
                                // Increment the value by 1.
                                value = (int)value + 1;
                            }

                            setProperty (obj, value);
                        };
                    }
                }
            }
        }

        public static void RunIt()
        {
            var product = new Product
            {
                Name = "Aquafresh"
            };

            JsonSerializerOptions options = new()
            {
                TypeInfoResolver = new DefaultJsonTypeInfoResolver
                {
                    Modifiers = { IncrementCounterModifier }
                }
            };

            // First serialization and deserialization.
            string serialized = JsonSerializer.Serialize(product, options);
            Console.WriteLine(serialized);
            // {"Name":"Aquafresh","RoundTrips":0}

            Product deserialized = JsonSerializer.Deserialize<Product>(serialized, options)!;
            Console.WriteLine($"{deserialized.RoundTrips}");
            // 1

            // Second serialization and deserialization.
            serialized = JsonSerializer.Serialize(deserialized, options);
            Console.WriteLine(serialized);
            // { "Name":"Aquafresh","RoundTrips":1}

            deserialized = JsonSerializer.Deserialize<Product>(serialized, options)!;
            Console.WriteLine($"{deserialized.RoundTrips}");
            // 2
        }
    }
}

出力では、Product インスタンスが逆シリアル化されるたびに RoundTrips の値がインクリメントされています。

例: プライベート フィールドをシリアル化する

既定では、System.Text.Json によってプライベート フィールドおよびプロパティは無視されます。 この例では、新しいクラス全体の属性 JsonIncludePrivateFieldsAttribute を追加して、その既定値を変更します。 修飾子によって型の属性が見つかると、その型のすべてのプライベート フィールドが新しいプロパティとして JsonTypeInfo に追加されます。

using System.Reflection;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;

namespace Serialization
{
    [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)]
    public class JsonIncludePrivateFieldsAttribute : Attribute { }

    [JsonIncludePrivateFields]
    public class Human
    {
        private string _name;
        private int _age;

        public Human()
        {
            // This constructor should be used only by deserializers.
            _name = null!;
            _age = 0;
        }

        public static Human Create(string name, int age)
        {
            Human h = new()
            {
                _name = name,
                _age = age
            };

            return h;
        }

        [JsonIgnore]
        public string Name
        {
            get => _name;
            set => throw new NotSupportedException();
        }

        [JsonIgnore]
        public int Age
        {
            get => _age;
            set => throw new NotSupportedException();
        }
    }

    public class PrivateFieldsExample
    {
        static void AddPrivateFieldsModifier(JsonTypeInfo jsonTypeInfo)
        {
            if (jsonTypeInfo.Kind != JsonTypeInfoKind.Object)
                return;

            if (!jsonTypeInfo.Type.IsDefined(typeof(JsonIncludePrivateFieldsAttribute), inherit: false))
                return;

            foreach (FieldInfo field in jsonTypeInfo.Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic))
            {
                JsonPropertyInfo jsonPropertyInfo = jsonTypeInfo.CreateJsonPropertyInfo(field.FieldType, field.Name);
                jsonPropertyInfo.Get = field.GetValue;
                jsonPropertyInfo.Set = field.SetValue;

                jsonTypeInfo.Properties.Add(jsonPropertyInfo);
            }
        }

        public static void RunIt()
        {
            var options = new JsonSerializerOptions
            {
                TypeInfoResolver = new DefaultJsonTypeInfoResolver
                {
                    Modifiers = { AddPrivateFieldsModifier }
                }
            };

            var human = Human.Create("Julius", 37);
            string json = JsonSerializer.Serialize(human, options);
            Console.WriteLine(json);
            // {"_name":"Julius","_age":37}

            Human deserializedHuman = JsonSerializer.Deserialize<Human>(json, options)!;
            Console.WriteLine($"[Name={deserializedHuman.Name}; Age={deserializedHuman.Age}]");
            // [Name=Julius; Age=37]
        }
    }
}

ヒント

プライベート フィールド名がアンダースコアで始まる場合は、フィールドを新しい JSON プロパティとして追加するときに、名前からアンダースコアを削除することを検討してください。

例: 特定の型のプロパティを無視する

モデルには、ユーザーに公開したくない特定の名前または型を持つプロパティが含まれる場合があります。 たとえば、資格情報を格納するプロパティや、ペイロードに含めても意味のない情報がある場合があります。

次の例は、特定の型 SecretHolder のプロパティをフィルターして除外する方法を示しています。 これを行うには、IList<T> 拡張メソッドを使用して、指定した型を持つプロパティを JsonTypeInfo.Properties リストから削除します。 フィルター処理されたプロパティは、コントラクトから完全に消去されます。つまり、シリアル化または逆シリアル化中に System.Text.Json によってプロパティが確認されることはありません。

using System.Text.Json;
using System.Text.Json.Serialization.Metadata;

namespace Serialization
{
    class ExampleClass
    {
        public string Name { get; set; } = "";
        public SecretHolder? Secret { get; set; }
    }

    class SecretHolder
    {
        public string Value { get; set; } = "";
    }

    class IgnorePropertiesWithType
    {
        private readonly Type[] _ignoredTypes;

        public IgnorePropertiesWithType(params Type[] ignoredTypes)
            => _ignoredTypes = ignoredTypes;

        public void ModifyTypeInfo(JsonTypeInfo ti)
        {
            if (ti.Kind != JsonTypeInfoKind.Object)
                return;

            ti.Properties.RemoveAll(prop => _ignoredTypes.Contains(prop.PropertyType));
        }
    }

    public class IgnoreTypeExample
    {
        public static void RunIt()
        {
            var modifier = new IgnorePropertiesWithType(typeof(SecretHolder));

            JsonSerializerOptions options = new()
            {
                TypeInfoResolver = new DefaultJsonTypeInfoResolver
                {
                    Modifiers = { modifier.ModifyTypeInfo }
                }
            };

            ExampleClass obj = new()
            {
                Name = "Password",
                Secret = new SecretHolder { Value = "MySecret" }
            };

            string output = JsonSerializer.Serialize(obj, options);
            Console.WriteLine(output);
            // {"Name":"Password"}
        }
    }

    public static class ListHelpers
    {
        // IList<T> implementation of List<T>.RemoveAll method.
        public static void RemoveAll<T>(this IList<T> list, Predicate<T> predicate)
        {
            for (int i = 0; i < list.Count; i++)
            {
                if (predicate(list[i]))
                {
                    list.RemoveAt(i--);
                }
            }
        }
    }
}

例: int 値を文字列にすることを許可する

入力 JSON では、数値型の 1 つを引用符で囲むことができますが、それ以外は囲むことができません。 クラスを制御していれば、型に JsonNumberHandlingAttribute を配置してこれを修正できますが、制御はしていません。 .NET 7 より前は、この動作を修正するためにカスタム コンバーターを記述する必要がありました。その場合、かなりのコードを記述する必要があります。 コントラクトのカスタマイズを使用すると、任意の型の番号処理動作をカスタマイズできます。

次の例では、すべての int 値の動作を変更します。 この例は、任意の型、または任意の型の特定のプロパティに適用できるように簡単に調整できます。

using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;

namespace Serialization
{
    public class Point
    {
        public int X { get; set; }
        public int Y { get; set; }
    }

    public class AllowIntsAsStringsExample
    {
        static void SetNumberHandlingModifier(JsonTypeInfo jsonTypeInfo)
        {
            if (jsonTypeInfo.Type == typeof(int))
            {
                jsonTypeInfo.NumberHandling = JsonNumberHandling.AllowReadingFromString;
            }
        }

        public static void RunIt()
        {
            JsonSerializerOptions options = new()
            {
                TypeInfoResolver = new DefaultJsonTypeInfoResolver
                {
                    Modifiers = { SetNumberHandlingModifier }
                }
            };

            // Triple-quote syntax is a C# 11 feature.
            Point point = JsonSerializer.Deserialize<Point>("""{"X":"12","Y":"3"}""", options)!;
            Console.WriteLine($"({point.X},{point.Y})");
            // (12,3)
        }
    }
}

文字列からの int 値の読み取りを許可する修飾子がない場合、プログラムは例外で終了します。

ハンドルされていない例外です。 System.Text.Json.JsonException: JSON 値を System.Int32 に変換できませんでした。 Path: $.X | LineNumber: 0 | BytePositionInLine: 9。

シリアル化をカスタマイズするその他の方法

コントラクトのカスタマイズに加えて、次のように、シリアル化と逆シリアル化の動作に影響を与える方法は他にもあります。

  • JsonAttribute から派生した属性 (例: JsonIgnoreAttributeJsonPropertyOrderAttribute) を使用する。
  • JsonSerializerOptions を変更する (例: 名前付けポリシーを設定する、列挙値を数値ではなく文字列としてシリアル化する)。
  • JSON の実際の書き込みを行い、逆シリアル化中にオブジェクトを構築するカスタム コンバーターを記述する。

属性を追加する型にアクセスできない可能性があり、カスタム コンバーターの記述が複雑でパフォーマンスが低下するため、コントラクトのカスタマイズは、これらの既存のカスタマイズよりも改良されています。

関連項目