Dans cet article, nous allons aborder une méthode originale pour stocker des données de manière persistante, en utilisant les types natifs des langages concernés, sans devoir écrire un parser nous-mêmes.
Le « stockage persistant » veut dire que nous allons enregistrer des données pour notre application d'une exécution sur l'autre, et nous les consulterons - et éventuellement les mettrons à jour - à chaque lancement de celle-ci.
« En utilisant les types natifs » signifie que l'on manipulera directement des entiers, des listes, des couples ou d'autres valeurs, sans devoir les convertir en chaîne/depuis une chaîne manuellement.
Interéssés ? Bonne lecture !
Cet article m'a été inspiré par celui traitant de la configuration des applications erlang paru sur spawn_link. L'article en question est disponible ici.
Un problème : la configuration
Pratiquement toutes les applications peuvent être configurées par les utilisateurs : ceux-ci peuvent définir leurs préférences, qui seront enregistrées, puis utilisées à chaque démarrage de l'application pour s'adapter à l'utilisateur.
Un des moyens d'enregistrer la configuration est de la définir à l'intérieur même du code source du programme, mais cela implique que les utilisateurs doivent recompiler1 l'application à chaque fois qu'ils désirent changer leurs préférences. C'est la méthode utilisée par le WM (Window Manager) dwm, par exemple, où on édite un fichier C (config.h) pour le reconfigurer.
Pour la suite de cet article, nous allons prendre l'exemple d'un client IRC : pour fonctionner, il doit savoir à quel serveur se connecter, sur quel port et avec quel pseudo. Dans notre cas, devoir recompiler l'application à chaque fois que l'on souhaite se connecter à un nouveau serveur serait un poil contraignant, n'est-ce pas ?
Une solution à ce problème serait de demander à chaque fois ces données à l'utilisateur lorsqu'il lance le programme, mais cela reste toutefois assez lourd. En effet, même s'il arrive de changer de serveur ou de pseudo, on ne le fait pas non plus tous les jours, et taper le même serveur/pseudo à chaque connexion finira par devenir ennuyeux.
La troisième solution est l'utilisation de ce qu'on appelle des fichiers de configuration. Le principe est simple : l'utilisateur rentre une première fois ses préférences, puis on les sauvegarde dans un fichier. Par la suite, on retourne dans ce fichier chaque fois qu'on a besoin d'accéder à ces informations.
Cette solution a le mérite de laisser à l'utilisateur la liberté de modifier ces données sans toutefois l'obliger à les ressaisir à chaque utilisation du programme.
1 : À noter que cela ne vaut que pour les langages compilés, pour ce qui est des langages interprétés on peut toujours trouver les sources quelque part si on est motivé et les retoucher.
Mise en place pratique
En pratique, le problème qui se pose est celui du format de fichier. En d'autres termes, comment allons-nous organiser les informations au sein de notre fichier ? L'idéal serait d'avoir un format qui soit à la fois aisément modifiable par un humain et lisible par un ordinateur. Ce dernier point implique que l'on puisse facilement accéder aux données du fichier depuis le langage dans lequel on développe notre programme.
La plupart du temps, la solution retenue est simple, et les fichiers ressembleront à quelque chose du style :
# Ceci est un commentaire # Je garde ici l'exemple du client IRC imaginaire et des données # théoriques dont il peut avoir besoin. host=irc.epiknet.org port=6667 nick=Dark-Side
Il s'agit juste de vous montrer le format par un exemple, les vrais fichiers de configuration sont en réalité bien plus étoffés que celui-ci. Toutefois, cette syntaxe est très répandue pour les applications qui ont besoin d'une configuration simple, puisqu'une syntaxe de ce type est très facilement exploitable, le "parser" étant très facile à coder. En effet, comme dirait Octal :
« J'me fais pas chier, je split sur '=' »
Cependant, pour des fichiers un peu plus complexes ou volumineux que celui de notre exemple, la syntaxe des '=' présente un inconvénient de taille : le typage n'est pas fait automatiquement. En d'autres termes, c'est au programmeur de vérifier que le port est bien un nombre, puis le convertir, ou que le pseudo est bien une chaîne de caractères. De plus, si on utilise des valeurs un peu plus évoluées que de simples nombres, comme des couples ou des listes, il va falloir spécifier le format et écrire un parser.
On se retrouve au final à faire beaucoup de manipulation de chaînes assez répétitive et peu intéressante. Heureusement pour nous, certains langages sont là pour nous faciliter la vie. :)
Moi à mon langage je lui fais des bisous
La ressemblance entre cette chanson et celle des bisounours est pûrement fortuite.
Des gentils, des tout doux Des géants, des tout fous Un bisou sur la joue Un bisou dans le cou Car mon p'tit langage Il adore les bisous
Plus sérieusement, il est possible, avec certains langages, de lire des données typées dans un fichier très facilement. Ce qui va grandement nous intéresser pour la gestion des fichiers de configuration. L'exemple le plus évident est fourni par les langages de la famille lisp et illustré par le très célèbre : « Code as data ».
Exemple en scheme
Dans cette partie, je présuppose que vous connaissez déjà brièvement le scheme, ou tout du moins les types qu'il propose (listes, couples, symboles, etc).
Comme vous le savez sûrement (ou pas, mais ce n'est pas grave :p ) scheme est un langage "lisp-like", tout comme l'est par exemple Common Lisp. En tant que représentant de cette famille, il permet de jouer avec les S-expressions.
On va donc pouvoir construire nos fichiers de configuration à base de s-expressions. Voici donc à quoi ressemblerait notre exemple précédent une fois adapté à cette nouvelle syntaxe :
;;; Ceci est un commentaire
;;; Je garde ici l'exemple du client IRC imaginaire et des
;;; données théoriques dont il peut avoir besoin
(host . "irc.epiknet.org")
(port . 6667)
(nick . "Dark-Side")
Surprise ! Le fichier est typé cette fois, et on a en outre accès à tous les types de scheme, comme ici les couples, les symboles, les chaînes de caractères et les entiers. En partant de là, on peut facilement utiliser des types évolués, comme des listes. Par exemple si on veut rajouter une liste de canaux à rejoindre automatiquement on aurait qu'à ajouter cette ligne :
(autojoin . (list "#sdz" "#zds"))
Bien, maintenant que notre fichier de configuration est écrit, encore faut-il pouvoir le lire. On ouvre tout d'abord notre fichier, en utilisant la fonction open-input-file, qui demande comme argument une chaîne de caractère : l'adresse du fichier. Ceci fait, on va utilise la fonction read pour lire une à une les s-expressions du fichier, jusqu'à rencontrer l'expression #<eof> qui signifie : « End of file », soit : « on est arrivé à la fin du fichier ». Exemple :
> (define file (open-input-file "config.scm"))
> (read file)
(host . "irc.epiknet.org")
> (read file)
(port . 6667)
...
> (read file)
#<eof>
Pour utiliser ces informations il suffira de binder le résultat d'un read à une variable, exemple :
> (define couple (read file)) >
Ce qui est tout de même nettement plus pratique que d'écrire notre propre parser de zéro ! Le seul détail gênant ici est que l'on ne peut pas lire tout le fichier d'un coup, nous sommes obligés de lire les termes un à un. On peut y pallier facilement avec une fonction récursive, mais c'est toujours un manque.
Un exemple surprenant : Erlang
C'est ici que je rejoins mon confrère de spawn_link.
Lisp, avec le principe du « Code as Data », est bien connu pour pouvoir stocker et lire des s-expressions comme on l'a fait, aussi le premier exemple n'a-t-il surpris personne. Par contre, le fait qu'Erlang ait cette capacité est peut-être un peu plus étonnant : même si ce n'est pas inconcevable, on ne s'y attendrait pas.
Quoiqu'il en soit, Erlang aussi nous permet de gérer la configuration, et même de le faire bien. On peut notamment lire des termes Erlang un à un, comme en Scheme, mais aussi - et surtout - lire l'intégralité du fichier en une seule fois. Tout d'abord, réécrivons le fichier de configuration avec la syntaxe Erlang :
%%% Ceci est un commentaire
%%% Je garde ici l'exemple du client IRC imaginaire et des
%%% données théoriques dont il peut avoir besoin
{host, "irc.epiknet.org"}.
{port, 6667}.
{nick, "Dark-Side"}.
{autojoin, ["#sdz", "#zds"]}.
Encore une fois, on a accès à tous les types du langages : couples, string, entiers, listes, atomes2 ...
Pour lire les termes un à un, on procède ainsi :
1> {ok, S} = file:open("config.cfg", read).
{ok,<0.36.0>}
2> io:read(S, '').
{ok, {host, "irc.epiknet.org"}}
3> io:read(S, '').
{ok, {port, 6667}
...
6> io:read(S, '').
eof
7> file:close(S)
À noter qu'ici vous pouvez voir la procédure pour ouvrir et fermer un fichier. Le principe étant le même qu'en scheme, les gens connaissant Erlang sauront enregistrer les données lues dans une variable et les exploiter - cette partie étant relativement peu intéressante, je ne la détaille pas ici.
Passons maintenant au point qui fait l'intérêt de la méthode Erlang : lire l'intégralité d'un fichier. Cette fois, plus besoin d'ouvrir et fermer le fichier manuellement, Erlang le fait pour nous, il suffit juste d'appeller la bonne fonction, qui va nous renvoyer un couple de la forme {ok, Liste}3 si tout s'est bien déroulé et un couple {error, Raison} sinon.
Par exemple :
8> file:consult("config.cfg").
{ok, [{host, "irc.epiknet.org"}, {port, 6667}, {nick, "Dark-Side"},
{autojoin, ["#sdz", "#zds"]}]}
9>
Plutôt sympathique, n'est-ce pas ? :)
Un troisième exemple, en Haskell
En langage Haskell, on dispose de deux fonctions pour faire notre travail : ce sont « show » et « read ». Elles prennent respectivement n'importe quelle valeur Haskell pour la transformer en chaîne, et inversement une chaîne qu'elle transforme en valeur Haskell. La première des choses à faire est de définir le type des valeurs que l'on va lire dans notre fichier :
data Champ = Pseudo String | Port Int | Serveur String
deriving (Read,Show)
On définit ici un type champ, qui peut être : soit un pseudo (qui est une chaîne), soit un port (qui est un entier), soit un serveur (qui est une chaîne). Le « deriving Read » demande au compilateur que Champ appartienne à la classe4 Read. Pour simplifier à outrance, le compilateur va générer tout seul une fonction read, qui convertit une chaîne de caractères en Champ.
Le principe est le même pour Show, qui n'est pas vraiment utile dans notre cas. On s'en serivra pour visualiser le résultat de nos tests dans GHCi.
Maintenant, on écrit notre fichier de configuration, comme d'habitude :
[Pseudo "Dark-Side",
Serveur "irc.epiknet.org",
Port 6667]
Comme vous pouvez le voir, il s'agit d'une liste de Champ, le type que nous avons défini juste avant. Il est à noter que si Haskell sait lire les valeurs Champ (par exemple), il sait automatiquement lire les listes de Champs également, avec la syntaxe habituelle des listes.
On peut maintenant tester notre configuation dans GHCi :
*Prelude> :l Champ.hs [1 of 1] Compiling Main ( Champ.hs, interpreted ) Ok, modules loaded: Main. *Main> monFichier <- readFile "configuration.txt" *Main> let configuration = (read monFichier)::[Champ] *Main> configuration [Pseudo "Dark-Side",Serveur "irc.epiknet.org",Port 6667] *Main> :t configuration configuration :: [Champ] *Main> (configuration !! 0) Pseudo "Dark-Side" *Main>
La première commande charge le fichier Champ.hs, qui contient uniquement la déclaration du type champ. La deuxième lit le contenu du fichier configuration.txt (donné ci-dessus) et le stoque une chaîne appelée « monFichier ».
Ensuite vient la partie intéressante : on appelle fonction read sur la chaîne monFichier. Le « :: [Champ] » signifie que le résultat doit avoir pour type « liste de Champ ». Bien que les types soient habituellement inférés en Haskell, on le spécifie manuellement ici pour indiquer à read comment il doit parser la chaîne. Le résultat est nommé « configuration ».
On peut alors jouer avec : l'afficher (bien qu'à l'affichage, la configuration soit identique au contenu du fichier, ce qui est cohérent mais ne permet pas de voir grand chose), observer son type, qui est bien une liste de champs, ou encore extraire le premier élément, qui se révèle être le Pseudo.
Cette technique peut être étendue à tous les types dont vous pouvez spécifier comment les lire depuis une chaîne - ou dont le compilateur peut deviner tout seul comment faire, comme dans notre exemple simpliste.
Par ailleurs, dans le cadre d'un programme Haskell, la question « peut-on lire le fichier d'un coup ou bien champ par champ » n'as plus vraiment de sens. Soit on définira soit une liste de champs, soit on lira le fichier ligne à ligne avant de convertir chaque ligne.
2 : L'équivalent des symboles de scheme
3 : Où Liste est la liste de tous les termes du fichier
4 : à ne pas confondre avec les classes en Java. Ici, grossièrement, le sens du mot « classe » est plus proche des « interfaces » Java.
Conclusion
Vous avez donc pu voir différentes possibilités pour enregistrer la configuration de vos applications, et, plus généralement, enregistrer facilement n'importe quel type natif de nos langages dans un fichier - et les récupérer facilement.
Ceci nous permet de profiter de la détection des erreurs de nos langages favoris, qui signalera automatiquement toute erreur de syntaxe dans le fichier de configuration sans que nous n'ayons rien à faire, ce qui nous évite de devoir tout implémenter à la main ou passer par une bibliothèque comme on le ferait en PHP ou en C.
La liste des langages utilisés (pour nous exemples) n'est toutefois pas exhaustive. Plus généralement tous les langages homoiconiques5 offrent ce genre de service. On pourra ainsi citer Rebol, Io, etc...
La syntaxe des fichiers qui en résulte peut paraître assez étrange aux utilisateurs novices, mais, comme vous avez pu le voir dans nos exemples, elle demeure assez simple pour qu'une personne un peu curieuse puisse l'éditer facilement.
Pour finir, je vous invite à aller regarder l'article de spawn_link qui met en place un système de configuration bien plus poussé que ce qu'on a pu faire ici.
#1 Par gnomnain, le 16 Août 2009 à 20h01