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, 20 de febrero de 2024
.NET

Hace poco, andaba enfrascado en el proceso de modernización de una aplicación antigua que, entre otras cosas, guardaba datos en formato JSON en un repositorio de archivos. Dado que se trataba de una aplicación creada con .NET "clásico", la serialización y deserialización de estos datos se realizaba utilizando la popular biblioteca Newtonsoft.Json.

Al pasar a versiones modernas de .NET, esta biblioteca ya no es la mejor opción, pues ya el propio framework nos facilita las herramientas necesarias para realizar estas tareas de forma más eficiente mediante los componentes del espacio de nombres System.Text.Json. Y aquí es donde empiezan a explotar las cosas 😉.

Si habéis trabajado con este tipo de asuntos, probablemente habréis notado que, por defecto, los componentes de deserialización creados por James Newton-King son bastante permisivos y dejan pasar cosas que System.Text.Json no permite. Por ejemplo, si tenemos una clase .NET con una propiedad de tipo string y queremos deserializar un valor JSON numérico sobre ella, Newtonsoft.Json lo hará sin problemas, pero System.Text.Json nos lanzará una excepción. Esa laxitud de Newtonsoft.Json es algo que en ocasiones nos puede venir bien, pero en otras puede puede hacer pasar por alto errores en nuestros datos que luego, al ser procesados por componentes de deserialización distintos, podrían ocasionar problemas.

Por ejemplo, observad el siguiente código:

var json = """
           {
              "Count": "1234"
           }
           """;

// Deserializamos usando Newtonsoft.Json:
var nsj = Newtonsoft.Json.JsonConvert.DeserializeObject<Data>(json);
Console.WriteLine("Newtonsoft: " + nsj.Count);

// Intentamos deserializar usando System.Text.Json
// y se lanzará una excepción:
var stj = System.Text.Json.JsonSerializer.Deserialize<Data>(json);
Console.WriteLine("System.Text.Json: " + stj.Count);
Console.Read();

// La clase de datos utilizada
record Data(int Count);

Para casos como este, nos vendrá bien conocer qué son los custom converters y cómo podemos utilizarlos.

¿Qué es un custom converter?

Un convertidor personalizado o custom converter es un componente que podemos insertar en el proceso de serialización y deserialización de System.Text.Json para que se encargue de convertir valores JSON en objetos .NET y viceversa. Este componente nos permite tener un control muy fino sobre los procesos de serializacón y deserialización, lo que nos puede ser útil en situaciones en las que necesitamos adaptar el comportamiento de System.Text.Json a nuestras necesidades, como por ejemplo cuando nos encontramos casos como el que hemos visto antes.

En la práctica, los custom converters son clases que implementan la interfaz JsonConverter<T>, donde T es el tipo de dato .NET de las propiedades cuya serialización o deserialización queremos controlar. Por ejemplo, en el caso anterior, donde nuestra propiedad era de tipo int, necesitaríamos implementar un JsonConverter<int>.

Esta interfaz requiere la implementación de dos métodos:

public class MyCustomConverter : JsonConverter<T>
{
    public override T Read(ref Utf8JsonReader reader, Type typeToConvert, 
                           JsonSerializerOptions options)
    { 
    }

    public override void Write(Utf8JsonWriter writer, T value, 
                               JsonSerializerOptions options)
    {
    }
}   
  • El tipo genérico T es el tipo de dato hacia o desde el que queremos convertir, según hablemos de deserialización o serialización. Por ejemplo, si usamos un int, el conversor sería utilizado cuando:
    • Se está deserializando y System.Text.Json encuentra una propiedad de tipo int en la que debe almacenar el valor presente en el JSON.
    • Se está serializando y hay que generar la representación JSON de una propiedad de tipo int.
  • Read(): usado durante la deserialización, este método se encarga de obtener el valor JSON entrante y transformarlo en el objeto de tipo T que retorna.
  • Write(): escribe sobre el writer JSON el valor del objeto de tipo T que recibe como parámetro.

Vamos a ver un ejemplo práctico de cómo implementar un custom converter para serializar y deserializar propiedades de tipo int. Para ello, crearemos una clase heredando de JsonConverter<int>, como la siguiente:

public class IntConverter : System.Text.Json.Serialization.JsonConverter<int>
{
    public override int Read(ref Utf8JsonReader reader, Type typeToConvert, 
                             JsonSerializerOptions options)
    {
        // Aquí implementaremos la lógica de deserialización, es decir,
        // convertiremos el valor JSON que recibamos a entero
    }

    public override void Write(Utf8JsonWriter writer, int value,
                               JsonSerializerOptions options)
    {
        // Aquí implementaremos la lógica de serialización,
        // es decir, convertiremos del valor entero a un número JSON
    }
}

No siempre tendremos que implementar ambos métodos. Si, por ejemplo, únicamente vamos a necesitar deserializar de forma personalizada, podríamos dejar el método Write() vacío, o viceversa.

Veamos ahora cómo implementar cada uno de estos métodos.

