Acercándose el lanzamiento de .NET 9 el próximo mes de noviembre, podemos ver ya algunas de las novedades que traerá la nueva versión, con las que podemos jugar desde hace algunos meses instalando las previews que van apareciendo cada mes.
En esta ocasión vamos a ver algunas novedades más interesantes que se han añadido a la biblioteca de serialización JSON oficial, conocida por todos como System.Text.Json
:
- Opciones de personalización de la indentación
- Singleton de opciones de serialización y deserialización para la web
- Soporte de anotaciones de anulabilidad
- Comprobación de parámetros obligatorios del constructor
- Descripción de tipos .NET usando JSON Schema
¡Vamos a ello!
Nota: Este artículo está basado en .NET 9 RC1, por lo que algunas de las características descritas podrían cambiar en la versión final.
1. Opciones de personalización de la indentación
Hasta .NET 8, las opciones de personalización relativas al indentado de JSON generado era bastante limitada. De hecho, sólo podíamos decidir si lo queríamos indentar o no usando la propiedad WriteIndented
de JsonOptions
:
var obj = new
{
Name = "John",
Age = 30,
Address = new { Street = "123 Main St", City = "New York" }
};
var jsonOptions = new JsonSerializerOptions()
{
WriteIndented = true, // Queremos indentar el JSON
};
Console.WriteLine(JsonSerializer.Serialize(obj, jsonOptions));
El resultado será siempre un JSON indentado a dos espacios como el siguiente:
{
"Name": "John",
"Age": 30,
"Address": {
"Street": "123 Main St",
"City": "New York"
}
}
En .NET 9 disponemos de dos nuevas propiedades para personalizar la indentación del JSON generado. La propiedad IndentCharacter
permite especificar el carácter a utilizar (espacio o tabulador, exclusivamente), y mediante IndentSize
podemos indicar el número de caracteres a utilizar en cada nivel de indentación.
var jsonOptions = new JsonSerializerOptions()
{
WriteIndented = true,
IndentCharacter = '\t',
IndentSize = 2
};
Console.WriteLine(JsonSerializer.Serialize(obj, jsonOptions));
En este caso, el resultado será así (2 tabuladores por nivel de indentación):
{
"Name": "John",
"Age": 30,
"Address": {
"Street": "123 Main St",
"City": "New York"
}
}
2. Singleton de opciones de serialización y deserialización para la web
ASP.NET Core utiliza una configuración por defecto para serializar y deserializar objetos JSON, que ahora está disponible de forma pública en la propiedad JsonSerializerOptions.Web
.
El objeto retornado es un singleton JsonSerializerOptions
de sólo lectura, que estará disponible en cualquier punto de la aplicación.
Por defecto, este objeto está configurado de la siguiente manera:
PropertyNameCaseInsensitive
está establecido atrue
, por lo que la deserialización de propiedades es insensible a mayúsculas y minúsculas.JsonPropertyNamingPolicy
tiene el valorJsonNamingPolicy.CamelCase
, así que las propiedades se serializan en formato camelCase, el habitual en JavaScript y JSON.NumberHandling
esJsonNumberHandling.AllowReadingFromString
, así que será posible deserializar números que vengan especificados como texto.
Un ejemplo de utilización de este objeto sería el siguiente:
var obj = new
{
Name = "John",
Age = 30,
Address = new { Street = "123 Main St", City = "New York" }
};
Console.WriteLine(JsonSerializer.Serialize(obj, JsonSerializerOptions.Web));
// Resultado:
// {"name":"John","age":30,"address":{"street":"123 Main St","city":"New York"}}
Dado que el objeto es de sólo lectura, no podemos modificar sus propiedades. Si necesitásemos algún tipo de cambio en la configuración, podríamos crear una configuración nueva partiendo de él, e introducir las modificaciones deseadas, por ejemplo:
var jsonOptions = new JsonSerializerOptions(JsonSerializerOptions.Web)
{
WriteIndented = true,
IndentCharacter = '\t',
IndentSize = 2
};
3. Soporte de anotaciones de anulabilidad
En .NET 8 y versiones anteriores, System.Text.Json
no respetaba las anotaciones de anulabilidad de C# en las propiedades de los tipos de referencia. Por ejemplo, si tenemos una propiedad de tipo referencia que no puede ser nula como en el siguiente caso, podemos ver que el siguiente código deserializará sobre ella un valor nulo sin problema:
var myFriend = JsonSerializer.Deserialize<Friend>("""{ "Name": null }""");
Console.WriteLine(myFriend.Name ?? "Unknown"); // Muestra "Unknown"
// Friend tiene un campo "Name" que no puede ser nulo
public record Friend (string Name);
A partir de .NET 9, podemos indicar opcionalmente a System.Text.Json
que respete la anulabilidad de propiedades, es decir, si una propiedad no es anulable, al deserializarla no podremos establecerla al valor nulo.
Esto podemos conseguirlo simplemente estableciendo a true
la propiedad RespectNullableAnnotations
de las opciones de deserialización:
var opt = new JsonSerializerOptions() { RespectNullableAnnotations = true };
var myFriend = JsonSerializer.Deserialize<Friend>("""{ "Name": null }""", opt);
Console.WriteLine(myFriend.Name ?? "Unknown"); // Lanza una excepción
Según la documentación, otra posibilidad es hacerlo de forma global para todo el proyecto, añadiendo la siguiente configuración en el archivo .csproj
:
<ItemGroup>
<RuntimeHostConfigurationOption
Include="System.Text.Json.JsonSerializerOptions.RespectNullableAnnotations"
Value="true" />
</ItemGroup>
Supongo que, debido a que aún estamos jugando con versiones preview del compilador, esta última opción no conseguí que me funcionara.
Otra cosa que he visto curiosa es que el siguiente código, a mi entender, debería lanzar una excepción, pero no lo hace:
var opt = new JsonSerializerOptions() { RespectNullableAnnotations = true };
var myFriend = JsonSerializer.Deserialize<Friend>("{ }", opt);
Console.WriteLine(myFriend.Name ?? "Unknown"); // Muestra "Unknown"
En principio, si la propiedad Name
hemos dicho que no era anulable, ¿no debería lanzar una excepción al deserializar un objeto que no especifica su valor? Pues no, y parece ser que se trata de un comportamiento esperado; hay un interesante hilo al respecto en GitHub discutiendo el por qué de esta decisión, aunque en el siguiente apartado veremos que existe en algunos casos existe una forma alternativa de evitar esta situación.
Por último, la propiedad RespectNullableAnnotations
también afecta a la serialización, de forma que si una propiedad no anulable tiene un valor nulo al ser serializada, se lanzará una excepción:
var opt = new JsonSerializerOptions() { RespectNullableAnnotations = true };
var myFriend = new Friend(null);
Console.WriteLine(JsonSerializer.Serialize(myFriend, opt)); // Lanza una excepción
4. Comprobación de parámetros obligatorios del constructor
En .NET 8 y anteriores, los parámetros del constructor del tipo a deserializar eran tratados como siempre como opcionales. Por esta razón, el código siguiente no provoca errores:
var myFriend = JsonSerializer.Deserialize<Friend>("""{ }""");
Console.WriteLine(myFriend.Name ?? "Unknown"); // Muestra "Unknown"
public record Friend(string Name);
En .NET 9, es posible indicar al deserializador que queremos respetar los parámetros requeridos del constructor, de forma que si no pueden ser satisfechos se lanzará una excepción. Esto se consigue con el nuevo flag RespectRequiredConstructorParameters
en las opciones de deserialización:
var opt = new JsonSerializerOptions() { RespectRequiredConstructorParameters = true };
var myFriend = JsonSerializer.Deserialize<Friend>("""{ }""", opt);
Console.WriteLine(myFriend.Name ?? "Unknown"); // Lanza una excepción
La excepción, de tipo JsonException
es bastante clara en su texto de descripción: 'JSON deserialization for type 'Friend' was missing required properties including: 'Name'.'.
Como en el caso anterior, este comportamiento puede ser también configurado de forma global para todo el proyecto, añadiendo la siguiente configuración en el archivo .csproj
:
<ItemGroup>
<RuntimeHostConfigurationOption
Include="System.Text.Json.JsonSerializerOptions.RespectRequiredConstructorParameters"
Value="true" />
</ItemGroup>
Y como en el caso anterior, tampoco he conseguido que me funcione de esta forma 😆 Esperemos que las siguientes preview o la versión final lo solucionen.
5. Descripción de tipos .NET usando JSON Schema
La nueva clase estática JsonSchemaExporter
expone el método GetJsonSchemaAsNode()
, que permite obtener el esquema JSON de un tipo .NET en forma de objeto JsonNode
.
Su uso es muy sencillo, simplemente pasamos las opciones a utilizar para serializar el esquema, y el tipo del que queremos obtenerlo. Observad el siguiente ejemplo:
var schema = JsonSchemaExporter.GetJsonSchemaAsNode(
JsonSerializerOptions.Default,
typeof(Friend)
);
Console.WriteLine(schema);
public record Friend(string Name, string? nickName, int Age = 20);
El resultado que obtendremos es una descripción del tipo, siguiendo el estándar JSON Schema, que en este caso sería algo así:
{
"type": [
"object",
"null"
],
"properties": {
"Name": {
"type": "string"
},
"nickName": {
"type": [
"string",
"null"
]
},
"Age": {
"type": "integer",
"default": 20
}
},
"required": [
"Name",
"nickName"
]
}
Conclusión
En este artículo hemos visto algunas de las novedades que traerá la nueva versión de .NET 9 en la biblioteca de serialización JSON oficial, System.Text.Json
. Aunque no son cambios revolucionarios, sí que son mejoras que facilitarán la vida a los desarrolladores que trabajamos con JSON en nuestras aplicaciones.
¡Espero que os haya resultado interesante!
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
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.
La semana pasada veíamos algunas alternativas para comprobar de forma eficiente si una cadena de texto contiene un JSON válido y, al hilo de dicho post, el amigo Javier Campos aportó vía Twitter una fórmula adicional mejor que las que vimos aquí.
El código en cuestión es el siguiente:
public bool IsJsonWithReader(string maybeJson)
{
try
{
var reader = new Utf8JsonReader(Encoding.UTF8.GetBytes(maybeJson));
reader.Read();
reader.Skip();
return true;
}
catch
{
return false;
}
}
La estructura Utf8JsonReader ofrece una API de alto rendimiento para acceder en modo forward-only y read-only al JSON presente en una secuencia de bytes UTF8. Los métodos Read()
y Skip()
se encargan respectivamente de leer el primer token del JSON y saltarse todos sus hijos, con lo que en la práctica se recorrerá el documento completo, lanzándose una excepción en caso de ser inválido.
Al hilo del post Cómo recibir un JSON como string en una acción ASP.NET Core MVC, el amigo Alberto dejaba una interesante pregunta en los comentarios: ¿y si una vez hemos recibido el string
, queremos validar que sea un JSON válido?
Obviamente, una forma sencilla sería intentar deserializarlo por completo a la clase de destino, siempre que ésta sea conocida. Para ello podríamos utilizar el método Deserialize<T>()
del objeto JsonSerializer
de System.Text.Json
, disponible en todas las versiones modernas de .NET, de la siguiente manera: