xavier-van-de-woestyne-initial
Xavier Van de Woestyne
Functional Programmer

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

Architecture, Erlang, Programmation fonctionnelle26/10/2016

Présentation de Mnesia, la base de données intégrée à OTP, le framework de développement standard Erlang. Cet article présente brièvement comment l'utiliser.

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

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
  • 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.