Loupe

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

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

Azure Cosmos DB est la base de données NoSQL dans le cloud de Microsoft. Dans Cosmos DB, on stocke des documents JSON dans des conteneurs. Cela rend très facile la modélisation des données, car il n'est pas nécessaire de séparer les objets complexes en plusieurs tables et d'utiliser des jointures comme dans une base de données relationnelle. On sérialise simplement un graphe d'objets C# complet en JSON, et on l'enregistre dans la base de données. Le SDK .NET pour Cosmos DB se charge de la sérialisation, et permet de requêter la base de données de façon fortement typée à l'aide de Linq :

using var client = new CosmosClient(connectionString);
var database = client.GetDatabase(databaseId);
var container = database.GetContainer("Pets");

var pet = new Pet { Id = "max-0001", Name = "Max", Species = "Dog" };
await container.CreateItemAsync(pet);

…

var dogsQuery = container.GetItemLinqQueryable<Pet>()
    .Where(p => p.Species == "Dog");

var iterator = dogsQuery.ToFeedIterator();
while (iterator.HasMoreResults)
{
    var dogs = await iterator.ReadNextAsync();
    foreach (var dog in dogs)
    {
        Console.WriteLine($"{dog.Id}\t{dog.Name}\t{dog.Species}");
    }
}

Cependant, il y a un petit souci… De base, le SDK .NET ne sait pas gérer les hiérarchies de classes. Si on a une classe de base abstraite avec des classes dérivées, et qu'on stocke des instances de ces classes dans Cosmos DB, le SDK ne saura pas les désérialiser, et on aura une exception disant qu'il n'est pas possible de créer une instance d'une classe abstraite…

En fait, le problème n'est pas dans le SDK proprement dit, mais dans JSON.NET, qui est utilisé comme sérialiseur par défaut par le SDK. Donc, avant de pouvoir régler le problème pour Cosmos DB, il faut d'abord le régler pour JSON.NET ; on verra plus tard comment intégrer la solution dans le SDK Cosmos DB.

Une simple hiérarchie de classes

Prenons un exemple concret : un modèle objet (très simple) pour représenter un système de fichiers. On a deux classes concrètes, FileItem et FolderItem, qui héritent toutes les deux d'une même classe abstraite, FileSystemItem. Voici le code :

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

public class FileItem : FileSystemItem
{
    public long Size { get; set; }
}

public class FolderItem : FileSystemItem
{
    public int ChildrenCount { get; set; }
}

Dans un scénario réel, on aurait probablement un modèle un peu plus riche, mais gardons les choses simples pour cette démonstration.

Si on crée un tableau avec un FileItem et un FolderItem et qu'on le sérialise en JSON…

var items = new FileSystemItem[]
{
    new FolderItem
    {
        Id = "1",
        Name = "foo",
        ChildrenCount = 1
    },
    new FileItem
    {
        Id = "2",
        Name = "test.txt",
        ParentId = "1",
        Size = 42
    }
};
string json = JsonConvert.SerializeObject(items, Formatting.Indented);

… on remarque que le JSON ne contient aucune information sur le type des objets :

[
  {
    "ChildrenCount": 1,
    "id": "1",
    "Name": "foo",
    "ParentId": null
  },
  {
    "Size": 42,
    "id": "2",
    "Name": "test.txt",
    "ParentId": "1"
  }
]

Si l'information n'est pas disponible pour la désérialisation, on ne peut pas vraiment reprocher à JSON.NET de ne pas pouvoir deviner le type des objets. Il faut juste l'aider un peu !

TypeNameHandling

Un moyen de résoudre ce problème est d'utiliser une fonctionnalité de JSON.NET : TypeNameHandling. En gros, on configure JSON.NET pour qu'il inclue le nom du type dans les objets sérialisés, comme ceci :

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

Et on obtient des objets JSON annotés avec le nom complet du type et l'assembly :

[
  {
    "$type": "CosmosTypeHierarchy.FolderItem, CosmosTypeHierarchy",
    "id": "1",
    "Name": "foo",
    "ParentId": null
  },
  {
    "$type": "CosmosTypeHierarchy.FileItem, CosmosTypeHierarchy",
    "Size": 42,
    "id": "2",
    "Name": "test.txt",
    "ParentId": "1"
  }
]

