Procedimiento para escribir convertidores personalizados para la serialización de JSON (cálculo de referencias) en .NET

En este artículo se muestra cómo crear convertidores personalizados para las clases de serialización de JSON que se proporcionan en el espacio de nombres System.Text.Json. Para disponer de una introducción a System.Text.Json, vea Cómo serializar y deserializar JSON en .NET.

Un convertidor es una clase que convierte un objeto o un valor en JSON; también admite conversiones a partir de este formato. El espacio de nombres System.Text.Json tiene convertidores integrados para la mayoría de los tipos primitivos que se asignan a primitivos de JavaScript. Puede escribir convertidores personalizados para invalidar el comportamiento predeterminado de un convertidor integrado. Por ejemplo:

  • Es posible que desee que valores DateTime se representen mediante formato mm/dd/aaaa. De forma predeterminada, se admite ISO 8601-1:2019, incluido el perfil RFC 3339. Para obtener más información, consulte Compatibilidad con DateTime y DateTimeOffset en System.Text.Json.
  • Es posible que quiera serializar un POCO como cadena JSON, por ejemplo, con un tipo PhoneNumber.

También puede escribir convertidores personalizados para personalizar o extender System.Text.Json con funcionalidad nueva. Los siguientes escenarios se describen más adelante en este artículo:

Visual Basic no se puede usar para escribir convertidores personalizados, pero puede llamar a convertidores implementados en bibliotecas de C#. Para obtener más información, vea Soporte técnico de Visual Basic.

Patrones de convertidores personalizados

Hay dos patrones para crear un convertidor personalizado: el patrón básico y el patrón de fábrica. El patrón de fábrica es para los convertidores que controlan el tipo Enum o los valores genéricos abiertos. El patrón básico es para los tipos no genéricos y los tipos genéricos y cerrados. Por ejemplo, los convertidores de los siguientes tipos requieren el patrón de fábrica:

Entre los ejemplos de tipos que puede controlar el patrón básico se incluyen los siguientes:

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

El patrón básico crea una clase que puede controlar un tipo. El patrón de fábrica crea una clase que determina, en tiempo de ejecución, qué tipo específico es necesario y crea dinámicamente el convertidor adecuado.

Convertidor básico de ejemplo

El ejemplo siguiente es un convertidor que reemplaza la serialización predeterminada para un tipo de datos existente. El convertidor usa el formato mm/dd/aaaa para las propiedades 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));
    }
}

Convertidor de patrones de fábrica de ejemplo

En el código siguiente se muestra un convertidor personalizado que funciona con Dictionary<Enum,TValue>. El código sigue el patrón de fábrica porque el primer parámetro de tipo genérico es Enum y el segundo es abierto. El método CanConvert devuelve true solo para un elemento Dictionary con dos parámetros genéricos, el primero de los cuales es un tipo Enum. El convertidor interno obtiene un convertidor existente para controlar el tipo que se proporciona en tiempo de ejecución 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();
            }
        }
    }
}

Pasos para seguir el patrón básico

En los pasos siguientes se explica cómo crear un convertidor siguiendo el patrón básico:

  • Cree una clase que derive de JsonConverter<T>, donde T es el tipo que se va a serializar y deserializar.
  • Reemplace el método Read para deserializar el JSON de entrada y convertirlo al tipo T. Use el elemento Utf8JsonReader que se pasa al método para leer el JSON. No tiene que preocuparse por controlar datos parciales, ya que el serializador pasa todos los datos del ámbito JSON actual. Por lo tanto, no es necesario llamar a Skip o TrySkip ni validar que Read devuelve true.
  • Invalide el método Write para serializar el objeto de entrada de tipo T. Use el elemento Utf8JsonWriter que se pasa al método para escribir el JSON.
  • Reemplace el método CanConvert solo si es necesario. La implementación predeterminada devuelve true cuando el tipo que se va a convertir es de tipo T. Por lo tanto, los convertidores que solo admiten el tipo T no necesitan reemplazar este método. Para obtener un ejemplo de un convertidor que necesita reemplazar este método, consulte la sección sobre la deserialización polimórfica, más adelante en este artículo.

