Как написать настраиваемые преобразователи для сериализации JSON (маршалинг) в .NET

В этой статье показано, как создать настраиваемые преобразователи для классов сериализации JSON, предоставляемых в пространстве имен System.Text.Json. Общие сведения о System.Text.Json см. в статье Как сериализировать и десериализировать (маршалирование и демаршалирование) JSON в .NET.

Преобразователь — это класс, который преобразует объект или значение в формат JSON и обратно. Пространство имен System.Text.Json содержит встроенные преобразователи для большинства примитивных типов, которые сопоставляются с примитивами JavaScript. Пользовательские преобразователи можно написать для переопределения поведения встроенного преобразователя по умолчанию. Например:

  • Может потребоваться DateTime , чтобы значения были представлены форматом мм/дд/гггг. По умолчанию поддерживается стандарт ISO 8601-1:2019, включая профиль RFC 3339. Дополнительные сведения см. в разделе Поддержка DateTime и DateTimeOffset в System.Text.Json.
  • Может потребоваться сериализовать POCO в виде строки JSON, например с типом PhoneNumber .

Вы также можете написать пользовательские преобразователи для настройки или расширения System.Text.Json с помощью новых функций. Далее в этой статье описываются следующие сценарии:

Visual Basic не может использоваться для записи пользовательских преобразователей, но может вызывать преобразователи, реализованные в библиотеках C#. Дополнительные сведения см. в статье о поддержке Visual Basic.

Шаблоны настраиваемых преобразователей

Существует два шаблона для создания настраиваемого преобразователя: базовый шаблон и шаблон фабрики. Шаблон фабрики предназначен для преобразователей, обрабатывающих типы Enum или открытые универсальные шаблоны. Базовый шаблон предназначен для неуниверсальных и закрытых универсальных типов. Например, для преобразователей следующих типов требуется шаблон фабрики:

Ниже приведены некоторые примеры типов, которые могут быть обработаны базовым шаблоном:

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

Базовый шаблон создает класс, который может работать с одним типом. Шаблон фабрики создает класс, который в среде выполнения определяет, какой конкретный тип требуется, и динамически создает соответствующий преобразователь.

Пример базового преобразователя

Следующий пример представляет собой преобразователь, который переопределяет сериализацию по умолчанию для существующего типа данных. Для свойств DateTimeOffset преобразователь использует формат дд.мм.гггг.

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>. Код соответствует шаблону фабрики, так как первый параметр универсального типа является Enum, а второй — открытым. Метод CanConvert возвращает true только для Dictionary с двумя универсальными параметрами, первый из которых является типом 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 — это тип для сериализации и десериализации.
  • Переопределите метод Read, чтобы десериализировать входящие данные JSON и преобразовать их в тип T. Utf8JsonReader Используйте метод, передаваемый методу для чтения JSON. Вам не нужно беспокоиться об обработке частичных данных, так как сериализатор передает все данные для текущего область JSON. Поэтому не нужно вызывать Skip или TrySkip проверять, что Read возвращается true.
  • Переопределите метод Write для сериализации входящего объекта типа T. Для записи JSON используйте передаваемое в метод значение Utf8JsonWriter.
  • Переопределяйте метод CanConvert только при необходимости. Реализация по умолчанию возвращает true, если тип для преобразования имеет тип T. Поэтому для преобразователей, поддерживающих только тип T, не требуется переопределять этот метод. Пример преобразователя, в котором требуется переопределить этот метод, см. в разделе Поддержка полиморфной десериализации далее в этой статье.

Вы можете ссылаться на исходный код встроенных преобразователей в качестве эталонных реализаций для написания настраиваемых преобразователей.

Инструкции по шаблону фабрики

Ниже описывается, как создать преобразователь с помощью шаблона фабрики:

  • Создайте класс, наследующий от класса JsonConverterFactory.
  • Переопределите CanConvert метод, возвращаемый true при преобразовании типа, который может обрабатывать преобразователь. Например, если преобразователь предназначен List<T>для , он может обрабатывать List<int>только , List<string>и List<DateTime>.
  • Переопределите метод CreateConverter, чтобы он возвращал экземпляр класса преобразователя, обрабатывающего тип для преобразования, который будет предоставлен во время выполнения.
  • Создайте класс преобразователя с помощью метода CreateConverter.

Шаблон фабрики необходим для открытых универсальных шаблонов, так как код для преобразования объекта в строку и обратно не совпадает для всех типов. Преобразователь для открытого универсального типа (например, List<T>) должен создать преобразователь для закрытого универсального типа (например, List<DateTime>) в фоновом режиме. Необходимо написать код для обработки каждого закрытого универсального типа, который может обрабатывать преобразователь.

Тип Enum похож на открытый универсальный тип: преобразователь для Enum должен создать преобразователь для определенного типа Enum (например, WeekdaysEnum) в фоновом режиме.

Использование Utf8JsonReader метода Read

Если преобразователь преобразует объект JSON, Utf8JsonReader он будет размещен на маркере начального объекта при Read запуске метода. Затем необходимо прочитать все маркеры в этом объекте и выйти из метода с помощью средства чтения, размещенного на соответствующем маркере конечного объекта. Если вы считываете за пределы объекта или останавливаетесь перед достижением соответствующего конечного маркера, вы получите 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")), сериализатор по-прежнему задает PathLineNumberсвойства и BytePositionInLine свойства.

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] к классу или структуре, представляющей настраиваемый тип значения.

