Como gravar conversores personalizados para serialização JSON (marshalling) no .NET

Este artigo mostra como criar conversores personalizados para as classes de serialização JSON fornecidas no namespace System.Text.Json. Para obter uma introdução a System.Text.Json, confira Como serializar e desserializar o JSON no .NET.

Um conversor é uma classe que converte um objeto ou um valor de e para JSON. O namespace System.Text.Json tem conversores internos para a maioria dos tipos primitivos que são mapeados para primitivos JavaScript. Você pode escrever conversores personalizados para substituir o comportamento padrão de um conversor interno. Por exemplo:

  • Talvez você queira que os valores DateTime sejam representados pelo formato mm/dd/yyyyy. Por padrão, há suporte para ISO 8601-1:2019, incluindo o perfil RFC 3339. Para obter mais informações, confira Suporte para DateTime e DateTimeOffset em System.Text.Json.
  • Talvez você queira serializar um POCO como uma cadeia de caracteres JSON, por exemplo, com um tipo PhoneNumber.

Você também pode escrever conversores personalizados ou estender System.Text.Json com novas funcionalidades. Os seguintes cenários são abordados posteriormente neste artigo:

O Visual Basic não pode ser usado para gravar conversores personalizados, mas pode chamar conversores implementados em bibliotecas C#. Para saber mais, confira Suporte para Visual Basic.

Padrões de conversor personalizado

Há dois padrões para criar um conversor personalizado: o padrão básico e o padrão de fábrica. O padrão de fábrica é para conversores que lidam com tipos Enum ou genéricos abertos. O padrão básico é para tipos genéricos não genéricos e fechados. Por exemplo, os conversores para os seguintes tipos exigem o padrão de fábrica:

Alguns exemplos de tipos que podem ser tratados pelo padrão básico incluem:

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

O padrão básico cria uma classe que pode lidar com um tipo. O padrão de fábrica cria uma classe que determina, em tempo de execução, qual tipo específico é necessário e cria dinamicamente o conversor apropriado.

Conversor básico de exemplo

O exemplo a seguir é um conversor que substitui a serialização padrão para um tipo de dados. O conversor usa o formato mm/dd/aaaa para propriedades 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));
    }
}

Conversor de padrões de fábrica de exemplo

O código a seguir mostra um conversor personalizado que funciona com Dictionary<Enum,TValue>. O código segue o padrão de fábrica porque o primeiro parâmetro de tipo genérico é Enum e o segundo está aberto. O método CanConvert retorna true apenas para um Dictionary com dois parâmetros genéricos, o primeiro deles é um tipo Enum. O conversor interno obtém um conversor para lidar com qualquer tipo fornecido em tempo de execução para 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();
            }
        }
    }
}

Etapas para seguir o padrão básico

As seguintes etapas explicam como criar um conversor seguindo o padrão básico:

  • Crie uma classe que deriva de JsonConverter<T> em que T é o tipo a ser serializado e desserializado.
  • Substitua o método Read para desserializar o JSON de entrada e convertê-lo no tipo T. Use o Utf8JsonReader que é passado para o método para ler o JSON. Você não precisa se preocupar em lidar com os dados parciais, pois o serializador passa todos os dados para o escopo JSON atual. Portanto, não é necessário chamar Skip ou TrySkip para validar que Read retorna true.
  • Substitua o método Write para serializar o objeto de entrada do tipo T. Use o Utf8JsonWriter que é passado para o método para escrever o JSON.
  • Substitua o método CanConvert somente se necessário. A implementação padrão retorna true quando o tipo a ser convertido é do tipo T. Portanto, os conversores que dão suporte apenas ao tipo T não precisam substituir esse método. Para obter um exemplo de um conversor que precisa substituir esse método, confira a seção desserialização polimórfica mais adiante neste artigo.

Você pode consultar código-fonte dos conversores internos como implementações de referência para escrever conversores personalizados.

Etapas para seguir o padrão de fábrica

As seguintes etapas explicam como criar um conversor seguindo o padrão de fábrica:

  • Crie uma classe que deriva de JsonConverterFactory.
  • Substitua o método CanConvert para retornar true quando o tipo a ser convertido é aquele que o conversor pode manipular. Por exemplo, se o conversor for para List<T>, ele só poderá lidar com List<int>, List<string> e List<DateTime>.
  • Substitua o método CreateConverter para retornar uma instância de uma classe de conversor que lidará com o tipo a converter fornecido no tempo de execução.
  • Crie a classe de conversor que o método CreateConverter instancia.

