Saltar al contenido

Artículos, tutoriales, trucos, curiosidades, reflexiones y links sobre programación web ASP.NET Core, MVC, Blazor, SignalR, Entity Framework, C#, Azure, Javascript... y lo que venga ;)

17 años online

el blog de José M. Aguilar

Inicio El autor Contactar

Artículos, tutoriales, trucos, curiosidades, reflexiones y links sobre programación web
ASP.NET Core, MVC, Blazor, SignalR, Entity Framework, C#, Azure, Javascript...

¡Microsoft MVP!
martes, 16 de abril de 2024
.NET

Hace poco vimos cómo serializar y deserializar datos en JSON de forma personalizada usando custom converters e implementamos un ejemplo simple capaz de introducir en campos de tipo int de .NET casi cualquier valor que pudiera venir en un JSON.

Pero como comentamos en su momento, la serialización y deserialización de objetos más complejos no es una tarea tan sencilla y requiere algo más de esfuerzo. En este post vamos a ver la solución para un escenario que creo que puede ser relativamente habitual: deserializar un objeto JSON a un diccionario Dictionary<string, object>.

En otras palabras, queremos que funcione algo como el siguiente código:

using static System.Console;
...
var json = """
{
    "name": "Juan",
    "age": 30
}
""";

var dict = ... // Código de deserialización
WriteLine($"{dict["name"]} tiene {dict["age"]} años"); // --> Juan tiene 30 años

Con Newtonsoft.Json, la deserialización de un objeto JSON arbitrario a un diccionario genérico está contemplada de serie, por lo que no hay que hacer nada. Simplemente, funcionará:

using static System.Console;
...
var dict = JsonConvert.DeserializeObject<Dictionary<string, object>>(json);
object name = dict["name"];
object age = dict["age"];

WriteLine($"{name} tiene {age} años");          // --> Juan tiene 30 años
WriteLine("name es " + dict["name"].GetType()); // --> name es System.String
WriteLine("age es " + dict["age"].GetType());   // --> age es System.Int64

string n = (string)name;
System.Console.WriteLine(n); // --> Juan

Sin embargo, con System.Text.Json no es tan sencillo. Aunque esta biblioteca creará correctamente un diccionario cuyas claves se corresponderán con los nombres de las propiedades del objeto JSON, el convertidor por defecto para el tipo object deserializará los valores en objetos de tipo JsonElement, lo cual dificultará luego su manipulación. Podemos verlo en el siguiente código:

using static System.Console;
...
var dict = JsonConvert.DeserializeObject<Dictionary<string, object>>(json);
object name = dict["name"];
object age = dict["age"];

WriteLine($"{name} tiene {age} años");           // --> Juan tiene 30 años
WriteLine("name es " + dict["name"].GetType());  // --> name es System.Text.Json.JsonElement
WriteLine("age es " + dict["age"].GetType());    // --> age es System.Text.Json.JsonElement

string n = (string)name; // Boom! "InvalidCastException"

Para solucionarlo, debemos crear un convertidor personalizado para Dictionary<string, object> que sea capaz de deserializar al tipo apropiado cada valor del objeto JSON. Como vimos en el post anterior, la estructura básica del custom converter podría ser algo así:

public class ObjectDictionaryConverter : JsonConverter<Dictionary<string, object>>
{
    public override Dictionary<string, object> Read(
           ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        // Aquí irá el código de deserialización
    }

    public override void Write(
           Utf8JsonWriter writer, Dictionary<string, object> value, 
           JsonSerializerOptions options)
    {
        // En este caso vamos a implementar exclusivamente la deserialización,
        // por lo que no necesitamos implementar el método Write
        throw new NotImplementedException();
    }
}

Centrémonos ahora en el código del método Read() que, como ya sabemos, es el encargado de leer el JSON e ir generando los datos equivalentes en objetos .NET. A nivel de código, podemos tener una primera versión simplificada como la siguiente:

