.NET で JSON シリアル化 (マーシャリング) のためのカスタム コンバーターを作成する方法

この記事では、System.Text.Json 名前空間で提供される JSON シリアル化クラス用のカスタム コンバーターを作成する方法について説明します。 System.Text.Json の概要については、.NET で JSON のシリアル化と逆シリアル化を行う方法に関するページを参照してください。

"コンバーター" は、オブジェクトまたは値を JSON との間で双方向に変換するクラスです。 System.Text.Json 名前空間には、JavaScript のプリミティブに対応するほとんどのプリミティブ型に対して、組み込みのコンバーターがあります。 カスタム コンバーターを記述して、組み込みコンバーターの既定のビヘイビアーをオーバーライドできます。 次に例を示します。

  • DateTime の値を、mm/dd/yyyy の形式で表したい場合などが考えられます。 既定では、RFC 3339 プロファイルを含め、ISO 8601-1:2019 がサポートされています。 詳細については、「System.Text.Json での DateTime と DateTimeOffset のサポート」を参照してください。
  • PhoneNumber 型を使用して、POCO を JSON 文字列としてシリアル化することもできます。

また、カスタム コンバーターを作成し、新しい機能により System.Text.Json をカスタマイズまたは拡張することもできます。 この記事ではこの後、次のシナリオについて説明します。

Visual Basic を使用してカスタム コンバーターを作成することはできませんが、C# ライブラリに実装されているコンバーターを呼び出すことができます。 詳細については、「Visual Basic のサポート」をご覧ください。

カスタム コンバーターのパターン

カスタム コンバーターの作成には、基本パターンとファクトリ パターンという 2 つのパターンがあります。 ファクトリ パターンは、Enum 型またはオープン ジェネリックを処理するコンバーター用です。 基本パターンは、非ジェネリック型およびクローズ ジェネリック型用です。 たとえば、次のような型のコンバーターには、ファクトリ パターンが必要です。

基本パターンで処理できる型の例としては、次のようなものがあります。

  • Dictionary<int, string>
  • WeekdaysEnum
  • List<DateTimeOffset>
  • DateTime
  • Int32

基本パターンでは、1 つの型を処理できるクラスが作成されます。 ファクトリ パターンによって作成されるクラスの場合は、実行時に必要な特定の型が決定されて、適切なコンバーターが動的に作成されます。

基本コンバーターのサンプル

次のサンプルは、既存のデータ型に対する既定のシリアル化をオーバーライドするコンバーターです。 そのコンバーターでは、DateTimeOffset のプロパティに対して mm/dd/yyyy の形式が使用されます。

using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;

namespace SystemTextJsonSamples
{
    public class DateTimeOffsetJsonConverter : JsonConverter<DateTimeOffset>
    {
        public override DateTimeOffset Read(
            ref Utf8JsonReader reader,
            Type typeToConvert,
            JsonSerializerOptions options) =>
                DateTimeOffset.ParseExact(reader.GetString()!,
                    "MM/dd/yyyy", CultureInfo.InvariantCulture);

        public override void Write(
            Utf8JsonWriter writer,
            DateTimeOffset dateTimeValue,
            JsonSerializerOptions options) =>
                writer.WriteStringValue(dateTimeValue.ToString(
                    "MM/dd/yyyy", CultureInfo.InvariantCulture));
    }
}

ファクトリ パターン コンバーターのサンプル

次のコードでは、Dictionary<Enum,TValue> で動作するカスタム コンバーターを示します。 そのコードは、1 番目のジェネリック型パラメーターが Enum で、2 番目がオープンであるため、ファクトリ パターンに従います。 CanConvert メソッドでは、2 つのジェネリック パラメーターを持つ Dictionary に対してのみ true が返されます。最初のパラメーターは Enum 型です。 内部のコンバーターでは、実行時に TValue に対して提供される任意の型を処理するために、既存のコンバーターが取得されます。

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

namespace SystemTextJsonSamples
{
    public class DictionaryTKeyEnumTValueConverter : JsonConverterFactory
    {
        public override bool CanConvert(Type typeToConvert)
        {
            if (!typeToConvert.IsGenericType)
            {
                return false;
            }

            if (typeToConvert.GetGenericTypeDefinition() != typeof(Dictionary<,>))
            {
                return false;
            }

            return typeToConvert.GetGenericArguments()[0].IsEnum;
        }

        public override JsonConverter CreateConverter(
            Type type,
            JsonSerializerOptions options)
        {
            Type[] typeArguments = type.GetGenericArguments();
            Type keyType = typeArguments[0];
            Type valueType = typeArguments[1];

            JsonConverter converter = (JsonConverter)Activator.CreateInstance(
                typeof(DictionaryEnumConverterInner<,>).MakeGenericType(
                    [keyType, valueType]),
                BindingFlags.Instance | BindingFlags.Public,
                binder: null,
                args: [options],
                culture: null)!;

            return converter;
        }