O padrão de fábrica é necessário para genéricos abertos porque o código para converter um objeto de e para uma cadeia de caracteres não é o mesmo para todos os tipos. Um conversor para um tipo genérico aberto (List<T>, por exemplo) precisa criar um conversor para um tipo genérico fechado (List<DateTime>, por exemplo) nos bastidores. O código deve ser gravado para lidar com cada tipo genérico fechado com que o conversor pode lidar.

O tipo Enum é semelhante a um tipo genérico aberto: um conversor para Enum precisa criar um conversor para um Enum específico (WeekdaysEnum, por exemplo) nos bastidores.

O uso do Utf8JsonReader no método Read

Se o conversor estiver convertendo um objeto JSON, o Utf8JsonReader será posicionado no token de objeto inicial quando o método Read for iniciado. Em seguida, você deve ler todos os tokens nesse objeto e sair do método com o leitor posicionado no token de objeto final correspondente. Se você ler além do final do objeto ou parar antes de chegar ao token final correspondente, receberá uma exceção JsonException indicando que:

O conversor 'ConverterName' leu demais ou não o suficiente.

Para obter um exemplo, confira o conversor de exemplo de padrão de fábrica anterior. O método Read começa verificando se o leitor está posicionado em um token de objeto inicial. Ele lê até descobrir que está posicionado no próximo token de objeto final. Ele para no token de objeto de extremidade seguinte porque não há tokens de objeto de início intervindo que indicariam um objeto dentro do objeto. A mesma regra sobre token de início e token final se aplicará se você estiver convertendo uma matriz. Para obter um exemplo, confira o conversor de exemplo Stack<T> mais adiante neste artigo.

Tratamento de erros

O serializador fornece tratamento especial para tipos de exceção JsonException e NotSupportedException.

JsonException

Se você gerar um JsonException sem mensagem, o serializador criará uma mensagem incluindo o caminho para a parte do JSON que causou o erro. Por exemplo, a instrução throw new JsonException() produz uma mensagem de erro como o seguinte exemplo:

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

Se você fornecer uma mensagem (por exemplo), throw new JsonException("Error occurred")o serializador ainda definirá as propriedades e Path, LineNumber e BytePositionInLine.

NotSupportedException

Se você gerar um NotSupportedException, sempre obterá as informações do caminho na mensagem. Se você fornecer uma mensagem, as informações do caminho serão acrescentadas a ela. Por exemplo, a instrução throw new NotSupportedException("Error occurred.") produz uma mensagem de erro como o seguinte exemplo:

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

Quando gerar qual tipo de exceção

Quando o conteúdo JSON contiver tokens que não são válidos para o tipo que está sendo desserializado, gere um JsonException.

Quando você quiser desabilitar determinados tipos, gere um NotSupportedException. Essa exceção é o que o serializador gera automaticamente para tipos que não têm suporte. Por exemplo, System.Type não tem suporte por motivos de segurança, portanto, uma tentativa de desserializá-la resulta em uma NotSupportedException.

Você pode gerar outras exceções conforme necessário, mas elas não incluem automaticamente informações de caminho JSON.

Registrar um conversor personalizado

Registre um conversor personalizado para fazer com que os métodos Serialize e Deserialize o usem. Escolha uma das seguintes abordagens:

Exemplo de registro – coleção Converters

Aqui está um exemplo que torna o DateTimeOffsetJsonConverter o padrão para propriedades do tipo DateTimeOffset:

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

jsonString = JsonSerializer.Serialize(weatherForecast, serializeOptions);

Suponha que você serialize uma instância do seguinte tipo:

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

Aqui está um exemplo de saída JSON que mostra que o conversor personalizado foi usado:

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

O seguinte código usa a mesma abordagem para desserializar usando o conversor personalizado DateTimeOffset:

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

Exemplo de registro – [JsonConverter] em uma propriedade

O seguinte código seleciona um conversor personalizado para a propriedade Date:

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

O código para serializar WeatherForecastWithConverterAttribute não requer o uso de JsonSerializeOptions.Converters:

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

O código para desserializar também não requer o uso de Converters:

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

Exemplo de registro – [JsonConverter] em um tipo

Aqui está o código que cria um struct e aplica o atributo [JsonConverter] a ele:

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

Aqui está o conversor personalizado para o struct anterior:

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

O atributo [JsonConverter] no struct registra o conversor personalizado como o padrão para propriedades do tipo Temperature. O conversor é usado automaticamente na propriedade TemperatureCelsius do seguinte tipo ao serializá-la ou desserializá-la:

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

Precedência de registro do conversor

Durante a serialização ou desserialização, um conversor é escolhido para cada elemento JSON na seguinte ordem, listada da prioridade mais alta para a mais baixa:

  • [JsonConverter] aplicado a uma propriedade.
  • Um conversor adicionado à coleção Converters.
  • [JsonConverter] aplicado a um tipo de valor personalizado ou POCO.

