Records C# 9 comme ids fortement typés - Partie 3 : sérialisation JSON
Dans le précédent article de cette série, on avait remarqué un petit souci qu'on avait laissé de côté : les ids fortement typés étaient sérialisés de façon quelque peu étrange :
{ "id": { "value": 1 }, "name": "Apple", "unitPrice": 0.8 }
Quand on y réfléchit un peu, ce n'est pas vraiment étonnant : nos ids fortement typés sont des objets "complexes", pas des types primitifs, il est donc normal qu'ils soient sérialisés comme des objets. Mais évidemment, ce n'est pas ce qu'on veut… Voyons comment régler ça.
Avec System.Text.Json
Dans les versions récentes d'ASP.NET Core (à partir de 3.0 il me semble), le sérialiseur JSON par défaut est System.Text.Json, je vais donc commencer par là.
Pour contrôler la sérialisation JSON d'un type, il faut créer un JsonConverter
. En l'occurrence on veut que ce soit juste la valeur de l'id fortement typé qui soit sérialisée, pas l'objet entier. Ça donne quelque chose comme ça :
public class StronglyTypedIdJsonConverter<TStronglyTypedId, TValue> : JsonConverter<TStronglyTypedId> where TStronglyTypedId : StronglyTypedId<TValue> where TValue : notnull { public override TStronglyTypedId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { if (reader.TokenType is JsonTokenType.Null) return null; var value = JsonSerializer.Deserialize<TValue>(ref reader, options); var factory = StronglyTypedIdHelper.GetFactory<TValue>(typeToConvert); return (TStronglyTypedId)factory(value); } public override void Write(Utf8JsonWriter writer, TStronglyTypedId value, JsonSerializerOptions options) { if (value is null) writer.WriteNullValue(); else JsonSerializer.Serialize(writer, value.Value, options); } }
La logique de ce convertisseur est assez simple :
- Pour la désérialisation, on lit la valeur (qui est de type
int
dans le cas deProductId
), et on crée une instance de l'id fortement typé (ProductId
) avec cette valeur. - Pour la sérialisation, on écrit juste la valeur de l'id fortement typé (sa propriété
Value
)
Pour utiliser le convertisseur, on l'ajoute dans les paramètres du sérialiseur JSON, comme ceci :
services.AddControllers() .AddJsonOptions(options => { options.JsonSerializerOptions.Converters.Add( new StronglyTypedIdJsonConverter<ProductId, int>()); });
Et on obtient bien le résultat voulu :
{ "id": 1, "name": "Apple", "unitPrice": 0.8 }
Super, ça fonctionne ! Mais… ce n'est quand même pas très satisfaisant. Pourquoi ? Parce qu'on a ajouté au sérialiseur un convertisseur qui est spécifique à ProductId
! Pour utiliser d'autres types d'ids fortement typés, il faudra ajouter un convertisseur pour chacun. Certes, il est générique, pas besoin de le réécrire entièrement à chaque fois, mais c'est quand même ennuyeux… Ce serait mieux de pouvoir ajouter un seul convertisseur qui gère tous les ids fortement typés.
On pourrait sans doute réécrire le convertisseur sous une forme non spécifique à un type donné, mais le résultat serait sans doute assez moche, avec beaucoup de réflexion, etc. Heureusement, il y a une meilleure solution : créer une JsonConverterFactory
. Comme son nom l'indique, c'est une classe qui crée à la demande des instances de convertisseur JSON. Voilà à quoi ça ressemble :
public class StronglyTypedIdJsonConverterFactory : JsonConverterFactory { private static readonly ConcurrentDictionary<Type, JsonConverter> Cache = new(); public override bool CanConvert(Type typeToConvert) { return StronglyTypedIdHelper.IsStronglyTypedId(typeToConvert); } public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) { return Cache.GetOrAdd(typeToConvert, CreateConverter); } private static JsonConverter CreateConverter(Type typeToConvert) { if (!StronglyTypedIdHelper.IsStronglyTypedId(typeToConvert, out var valueType)) throw new InvalidOperationException($"Cannot create converter for '{typeToConvert}'"); var type = typeof(StronglyTypedIdJsonConverter<,>).MakeGenericType(typeToConvert, valueType); return (JsonConverter)Activator.CreateInstance(type); } }
Là encore, il n'y a rien de très compliqué. On regarde le type à convertir, on vérifie s'il s'agit bien d'un id fortement typé, et on crée une instance du convertisseur générique pour ce type. On ajoute juste un peu de cache pour éviter de faire le travail de réflexion à chaque appel, et l'affaire est dans le sac!
Au niveau des paramètres du sérialiseur JSON, au lieu d'ajouter un convertisseur spécifique, on ajoute la factory :
services.AddControllers() .AddJsonOptions(options => { options.JsonSerializerOptions.Converters.Add( new StronglyTypedIdJsonConverterFactory()); });
Et voilà ! Notre convertisseur s'applique maintenant à tous les ids fortement typés.
Avec Newtonsoft.Json
Si votre projet utilise Newtonsoft.Json, bonne nouvelle : vous n'avez rien à faire, ça fonctionne déjà ! Enfin, presque…
Quand il sérialise une valeur, Newtonsoft.Json cherche un JsonConverter
compatible avec le type de cette valeur, et s'il n'en trouve pas, il cherche un TypeConverter
associé au type. Si ce TypeConverter
existe et qu'il est capable de convertir l'objet en string
, il est utilisé pour sérialiser la valeur sous forme de chaîne de caractères. Puisqu'on avait défini un TypeConverter
pour nos ids fortement typés dans l'article précédent, Newtonsoft.Json le trouve et l'utilise, ce qui donne ça :
{ "id": "1", "name": "Apple", "unitPrice": 0.8 }
C'est presque correct… sauf qu'on voudrait que l'id soit sérialisé comme un nombre, pas comme une chaîne de caractères. Si la valeur de l'id était de type GUID ou string, ce ne serait pas gênant (puisque ces types sont de toute façon sérialisés sous forme de chaînes de caractères). Pour un nombre, ça pourrait à la rigueur être acceptable dans certains scénarios (si on considère que l'id est une valeur opaque pour le client, peu importe qu'il apparaisse comme une string). Mais si on veut vraiment qu'il soit sérialisé comme un nombre, il va falloir là aussi écrire un convertisseur personnalisé.
Celui-ci ressemble beaucoup à celui qu'on a écrit pour System.Text.Json, avec quelques différences d'API. Par exemple, Newtonsoft.Json n'a pas la notion de JsonConverterFactory
, donc il faut ruser : à la place, on va écrire un convertisseur non-générique, qui va créer une instance du convertisseur générique voulu, et déléguer la conversion à ce dernier :
public class StronglyTypedIdNewtonsoftJsonConverter : JsonConverter { private static readonly ConcurrentDictionary<Type, JsonConverter> Cache = new(); public override bool CanConvert(Type objectType) { return StronglyTypedIdHelper.IsStronglyTypedId(objectType); } public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) { var converter = GetConverter(objectType); return converter.ReadJson(reader, objectType, existingValue, serializer); } public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) { if (value is null) { writer.WriteNull(); } else { var converter = GetConverter(value.GetType()); converter.WriteJson(writer, value, serializer); } } private static JsonConverter GetConverter(Type objectType) { return Cache.GetOrAdd(objectType, CreateConverter); } private static JsonConverter CreateConverter(Type objectType) { if (!StronglyTypedIdHelper.IsStronglyTypedId(objectType, out var valueType)) throw new InvalidOperationException($"Cannot create converter for '{objectType}'"); var type = typeof(StronglyTypedIdNewtonsoftJsonConverter<,>).MakeGenericType(objectType, valueType); return (JsonConverter)Activator.CreateInstance(type); } } public class StronglyTypedIdNewtonsoftJsonConverter<TStronglyTypedId, TValue> : JsonConverter<TStronglyTypedId> where TStronglyTypedId : StronglyTypedId<TValue> where TValue : notnull { public override TStronglyTypedId ReadJson(JsonReader reader, Type objectType, TStronglyTypedId existingValue, bool hasExistingValue, JsonSerializer serializer) { if (reader.TokenType is JsonToken.Null) return null; var value = serializer.Deserialize<TValue>(reader); var factory = StronglyTypedIdHelper.GetFactory<TValue>(objectType); return (TStronglyTypedId)factory(value); } public override void WriteJson(JsonWriter writer, TStronglyTypedId value, JsonSerializer serializer) { if (value is null) writer.WriteNull(); else writer.WriteValue(value.Value); } }
Comme vous le voyez, c'est très similaire à ce qu'on avait fait avant, je ne vais donc pas expliquer ce code en détail.
On configure maintenant le sérialiseur comme ceci :
services.AddControllers() .AddNewtonsoftJson(options => { options.SerializerSettings.Converters.Add( new StronglyTypedIdNewtonsoftJsonConverter()); });
Et on obtient bien le résultat voulu :
{ "id": 1, "name": "Apple", "unitPrice": 0.8 }
Conclusion
Dans cet article, on a vu comment faire pour sérialiser correctement des ids fortement typés en JSON.
On avance, mais il reste encore quelques petites choses à régler pour que nos ids fortement typés soient réellement utilisables. La prochaine fois, on verra comment les intégrer à Entity Framework Core !
Commentaires