Pas mal ! Avec cette information, JSON.NET peut maintenant désérialiser les objets correctement :

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

Mais il y a quand même un souci : si on inclut le nom réel du type .NET dans les documents JSON, que va-t-il se passer quand on décidera de renommer une classe, ou de la déplacer vers un autre namespace ou assembly ? Eh bien les documents existants ne pourront plus être désérialisés ! Dommage…

Si on pouvait contrôler le nom de type inclus dans le document JSON, cela réglerait le problème. Eh bien bonne nouvelle : on peut !

Serialization binder

Il faut juste implémenter notre propre ISerializationBinder, qui va faire la traduction entre les noms de type en JSON et les types .NET réels :

class CustomSerializationBinder : ISerializationBinder
{
    public void BindToName(Type serializedType, out string assemblyName, out string typeName)
    {
        if (serializedType == typeof(FileItem))
        {
            assemblyName = null;
            typeName = "fileItem";
        }
        else if (serializedType == typeof(FolderItem))
        {
            assemblyName = null;
            typeName = "folderItem";
        }
        else
        {
            // Mimic the default behavior
            assemblyName = serializedType.Assembly.GetName().Name;
            typeName = serializedType.FullName;
        }
    }

    public Type BindToType(string assemblyName, string typeName)
    {
        if (string.IsNullOrEmpty(assemblyName))
        {
            if (typeName == "fileItem")
                return typeof(FileItem);
            if (typeName == "folderItem")
                return typeof(FolderItem);
        }

        // Mimic the default behavior
        var assemblyQualifiedName = typeName;
        if (!string.IsNullOrEmpty(assemblyName))
            assemblyQualifiedName += ", " + assemblyName;
        return Type.GetType(assemblyQualifiedName);
    }
}

…

var settings = new JsonSerializerSettings
{
    TypeNameHandling = TypeNameHandling.Objects,
    SerializationBinder = new CustomSerializationBinder()
};
string json = JsonConvert.SerializeObject(items, Formatting.Indented, settings);

Cela nous donne le JSON suivant :

[
  {
    "$type": "folderItem",
    "ChildrenCount": 1,
    "id": "1",
    "Name": "foo",
    "ParentId": null
  },
  {
    "$type": "fileItem",
    "Size": 42,
    "id": "2",
    "Name": "test.txt",
    "ParentId": "1"
  }
]

C'est plus compact, et plus flexible. Bien sûr, maintenant il faut continuer à utiliser les mêmes noms JSON pour ces types, mais c'est nettement moins gênant que de ne pas pouvoir renommer ou déplacer les classes.

Dans l'ensemble, c'est une approche assez robuste. Et si vous ne voulez pas modifier le binder pour ajouter le mapping de chaque type, vous pouvez toujours implémenter une approche dynamique à base d'attributs et de réflexion pour le faire automatiquement.

Il y a quand même une chose qui m'ennuie : en utilisant TypeNameHandling.Objects, tous les objets seront annotés avec leur type, y compris les objets imbriqués, même quand ce n'est pas nécessaire. Par exemple, si une classe en particulier est sealed (ou du moins si elle n'a aucun type dérivé), inclure le nom du type dans le document est superflu et ajoute juste du bruit. Il existe une autre option qui fait presque ce qu'il faut : TypeNameHandling.Auto. Avec cette option, JSON.NET écrit le type seulement s'il ne peut pas être deviné d'après le contexte, c'est-à-dire si le type réel de l'objet est différent du type connu statiquement. C'est presque parfait, à un détail près : cela n'inclut pas le type pour l'objet racine, sauf si le type connu statiquement est spécifié explicitement (par exemple JsonConvert.Serialize(new FileItem(), typeof(FileSystemItem))), ce qui n'est pas très pratique et qu'on a vite fait d'oublier. L'idéal serait une autre option pour inclure le type pour l'objet racine. Je l'ai suggéré sur GitHub, votez si vous voulez aussi cette option !

En attendant, il y a un autre moyen d'arriver au résultat voulu : un convertisseur sur mesure. Mais cet article est déjà assez long comme ça, donc on verra ça, ainsi que l'intégration avec le SDK Cosmos DB, dans le prochain article (dispo le 04/11/19).

Photo de profil

Ces billets pourraient aussi vous intéresser

Vous nous direz ?!

Commentaires

comments powered by Disqus