Personalización de un contrato JSON

La biblioteca System.Text.Json crea un contrato JSON de cada tipo de .NET que define cómo se debe serializar y deserializar el tipo. El contrato se deriva de la forma del tipo, que incluye características como sus propiedades y campos y si implementa la interfaz IEnumerable o IDictionary. Los tipos se asignan a contratos en tiempo de ejecución mediante reflexión o en tiempo de compilación mediante el generador de orígenes.

A partir de .NET 7, estos contratos JSON se pueden personalizar para proporcionar más control sobre cómo se convierten los tipos en JSON (y viceversa). Estos son solo algunos ejemplos de los tipos de personalizaciones de serialización y deserialización que se pueden realizar:

  • Serialización de propiedades y campos privados
  • Admisión de varios nombres para una misma propiedad (por ejemplo, si una versión de biblioteca anterior usó un nombre diferente)
  • Omisión de las propiedades con un nombre, un tipo o un valor específicos
  • Distinción entre valores null explícitos y ausencia de un valor en la carga JSON
  • Admisión de atributos System.Runtime.Serialization, como DataContractAttribute. Para obtener más información, consulte Atributos System.Runtime.Serialization.
  • Producción de una excepción si el JSON incluye una propiedad que no forma parte del tipo de destino. Para obtener más información, consulte Control de los miembros que faltan.

Cómo acceder

Existen dos maneras de acceder a estas personalizaciones. Ambas conllevan obtener un solucionador, cuyo trabajo es proporcionar una instancia de JsonTypeInfo para cada tipo que deba serializarse.

  • Llamando al constructor DefaultJsonTypeInfoResolver() para obtener el JsonSerializerOptions.TypeInfoResolver y agregando acciones personalizadas de su elección a la propiedad Modifiers

    Por ejemplo:

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

    Si se agregan varios modificadores, se llamarán en secuencia.

  • Escribiendo un solucionador personalizado que implementa IJsonTypeInfoResolver.

    • Si un tipo no se controla, IJsonTypeInfoResolver.GetTypeInfo debe devolver null para ese tipo.
    • El solucionador personalizado se puede combinar también con otros, por ejemplo, con el solucionador predeterminado. Los solucionadores se consultarán en orden hasta que se devuelva un valor de JsonTypeInfo distinto de NULL para el tipo.

Aspectos configurables

La propiedad JsonTypeInfo.Kind indica cómo serializa el convertidor un tipo determinado (por ejemplo, como un objeto o como una matriz) y si sus propiedades se serializan. Puede consultar esta propiedad para saber qué aspectos del contrato JSON de un tipo se pueden configurar. Hay cuatro clases de propiedad diferentes:

JsonTypeInfo.Kind Descripción
JsonTypeInfoKind.Object El convertidor serializará el tipo en un objeto JSON y usa sus propiedades. Se usa con la mayoría de los tipos de clase y estructura y es la más flexible de todas.
JsonTypeInfoKind.Enumerable El convertidor serializará el tipo en una matriz JSON. Se usa con tipos como List<T> y matriz.
JsonTypeInfoKind.Dictionary El convertidor serializará el tipo en un objeto JSON. Se usa con tipos como Dictionary<K, V>.
JsonTypeInfoKind.None El convertidor no especifica cómo serializará el tipo ni las propiedades JsonTypeInfo que usará. Se usa con tipos como System.Object, int y string, así como con todos los tipos que usan un convertidor personalizado.

Modificadores

Un modificador es un parámetro Action<JsonTypeInfo> o un método con un parámetro JsonTypeInfo que obtiene el estado actual del contrato como argumento y realiza modificaciones en el contrato. Por ejemplo, podría recorrer en iteración las propiedades rellenadas previamente en el parámetro JsonTypeInfo especificado para hallar la que le interese y, a continuación, modificar su propiedad (de serialización) JsonPropertyInfo.Get o su propiedad (de deserialización) JsonPropertyInfo.Set. También se puede crear una nueva propiedad mediante JsonTypeInfo.CreateJsonPropertyInfo(Type, String) y agregarla a la colección JsonTypeInfo.Properties.

En la siguiente tabla se muestran las modificaciones posibles y cómo realizarlas.