Puede hacer referencia al código fuente de convertidores integrados como implementaciones de referencia para escribir convertidores personalizados.

Pasos para seguir el patrón de fábrica

En los pasos siguientes se explica cómo crear un convertidor siguiendo el patrón de fábrica:

  • Cree una clase que derive de JsonConverterFactory.
  • Reemplace el método CanConvert para devolver true cuando el tipo que se va a convertir sea uno que el convertidor puede controlar. Por ejemplo, si el convertidor es para List<T>, solo puede controlar List<int>, List<string> y List<DateTime>.
  • Invalide el método CreateConverter para devolver una instancia de una clase de convertidor que controlará el tipo de conversión que se proporciona en tiempo de ejecución.
  • Cree la clase de convertidor de la que el método CreateConverter crea instancias.

El patrón de fábrica es necesario para los genéricos abiertos porque el código para convertir un objeto en una cadena (y también para realizar una conversión a partir de esta) no es el mismo para todos los tipos. Un convertidor para un tipo genérico abierto (List<T>, por ejemplo) tiene que crear un convertidor para un tipo genérico cerrado (List<DateTime>, por ejemplo) en segundo plano. Es necesario escribir código para controlar cada tipo genérico cerrado que el convertidor puede controlar.

El tipo Enum es similar a un tipo genérico abierto: un convertidor para Enum tiene que crear un convertidor para un elemento Enum específico (WeekdaysEnum, por ejemplo) en segundo plano.

Uso de Utf8JsonReader en el método Read

Si el convertidor está convirtiendo un objeto JSON, Utf8JsonReader se coloca en el token de objeto begin cuando comienza el método Read. Luego debe leer todos los tokens de ese objeto y salir del método con el lector colocado en el token de objeto end correspondiente. Si lee más allá del final del objeto, o si se detiene antes de alcanzar el token end correspondiente, obtiene una excepción JsonException que indica que:

El convertidor ConverterName ha leído demasiado o no lo suficiente.

Para obtener un ejemplo, vea el convertidor de ejemplo de patrones de fábrica anterior. El método Read comienza por comprobar que el lector está colocado en un token de objeto start. Lee hasta que detecta que se ha colocado en el siguiente token de objeto end. Se detiene en el siguiente token de objeto end porque no hay tokens de objeto start que intervengan, lo que indicaría un objeto dentro del objeto. La misma regla sobre el token begin y el token end se aplica si va a convertir una matriz. Para obtener un ejemplo, vea el convertidor de ejemplo Stack<T> más adelante en este artículo.

Control de errores

El serializador proporciona un control especial para los tipos de excepción JsonException y NotSupportedException.

JsonException

Si produce una excepción JsonException sin ningún mensaje, el serializador crea un mensaje que incluye la ruta a la parte del JSON que causó el error. Por ejemplo, la instrucción throw new JsonException() genera un mensaje de error como el ejemplo siguiente:

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

Si proporciona un mensaje (por ejemplo, throw new JsonException("Error occurred")), el serializador sigue estableciendo las propiedades Path, LineNumber y BytePositionInLine.

NotSupportedException

Si produce una excepción NotSupportedException, siempre obtendrá la información de la ruta en el mensaje. Si proporciona un mensaje, la información de la ruta estará anexada. Por ejemplo, la instrucción throw new NotSupportedException("Error occurred.") genera un mensaje de error como el ejemplo siguiente:

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

Cuándo se debe producir cada tipo de excepción

Cuando la carga de JSON contiene tokens que no son válidos para el tipo que se está deserializando, produzca una excepción JsonException.

Si quiere impedir determinados tipos, produzca una excepción NotSupportedException. Esta excepción es lo que el serializador produce de forma automática para los tipos que no se admiten. Por ejemplo, System.Type no se admite por motivos de seguridad, por lo que un intento de deserializarlo da como resultado una excepción NotSupportedException.

Puede iniciar otras excepciones según sea necesario, pero no incluyen automáticamente la información de la ruta de JSON.

Registro de un convertidor personalizado

Registre un convertidor personalizado para que los métodos Serialize y Deserialize lo utilicen. Elija uno de los enfoques siguientes:

  • Agregue una instancia de la clase de convertidor a la colección JsonSerializerOptions.Converters.
  • Aplique el atributo [JsonConverter] a las propiedades que requieren el convertidor personalizado.
  • Aplique el atributo [JsonConverter] a una clase o una estructura que represente un tipo de valor personalizado.

Ejemplo de registro: colección de convertidores

Este es un ejemplo que hace que DateTimeOffsetJsonConverter sea el valor predeterminado de las propiedades de tipo DateTimeOffset:

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

jsonString = JsonSerializer.Serialize(weatherForecast, serializeOptions);

Suponga que serializa una instancia del tipo siguiente:

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

Este es un ejemplo de salida JSON que muestra que se ha usado el convertidor personalizado:

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

En el código siguiente se usa el mismo enfoque para realizar una deserialización mediante el convertidor DateTimeOffset personalizado:

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

Ejemplo de registro: [JsonConverter] en una propiedad

El código siguiente selecciona un convertidor personalizado para la propiedad Date:

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

El código para serializar WeatherForecastWithConverterAttribute no requiere el uso de JsonSerializeOptions.Converters:

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

El código que se va a deserializar tampoco requiere el uso de Converters:

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

Ejemplo de registro: [JsonConverter] en un tipo

Este es el código que crea una estructura y le aplica el atributo [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);
        }
    }
}

Este es el convertidor personalizado para la estructura 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());
    }
}

El atributo [JsonConverter] de la estructura registra el convertidor personalizado como valor predeterminado para las propiedades de tipo Temperature. El convertidor se usa automáticamente en la propiedad TemperatureCelsius del tipo siguiente al serializarla o deserializarla:

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

Prioridad de registro del convertidor

Durante la serialización o la deserialización, se elige un convertidor para cada elemento JSON en el orden siguiente, que se muestra de la prioridad más alta a la más baja:

  • Elemento [JsonConverter] aplicado a una propiedad.
  • Convertidor agregado a la colección Converters.
  • Elemento [JsonConverter] aplicado a un tipo de valor personalizado o POCO.

Si se registran varios convertidores personalizados para un tipo en la colección Converters, se usa el primer convertidor que devuelve true para CanConvert.

Solo se elige un convertidor integrado si no se registra ningún convertidor personalizado aplicable.

Ejemplos de convertidor para escenarios comunes

En las secciones siguientes se proporcionan ejemplos de convertidor que abordan algunos escenarios comunes que la funcionalidad integrada no controla.

Para obtener un convertidor DataTable de ejemplo, vea Tipos de colección admitidos.

Deserialización de los tipos inferidos en propiedades de objeto

Al realizar una deserialización en una propiedad de tipo object, se crea un objeto JsonElement. La razón es que el deserializador no sabe qué tipo de CLR debe crear, y tampoco intenta averiguarlo. Por ejemplo, si una propiedad JSON tiene "true", el deserializador no infiere que el valor es de tipo Boolean, y si un elemento tiene "01/01/2019", el deserializador no infiere que es de tipo DateTime.

La inferencia de tipos puede ser incorrecta. Si el deserializador analiza un número JSON que no tiene un separador decimal como long, pueden producirse problemas de salida del intervalo si el valor se serializó originalmente como ulong o BigInteger. El análisis de un número que tiene un separador decimal como double puede perder precisión si el número se ha serializado originalmente como decimal.

En escenarios que requieren inferencia de tipos, el código siguiente muestra un convertidor personalizado para las propiedades object. El código convierte:

  • true y false, en Boolean
  • Números sin decimales, en long
  • Números con un decimal, en double
  • Fechas, en DateTime
  • Cadenas, en string
  • Todo lo demás, en 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"
//}