        private class DictionaryEnumConverterInner<TKey, TValue> :
            JsonConverter<Dictionary<TKey, TValue>> where TKey : struct, Enum
        {
            private readonly JsonConverter<TValue> _valueConverter;
            private readonly Type _keyType;
            private readonly Type _valueType;

            public DictionaryEnumConverterInner(JsonSerializerOptions options)
            {
                // For performance, use the existing converter.
                _valueConverter = (JsonConverter<TValue>)options
                    .GetConverter(typeof(TValue));

                // Cache the key and value types.
                _keyType = typeof(TKey);
                _valueType = typeof(TValue);
            }

            public override Dictionary<TKey, TValue> Read(
                ref Utf8JsonReader reader,
                Type typeToConvert,
                JsonSerializerOptions options)
            {
                if (reader.TokenType != JsonTokenType.StartObject)
                {
                    throw new JsonException();
                }

                var dictionary = new Dictionary<TKey, TValue>();

                while (reader.Read())
                {
                    if (reader.TokenType == JsonTokenType.EndObject)
                    {
                        return dictionary;
                    }

                    // Get the key.
                    if (reader.TokenType != JsonTokenType.PropertyName)
                    {
                        throw new JsonException();
                    }

                    string? propertyName = reader.GetString();

                    // For performance, parse with ignoreCase:false first.
                    if (!Enum.TryParse(propertyName, ignoreCase: false, out TKey key) &&
                        !Enum.TryParse(propertyName, ignoreCase: true, out key))
                    {
                        throw new JsonException(
                            $"Unable to convert \"{propertyName}\" to Enum \"{_keyType}\".");
                    }

                    // Get the value.
                    reader.Read();
                    TValue value = _valueConverter.Read(ref reader, _valueType, options)!;

                    // Add to dictionary.
                    dictionary.Add(key, value);
                }

                throw new JsonException();
            }

            public override void Write(
                Utf8JsonWriter writer,
                Dictionary<TKey, TValue> dictionary,
                JsonSerializerOptions options)
            {
                writer.WriteStartObject();

                foreach ((TKey key, TValue value) in dictionary)
                {
                    string propertyName = key.ToString();
                    writer.WritePropertyName
                        (options.PropertyNamingPolicy?.ConvertName(propertyName) ?? propertyName);

                    _valueConverter.Write(writer, value, options);
                }

                writer.WriteEndObject();
            }
        }
    }
}

基本パターンに従うための手順

次の手順では、基本パターンに従ってコンバーターを作成する方法について説明します。

  • JsonConverter<T> から派生するクラスを作成します。T は、シリアル化および逆シリアル化される型です。
  • 受信した JSON を逆シリアル化して T 型に変換するように、Read メソッドをオーバーライドします。 JSON を読み取るには、メソッドに渡される Utf8JsonReader を使用します。 シリアライザーによって現在の JSON スコープのすべてのデータが渡されるため、部分的なデータの処理について心配する必要はありません。 したがって、Skip または TrySkip を呼び出したり、Readtrue を返すことを検証したりする必要はありません。
  • 受信した T 型のオブジェクトをシリアル化するように、Write メソッドをオーバーライドします。 JSON を書き込むには、メソッドに渡される Utf8JsonWriter を使用します。
  • CanConvert メソッドは、必要な場合にのみオーバーライドします。 変換する型が T 型である場合、既定の実装では true が返されます。 したがって、T 型だけをサポートするコンバーターでは、このメソッドをオーバーライドする必要はありません。 このメソッドをオーバーライドする必要があるコンバーターの例については、後の「ポリモーフィックな逆シリアル化をサポートする」を参照してください。

カスタム コンバーターを作成するための参考の実装として、組み込みコンバーターのソース コードを参照できます。

ファクトリ パターンに従うための手順

次の手順では、ファクトリ パターンに従ってコンバーターを作成する方法について説明します。

  • JsonConverterFactory から派生するクラスを作成します。
  • 変換する型がコンバーターで処理できる型である場合は true を返すように、CanConvert メソッドをオーバーライドします。 たとえば、コンバーターが List<T> 用の場合は、List<int>List<string>List<DateTime> だけを処理できます。
  • 実行時に提供される変換対象の型を処理するコンバーター クラスのインスタンスを返すように、CreateConverter メソッドをオーバーライドします。
  • CreateConverter メソッドによってインスタンス化されるコンバーター クラスを作成します。

オブジェクトと文字列を双方向に変換するコードは、すべての型に対して同じではないため、オープン ジェネリックにはファクトリ パターンが必要です。 オープン ジェネリック型 (List<T> など) 用のコンバーターでは、背後にあるクローズ ジェネリック型 (List<DateTime> など) 用のコンバーターを作成する必要があります。 コンバーターで処理できる各クローズ ジェネリック型を処理するためのにコードを記述する必要があります。

Enum 型はオープン ジェネリック型に似ています。Enum 用のコンバーターでは、背後にある特定の Enum (WeekdaysEnum など) に対するコンバーターを作成する必要があります。

Read メソッドでの Utf8JsonReader の使用

コンバーターによって JSON オブジェクトを変換している場合、Read メソッドの開始時に Utf8JsonReader が開始オブジェクト トークンに配置されます。 次に、そのオブジェクト内のすべてのトークンを読み取り、対応する終了オブジェクト トークンにリーダーを配置してメソッドを終了する必要があります。 オブジェクトの末尾を越えて読み取る場合、または、対応する終了トークンに到達する前に停止した場合は、次のことを示す JsonException 例外が発生します。

コンバーター 'ConverterName' の読み取りが多すぎるか、十分ではありません。

例については、上記のファクトリ パターンのサンプル コンバーターを参照してください。 Read メソッドは、リーダーが開始オブジェクト トークンに配置されていることを確認することによって開始されます。 それが次の終了オブジェクト トークンに配置されていることが見つかるまで読み取られます。 オブジェクト内のオブジェクトを示す開始オブジェクト トークンが介在しないため、それは次の終了オブジェクト トークンで停止します。 配列を変換する場合は、開始トークンと終了トークンについて同じルールが適用されます。 例については、この記事で後ほど説明するサンプル コンバーターの Stack<T> を参照してください。

エラー処理

シリアライザーは、JsonException および NotSupportedException の例外の種類を特別に処理します。

JsonException

メッセージなしで JsonException をスローする場合、シリアライザーがそのエラーの原因となった JSON の部分へのパスを含むメッセージを作成します。 たとえば、throw new JsonException() というステートメントでは、次の例のようなエラー メッセージが生成されます。

Unhandled exception. System.Text.Json.JsonException:
The JSON value could not be converted to System.Object.
Path: $.Date | LineNumber: 1 | BytePositionInLine: 37.

メッセージ (例: throw new JsonException("Error occurred")) を指定した場合でも、シリアライザーは PathLineNumberBytePositionInLine の各プロパティを設定します。

NotSupportedException

NotSupportedException をスローした場合は、メッセージには常にパス情報が含まれます。 メッセージを指定すると、そのメッセージにパス情報が追加されます。 たとえば、throw new NotSupportedException("Error occurred.") というステートメントでは、次の例のようなエラー メッセージが生成されます。

Error occurred. The unsupported member type is located on type
'System.Collections.Generic.Dictionary`2[Samples.SummaryWords,System.Int32]'.
Path: $.TemperatureRanges | LineNumber: 4 | BytePositionInLine: 24

どのようなときにどのような種類の例外をスローするか

JSON ペイロードに、逆シリアル化される型に無効なトークンが含まれている場合は JsonException をスローします。

特定の型を許可しない場合は、NotSupportedException をスローします。 この例外は、サポートされていない型にシリアライザーが自動的にスローします。 たとえば、セキュリティ上の理由で System.Type はサポートされていないため、これを逆シリアル化しようとすると、NotSupportedException になります。

必要に応じて他の例外をスローすることもできますが、それらには JSON パス情報は自動的に含まれません。

カスタム コンバーターを登録する

"カスタム コンバーター" を登録して、Serialize メソッドと Deserialize メソッドでそれが使用されるようにします。 次のいずれかの方法を使用します。

  • コンバーター クラスのインスタンスを JsonSerializerOptions.Converters コレクションに追加します。
  • カスタム コンバーターを必要とするプロパティに、[JsonConverter] 属性を適用します。
  • カスタム値型を表すクラスまたは構造体に、[JsonConverter] 属性を適用します。

登録の例 - Converters コレクション

DateTimeOffsetJsonConverterDateTimeOffset 型のプロパティの既定値にする例を次に示します。

var serializeOptions = new JsonSerializerOptions
{
    WriteIndented = true,
    Converters =
    {
        new DateTimeOffsetJsonConverter()
    }
};

jsonString = JsonSerializer.Serialize(weatherForecast, serializeOptions);

次の型のインスタンスをシリアル化するとします。

public class WeatherForecast
{
    public DateTimeOffset Date { get; set; }
    public int TemperatureCelsius { get; set; }
    public string? Summary { get; set; }
}

カスタム コンバーターが使用されたことを示す JSON 出力の例を次に示します。

{
  "Date": "08/01/2019",
  "TemperatureCelsius": 25,
  "Summary": "Hot"
}

次のコードでは、同じ方法を使用し、カスタム DateTimeOffset コンバーターを使用して逆シリアル化します。

var deserializeOptions = new JsonSerializerOptions();
deserializeOptions.Converters.Add(new DateTimeOffsetJsonConverter());
weatherForecast = JsonSerializer.Deserialize<WeatherForecast>(jsonString, deserializeOptions)!;

登録の例 - [JsonConverter] プロパティ

次のコードでは、Date プロパティのカスタム コンバーターを選択します。

public class WeatherForecastWithConverterAttribute
{
    [JsonConverter(typeof(DateTimeOffsetJsonConverter))]
    public DateTimeOffset Date { get; set; }
    public int TemperatureCelsius { get; set; }
    public string? Summary { get; set; }
}