Modificación JsonTypeInfo.Kind aplicable Cómo realizarla Ejemplo
Personalizar el valor de una propiedad JsonTypeInfoKind.Object Modifique el delegado (de serialización) JsonPropertyInfo.Get o el delegado (de deserialización) JsonPropertyInfo.Set de la propiedad. Incrementar el valor de una propiedad
Agregar o quitar propiedades JsonTypeInfoKind.Object Agregue o quite elementos de la lista de JsonTypeInfo.Properties. Serializar campos privados
Serializar una propiedad con condiciones JsonTypeInfoKind.Object Modifique el predicado JsonPropertyInfo.ShouldSerialize de la propiedad. Omitir propiedades con un tipo específico
Personalizar el tratamiento de números de un tipo específico JsonTypeInfoKind.None Modifique el valor de JsonTypeInfo.NumberHandling del tipo. Permitir que los valores int sean cadenas

Ejemplo: Incrementar el valor de una propiedad

Vea el siguiente ejemplo, donde el modificador incrementa el valor de una propiedad determinada en la deserialización, modificando para ello su delegado JsonPropertyInfo.Set. Además de definir el modificador, el ejemplo también presenta un nuevo atributo que usa para localizar la propiedad cuyo valor debe incrementarse. Este es un ejemplo de personalización de una propiedad.

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

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

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

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

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

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

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

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

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

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

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

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

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

Fíjese en que, en la salida, el valor de RoundTrips se incrementa cada vez que la instancia de Product se deserializa.

Ejemplo: Serializar campos privados

De forma predeterminada, System.Text.Json omite las propiedades y los campos privados. En este ejemplo se agrega un nuevo atributo para toda la clase, JsonIncludePrivateFieldsAttribute, para cambiar ese valor predeterminado. Si el modificador halla ese atributo en un tipo, agrega todos los campos privados del tipo a JsonTypeInfo como propiedades nuevas.

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

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

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

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

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

            return h;
        }

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

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

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

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

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

                jsonTypeInfo.Properties.Add(jsonPropertyInfo);
            }
        }

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

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

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

Sugerencia

Si los nombres de los campos privados comienzan por caracteres de subrayado, considere la posibilidad de quitar esos caracteres al agregar los campos como propiedades JSON nuevas.

Ejemplo: Omitir propiedades con un tipo específico

Puede que el modelo tenga propiedades con nombres o tipos específicos que no quiera exponer a los usuarios. Por ejemplo, puede tener una propiedad que almacene credenciales o alguna información que no tiene sentido incluir en la carga.

En el siguiente ejemplo se muestra cómo filtrar las propiedades con un tipo específico, SecretHolder. Para ello, se usa un método de extensión IList<T> para quitar de la lista de JsonTypeInfo.Properties aquellas propiedades que tengan el tipo especificado. Las propiedades filtradas desaparecen completamente del contrato, lo que significa que System.Text.Json no las examina durante la serialización o deserialización.

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

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

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

    class IgnorePropertiesWithType
    {
        private readonly Type[] _ignoredTypes;

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

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

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

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

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

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

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

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

Ejemplo: Permitir que los valores int sean cadenas

Puede que el JSON de entrada contenga uno de los tipos numéricos entre comillas, pero otros no. Si tuviera control sobre la clase, podría colocar JsonNumberHandlingAttribute en el tipo para corregirlo, pero no lo tiene. Antes de .NET 7, tendría que escribir un convertidor personalizado para corregir este comportamiento, lo que requiere escribir un poco de código. Con la personalización del contrato, puede personalizar el comportamiento de tratamiento de números de cualquier tipo.

En el siguiente ejemplo se cambia el comportamiento de todos los valores int. Este ejemplo se puede adaptar fácilmente para usarlo con cualquier tipo o con una propiedad específica de cualquier tipo.

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

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

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

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

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

Sin el modificador que permite la lectura de valores int de una cadena, el programa habría terminado con una excepción:

Excepción no controlada. System.Text.Json.JsonException: El valor JSON no se pudo convertir a System.Int32. Ruta: $.X | LineNumber: 0 | BytePositionInLine: 9.

Otras formas de personalizar la serialización

Además de personalizar un contrato, existen otras formas de influir en el comportamiento de serialización y deserialización, como las siguientes:

  • Usando atributos derivados de JsonAttribute, por ejemplo, JsonIgnoreAttribute y JsonPropertyOrderAttribute
  • Modificando JsonSerializerOptions, por ejemplo, para establecer una directiva de nomenclatura o para serializar los valores de enumeración como cadenas en lugar de números
  • Escribiendo un convertidor personalizado que realice de facto la tarea de escribir el JSON y, durante la deserialización, construyendo un objeto

La personalización de contratos es una mejora con respecto a estas personalizaciones que ya existían, porque es posible que no pueda acceder al tipo para agregar atributos, y escribir un convertidor personalizado es complejo y perjudica el rendimiento.

Vea también