Loupe

Gérer des hiérarchies de classes dans Cosmos DB (partie 2)

Ceci est le second article d'une série de deux :

Dans l'article précédent, j'ai parlé des difficultés qu'on peut rencontrer pour gérer des hiérarchies de classes dans Cosmos DB, montré que le problème était en fait au niveau du sérialiseur JSON, et proposé une solution utilisant la fonctionnalité TypeNameHandling de JSON.NET. Dans cet article, je vais présenter une autre approche à base d'un convertisseur sur mesure, et montrer comment intégrer la solution au SDK .NET pour Cosmos DB.

Convertisseur JSON sur mesure

Avec JSON.NET, il est possible de créer nos propres convertisseurs pour spécifier comment sérialiser et désérialiser des types spécifiques. Voyons comment appliquer cela à notre problème.

Pour commencer, ajoutons une propriété abstraite Type à la classe de base de notre modèle objet, et implémentons-la dans les classes dérivées :

public abstract class FileSystemItem
{
    [JsonProperty("id")]
    public string Id { get; set; }
    [JsonProperty("$type")]
    public abstract string Type { get; }
    public string Name { get; set; }
    public string ParentId { get; set; }
}

public class FileItem : FileSystemItem
{
    public override string Type => "fileItem";
    public long Size { get; set; }
}

public class FolderItem : FileSystemItem
{
    public override string Type => "folderItem";
    public int ChildrenCount { get; set; }
}

Il n'y a rien de spécial à faire pour la sérialisation, JSON.NET prendra automatiquement en compte la propriété Type. Par contre, nous allons avoir besoin d'un convertisseur pour gérer la désérialisation quand le type cible est la classe abstraite FileSystemItem. Le voici :

class FileSystemItemJsonConverter : JsonConverter
{
    // This converter handles only deserialization, not serialization.
    public override bool CanRead => true;
    public override bool CanWrite => false;

    public override bool CanConvert(Type objectType)
    {
        // Only if the target type is the abstract base class
        return objectType == typeof(FileSystemItem);
    }

    public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
    {
        // First, just read the JSON as a JObject
        var obj = JObject.Load(reader);
        
        // Then look at the $type property:
        var typeName = obj["$type"]?.Value<string>();
        switch (typeName)
        {
            case "fileItem":
                // Deserialize as a FileItem
                return obj.ToObject<FileItem>(serializer);
            case "folderItem":
                // Deserialize as a FolderItem
                return obj.ToObject<FolderItem>(serializer);
            default:
                throw new InvalidOperationException($"Unknown type name '{typeName}'");
        }
    }

    public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
    {
        throw new NotSupportedException("This converter handles only deserialization, not serialization.");
    }
}

Et voici comment on utilise ce convertisseur :

var settings = new JsonSerializerSettings
{
    Converters =
    {
        new FileSystemItemJsonConverter()
    }
};
string json = JsonConvert.SerializeObject(items, Formatting.Indented, settings);

…

var deserializedItems = JsonConvert.DeserializeObject<FileSystemItem[]>(json, settings);

Cela nous donne le même résultat qu'avec le serialization binder, sauf qu'on contrôle quels types sont sérialisés avec la propriété $type.

Ce convertisseur est spécifique à FileSystemItem, mais il est bien sûr possible d'en faire un plus générique en utilisant la réflexion.

Intégration avec le SDK Cosmos DB

Nous avons maintenant deux moyens de sérialiser et désérialiser des hiérarchies de classes en JSON. À mon avis, celle basée sur TypeNameHandling est soit trop verbeuse quand on utilise TypeNameHandling.Objects, soit un peu risquée quand on utilise TypeNameHandling.Auto, car il est facile d'oublier de spécifier le type racine et de se retrouver avec des documents JSON sans $type à la racine. Je m'en tiendrai donc à la solution basée sur un convertisseur, du moins jusqu'à ce que ma suggestion pour JSON.NET soit implémentée.

Voyons maintenant comment intégrer cela avec le SDK .NET Cosmos DB.

Si vous utilisez encore le SDK 2.x, c'est trivial : passez simplement l'objet JsonSerializerSettings avec le convertisseur au constructeur de DocumentClient (mais vous devriez vraiment envisager de passer au SDK 3.x, qui est à mon avis beaucoup plus agréable à utiliser).

Avec le SDK 3.x, ça demande un peu plus de travail. Le sérialiseur par défaut est basé sur JSON.NET, donc ça devrait être simple de passer son propre objet JsonSerializerSettings… Mais malheureusement, la classe n'est pas publique, donc on ne peut pas l'instancier soi-même. Tout ce qu'on peut faire est spécifier un objet CosmosSerializationOptions qui sera passé au sérialiseur par défaut, et ces options n'exposent qu'une toute petite partie de ce qu'il est possible de faire avec JSON.NET. L'alternative est d'implémenter notre propre sérialiseur Cosmos, également basé sur JSON.NET.