WeatherForecastWithConverterAttribute をシリアル化するコードでは、JsonSerializeOptions.Converters を使用する必要はありません。

var serializeOptions = new JsonSerializerOptions
{
    WriteIndented = true
};
jsonString = JsonSerializer.Serialize(weatherForecast, serializeOptions);

逆シリアル化するコードでも、Converters を使用する必要はありません。

weatherForecast = JsonSerializer.Deserialize<WeatherForecastWithConverterAttribute>(jsonString)!;

登録の例 - 型での [JsonConverter]

構造体を作成し、それに [JsonConverter] 属性を適用するコードを次に示します。

using System.Text.Json.Serialization;

namespace SystemTextJsonSamples
{
    [JsonConverter(typeof(TemperatureConverter))]
    public struct Temperature
    {
        public Temperature(int degrees, bool celsius)
        {
            Degrees = degrees;
            IsCelsius = celsius;
        }

        public int Degrees { get; }
        public bool IsCelsius { get; }
        public bool IsFahrenheit => !IsCelsius;

        public override string ToString() =>
            $"{Degrees}{(IsCelsius ? "C" : "F")}";

        public static Temperature Parse(string input)
        {
            int degrees = int.Parse(input.Substring(0, input.Length - 1));
            bool celsius = input.Substring(input.Length - 1) == "C";

            return new Temperature(degrees, celsius);
        }
    }
}

上記の構造体のカスタム コンバーターは次のようになります。

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

namespace SystemTextJsonSamples
{
    public class TemperatureConverter : JsonConverter<Temperature>
    {
        public override Temperature Read(
            ref Utf8JsonReader reader,
            Type typeToConvert,
            JsonSerializerOptions options) =>
                Temperature.Parse(reader.GetString()!);

        public override void Write(
            Utf8JsonWriter writer,
            Temperature temperature,
            JsonSerializerOptions options) =>
                writer.WriteStringValue(temperature.ToString());
    }
}

構造体の [JsonConverter] 属性では、Temperature 型のプロパティに対する既定値としてカスタム コンバーターが登録されます。 次の型の TemperatureCelsius プロパティでは、シリアル化または逆シリアル化のときに、コンバーターが自動的に使用されます。

public class WeatherForecastWithTemperatureStruct
{
    public DateTimeOffset Date { get; set; }
    public Temperature TemperatureCelsius { get; set; }
    public string? Summary { get; set; }
}

コンバーターの登録の優先順位

シリアル化または逆シリアル化のとき、コンバーターは各 JSON 要素に対して次の順序 (優先度が高い順) で選択されます。

  • [JsonConverter] がプロパティに適用されます。
  • Converters コレクションにコンバーターが追加されます。
  • [JsonConverter] がカスタム値型または POCO に適用されます。

1 つの型に対して複数のカスタム コンバーターが Converters コレクションに登録されている場合は、CanConvert に対して true を返す最初のコンバーターが使用されます。

適用可能なカスタム コンバーターが登録されていない場合にのみ、組み込みコンバーターが選択されます。

一般的なシナリオでのコンバーターの例

以下のセクションでは、組み込み機能では処理されない一般的なシナリオに対処するコンバーターの例を示します。

サンプルの DataTable コンバーターについては、「サポートされているコレクション型」を参照してください。

推論された型をオブジェクトのプロパティに逆シリアル化する

object 型のプロパティに逆シリアル化すると、JsonElement オブジェクトが作成されます。 その理由は、逆シリアライザーでは、作成する CLR 型がわからず、推測が試みられないためです。 たとえば、JSON プロパティが "true" である場合、逆シリアライザーでは値が Boolean であることは推定されません。また、要素が "01/01/2019" である場合、逆シリアライザーではそれが DateTime であることは推定されません。

型の推定は不正確になる可能性があります。 逆シリアライザーで、小数点を含まない JSON の数値が long と解析された場合、値がもともと ulong または BigInteger としてシリアル化されていたとすると、範囲外の問題が発生する可能性があります。 数値がもともと decimal としてシリアル化されていた場合、小数点を含む数値が double と解析されると、精度が失われる可能性があります。

次のコードでは、型の推定が必要なシナリオでの、object プロパティに対するカスタム コンバーターを示します。 次のような変換が行われます。

  • truefalseBoolean
  • 小数点を含まない数値は long
  • 小数点を含む数値は double
  • 日付は DateTime
  • 文字列は string
  • それ以外はすべて JsonElement
using System.Text.Json;
using System.Text.Json.Serialization;

namespace CustomConverterInferredTypesToObject
{
    public class ObjectToInferredTypesConverter : JsonConverter<object>
    {
        public override object Read(
            ref Utf8JsonReader reader,
            Type typeToConvert,
            JsonSerializerOptions options) => reader.TokenType switch
            {
                JsonTokenType.True => true,
                JsonTokenType.False => false,
                JsonTokenType.Number when reader.TryGetInt64(out long l) => l,
                JsonTokenType.Number => reader.GetDouble(),
                JsonTokenType.String when reader.TryGetDateTime(out DateTime datetime) => datetime,
                JsonTokenType.String => reader.GetString()!,
                _ => JsonDocument.ParseValue(ref reader).RootElement.Clone()
            };