Se diversos conversores personalizados para um tipo forem registrados na coleção Converters, o primeiro conversor que retornará true para CanConvert será usado.

Um conversor interno será escolhido somente se nenhum conversor personalizado aplicável for registrado.

Exemplos de conversor para cenários comuns

As seções a seguir apresentam exemplos de conversor que abordam alguns cenários comuns com que a funcionalidade interna não lida.

Para obter um conversor de exemplo DataTable, confira Tipos de coleção com suporte.

Desserializar tipos inferidos para propriedades de objeto

Ao desserializar para uma propriedade do tipo object, um objeto JsonElement é criado. O motivo é que o desserializador não sabe qual tipo CLR criar e não tenta adivinhar. Por exemplo, se uma propriedade JSON tiver "true", o desserializador não inferirá que o valor é um Boolean; se um elemento tiver "01/01/2019", o desserializador não inferirá que ele é uma DateTime.

A inferência de tipos pode ser imprecisa. Se o desserializador analisar um número JSON que não tem nenhum ponto decimal como um long, isso poderá resultar em problemas fora do intervalo se o valor tiver sido originalmente serializado como ulong ou BigInteger. Analisar um número que tem um ponto decimal como um double pode perder precisão se o número tiver sido originalmente serializado como um decimal.

Para cenários que exigem inferência de tipos, o código a seguir mostra um conversor personalizado para propriedades object. O código converte:

  • true e false em Boolean
  • Números sem um decimal em long
  • Números com um decimal em double
  • Datas em DateTime
  • Cadeias de caracteres em string
  • Todo o resto em 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"
//}

O exemplo mostra o código do conversor e uma classe WeatherForecast com propriedades object. O método Main desserializa uma cadeia de caracteres JSON em uma instância WeatherForecast, primeiro sem usar o conversor e então usando o conversor. A saída do console mostra que, sem o conversor, o tipo de tempo de execução da propriedade Date é JsonElement; com o conversor, o tipo de tempo de execução é DateTime.

A pasta de testes de unidade no namespace System.Text.Json.Serialization tem mais exemplos de conversores personalizados que lidam com a desserialização em propriedades object.

Dar suporte para desserialização polimórfica

O .NET 7 dá suporte à serialização e à desserialização polimórficas. No entanto, em versões anteriores do .NET, havia suporte limitado para serialização polimórfica e nenhum suporte para desserialização. Se você estiver usando o .NET 6 ou uma versão anterior, a desserialização exigirá um conversor personalizado.

Suponha, por exemplo, que você tenha uma classe Person base abstrata, com as classes derivadas Employee e Customer. A desserialização polimórfica significa que, no momento do design, você pode especificar Person como o alvo da desserialização e os objetos Customer e Employee no JSON são corretamente desserializados no tempo de execução. Durante a desserialização, você precisa encontrar pistas que identifiquem o tipo necessário no JSON. Os tipos de pistas disponíveis variam conforme cada cenário. Por exemplo, uma propriedade discriminatória pode estar disponível ou talvez você precise confiar na presença ou na ausência de uma propriedade específica. A versão atual de System.Text.Json não fornece atributos para especificar como lidar com cenários de desserialização polimórfica, portanto, conversores personalizados são necessários.

O código a seguir mostra uma classe base, duas classes derivadas e um conversor personalizado para elas. O conversor usa uma propriedade discriminatória para fazer a desserialização polimórfica. O tipo discriminatório não está nas definições de classe, mas é criado durante a serialização e é lido durante a desserialização.

Importante

O código de exemplo requer que pares de nome/valor de objeto JSON permaneçam em ordem, o que não é um requisito padrão do 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();
        }
    }
}

O seguinte código registra o conversor:

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

O conversor pode desserializar o JSON criado usando o mesmo conversor para serializar, por exemplo:

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

O código conversor no exemplo anterior lê e grava cada propriedade manualmente. Uma alternativa é chamar Deserialize ou Serialize fazer parte do trabalho. Para obter um exemplo, confira esta postagem StackOverflow.

Uma forma alternativa de fazer a desserialização polimórfica

Você pode chamar Deserialize no método Read:

  • Faça um clone da instância Utf8JsonReader. Como Utf8JsonReader é um struct, isso requer apenas uma instrução de atribuição.
  • Use o clone para ler os tokens discriminatórios.
  • Chame Deserialize usando a instância original Reader depois de saber o tipo necessário. Você pode chamar Deserialize porque a instância original Reader ainda está posicionada para ler o token de objeto begin.