En el ejemplo se muestra el código del convertidor y una clase WeatherForecast con propiedades object. El método Main deserializa una cadena JSON en una instancia WeatherForecast, primero sin usar el convertidor y luego con él. La salida de la consola muestra que, sin el convertidor, el tipo de tiempo de ejecución de la propiedad Date es JsonElement; con el convertidor, el tipo de tiempo de ejecución es DateTime.

La carpeta de pruebas unitarias en el espacio de nombres System.Text.Json.Serialization tiene más ejemplos de convertidores personalizados que controlan la deserialización en propiedades object.

Compatibilidad con la deserialización polimórfica

.NET 7 proporciona compatibilidad con la serialización polimórfica y la deserialización. Sin embargo, en versiones anteriores de .NET, la compatibilidad con la serialización polimórfica era limitada y no se admitía la deserialización. Si usa .NET 6 o una versión anterior, la deserialización requiere un convertidor personalizado.

Suponga, por ejemplo, que tiene una clase base abstracta Person, con clases derivadas Employee y Customer. La deserialización polimórfica significa que puede especificar Person como destino de la deserialización en tiempo de diseño, y los objetos Customer y Employee en el JSON se deserializan correctamente en tiempo de ejecución. Durante la deserialización, debe buscar pistas que identifiquen el tipo requerido en JSON. Los tipos de pistas disponibles varían según cada escenario. Por ejemplo, una propiedad de discriminador puede estar disponible o puede tener que confiar en la presencia o ausencia de una propiedad determinada. La versión actual de System.Text.Json no proporciona atributos para especificar cómo controlar los escenarios de deserialización polimórficos, por lo que se requieren convertidores personalizados.

En el código siguiente se muestra una clase base, dos clases derivadas y un convertidor personalizado para ellas. El convertidor usa una propiedad de discriminador para realizar la deserialización polimórfica. El discriminador de tipo no se encuentra en las definiciones de clase, pero se crea durante la serialización y se lee durante la deserialización.

Importante

El código de ejemplo requiere que los pares de nombre y valor del objeto JSON permanezcan en orden, lo que no es un requisito estándar de 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();
        }
    }
}

El código siguiente registra el convertidor:

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

El convertidor puede deserializar el JSON que se creó con el mismo convertidor para serializar, por ejemplo:

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

El código del convertidor en el ejemplo anterior lee y escribe cada propiedad manualmente. Una alternativa es llamar a Deserialize o Serialize para realizar parte del trabajo. Para obtener un ejemplo, vea esta publicación de StackOverflow.

Una manera alternativa de realizar la deserialización polimórfica

Puede llamar a Deserializeen el método Read:

  • Cree un clon de la instancia Utf8JsonReader. Puesto que Utf8JsonReader es una structura, solo requiere una instrucción de asignación.
  • Use el clon para leer los tokens discriminadores.
  • Llame a Deserialize mediante la instancia original Reader una vez que sepa el tipo que necesita. Puede llamar a Deserialize porque la instancia original Reader todavía está colocada para leer el token de objeto begin.

Una desventaja de este método es que no se puede pasar la instancia de opciones original que registra el convertidor en Deserialize. Esto produciría un desbordamiento de pila, como se explica en Propiedades requeridas. En el ejemplo siguiente se muestra un 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;
}

Compatibilidad con recorrido de ida y vuelta para los tipos Stack

Si deserializa una cadena JSON en un objeto Stack y después serializa ese objeto, el contenido de la pila está en orden inverso. Este comportamiento se aplica a los siguientes tipos e interfaz, así como a los tipos definidos por el usuario que derivan de ellos:

Para admitir la serialización y deserialización que conserva el orden original en la pila, se requiere un convertidor personalizado.

En el código siguiente se muestra un convertidor personalizado que permite el recorrido de ida y vuelta hacia y desde 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();
        }
    }
}

El código siguiente registra el convertidor:

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

Directivas de nomenclatura para la deserialización de cadenas de enumeración