        public override void Write(
            Utf8JsonWriter writer,
            object objectToWrite,
            JsonSerializerOptions options) =>
            JsonSerializer.Serialize(writer, objectToWrite, objectToWrite.GetType(), options);
    }

    public class WeatherForecast
    {
        public object? Date { get; set; }
        public object? TemperatureCelsius { get; set; }
        public object? Summary { get; set; }
    }

    public class Program
    {
        public static void Main()
        {
            string jsonString = """
                {
                  "Date": "2019-08-01T00:00:00-07:00",
                  "TemperatureCelsius": 25,
                  "Summary": "Hot"
                }
                """;

            WeatherForecast weatherForecast = JsonSerializer.Deserialize<WeatherForecast>(jsonString)!;
            Console.WriteLine($"Type of Date property   no converter = {weatherForecast.Date!.GetType()}");

            var options = new JsonSerializerOptions();
            options.WriteIndented = true;
            options.Converters.Add(new ObjectToInferredTypesConverter());
            weatherForecast = JsonSerializer.Deserialize<WeatherForecast>(jsonString, options)!;
            Console.WriteLine($"Type of Date property with converter = {weatherForecast.Date!.GetType()}");

            Console.WriteLine(JsonSerializer.Serialize(weatherForecast, options));
        }
    }
}

// Produces output like the following example:
//
//Type of Date property   no converter = System.Text.Json.JsonElement
//Type of Date property with converter = System.DateTime
//{
//  "Date": "2019-08-01T00:00:00-07:00",
//  "TemperatureCelsius": 25,
//  "Summary": "Hot"
//}

この例は、コンバーター コードと、object プロパティを持つ WeatherForecast クラスを示しています。 Main メソッドは、最初はコンバーターを使用せずに、次にコンバーターを使用して、JSON 文字列を WeatherForecast インスタンスに逆シリアル化します。 コンソール出力には、コンバーターを使用しない場合、Date プロパティのランタイム型は JsonElement になり、コンバーターを使用した場合のランタイム型は DateTime になることが示されています。

System.Text.Json.Serialization 名前空間の単体テスト フォルダーには、object プロパティへの逆シリアル化を処理するカスタム コンバーターの例がさらにあります。

ポリモーフィックな逆シリアル化をサポートする

.NET 7 では、多相型のシリアル化と逆シリアル化の両方がサポートされています。 ただし、以前の .NET バージョンでは、多相型のシリアル化が限定的にサポートされ、逆シリアル化はサポートされていませんでした。 .NET 6 以前のバージョンを使用している場合、逆シリアル化にはカスタム コンバーターが必要です。

たとえば、抽象基底クラス Person と、派生クラス Employee および Customer があるとします。 ポリモーフィックな逆シリアル化とは、デザイン時に逆シリアル化ターゲットとして Person を指定すると、実行時に JSON 内の Customer オブジェクトと Employee オブジェクトが正しく逆シリアル化されることを意味します。 逆シリアル化の間に、JSON で必要な型を識別する手掛かりを見つける必要があります。 使用できる手掛かりの種類は、シナリオによって異なります。 たとえば、識別子プロパティを使用できる場合や、特定のプロパティの有無に依存しなければならない場合があります。 System.Text.Json の現在のリリースでは、ポリモーフィックな逆シリアル化のシナリオを処理する方法を指定する属性が提供されていないため、カスタム コンバーターが必要になります。

次のコードでは、基底クラス、2 つの派生クラス、およびそれらのカスタム コンバーターを示します。 コンバーターでは、識別子プロパティを使用して、ポリモーフィックな逆シリアル化を行います。 型の識別子はクラス定義には含まれていませんが、シリアル化の間に作成され、逆シリアル化の間に読み取られます。

重要

このコード例では、JSON オブジェクトの名前と値のペアの順序を維持する必要がありますが、これは JSON の標準要件ではありません。

public class Person
{
    public string? Name { get; set; }
}

public class Customer : Person
{
    public decimal CreditLimit { get; set; }
}

public class Employee : Person
{
    public string? OfficeNumber { get; set; }
}
using System.Text.Json;
using System.Text.Json.Serialization;

namespace SystemTextJsonSamples
{
    public class PersonConverterWithTypeDiscriminator : JsonConverter<Person>
    {
        enum TypeDiscriminator
        {
            Customer = 1,
            Employee = 2
        }

        public override bool CanConvert(Type typeToConvert) =>
            typeof(Person).IsAssignableFrom(typeToConvert);