Пример регистрации — коллекция преобразователей

Ниже приведен пример, который делает DateTimeOffsetJsonConverter значением по умолчанию для свойств типаDateTimeOffset:

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.

Если в коллекции зарегистрировано Converters несколько пользовательских преобразователей для типа, используется первый преобразователь, возвращающийся true для CanConvert .

Встроенный преобразователь выбирается только в том случае, если соответствующий настраиваемый преобразователь не зарегистрирован.

Примеры преобразователей для выполнения стандартных сценариев

В следующих разделах приведены примеры преобразователей, в которых рассматриваются распространенные сценарии, необрабатываемые встроенными функциями.

Пример преобразователя см. в разделе "Поддерживаемые DataTable типы коллекций".

Десериализация выводимых типов в свойства объекта

При десериализации в свойство типа object создается объект JsonElement. Причина заключается в том, что десериализатор не знает, какой тип среды выполнения создать, и не пытается угадать. Например, если свойство JSON имеет значение true, десериализатор не определит, что значение является Boolean, а если у элемента есть значение 01/01/2019, десериализатор не определит, что это DateTime.

Определение типа может быть неточным. Если десериализатор анализирует число JSON, не имеющее десятичного разделителя в качестве long, это может привести к проблемам в виде выхода за пределы диапазона, если значение первоначально было сериализовано как ulong или BigInteger. Анализ числа с десятичным разделителем в качестве double может привести к потере точности, если это число было первоначально сериализовано как decimal.

В следующем коде показан настраиваемый преобразователь для свойств object сценариев с определением типа. Код преобразует:

  • true и false в Boolean.
  • Числа без десятичного числа в 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"
//}

В примере показан код преобразователя и WeatherForecast класс со свойствами object . Метод Main десериализирует строку JSON в WeatherForecast экземпляр, сначала без использования преобразователя, а затем с помощью преобразователя. Выходные данные консоли показывают, что без преобразователя тип времени выполнения для Date свойства имеет значение JsonElement; с преобразователем — тип DateTimeвремени выполнения.

В папке модульного теста в пространстве имен System.Text.Json.Serialization содержится больше примеров настраиваемых преобразователей, обрабатывающих десериализацию свойств object.

Поддержка полиморфной десериализации

.NET 7 обеспечивает поддержку полиморфной сериализации и десериализации. Однако в предыдущих версиях .NET была ограничена поддержка полиморфной сериализации и не поддерживает десериализацию. Если вы используете .NET 6 или более раннюю версию, десериализация требует пользовательского преобразователя.

Например, предположим, что имеется абстрактный базовый класс Person с производными классами Employee и Customer. Полиморфная десериализации означает, что во время разработки можно указать Person в качестве цели десериализации, а объекты Customer и Employee в JSON правильно десериализованы во время выполнения. Во время десериализации необходимо найти признаки, которые определяют требуемый тип в JSON. В каждом сценарии доступны различные типы признаков. Например, может быть доступно свойство дискриминатора или придется полагаться на присутствие или отсутствие конкретного свойства. В текущем выпуске System.Text.Json не предоставлены атрибуты для указания способов обработки сценариев полиморфной десериализации, поэтому необходимо использовать настраиваемые преобразователи.

В следующем коде показан базовый класс, два производных класса и настраиваемый преобразователь для них. Преобразователь использует свойство дискриминатора для выполнения в полиморфной десериализации. Дискриминатор типа не находится в определениях классов, но создается во время сериализации и считывается во время десериализации.

Внимание

В примере кода требуется, чтобы пары имен и значений объекта 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.

Альтернативный способ сделать полиморфную десериализацию

Можно вызвать Deserialize в методе Read :

  • Создайте клон экземпляра Utf8JsonReader . Так как Utf8JsonReader это структура, это просто требует инструкции назначения.
  • Используйте клон для чтения через дискриминационные маркеры.
  • Вызов Deserialize с помощью исходного Reader экземпляра после того, как вы знаете нужный тип. Можно вызвать Deserialize , так как исходный Reader экземпляр по-прежнему расположен для чтения маркера начального объекта.

Недостатком этого метода является то, что вы не можете передать исходный экземпляр параметров, в который регистрируется преобразователь 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 issue 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 в основном предназначено для оптимизации производительности путем пропуска дополнительного вызова преобразователя. Кроме того, оно позволяет избежать принудительного выполнения преобразователей для типов, допускающих значение null, для проверки null в начале каждого переопределения метода Read и Write.

Чтобы разрешить пользовательскому преобразователю обработку 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 вызова на другой, корень ReferenceResolver экземпляра на сайте Serialize/Deserializeвызова. В следующем коде показан пример для этого сценария:

  • Вы пишете настраиваемый преобразователь для Company типа.
  • Вы не хотите вручную сериализовать Supervisor свойство, которое является .Employee Вы хотите делегировать это сериализатору, а также сохранить сохраненные ссылки.

Ниже приведены Employee классы и Company классы:

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();
}

Когда пример кода вызывает сериализатор, он использует JsonSerializerOptions экземпляр, в котором ReferenceHandler свойство задано экземпляром MyReferenceHandler. Когда вы следуйте этому шаблону, обязательно сбросьте 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 есть и другие примеры настраиваемых преобразователей, например:

Если необходимо создать преобразователь, изменяющий поведение существующего встроенного преобразователя, можно получить исходный код существующего преобразователя в качестве отправной точки для настройки.

Дополнительные ресурсы