Uma desvantagem desse método é que você não pode passar a instância de opções original que registra o conversor para Deserialize. Isso causaria um estouro de pilha, conforme explicado nas propriedades necessárias. O seguinte exemplo mostra um método Read que usa esta alternativa:

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

Suporte de ida e volta para tipos Stack

Se você desserializar uma cadeia de caracteres JSON em um objeto Stack e serializar esse objeto, o conteúdo da pilha estará em ordem inversa. Esse comportamento se aplica aos seguintes tipos e interfaces e aos tipos definidos pelo usuário que derivam deles:

Para dar suporte à serialização e à desserialização que retém a ordem original na pilha, é necessário um conversor personalizado.

O seguinte código mostra um conversor personalizado que permite a viagem de ida e volta de objetos 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();
        }
    }
}

O seguinte código registra o conversor:

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

Políticas de nomenclatura para desserialização da cadeia de caracteres de enumeração

Por padrão, o JsonStringEnumConverter interno pode serializar e desserializar valores de cadeia de caracteres para enumerações. Ele funciona sem uma política de nomenclatura especificada ou com a política de nomenclatura CamelCase. Ele não dá suporte a outras políticas de nomenclatura, como snake case. Para obter informações sobre o código de conversor personalizado que pode dar suporte a viagens de ida e volta de e para valores de cadeia de caracteres de enumeração ao usar uma política de nomenclatura de snake case, confira o problema do GitHub dotnet/runtime #31619. Alternativamente, atualize para o .NET 7 ou versões posteriores, que dão suporte interno para a aplicação de políticas de nomenclatura na conversão de ida e volta de e para valores de cadeia de caracteres de enumeração.

Usar o conversor de sistema padrão

Em alguns cenários, pode ser útil usar o conversor de sistema padrão em um conversor personalizado. Para fazer isso, obtenha o conversor do sistema da propriedade JsonSerializerOptions.Default, conforme mostra o seguinte exemplo:

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

Manipular valores nulos

Por padrão, o serializador manipula valores nulos da seguinte maneira:

  • Para tipos de referência e tipos Nullable<T>:

    • Ele não passa null para conversores personalizados na serialização.
    • Ele não passa JsonTokenType.Null para conversores personalizados na desserialização.
    • Ele retorna uma instância null na desserialização.
    • Ele grava null diretamente com o gravador na serialização.
  • Para tipos de valor não anuláveis:

    • Ele passa JsonTokenType.Null para conversores personalizados na desserialização. (Se nenhum conversor personalizado estiver disponível, uma exceção JsonException será gerada pelo conversor interno do tipo.)

Esse comportamento de tratamento nulo é principalmente para otimizar o desempenho ignorando uma chamada extra para o conversor. Além disso, evita forçar conversores para tipos anuláveis para verificar null no início de cada substituição de método Read e Write.

Para habilitar um conversor personalizado a lidar com null para um tipo de referência ou valor, substitua JsonConverter<T>.HandleNull para retornar true, conforme mostra o seguinte exemplo:

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.

Preservar referências

Por padrão, os dados de referência só são armazenados em cache para cada chamada a Serialize ou Deserialize. Para persistir as referências de uma chamada Serialize/Deserialize para outra, enraíze a instância ReferenceResolver no site de chamada de Serialize/Deserialize. O seguinte código mostra um exemplo para esse cenário:

  • Você escreve um conversor personalizado para o tipo Company.
  • Não é recomendável serializar manualmente a propriedade Supervisor, que é uma Employee. É recomendável delegar isso ao serializador e preservar as referências que você já salvou.

Aqui estão as classes Employee e 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; }
}

O conversor tem esta aparência:

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

Uma classe derivada de ReferenceResolver armazena as referências em um dicionário:

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

Uma classe derivada de ReferenceHandler contém uma instância e MyReferenceResolver cria uma nova instância somente quando necessário (em um método chamado Reset neste exemplo):

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

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

Quando o código de exemplo chama o serializador, ele usa uma instância JsonSerializerOptions na qual a propriedade ReferenceHandler é definida como uma instância de MyReferenceHandler. Ao seguir esse padrão, redefina o dicionário ReferenceResolver ao terminar de serializar para evitar que ele continue crescendo para sempre.

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

O exemplo anterior só faz serialização, mas uma abordagem semelhante pode ser adotada para desserialização.

Outros exemplos de conversor personalizado

O artigo Migrar de Newtonsoft.Json para System.Text.Json contém exemplos adicionais de conversores personalizados.

A pasta de testes de unidade no código-fonte System.Text.Json.Serialization inclui outros exemplos de conversor personalizado, como:

Se você precisar fazer um conversor que modifique o comportamento de um conversor interno, poderá obter o código-fonte do conversor para servir como um ponto de partida para personalização.

Recursos adicionais