De manera predeterminada, el elemento integrado JsonStringEnumConverter puede serializar y deserializar valores de cadena en enumeraciones. Funciona sin una directiva de nomenclatura especificada o con la directiva de nomenclatura CamelCase. No admite otras directivas de nomenclatura, como el caso de la convención conocida como snake case (palabras separadas por guion bajo). Para obtener información sobre un código de convertidor personalizado que puede admitir el recorrido de ida y vuelta hacia y desde valores de cadena de enumeración y usar una directiva de nomenclatura de snake case, vea el problema de GitHub dotnet/runtime #31619. También puede actualizar a .NET 7 o versiones posteriores, que proporcionan compatibilidad integrada para aplicar directivas de nomenclatura cuando se realiza un recorrido de ida y vuelta a los valores de cadena de enumeración y desde ellos.

Uso del convertidor del sistema predeterminado

En algunos escenarios, es posible que quiera usar el convertidor del sistema predeterminado en un convertidor personalizado. Para ello, obtenga el convertidor del sistema desde la propiedad JsonSerializerOptions.Default, como se muestra en el ejemplo siguiente:

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

Control de valores NULL

De forma predeterminada, el serializador controla los valores NULL de la siguiente manera:

  • Para tipos de referencia y tipos de Nullable<T>:

    • No pasa null a los convertidores personalizados en la serialización.
    • No pasa JsonTokenType.Null a los convertidores personalizados en la deserialización.
    • Devuelve una instancia de null en la deserialización.
    • Escribe null directamente con el escritor en la serialización.
  • Para los tipos de valor distintos a NULL:

    • Pasa JsonTokenType.Null a los convertidores personalizados en la deserialización. (Si no hay ningún convertidor personalizado disponible, el convertidor interno produce una excepción JsonException para el tipo).

Este comportamiento de administración de valores NULL sirve principalmente para optimizar el rendimiento omitiendo una llamada adicional al convertidor. Además, evita la aplicación de convertidores para tipos que aceptan valores NULL que comprobar para null al principio de cada invalidación de método Read y Write.

Para permitir que un convertidor personalizado administre null para un tipo de valor o referencia, invalide JsonConverter<T>.HandleNull para devolver true, como se muestra en el ejemplo siguiente:

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.

Conservación de las referencias

De manera predeterminada, los datos de referencia solo se almacenan en caché para cada llamada a Serialize o Deserialize. Para conservar las referencias de una llamada Serialize/Deserialize a otra, coloque la instancia ReferenceResolver en el sitio de llamada de Serialize/Deserialize. El código siguiente muestra un ejemplo de este escenario:

  • Escriba un convertidor personalizado para el tipo Company.
  • No se recomienda serializar manualmente la propiedad Supervisor, que es Employee. Se recomienda delegarla en el serializador y además conservar las referencias que ya ha guardado.

Estas son las clases Employee y 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; }
}

El convertidor tiene este aspecto:

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

Una clase que deriva de ReferenceResolver almacena las referencias en un diccionario:

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

Una clase que deriva de ReferenceHandler contiene una instancia de MyReferenceResolver y crea una nueva instancia solo cuando es necesario (en un método de nombre Reset en este ejemplo):

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

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

Cuando el código de ejemplo llama al serializador, usa una instancia JsonSerializerOptions en la que la propiedad ReferenceHandler se establece en una instancia de MyReferenceHandler. Al seguir este patrón, asegúrese de restablecer el diccionario ReferenceResolver cuando haya terminado de serializar, para evitar que crezca indefinidamente.

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

El ejemplo anterior solo realiza la serialización, pero se puede adoptar un enfoque similar para la deserialización.

Otros ejemplos de convertidor personalizado

El artículo Migración de Newtonsoft.Json a System.Text.Json contiene ejemplos adicionales de convertidores personalizados.

La carpeta de pruebas unitarias en el código fuente de System.Text.Json.Serialization incluye otros ejemplos de convertidor personalizado, como:

Si necesita crear un convertidor que modifique el comportamiento de un convertidor integrado existente, puede obtener el código fuente del convertidor existente para que sirva como punto de partida para la personalización.

Recursos adicionales