        public override Person Read(
            ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
        {
            if (reader.TokenType != JsonTokenType.StartObject)
            {
                throw new JsonException();
            }

            reader.Read();
            if (reader.TokenType != JsonTokenType.PropertyName)
            {
                throw new JsonException();
            }

            string? propertyName = reader.GetString();
            if (propertyName != "TypeDiscriminator")
            {
                throw new JsonException();
            }

            reader.Read();
            if (reader.TokenType != JsonTokenType.Number)
            {
                throw new JsonException();
            }

            TypeDiscriminator typeDiscriminator = (TypeDiscriminator)reader.GetInt32();
            Person person = typeDiscriminator switch
            {
                TypeDiscriminator.Customer => new Customer(),
                TypeDiscriminator.Employee => new Employee(),
                _ => throw new JsonException()
            };

            while (reader.Read())
            {
                if (reader.TokenType == JsonTokenType.EndObject)
                {
                    return person;
                }

                if (reader.TokenType == JsonTokenType.PropertyName)
                {
                    propertyName = reader.GetString();
                    reader.Read();
                    switch (propertyName)
                    {
                        case "CreditLimit":
                            decimal creditLimit = reader.GetDecimal();
                            ((Customer)person).CreditLimit = creditLimit;
                            break;
                        case "OfficeNumber":
                            string? officeNumber = reader.GetString();
                            ((Employee)person).OfficeNumber = officeNumber;
                            break;
                        case "Name":
                            string? name = reader.GetString();
                            person.Name = name;
                            break;
                    }
                }
            }

            throw new JsonException();
        }

        public override void Write(
            Utf8JsonWriter writer, Person person, JsonSerializerOptions options)
        {
            writer.WriteStartObject();

            if (person is Customer customer)
            {
                writer.WriteNumber("TypeDiscriminator", (int)TypeDiscriminator.Customer);
                writer.WriteNumber("CreditLimit", customer.CreditLimit);
            }
            else if (person is Employee employee)
            {
                writer.WriteNumber("TypeDiscriminator", (int)TypeDiscriminator.Employee);
                writer.WriteString("OfficeNumber", employee.OfficeNumber);
            }

            writer.WriteString("Name", person.Name);

            writer.WriteEndObject();
        }
    }
}

次のコードではコンバーターが登録されます。

var serializeOptions = new JsonSerializerOptions();
serializeOptions.Converters.Add(new PersonConverterWithTypeDiscriminator());

コンバーターでは、次のように、同じコンバーターを使用してシリアル化することによって作成された JSON を逆シリアル化できます。

[
  {
    "TypeDiscriminator": 1,
    "CreditLimit": 10000,
    "Name": "John"
  },
  {
    "TypeDiscriminator": 2,
    "OfficeNumber": "555-1234",
    "Name": "Nancy"
  }
]

前の例のコンバーターのコードでは、各プロパティの読み取りと書き込みを手動で行います。 別の方法として、Deserialize または Serialize を呼び出すことにより、一部の作業を行うことができます。 例としては、こちらの StackOverflow の投稿を参照してください。

ポリモーフィックな逆シリアル化を行う別の方法

Read メソッドで Deserialize を呼び出すことができます。

  • Utf8JsonReader インスタンスの複製を作成します。 Utf8JsonReader は構造体なので、これに必要なのは代入ステートメントだけです。
  • 複製を使用して、識別子トークンを読み取ります。
  • 必要な型がわかったら、元の Reader インスタンスを使用して Deserialize を呼び出します。 元の Reader インスタンスはまだ開始オブジェクト トークンを読み取る位置にあるため、Deserialize を呼び出すことができます。

このメソッドの欠点は、コンバーターを Deserialize に登録する元のオプション インスタンスを渡すことができない点です。 これを行うと、「必須プロパティ」で説明されているように、スタック オーバーフローが発生します。 次の例では、この代替手段を使用する Read メソッドが示されています。

public override Person Read(
    ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
    Utf8JsonReader readerClone = reader;

    if (readerClone.TokenType != JsonTokenType.StartObject)
    {
        throw new JsonException();
    }

    readerClone.Read();
    if (readerClone.TokenType != JsonTokenType.PropertyName)
    {
        throw new JsonException();
    }

    string? propertyName = readerClone.GetString();
    if (propertyName != "TypeDiscriminator")
    {
        throw new JsonException();
    }

    readerClone.Read();
    if (readerClone.TokenType != JsonTokenType.Number)
    {
        throw new JsonException();
    }

    TypeDiscriminator typeDiscriminator = (TypeDiscriminator)readerClone.GetInt32();
    Person person = typeDiscriminator switch
    {
        TypeDiscriminator.Customer => JsonSerializer.Deserialize<Customer>(ref reader)!,
        TypeDiscriminator.Employee => JsonSerializer.Deserialize<Employee>(ref reader)!,
        _ => throw new JsonException()
    };
    return person;
}

Stack 型のラウンド トリップをサポートする

JSON 文字列を Stack オブジェクトに逆シリアル化した後、そのオブジェクトをシリアル化した場合、スタックの内容は逆の順序になります。 この動作は、次の型とインターフェイス、およびそれらから派生するユーザー定義型に適用されます。

スタックの元の順序を維持するシリアル化と逆シリアル化をサポートするには、カスタム コンバーターが必要です。

次のコードでは、Stack<T> オブジェクトとの間でのラウンドトリップを可能にするカスタム コンバーターを示します。

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

namespace SystemTextJsonSamples
{
    public class JsonConverterFactoryForStackOfT : JsonConverterFactory
    {
        public override bool CanConvert(Type typeToConvert)
            => typeToConvert.IsGenericType
            && typeToConvert.GetGenericTypeDefinition() == typeof(Stack<>);

