Mnesia : la base de données intégrée à Erlang

    Dans le développement d'un prototype, on se pose souvent la question de comment faire persister ses données. On commencera souvent avec une solution telle que SQLite comme outil de stockage temporaire.

    La bibliothèque standard de Erlang (OTP) possède son système de base de données intégré : Mnesia. Dans cet article, nous allons découvrir ses caractéristiques et implémenter une petite application l'utilisant.

    Cet article est destiné aux lecteurs ayant une base en Erlang.

    Présentation de Mnesia

    Mnesia a été conçu dans les laboratoires de Ericsson durant le développement de Erlang. Ses deux
    auteurs principaux sont Claes Wikström (qui est aussi, entre autres, le développeur du serveur
    web Yaws et Håkan Mattsson.

    L'objectif de Mnesia était de fournir une base de données capable de s'intégrer dans des
    systèmes temps réels distribués et massivement concurrents avec des contraintes de haute
    disonibilité : les systèmes de télécomunications (raison pour laquelle Erlang a été développé).
    Elle a été écrite en pure Erlang et est intégrée dans sa distribution standard.

    L'intégration d'une base de données dans la bibliothèque standard du langage peut sembler
    original, cependant, elle permet de construire des systèmes complets, autonomnes, plus facile
    à développer mais aussi à déployer.

    Caractéristiques principales

    • Base de données clé-valeur ;
    • données distribuées et répliquées de manière transparente ;
    • persistance des données ;
    • reconfigurable à l'exécution ;
    • données organisées sous forme de table ;
    • stockage sur le disque ou en RAM par table;
    • indexation des données ;
    • support des transactions (ACID) ;
    • tolérance aux pannes (comme tout système Erlang classique) ;
    • peut stocker n'importe quel type de données Erlang ;
    • intègre un langage de requêtes.

    L'idéologie mise en avant par Mnesia est de fournir une base de données robuste, distribuée,
    transactionnelle et tolérante aux pannes.
    Comme Erlang est un langage dynamiquement typé, les champs d'une table Mnesia ne sont pas
    vérifié statiquement (et n'ont pas de type fixé). Chaque champ peut stocker n'importe quel
    terme Erlang. L'absence de contrainte de type peut être perçue comme une lacune, mais les raisons
    qui ont poussé les développeurs à ne pas mettre en place de vérification de types provient
    de la contrainte de temps réel. L'existence de traitements automatiques comme de la conversion
    ou encore de la suppression en cascade peut être problématique lorsque l'on tente d'approcher
    le temps réel.

    Ces caractéristiques rendent Mnesia peu conseillé pour certains usages :

    • la recherche clé-valeur simple (privilégier les Maps ou les dictionnaires) ;
    • le stockage de fichiers binaires très volumineux ;
    • les journaux persistants (Erlang fourni un module disk_log plus adéquat) ;
    • le stockage de plusieurs giga-octets ;
    • l'archivage de données dont le volume croît sans arrêt.

    Mnesia limite la taille des tables stockées sur le disque à 2 giga-octets sur architecture 32bits et 4 giga-octets sur architecture 64bits. La taille des tables
    stockées en RAM dépend de l'architecture d'exécution. Par contre, il est possible de composer
    des tables entre elles pour pallier à cette limite de taille.

    Mnesia n'est donc pas la base de données idéale pour lancer une application qui agrégera des
    millions d'utilisateurs, mais elle reste un outil agréable et plutôt simple à utiliser qui peut
    être une solution idéale pour le démarrage de petites et moyennes applications (et parfois de
    plus grosses applications, comme le démontrent les développeurs de
    Demonware).

    Pour se rendre compte de la facilité d'utilisation de Mnesia, faire un petit module est une
    bonne approche.

    Utilisation de Mnesia : implémentation d'une liste de tâches

    Nous allons implémenter un module Erlang qui nous permettra de manipuler
    une liste de tâche à exécuter (une TODO liste). Notre implémentation sera naïve et ne se
    focalisera pas sur des points particuliers comme la gestion des erreurs pour nous focaliser
    sur l'utilisation de Mnesia. Ce n'est pas vraiment un exercice original... on fait comme
    React, mais j'ai l'intime conviction que c'est un exercice suffisant pour comprendre les
    mécanismes primaires de Mnesia.

    Mnesia est une application OTP,
    ce qui implique qu'elle doit être démarrée pour être utilisable. Comme il a été dit dans
    la présentation, Mnesia peut se lancer en mode distribué, cependant, nous ne nous attarderons
    pas sur cet aspect dans cet article pour nous focaliser sur l'usage de la base de données. Pour
    démarrer Mnesia, il suffit de lancer dans un terminal Erlang la commande mnesia:start()..
    Si aucun schéma n'existe, Erlang en créera un, sinon Erlang rendra les données comprises sur
    le nœud accessible. Dans le cas d'une application distribuée, il aurait fallu créer le schéma
    à la main en référençant tous les nœuds concernés par la base de données. Vous pouvez
    maintenant terminer Mnesia en lançant dans le terminal la commande mnesia:stop().

    Il est très important de toujours bien terminer une session Mnesia, au moyen de
    mnesia:stop().
    pour qu'elle se place dans un état cohérent. Si la base de données n'est pas correctement
    arrêtée, la vérification de l'intégrité des données sera effectuée au prochain démarrage
    de la base de données.

    Création de tables

    Dans un module todo.erl, nous allons créer un record, qui sera la structure de notre
    table. Bien qu'il existe plusieurs manières de structurer une table Mnesia, le record semble
    être la plus élégante. Il sera possible de manipuler nos entités avec la syntaxe des records
    qui n'impose pas de devoir effectuer de la correspondance de motifs pour extraire les
    informations nécéssaires.

    %% Un module de manipulation de liste de tâche utilisant Mnesia
    %% le code est ... donné au domaine public, évidemment.
    
    -module(todo).
    -compile(export_all). 
    %% Normalement, il faut exporter les fonctions 
    %% une à une, cependant, pour ne pas devoir revenir 
    %% sur l'en-tête du module, j'ai mis en place cette 
    %% mauvaise pratique :'(
    
    %M Record représentant une tâche à réaliser
    -record(tasks, 
    	{
    	  id, 
    	  title, 
    	  state %% fini ou non
    	}).
    
    

    Pour créer une table, le module Mnesia expose une
    fonction très utile : mnesia:create_table(Nom, Options), où le nom est un atome et les
    options sont une liste de tuples ayant la forme {Propriété, Valeur}. Voici la liste des
    options que l'on peut donner à la création d'une table :

    • {disc_copies, Liste_des_noeuds} : la liste des noeuds sur lesquels vous souhaitez répliquer la table (en mémoire vive et sur le disque) ;
    • {disc_copies_only, Liste_des_noeuds} : comme pour l'entrée précédente mais uniquement pour le disque ;
    • {ram_copies, Liste_des_noeuds} : comme pour l'entrée précédente mais uniquement pour la mémoire vive. Cette option est définie par défaut à [node()], soit seulement sur le nœud local ;
    • {type, Type} : le type de la table, soit set (valeur par défaut), ordered_set ou bag, par défaut, cette valeur vaut set ;
    • {attributes, ListeDesChamps} : la liste des champs de la table (que l'on peut obtenir via la fonction record_info(fields, Record) ;
    • {index, ListeDesChampsIndex} : la liste des champs pouvant servir de clé secondaire, par défaut, l'index choisi est le premier champ (et il doit être unique sauf pour un bag).

    La procédure de création des tables ne doit avoir lieu qu'une seule fois. En effet, créer
    plusieurs fois la même table entrainera une erreur. Pour ça, il est courant de créer une
    fonction dans notre module qui ne sera appelée qu'une seule fois, au premier lancement du
    programme et qui se chargera de créer toutes nos tables.

    %% A ne lancer qu'une seule fois pour initialiser 
    %% la base de données
    database_initialize() ->
        mnesia:start(),
        %% Création de la table tasks 
        mnesia:create_table(
          tasks, 
          [
           %% On sauvegarde les données sur le nœud local
           {disc_copies, [node()]}, 
           %% on extrait les champs du record tasks
           {attributes, record_info(fields, tasks)}
          ]),
        io:format("La table a bien été créée, arrêt de mnesia ~n", []),
        mnesia:stop().
    

    Vous pouvez maintenant compiler votre module et lancer dans un terminal Erlang :
    todo:database_initialize().. Cette opération aura pour effet de créer la table "tasks".
    Dorénavant, quand vous lancerez votre terminal Erlang, vous pourrez directement démarrer Mnesia
    car nous l'utiliserons tout le temps.

    Les transactions avec Mnesia

    En général, toute requête est formulée sous forme transactionnelle. Il suffit d'emballer
    la modification de la base de données dans une fonction qui ne prend aucun argument et de
    la passer à la fonction mnesia:transaction :

    T = fun() ->
    	  %% Ici les transformations de la base de données
    	end,
    mnesia:transaction(T).
    

    Les opérations les plus courantes pour modifier la base de données sont :

    • mnesia:write(Record) : pour l'écriture d'un record en base de données ;
    • mnesia:read({Table, Clé}) : pour lire un record dans la base de données ;
    • mnesia:delete({Table, Clé}) : pour supprimer un record de la base de données ;
    • mnesia:index_read(Table, Valeur, NomDuChamp) : pour récupérer un record en fonction de sa clé secondaire.

    Cependant, je vous invite à lire la documentation du
    module Mnesia pour découvrir toutes les fonctionnalités
    qu'offre la base de données.

    Insérer une tâche

    Maintenant que notre table est créée, nous pouvons passer à l'insertion de données. On crée
    une fonction insert qui se chargera d'insérer des tâches dans la table tasks :

    %% Insertion d'une tâche
    insert(Id, Title) ->
        %% Création du record
        Task = 
    	#tasks{
    	   id    = Id, 
    	   title = Title,
    	   %% par défaut la tâche n'est pas finie
    	   state = false
    	  }, 
        %% Transaction
        Transaction = fun() -> mnesia:write(Task) end,
        %% Exécution de la transaction
        mnesia:transaction(Transaction).
    

    Les étapes sont assez compréhensibles :

    • on construit un record avec les données désirées ;
    • on crée une transaction ;
    • on exécute la transaction.

    Une fois votre module compilé, vous pouvez insérer des données dans votre table au moyen de
    la commande todo:insert(Key, "titre de la tâche"). dans un terminal Erlang.

    Il est possible d'inspecter les données en lancant dans le terminal Erlang la commande
    observer:start(). (depuis Erlang 17, avant il faut utiliser tv:start().), qui ouvre
    une fenêtre dans laquelle il est possible d'afficher les tables Mnesia ainsi que les
    records qu'elles contiennent (et même d'en ajouter, éditer, supprimer).

    Afficher la liste des tâches

    Nous allons aussi nous servir d'une transaction pour afficher la liste des tâches. Le code est
    assez similaire à celui de l'insertion :

    %% Affiche toutes les tâches
    print() ->
        %% Fonction pour afficher une tâche
        F = fun(Task, _) -> 
    		Id = Task#tasks.id, 
    		Title = Task#tasks.title,
    		Finish = 
    		    case Task#tasks.state of 
    			true -> "FINI"; 
    			_    -> "EN COURS"
    		    end, 
    		io:format("~w.) ~s [~s]~n", [Id, Title, Finish]) 
    	end,
        %% Transaction
        T = fun() -> mnesia:foldl(F, ok, tasks) end,
        %% Exécution de la transaction
        mnesia:transaction(T).
    

    Cette fois-ci, on utilise foldl dans la transaction pour itérer sur tous les enregistrements
    de la table. On utilise ok comme accumulateur par défaut car on ne se soucie pas de la valeur
    de retour de la fonction fold. Une fois votre code recompilé, l'usage de la commande
    todo:print(). dans un terminal Erlang affichera la liste des tâches.

    Modifier l'état d'une tâche

    Quand la mécanique de transaction est appréhendée, il ne reste plus beaucoup de difficultés :

    %% Modifie l'état d'une tâche
    change_state(Id, Flag) ->
        %% Transaction
        T = 
    	fun() ->
    		%% Lecture d'une tâche
    		[Task] = mnesia:read({tasks, Id}),
    		%% Modification d'un de ses champs
    		mnesia:write(Task#tasks{state=Flag})
    	end,
        %% Exécution de la transaction
        mnesia:transaction(T).
    
    %% Modifie une tâche
    reopen(Id) -> change_state(Id, false).
    close(Id) -> change_state(Id, true).
    

    Vous pouvez recompiler votre module est tester les deux fonctions todo:close(Id). et
    todo:reopen(Id). et ensuite afficher au moyen de todo:print(). dans un terminal Erlang
    pour vérifier le bon fonctionnement de vos requêtes.

    Le code complet du module todo

    %% Un module de manipulation de liste de tâche utilisant Mnesia
    %% le code est ... donné au domaine public, évidemment.
    
    -module(todo).
    -compile(export_all). 
    %% Normalement, il faut exporter les fonctions 
    %% une à une, cependant, pour ne pas devoir revenir 
    %% sur l'en-tête du module, j'ai mis en place cette 
    %% mauvaise pratique :'(
    
    %M Record représentant une tâche à réaliser
    -record(tasks, 
    	{
    	  id,   
    	  title, 
    	  state %% fini ou non
    	}).
    
    %% A ne lancer qu'une seule fois pour initialiser 
    %% la base de données
    database_initialize() ->
        mnesia:start(),
        %% Création de la table tasks 
        mnesia:create_table(
          tasks, 
          [
           %% On sauvegarde les données sur le nœud local
           {disc_copies, [node()]}, 
           %% on extrait les champs du record tasks
           {attributes, record_info(fields, tasks)}
          ]),
        io:format("La table a bien été créée, arrêt de mnesia ~n", []),
        mnesia:stop().
    
    
    %% Insertion d'une tâche
    insert(Id, Title) ->
        %% Création du record
        Task = 
    	#tasks{
    	   id    = Id, 
    	   title = Title,
    	   %% par défaut la tâche n'est pas finie
    	   state = false
    	  }, 
        %% Transaction
        Transaction = fun() -> mnesia:write(Task) end,
        %% Exécution de la transaction
        mnesia:transaction(Transaction).
    
    
    %% Affiche toutes les tâches
    print() ->
        %% Fonction pour afficher une tâche
        F = fun(Task, _) -> 
    		Id = Task#tasks.id, 
    		Title = Task#tasks.title,
    		Finish = 
    		    case Task#tasks.state of 
    			true -> "FINI"; 
    			_    -> "EN COURS"
    		    end, 
    		io:format("~w.) ~s [~s]~n", [Id, Title, Finish]) 
    	end,
        %% Transaction
        T = fun() -> mnesia:foldl(F, ok, tasks) end,
        %% Exécution de la transaction
        mnesia:transaction(T).
    
    
    %% Modifie l'état d'une tâche
    change_state(Id, Flag) ->
        %% Transaction
        T = 
    	fun() ->
    		%% Lecture d'une tâche
    		[Task] = mnesia:read({tasks, Id}),
    		%% Modification d'un de ses champs
    		mnesia:write(Task#tasks{state=Flag})
    	end,
        %% Exécution de la transaction
        mnesia:transaction(T).
    
    %% Modifie une tâche
    reopen(Id) -> change_state(Id, false).
    close(Id) -> change_state(Id, true).
    
    

    Plus loin dans les requêtes

    Il existe des outils de requêtage plus puissants que ceux qui n'utilisent que les clés primaires
    et secondaires. Par exemple :

    • mnesia:match_object(Record) : qui permet de filtrer la liste des records avec un record de référence ;
    • mnesia:select : qui permet de composer dynamiquement des contraintes de correspondance (à la manière de Scanf) ;
    • Mnemosyne : un langage de requête qui doit être démarré comme une application mais qui n'est plus vraiment utilisé ;
    • Des requêtes dites "dirty", qui s'affranchissent du modèle de transaction et qui peuvent potentiellement échouer. Leur usage est déconseillé.

    QLC

    Mnesia offre un mécanisme de requête plus complexe et plus proche du SQL qui repose sur la
    syntaxe des listes-compréhension. Cet outil se trouve dans la bibliothèque QLC, il est possible d'implémenter la
    syntaxe QLC
    pour n'importe quelle structure de données itérable. Voici quelques correspondances avec le SQL :

    SELECT * FROM tasks 
    qlc:q([ X || X <- mnesia:table(tasks)])
    
    
    SELECT id, title FROM tasks
    qlc:q([ {X#tasks.id, X#tasks.title} || X <- mnesia:table(tasks)])
    
    SELECT * FROM tasks WHERE id > 10
    qlc:q([ X || X <- mnesia:table(tasks), X#tasks.id > 10])
    
    SELECT t1.id, t2.id FROM t1, t2 WHERE t1.name = t2.name
    qlc:q([ {X#t1.id, Y#t2.id} || X <- mnesia:table(t1), Y <- mnesia:table(t2), X == Y])
    ```
    
    Cette syntaxe permet de représenter des prédicats plus complexes, des jointures, et optimise la 
    compilation des requêtes pour éviter de multiplier le nombre d'itérations.
    
    [En savoir plus sur QLC](http://erlang.org/doc/man/qlc.html)
    
    ## Conclusion
    
    Nous avons survolé comment utiliser normalement Mnesia. Nous avons pu voir que son déploiement
    dans un environnement dôté d'Erlang est très simple. Son mécanisme transactionnel permet de 
    faire aboutir ses requêtes, y compris en cas d'accès multiples à des ressources.  
    Mnesia fait partie des outils qui rendent le développement en Erlang très agréable. Le fait 
    que le développeur ne manipule que des termes Erlang n'impose pas de conversion de types et 
    permet d'étendre assez facilement un schéma existant.
    
    Mnesia est très utilisé dans le développement d'applications web (de taille raisonnable), via 
    la pile technologique **LYME** pour : 
    
    -  Linux ; 
    -  [Yaws](http://yaws.hyber.org)
    -  Mnesia
    -  Erlang.
    
    Cette approche de la conception d'application web est assez simple et demande peu de 
    génération de code. Ce sera le sujet d'un prochain article.
    

    Xavier Van de Woestyne

    Lire d'autres articles par cet auteur.