public override Dictionary<string, object> Read(
       ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
    if (reader.TokenType == JsonTokenType.StartObject)
    {
        var dictionary = new Dictionary<string, object>();
        while (reader.Read())
        {
            if (reader.TokenType == JsonTokenType.PropertyName)
            {
                // Get the property name
                var key = reader.GetString();

                // Get the property value
                reader.Read();

                // Add the property to the dictionary
                dictionary[key] = ReadValue(ref reader, options);
            }
            else if (reader.TokenType == JsonTokenType.EndObject)
            {
                return dictionary;
            }
        }
    }
    throw new JsonException("Invalid json format. Expected object.");
}

Lo que estamos haciendo es:

  • Comprobar que el primer token del JSON es el carácter de apertura de un objeto ({).
  • Tras ello, recorreremos el JSON leyendo primero el nombre de la propiedad.
  • Por cada una de estas propiedades, obtendremos el valor .NET correspondiente y lo añadiremos al diccionario. El valor lo obtenemos llamando al método ReadValue() que definiremos más abajo.
  • Cuando llegamos al final del objeto, devolvemos el diccionario.

Por otra parte, el método ReadValue() será el encargado de obtener el valor de la propiedad. Usaremos un switch que nos permita identificar el tipo de valor que estamos leyendo y actuar en consecuencia.

Comencemos con una implementación simple:

private object ReadValue(ref Utf8JsonReader reader, JsonSerializerOptions options)
{
    switch (reader.TokenType)
    {
        case JsonTokenType.String:
            return reader.GetString();
        case JsonTokenType.Number:
            if (reader.TryGetInt32(out int intValue))
            {
                return intValue;
            }
            else
            {
                return reader.GetDouble();
            }
        case JsonTokenType.True:
            return true;
        case JsonTokenType.False:
            return false;
        case JsonTokenType.Null:
            return null;
        default:
            throw new JsonException($"Unexpected token type: {reader.TokenType}");
    }
}

Esto es similar a lo que vimos en el post anterior: en función del tipo de token obtenido, creamos el objeto .NET correspondiente.

Soportando arrays y objetos anidados

Lo que hemos visto hasta el momento podría ser una primera versión aceptable que cubriría los escenarios más simples, pero tiene un par de problemas: no soporta que el valor de una propiedad sea un array, ni tampoco que sea un objeto JSON anidado. Es decir, funcionaría con un JSON plano como el que hemos visto antes, pero no con este caso:

{
    "name": "Juan",
    "age": 30,
    "favoriteColors": ["rojo", "verde", "azul"],
    "address": {
        "street": "Sesame Street",
        "city": "New York",
    }
}

Si intentamos deserializar este JSON con el código anterior, fallará en tiempo de ejecución cuando intente deserializar el valor de la propiedad favoriteColors o address.

Vamos a solucionar el problema con los arrays. Para ello, necesitamos añadir otro caso al switch, de forma que contemplemos la posibilidad de que el valor de la propiedad sea un array, algo que detectamos fácilmente cuando el token leído es de tipo JsonTokenType.StartArray:

case JsonTokenType.StartArray:
    var list = new List<object>();
    while (reader.Read())
    {
        if (reader.TokenType == JsonTokenType.EndArray)
        {
            break;
        }
        list.Add(ReadValue(ref reader, options));
    }
    return list.ToArray();

Como podéis observar, cuando detectamos el inicio de un array, recorremos todos sus elementos y, por cada uno de ellos, de forma recursiva, llamamos a ReadValue()para obtener su valor.. Finalmente, devolvemos el array resultante en forma de object[].

Para contemplar el caso de los objetos JSON anidados, debemos añadir igualmente otro caso al switch que detecte el token de inicio de un objeto y llame recursivamente a Read() para obtener el diccionario correspondiente:

case JsonTokenType.StartObject:
    return Read(ref reader, typeof(Dictionary<string, object>), options);

El código completo del convertidor personalizado

Pues para que lo tengáis todo a mano y poder echarle un vistazo completo, aquí tenéis el código del convertidor personalizado:

public class ObjectDictionaryConverter : JsonConverter<Dictionary<string, object>>
{
    public override Dictionary<string, object> Read(
           ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        if (reader.TokenType == JsonTokenType.StartObject)
        {
            var dictionary = new Dictionary<string, object>();
            while (reader.Read())
            {
                if (reader.TokenType == JsonTokenType.PropertyName)
                {
                    // Get the property name
                    var key = reader.GetString();

                    // Get the property value
                    reader.Read();

                    // Add the property to the dictionary
                    dictionary[key] = ReadValue(ref reader, options);
                }
                else if (reader.TokenType == JsonTokenType.EndObject)
                {
                    return dictionary;
                }
            }
        }
        throw new JsonException("Invalid json format. Expected object.");
    }


    private object ReadValue(ref Utf8JsonReader reader, JsonSerializerOptions options)
    {
        switch (reader.TokenType)
        {
            case JsonTokenType.String:
                return reader.GetString();

            case JsonTokenType.Number:
                if (reader.TryGetInt32(out int intValue))
                {
                    return intValue;
                }
                else
                {
                    return reader.GetDouble();
                }
            case JsonTokenType.True:
                return true;
            case JsonTokenType.False:
                return false;
            case JsonTokenType.Null:
                return null;
            case JsonTokenType.StartArray:
                var list = new List<object>();
                while (reader.Read())
                {
                    if (reader.TokenType == JsonTokenType.EndArray)
                    {
                        break;
                    }
                    list.Add(ReadValue(ref reader, options));
                }
                return list.ToArray();
            case JsonTokenType.StartObject:
                return Read(ref reader, typeof(Dictionary<string, object>), options);                
            default:
                throw new JsonException($"Unexpected token type: {reader.TokenType}");
        }
    }

    public override void Write(
           Utf8JsonWriter writer, Dictionary<string, object> value, 
           JsonSerializerOptions options)
    {
        throw new NotImplementedException();
    }
}

Una prueba rápida

Para probar el convertidor personalizado, podríamos usar un código como el siguiente, donde deserializamos un JSON complejo a un diccionario y luego accedemos a distintos valores:

using System.Text.Json;
using System.Text.Json.Serialization;
using static System.Console;

var json = """
{
    "name": "Juan",
    "age": 30,
    "favoriteColors": ["red", "green", "blue"],
    "address": {
        "street": "123 Main St",
        "city": "Springfield"
    }
}
""";

var options = new JsonSerializerOptions();
options.Converters.Add(new ObjectDictionaryConverter());
var dict = JsonSerializer.Deserialize<Dictionary<string, object>>(json, options);
object name = dict["name"];
object age = dict["age"];

WriteLine($"{name} tiene {age} años");           // --> Juan tiene 30 años
WriteLine("name es " + dict["name"].GetType());  // --> name es System.String
WriteLine("age es " + dict["age"].GetType());    // --> age es System.Int32

WriteLine("colors: " + dict["favoriteColors"].GetType());  // --> colors: System.Object[]
object[] colors = dict["favoriteColors"] as object[];
WriteLine(colors[1]);         // --> verde

var city = (dict["address"] as Dictionary<string, object>)["city"];
WriteLine(city);              // --> Springfield

¡Espero que os sea de utilidad!

Publicado en Variable not found.

2 Comentarios:

Rubén dijo...

Es posible aplicar este convertidor en la deserialización del fichero appsettings.json? Tengo una clase con una propiedad como dynamic y al realizar un cast a double, integer u otro tipo ¡boom!

José María Aguilar dijo...

Hola!

Como archivo JSON que es, debería funcionar. Aunque claro, estarías accediendo al archivo crudo, no a los valores de los settings que podrían resultar de la fusión de appsettings.json con el específico del entorno (p.e. appsettings.development.json).

Si lo que quieres es acceder de forma tipada a los settings, puedes echar un vistazo a estos artículos (aunque hay muchos más en la red):

* El patrón options
* Strongly Typed Configuration Settings in ASP.NET Core

Saludos!