        public override JsonConverter CreateConverter(
            Type typeToConvert, JsonSerializerOptions options)
        {
            Debug.Assert(typeToConvert.IsGenericType &&
                typeToConvert.GetGenericTypeDefinition() == typeof(Stack<>));

            Type elementType = typeToConvert.GetGenericArguments()[0];

            JsonConverter converter = (JsonConverter)Activator.CreateInstance(
                typeof(JsonConverterForStackOfT<>)
                    .MakeGenericType(new Type[] { elementType }),
                BindingFlags.Instance | BindingFlags.Public,
                binder: null,
                args: null,
                culture: null)!;

            return converter;
        }
    }

    public class JsonConverterForStackOfT<T> : JsonConverter<Stack<T>>
    {
        public override Stack<T> Read(
            ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
        {
            if (reader.TokenType != JsonTokenType.StartArray)
            {
                throw new JsonException();
            }
            reader.Read();

            var elements = new Stack<T>();

            while (reader.TokenType != JsonTokenType.EndArray)
            {
                elements.Push(JsonSerializer.Deserialize<T>(ref reader, options)!);

                reader.Read();
            }

            return elements;
        }

        public override void Write(
            Utf8JsonWriter writer, Stack<T> value, JsonSerializerOptions options)
        {
            writer.WriteStartArray();

            var reversed = new Stack<T>(value);

            foreach (T item in reversed)
            {
                JsonSerializer.Serialize(writer, item, options);
            }

            writer.WriteEndArray();
        }
    }
}

次のコードではコンバーターが登録されます。

var options = new JsonSerializerOptions
{
    Converters = { new JsonConverterFactoryForStackOfT() },
};

列挙型文字列の逆シリアル化の名前付けポリシー

既定では、組み込みの JsonStringEnumConverter では、列挙型の文字列値をシリアル化および逆シリアル化できます。 指定された名前付けポリシーがなくても、CamelCase 名前付けポリシーが指定されていても機能します。 その他の名前付けポリシー (スネーク ケースなど) はサポートされていません。 スネーク ケース名前付けポリシーを使用しながら列挙型文字列値との間のラウンドトリップをサポートできるカスタム コンバーター コードについては、GitHub イシュー dotnet/runtime #31619 を参照してください。 または、.NET 7 以降のバージョンにアップグレードします。このバージョンでは、列挙型文字列の値との間でラウンドトリップする場合に、名前付けポリシーの適用の組み込みのサポートを提供しています。

既定のシステム コンバーターを使用する

シナリオによっては、カスタム コンバーターで既定のシステム コンバーターを使用することが必要になる場合があります。 これを行うには、次の例に示すように、JsonSerializerOptions.Default プロパティからシステム コンバーターを取得します。

public class MyCustomConverter : JsonConverter<int>
{
    private readonly static JsonConverter<int> s_defaultConverter = 
        (JsonConverter<int>)JsonSerializerOptions.Default.GetConverter(typeof(int));

    // Custom serialization logic
    public override void Write(
        Utf8JsonWriter writer, int value, JsonSerializerOptions options)
    {
        writer.WriteStringValue(value.ToString());
    }

    // Fall back to default deserialization logic
    public override int Read(
        ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        return s_defaultConverter.Read(ref reader, typeToConvert, options);
    }
}

null 値を処理する

既定では、null 値はシリアライザーにより次のように処理されます。

  • 参照型と Nullable<T> 型の場合:

    • シリアル化のときにカスタム コンバーターに null は渡されません。
    • 逆シリアル化のときにカスタム コンバーターに JsonTokenType.Null は渡されません。
    • 逆シリアル化では null インスタンスが返されます。
    • シリアル化ではライターを使用して null が直接書き込まれます。
  • null 非許容値型の場合:

    • 逆シリアル化のときにカスタム コンバーターに JsonTokenType.Null が渡されます。 (カスタム コンバーターが使用できない場合は、その型の内部コンバーターによって JsonException 例外がスローされます)。

この null 処理動作は、主に、コンバーターの余分な呼び出しをスキップすることでパフォーマンスを最適化するためのものです。 また、すべての Read および Write メソッドのオーバーライドの開始時に、null 許容型のコンバーターで null のチェックを強制的に行うことが回避されます。

カスタム コンバーターで参照型または値型の null を処理できるようにするには、次の例で示すように、JsonConverter<T>.HandleNull をオーバーライドして true を返します。

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

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

        [JsonConverter(typeof(DescriptionConverter))]
        public string? Description { get; set; }
    }

    public class DescriptionConverter : JsonConverter<string>
    {
        public override bool HandleNull => true;

