Loupe

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 de ProductId), 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 !

Photo de profil

Ces billets pourraient aussi vous intéresser

Vous nous direz ?!

Commentaires

comments powered by Disqus