Pour cela, il faut hériter de la classe abstraite CosmosSerializer :

public abstract class CosmosSerializer
{
    public abstract T FromStream<T>(Stream stream);
    public abstract Stream ToStream<T>(T input);
}

FromStream accepte un flux et lit un objet du type spécifié à partir de ce flux. ToStream accepte un objet, l'écrit sur un flux et retourne ce flux.

Parenthèse : Pour être franc, je ne pense pas que ce soit une très bonne abstraction… Renvoyer un flux est étrange, il serait plus naturel de recevoir un flux sur lequel on écrit. Telle que l'API est conçue, on est obligé de créer un nouveau MemoryStream pour chaque objet à sérialiser, et les données seront ensuite copiées depuis ce flux vers le document. Ce n'est pas très efficace… De plus, il faut disposer le flux reçu dans FromStream, ce qui est très inhabituel (on est généralement pas responsable de libérer un objet qu'on n'a pas créé) ; cela implique aussi que le SDK crée un nouveau flux pour chaque document à lire, ce qui, encore une fois, est inefficace. Enfin… Il est un peu tard pour changer l'API en v3 (ce serait un breaking change), mais peut-être en v4?

Heureusement, on n'a pas besoin de réinventer la roue : on peut simplement copier le code depuis l'implémentation par défaut, et l'adapter à nos besoins. Voilà ce que ça donne :

public class NewtonsoftJsonCosmosSerializer : CosmosSerializer
{
    private static readonly Encoding DefaultEncoding = new UTF8Encoding(false, true);

    private readonly JsonSerializer _serializer;

    public NewtonsoftJsonCosmosSerializer(JsonSerializerSettings settings)
    {
        _serializer = JsonSerializer.Create(settings);
    }

    public override T FromStream<T>(Stream stream)
    {
        string text;
        using (var reader = new StreamReader(stream))
        {
            text = reader.ReadToEnd();
        }

        if (typeof(Stream).IsAssignableFrom(typeof(T)))
        {
            return (T)(object)stream;
        }

        using (var sr = new StringReader(text))
        {
            using (var jsonTextReader = new JsonTextReader(sr))
            {
                return _serializer.Deserialize<T>(jsonTextReader);
            }
        }
    }

    public override Stream ToStream<T>(T input)
    {
        var streamPayload = new MemoryStream();
        using (var streamWriter = new StreamWriter(streamPayload, encoding: DefaultEncoding, bufferSize: 1024, leaveOpen: true))
        {
            using (JsonWriter writer = new JsonTextWriter(streamWriter))
            {
                writer.Formatting = _serializer.Formatting;
                _serializer.Serialize(writer, input);
                writer.Flush();
                streamWriter.Flush();
            }
        }

        streamPayload.Position = 0;
        return streamPayload;
    }
}

On a maintenant un sérialiseur pour lequel on peut spécifier les paramètres JSON.NET. Pour l'utiliser, il suffit de le spécifier quand on crée le CosmosClient :

var serializerSettings = new JsonSerializerSettings
{
    Converters =
    {
        new FileSystemItemJsonConverter()
    }
};
var clientOptions = new CosmosClientOptions
{
    Serializer = new NewtonsoftJsonCosmosSerializer(serializerSettings)
};
var client = new CosmosClient(connectionString, clientOptions);

Et c'est tout ! On peut maintenant requêter notre collection de FileItem et FolderItem, en les désérialisant vers le type approprié :

var query = container.GetItemLinqQueryable<FileSystemItem>();
var iterator = query.ToFeedIterator();
while (iterator.HasMoreResults)
{
    var items = await iterator.ReadNextAsync();
    foreach (var item in items)
    {
        var description = item switch
        {
            FileItem file =>
                $"File {file.Name} (id {file.Id}) has a size of {file.Size} bytes",
            FolderItem folder =>
                $"Folder {folder.Name} (id {folder.Id}) has {folder.ChildrenCount} children",
            _ =>
                $"Item {item.Name} (id {item.Id}) is of type {item.GetType()}... I don't know what that is."
        };
        Console.WriteLine(description);
    }
}

Il est possible que de meilleures solutions existent. Si vous utilisez Entity Framework Core 3.0, qui supporte Cosmos DB, ce scénario semble être supporté, mais je n'ai pas réussi à le faire fonctionner pour l'instant. En attendant, la solution présentée ici fonctionne très bien pour moi, et j'espère qu'elle vous servira aussi !

Photo de profil

Ces billets pourraient aussi vous intéresser

Vous nous direz ?!

Commentaires

comments powered by Disqus