Deserialización personalizada con custom converters

El método Read() es el encargado de deserializar el valor JSON entrante y convertirlo en un objeto de tipo T. En el caso de nuestro ejemplo, donde estamos trabajando con un int, el método Read() podría ser algo así:

public override int Read(ref Utf8JsonReader reader, Type typeToConvert,
                         JsonSerializerOptions options)
{
    switch (reader.TokenType)
    {
        case JsonTokenType.String:
            return int.TryParse(reader.GetString(), out int stringValueAsInt) 
                   ? stringValueAsInt : 0;
        case JsonTokenType.Number:
            return reader.TryGetInt32(out int numberValueAsInt) ? numberValueAsInt : 0;
        case JsonTokenType.True:
            return 1;
        default:
            return 0;
    }
}

Se trata de un ejemplo básico, pero creo que os valdrá para que os quedéis con la idea de que aquí podríamos implementar cualquier tipo de lógica de deserialización. En este caso, la lógica es muy simple, pues sea cual sea el valor JSON entrante, intentaremos generar un valor entero para él:

  • Si es una cadena, la parsearemos para transformarla en un valor entero.
  • Si es un número, lo usaremos tal cual.
  • Si es un true, devolveremos 1 (porque nos apetece, podríamos haber elegido cualquier otro criterio)
  • En otros casos, siempre retornaremos el valor 0.

Fijaos que podemos acceder al valor JSON entrante usando el objeto reader que nos llega como parámetro. Con él, podemos determinar el tipo de dato que estamos leyendo y actuar en consecuencia: si es un JsonTokenType.String, obtenemos su valor con el método reader.GetString() e intentamos convertir el valor obtenido a entero; en cambio, si es un JsonTokenType.Number, intentamos obtener el valor como entero, mientras que si es un JsonTokenType.True, devolveremos 1. En cualquier otro caso, devolvemos 0.

Para crear deserializadores personalizados robustos, es importante considerar todos los tipos de datos JSON que pueden llegarnos y realizar las acciones de conversión apropiadas para que en todos los casos el método retorne un valor que encaje con lo que necesitamos.

Serialización personalizada con custom converters

La serialización es normalmente más sencilla, pues consiste únicamente en representar con JSON el valor de la propiedad, que nos llega en el parámetro value del método Write().

En nuestro caso, que estamos trabajando con un entero, la implementación de este método sería algo así:

public override void Write(Utf8JsonWriter writer, int value, 
                           JsonSerializerOptions options)
{
    writer.WriteNumberValue(value);
}
Como podéis intuir, el objeto writer dispone de un buen número de métodos WriteXXX() para generar valores JSON de múltiples tipos:  WriteStringValue(), WriteBooleanValue(), WriteNullValue(), etc., por lo que se trata simplemente de usar el o los métodos apropiados en cada caso para emitir el JSON que queramos.

Aplicar el custom converter a operaciones de serialización y deserialización

Seguro que en este punto os habéis dado cuenta de que falta algo, ¿verdad? Hemos implementado nuestro custom converter, pero en ningún sitio le hemos dicho a System.Text.Json que lo utilice, por lo que no tendrá ningún efecto.

Para que se utilice nuestro custom converter tendremos que añadirlo a las opciones de serialización o deserialización que estemos utilizando, materializadas en un objeto de tipo JsonSerializerOptions. Seguro que habéis usado esta clase alguna vez para configurar opciones de casing, ignorar propiedades, etc. Pues bien, también podemos añadir nuestros custom converters a estas opciones.

var options = new JsonSerializerOptions();
options.Converters.Add(new IntConverter());
// Podemos establecer otras opciones aquí, por ejemplo:
options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
...

Luego, podemos usar estas opciones tanto para serializar como para deserializar:

var options = new JsonSerializerOptions();
options.Converters.Add(new IntConverter());

// Se usará nuestro custom converter para deserializar las propiedades `int`:
var data = System.Text.Json.JsonSerializer.Deserialize<Data>(json, options);

// Y también para serializarlas:
var json = System.Text.Json.JsonSerializer.Serialize(data, options);

¡Hasta aquí hemos llegado!

Obviamente, lo que hemos visto es sólo el principio. Podemos crear custom converters para cualquier tipo de dato, y no sólo para tipos primitivos, sino también para tipos complejos, como por ejemplo, para deserializar fechas en un formato específico, colecciones, diccionarios, etc.. En algunos casos la cosa se puede llegar a complicar un poco, pero nada que no pueda resolverse en un ratillo de diversión 😉 De hecho, me apunto para ver la implementación de algunos de estos casos algo más complejos en un post futuro.

Espero que os resulte útil en proyectos en los que tengáis que lidiar con JSON, sobre todo si tenéis que ser compatibles con otros sistemas cuyos criterios de serialización o deserialización sean distintos a los proporcionados por defecto por las bibliotecas de .NET.

Publicado en Variable not found.

Aún no hay comentarios, ¡sé el primero!