        public override string Read(
            ref Utf8JsonReader reader,
            Type typeToConvert,
            JsonSerializerOptions options) =>
            reader.GetString() ?? "No description provided.";

        public override void Write(
            Utf8JsonWriter writer,
            string value,
            JsonSerializerOptions options) =>
            writer.WriteStringValue(value);
    }

    public class Program
    {
        public static void Main()
        {
            string json = @"{""x"":1,""y"":2,""Description"":null}";

            Point point = JsonSerializer.Deserialize<Point>(json)!;
            Console.WriteLine($"Description: {point.Description}");
        }
    }
}

// Produces output like the following example:
//
//Description: No description provided.

参照を保持する

既定では、参照データは Serialize または Deserialize への呼び出しごとにキャッシュされます。 ある Serialize/Deserialize 呼び出しから別の呼び出しへの参照を保持するには、Serialize/Deserialize の呼び出しサイトで ReferenceResolver インスタンスをルート化します。 このスクリプトの例を次のコードに示します。

  • Company 型のカスタム コンバーターを記述する。
  • Employee である Supervisor プロパティを手動でシリアル化せずに済むようにしたい。 これをシリアライザーに委任し、既に保存されている参照も保持したい。

EmployeeCompany のクラスを次に示します。

public class Employee
{
    public string? Name { get; set; }
    public Employee? Manager { get; set; }
    public List<Employee>? DirectReports { get; set; }
    public Company? Company { get; set; }
}

public class Company
{
    public string? Name { get; set; }
    public Employee? Supervisor { get; set; }
}

コンバーターは次のようになります。

class CompanyConverter : JsonConverter<Company>
{
    public override Company Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        throw new NotImplementedException();
    }

    public override void Write(Utf8JsonWriter writer, Company value, JsonSerializerOptions options)
    {
        writer.WriteStartObject();

        writer.WriteString("Name", value.Name);

        writer.WritePropertyName("Supervisor");
        JsonSerializer.Serialize(writer, value.Supervisor, options);

        writer.WriteEndObject();
    }
}

ReferenceResolver から派生するクラスは、参照をディクショナリに格納します。

class MyReferenceResolver : ReferenceResolver
{
    private uint _referenceCount;
    private readonly Dictionary<string, object> _referenceIdToObjectMap = new ();
    private readonly Dictionary<object, string> _objectToReferenceIdMap = new (ReferenceEqualityComparer.Instance);

    public override void AddReference(string referenceId, object value)
    {
        if (!_referenceIdToObjectMap.TryAdd(referenceId, value))
        {
            throw new JsonException();
        }
    }

    public override string GetReference(object value, out bool alreadyExists)
    {
        if (_objectToReferenceIdMap.TryGetValue(value, out string? referenceId))
        {
            alreadyExists = true;
        }
        else
        {
            _referenceCount++;
            referenceId = _referenceCount.ToString();
            _objectToReferenceIdMap.Add(value, referenceId);
            alreadyExists = false;
        }

        return referenceId;
    }

    public override object ResolveReference(string referenceId)
    {
        if (!_referenceIdToObjectMap.TryGetValue(referenceId, out object? value))
        {
            throw new JsonException();
        }

        return value;
    }
}

ReferenceHandler から派生するクラスは、MyReferenceResolver のインスタンスを保持し、必要な場合にのみ新しいインスタンスを作成します (この例では Reset という名前のメソッド)。

class MyReferenceHandler : ReferenceHandler
{
    public MyReferenceHandler() => Reset();

    private ReferenceResolver? _rootedResolver;
    public override ReferenceResolver CreateResolver() => _rootedResolver!;
    public void Reset() => _rootedResolver = new MyReferenceResolver();
}

このサンプル コードは、シリアライザーを呼び出すときに、ReferenceHandler プロパティが MyReferenceHandler のインスタンスに設定されている JsonSerializerOptions インスタンスを使用します。 このパターンに従う場合は、シリアル化が終了したときに必ず ReferenceResolver ディクショナリをリセットして、継続的に拡大しないようにしてください。

var options = new JsonSerializerOptions();

options.Converters.Add(new CompanyConverter());
var myReferenceHandler = new MyReferenceHandler();
options.ReferenceHandler = myReferenceHandler;
options.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
options.WriteIndented = true;

string str = JsonSerializer.Serialize(tyler, options);

// Reset after serializing to avoid out of bounds memory growth in the resolver.
myReferenceHandler.Reset();

前の例ではシリアル化のみが行われますが、逆シリアル化にも同様のアプローチを採用できます。

他のカスタム コンバーターのサンプル

Newtonsoft.Json から System.Text.Json への移行に関する記事には、カスタム コンバーターの他のサンプルが含まれています。

System.Text.Json.Serialization のソース コードの単体テスト フォルダーには、次のような他のカスタム コンバーターのサンプルが含まれています。

既存の組み込みコンバーターの動作を変更するコンバーターを作成する必要がある場合は、既存のコンバーターのソース コードを入手し、それを基にしてカスタマイズを始めることができ ます。

その他の技術情報