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.