Système extensible et hautement disponible avec Erlang/OTP

Magazine
Marque
GNU/Linux Magazine
Numéro
241
Mois de parution
octobre 2020
Spécialité(s)


Résumé

Erlang est un langage de programmation fonctionnel et distribué, créé à la fin des années 80 pour régler de nombreux problèmes issus du monde des télécoms, et plus généralement de l’industrie. Outre le fait qu’il soit l’une des seules implémentations réussies du modèle acteur disponible sur le marché, son autre grande particularité est d’être livré avec une suite d’outils, de modèles et de principes conçus pour offrir un environnement cohérent. Ce framework, nommé OTP, fait partie intégrante de la vie des développeurs utilisant Erlang au jour le jour...


Body

L’objectif du précédent article sur Erlang (voir GNU/Linux Magazine no237) était de présenter les bases, les concepts, la syntaxe ainsi que la logique du langage au travers d’une utilisation assez « conventionnelle ». Son histoire, certes résumée en quelques paragraphes, a pu permettre de présenter au lecteur, même le plus dubitatif, le cheminement et la réflexion autour de la conception et l’utilisation de ce langage. Il est pourtant difficile d’expliquer en quelques pages des idées aussi complexes et différentes, qui vont globalement à l’encontre des principes de développement « classiques ». Certains concepts et autres techniques ont été volontairement éludés ou simplifiés pour permettre une plus grande facilité de compréhension et une immersion moins violente pour le profane. L’aventure Erlang n’a pourtant fait que commencer, et tout au long de ce nouvel article, le lecteur sera invité à étendre son champ de vision sur les possibilités offertes par l’imposant écosystème fourni avec les releases standards d’Erlang.

1. Introduction

Open Telecom Platform (OTP) est le nom de la suite logicielle distribuée et designée en 1997 au sein d’Ericsson. Il peut être vu comme un framework regroupant une vaste série de standards, d’outils, de bibliothèques et de middlewares livrés par défaut avec les releases d’Erlang. Il définit aussi une philosophie et des principes de développement. Le terme framework peut ici porter à confusion ; dans le cadre de cet article, il peut être défini comme un support ou une base cohérente permettant de réutiliser des méthodes et des méthodologies. Tout comme certains langages imposent une syntaxe plus ou moins stricte (p. ex. Go), Erlang définit des modèles d’utilisation basés sur des interfaces spécifiées. La majorité de ces composants sont généralement des programmes écrits en Erlang, utilisant eux-mêmes les standards disponibles et livrés avec Erlang/OTP.

À titre de comparaison, les systèmes d’exploitation BSD (p. ex. FreeBSD ou OpenBSD) fonctionnement sur un modèle pratiquement similaire. Pour garantir une cohérence et une stabilité au sein de leur système, le kernel (programme central) et l’userland (programmes utilisateurs) sont livrés sous la même bannière. A contrario, les distributions Linux se basent sur un kernel dont les interfaces avec l’userland doivent rester stables, un des fondements de la philosophie diffusée par Linus Torvald. L’userland, quant à lui, est choisi par les concepteurs des distributions, impliquant des changements de paradigmes parfois profonds entre les différents systèmes, ayant pourtant une base similaire.

Pourquoi comparer Erlang à un système d’exploitation ? Parce qu’Erlang peut être aussi considéré comme tel. Effectivement, ce langage utilise un kernel fonctionnant dans une machine virtuelle et s’appuyant sur des processus légers permettant d’exécuter des suites d’instructions isolées les unes des autres, le tout mettant en pratique une isolation forte entre chacune de ces procédures. Ce cloisonnement rappelle fortement le concept des processus et daemons Unix. À vrai dire, les ressemblances avec Minix, système d’exploitation développé par Andrew Tanenbaum, sont même encore plus flagrantes. Ce système d’exploitation utilise le kernel comme un routeur en kernelland, transférant tous les appels hardware en userland pour limiter le risque de crash. En cas d’arrêt, les processus en userland, pouvant être des drivers, sont alors redémarrés ou s’arrêtent sans pour autant faire s’arrêter tout le système.

Cependant, Erlang est et reste avant tout un langage de programmation. En tant que tel, il est comparable à tous les autres langages présents sur le marché. Ce qui le rend différent est la modularité et la flexibilité offerte par l’environnement Erlang/OTP. Ceux-ci permettent de répondre à de nombreuses problématiques communes, sans avoir besoin d’aller chercher des solutions en dehors du système livré. À titre d’exemple, OTP fournit un gestionnaire de service nommé inets permettant la gestion des protocoles FTP et HTTP, en tant que client et/ou serveur. OTP fournit aussi une implémentation de SSH, SNMP, SSL/TLS, TFTP, LDAP, une interface à X11, des outils d’analyse syntaxique, de test, de compilation, de benchmarking, de debugging, de traçabilité ou encore de releasing... La liste des outils fournis est longue, et ne requiert aucune action externe ou bootstrapping de la part de l’utilisateur. Tous ces services sont disponibles par défaut avec toutes les releases Erlang.

Ces outils ne seront pas présentés dans cet article. Celui-ci aura déjà la lourde tâche de faire une introduction aux principes de développement et à la philosophie fournie avec OTP. Par ailleurs, le lecteur est invité à s’intéresser à tous ces logiciels, où il pourra y trouver des exemples d’implémentations concrètes. De plus, ces nombreux outils permettent aussi de s’assurer du bon fonctionnement de son code, tout en offrant un contrôle du niveau de qualité produit. Avant de rentrer dans le vif du sujet, il est important d’avoir quelques bases théoriques et d’essayer de comprendre comment régler les problèmes que nous rencontrons fréquemment dans le monde du développement et, plus largement, dans le monde de l’informatique.

2. Théorie

Cette partie de l’article n’a pas pour objectif de rendre le lecteur expert dans le domaine des systèmes distribués et de la gestion d’erreurs, mais plutôt d’offrir un axe de réflexion externe sur le sujet. Expliquer un sujet théorique aussi vaste dans un article de magazine est une tâche pratiquement impossible.

Combattre l'entropie, combattre l'infinitude des choix et des possibles qu'offre l'informatique : voici le leitmotiv du programmeur, mais quelles armes a-t-il à sa disposition pour lutter contre de tels géants? Chaque développeur recherche une part de déterminisme pour garder le contrôle sur sa production et se défendre des effets de bords. Rechercher l'ordre dans le chaos, tel est le travail d’alchimiste du codeur.

L’informatique étant un domaine appliqué des mathématiques, une partie de l’armement se trouve dans ce monde théorique. Les principes fonctionnels sont issus des recherches sur la calculabilité, solutionnés par de grands noms tels qu’Alonzo Church, créateur du calcul lambda et Alan Turing, qui donna naissance à la machine de Turing ou automate abstrait. Dans les années 50, la première arme trouvée est la machine à état fini, nommée par deux chercheurs, McCulloch et Pitts, travaillant sur l’activité nerveuse et les neurones dans leur papier « A Logical Calculus Immanent in Nervous Activity ».

L’une des forces de ce concept est qu’il n’est pas limité à l’informatique, et permet de définir un nombre important de modèles. Dans le cas de la programmation, il reste une arme élégante contre le bruit des milliards de bits venant étrangler le codeur. Une telle machine réagit à des actions externes (dites entrées) qui vont interagir avec l’état de cet objet. Un tel modèle permet de redéfinir des concepts concrets tels que des objets de la vie de tous les jours. Le premier exemple généralement présenté est l’interrupteur. Un tel objet n’a que deux états, ouvert ou fermé. Quand on appuie sur ce dernier, s’il est dans l’état ouvert, il passera alors à l’état fermé. Si on appuie alors qu’il est dans un état fermé, alors il passera à l’état ouvert. Le concepteur contrôle ici le comportement d’un tel objet créé et lui définit un univers de possibilités restreint : il ne peut être qu’ouvert ou fermé, mais pas les deux en même temps.

Pour rajouter un peu de profondeur au concept, voici un exemple plus concret, issu du monde des jeux vidéos : la création d’un Personnage Non Joueur (PNJ). Un PNJ est généralement un robot qui exécute des actions quand un joueur interagit avec lui. Son état initial est en attente. Quand un joueur vient lui parler, il passe dans l’état discute et réagit aux demandes du joueur comme pour lui donner une quête à réaliser. Quand le joueur a fini de lui parler, le PNJ retourne dans son état initial en attente.

Pourquoi donc s’attarder sur la définition théorique de ce concept ? Car grâce à celui-ci, il est possible de reproduire tous les autres modèles, comme le client/serveur, gestionnaire d’événement et bien d’autres. Il est en quelque sorte la brique essentielle de l’informatique moderne.

3. Pratique

La machine à état fini présentée dans la partie théorique est l’un des design patterns utilisés en Erlang et est le pilier de nombreux autres. Ces designs sont nommés behaviours au sein du langage et représentent une abstraction généralement utilisée lors de la création d’un logiciel. Ces behaviours détiennent la clé pour la création de systèmes tolérants aux erreurs en offrant une abstraction forte, tout en permettant de simplifier des concepts parfois complexes. Ils sont le résultat de nombreuses années d’expérience dans la création de systèmes résistant aux erreurs au sein d’Ericsson et de l’équipe OTP.

Effectivement, une grande majorité des développeurs ont d’ores et déjà dû rencontrer la lourde tâche d’écrire du code réutilisable avec des interfaces d’utilisation « simples ». En programmation orientée objet, l’héritage ou le polymorphisme font partie de ces fausses bonnes idées qui, généralement, conduisent un projet à ressembler à une assiette de spaghettis. Le concept de behaviour, pour essayer de le résumer, permet donc d’exporter des fonctions nommées callbacks ou handlers permettant d’interagir avec un code qui n’est visible que du processus et du développeur. Par cette méthode, la complexité est complètement cachée et seul l’API exposée permet de communiquer avec le processus. Avec l’aide d’un tel design, les interfaces restent normalement stables, et seul le code non visible est alors modifié. Cette technique permet d’éviter de réécrire du code, facilite le développement d’application, tout en offrant une flexibilité importante pour le rajout de fonctionnalités.

4. Interfaces communes

Un nombre important de callbacks possèdent les mêmes noms et généralement les mêmes buts. Cette partie va essayer de résumer les différentes interfaces communes accessibles par un développeur Erlang, quand ce dernier utilise les behaviours livrés avec Erlang/OTP. Il est évidemment possible de créer ses propres behaviours avec des noms d’interfaces différents, mais ce serait aller à l’encontre, en quelque sorte, de la standardisation du langage qui a été définie depuis maintenant de nombreuses années.

4.1 Callbacks

Un callback est une fonction classique portant un nom spécifié dans le behaviour et qui doit être défini dans le code d’un module. Dans le cas d’Erlang, un callback est aussi livré avec des spécifications sur les types de données attendues en entrée ainsi que ceux retournés par la fonction lors de son exécution. Cette fonctionnalité permet de créer des règles et des interfaces, tout en cachant la complexité réelle du code.

La première fonction exposée que le développeur va rencontrer est le callback init/1. Ce dernier est retrouvé dans pratiquement tous les behaviours et modules présents dans Erlang/OTP. Cette fonction est le point d’entrée et récupère en argument la configuration par l’utilisateur du type de son choix, généralement une list ou une map. Pour le lecteur habitué à développer en programmation orientée objet, cette fonction est l’équivalent d’un constructeur. Le code suivant, par exemple, récupère la valeur associée à la clé mode stockée dans la proplist Arguments. Si cette valeur n’est pas définie, alors Etat aura la valeur undefined. Finalement, la fonction init retourne un tuple, contenant l’atom ok suivi de la variable Etat contenant la valeur précédemment récupérée depuis la liste d’arguments. Ce retour de fonction est important, car c’est ce qui va permettre d’initialiser les données de l’état interne du processus.

init(Arguments)
   -> Etat = proplist:get_value(mode, Arguments, undefined),
   {ok, Etat}.

L’autre callback souvent rencontré est la fonction terminate/2. Cette fonction prend pour premier argument la raison de l’arrêt d’un processus. Pour un arrêt sans erreur, un atom avec la valeur normal est émis. Dans le cas d’un arrêt inopiné, comme un crash, cette valeur est un atom ayant pour valeur stop ou error suivi de la raison de l’arrêt. Le second argument contient, quant à lui, l’état actuel du processus, ce qui aura été défini en sortie de la fonction init/1. Sa valeur de retour n’a pas d’importance, et retourne généralement un atom ok.

terminate(Raison, Etat) ->
   ok.

Le dernier callback fréquemment rencontré est la fonction code_change/3 qui permet de faire une migration de version. Il serait bien trop long de parler de cette fonction ici, mais il est bon de savoir qu’un processus au sein de la BEAM peut-être versionné. Si un nouveau processus arrive avec une version N+1, alors le callback code_change/3 sera exécuté et permettra de gérer la transition de l’état du processus N vers le processus avec une version N+1. Une fonction évidemment très pratique pour la mise à jour à chaud sans perte de service.

code_change(AncienneVersion, Etat, ExtraParametre) ->
   {ok, Etat}.

4.2 Fonctions d’aide

Outre les callbacks communs présent dans l’écosystème, chaque module utilisant un behaviour présent dans Erlang/OTP offre des fonctions d’aide pour démarrer, arrêter ou encore mettre en veille les processus en cours de fonctionnement. La majorité des behaviours offrent aussi des fonctions pour communiquer avec ces derniers, offrant deux types de communications : synchrone ou asynchrone. Contrairement à un envoi de message classique envoyé avec erlang:send/2, ces fonctions permettent de garantir l’arrivée du message ou de le tracer plus facilement dans le cadre d’un debug.

Les fonctions start/3 et start/4 permettent de démarrer un processus. Le premier argument fait généralement référence au nom du module. Le second argument correspond aux paramètres à passer à la fonction init/1. Le dernier argument permet de définir les paramètres liés au behaviour et offre la possibilité de configurer le debugging, la traçabilité ou encore de générer des statistiques.

La fonction start/4 permet de rajouter le support de l’enregistrement du processus à démarrer ainsi que son unicité au sein d’un cluster (disponible localement ou non). Ces fonctions retournent un tuple contenant un PID ou le message d’erreur en cas d’échec.

start(NomDuModule, ArgumentsInit, Options) -> {ok, Pid}.
start(NomDuServeur, NomDuModule, ArgumentsInit, Options) -> {ok, Pid}.

Les fonctions start_link/3 et start_link/4 permettent aussi de démarrer un processus et utilisent les mêmes conventions utilisées par les fonctions start/3 et start/4 vues précédemment. La grande différence est la création d’un lien entre le processus qui démarre le module et le processus démarré.

start_link(Module, Args, Options) -> Result.
start_link(ServerName, Module, Args, Options) -> Result.

Les fonctions permettant d'arrêter un processus se nomment généralement stop/1 ou stop/3. Le premier argument correspond à la référence du processus, sous la forme d’un atom ou d’un PID. Dans le cas de stop/3, le second argument contient la raison de l’arrêt ainsi qu’un temps d’expiration permettant d’attendre l’arrêt du processus.

stop(ReferenceProcessus) -> ok.
stop(ReferenceProcessus, Raison, Expiration) -> ok.

La communication asynchrone est possible avec la fonction cast/2. Le premier argument correspond à la référence du processus, sous la forme d’un atom ou d’un PID. Le second argument est le contenu du message. Cette fonction retournera tout le temps l’atom ok et n’attend aucune réponse du processus contacté.

cast(ServerRef, Request) -> ok.

La communication synchrone est possible via les fonctions call/2 et call/3 qui permettent d’envoyer des messages à un processus en attendant une réponse. Tout comme les précédentes fonctions, le premier argument contient la référence d’un processus sous forme d’un atom ou d’un PID. Le second argument contient le contenu du message. Finalement, le troisième argument contient un temps d’expiration permettant d’arrêter la fonction en cas de non-réponse de la part du processus contacté. Cette fonction retourne la réponse du processus qui a reçu le message.

call(ServerRef, Request) -> Reply.
call(ServerRef, Request, Expiration) -> Reply.

5. Gen_Server

Dans la liste des behaviours disponibles, gen_server [2] est probablement celui qui est le plus utilisé et le plus connu. Il se retrouve d’ailleurs nativement dans bien d’autres langages utilisés sur la BEAM tel qu’Elixir. gen_server permet de créer un processus utilisant le modèle client/serveur. Lors du lancement de ce processus, ce dernier attend un message et réagit en fonction de celui-ci, par exemple en modifiant son état, ou en transférant la requête à un autre processus ou une fonction particulière. Voici un export relativement classique, avec les fonctions init/1 et terminate/2 que nous avons précédemment étudié.

-module(exemple_server).
-behaviour(gen_server).
-export([init/1, terminate/2]).

gen_server possède trois handlers utilisés pour communiquer avec les autres processus présents sur la machine virtuelle. handle_cast/2 permet de recevoir les requêtes asynchrones. handle_call/3 permet de recevoir les requêtes synchrones. Finalement, handle_info/2 permet de récupérer des messages ne suivant pas le standard OTP, envoyés par exemple avec la fonction erlang:send/2.

-export([handle_cast/2, handle_call/3, handle_info/2).

La définition de la fonction init/1 est obligatoire, ce qui n’est pas le cas pour la fonction terminate/2. Pour rappel, ces deux fonctions sont équivalentes à un constructeur et un destructeur en programmation orientée objet.

init(Arguments) ->
    io:format("Argument: ~p~n", [Arguments]),
    {ok, Arguments}.
terminate(Raison, Etat) ->
    io:format("arret raison: ~p~n", [Raison]),
    io:format("arret etat: ~p~n", [Etat]),
    ok.

La fonction handle_cast/2 attend deux arguments. Le premier correspond au message reçu de la part d’un autre processus présent sur le système et utilisant la fonction gen_server:cast/2. Le second argument contient l’état du processus démarré. Cette fonction doit retourner certaines valeurs prédéfinies dans le behaviour. Dans le cas de la fonction handle_cast/2, celle-ci doit retourner un tuple contenant l’atom noreply, suivi de l’état du processus dans le cas d’une exécution réussie. En cas de problème ou d’arrêt du processus, cette fonction pourra retourner un tuple contenant l’atom stop suivi de la raison ainsi que de l’état du processus lors de l’arrêt.

handle_cast(Message, Etat) ->
    io:format("cast message: ~p~n", [Message]),
    io:format("cast etat: ~p~n", [Etat]),
    {noreply, Etat}.

La fonction handle_call/3 attend trois arguments. Le premier correspond au message reçu d’un processus externe utilisant les fonctions gen_server:call/2 ou gen_server:call/3. Le second argument contient les informations concernant le processus qui a émis le message d’origine, tel que le PID et l’identifiant du message. Le dernier argument contient l’état du processus. Tout comme la fonction handle_cast/2, cette fonction doit retourner une valeur définie dans les spécifications. Dans le cas d’une réponse, le tuple devra contenir l’atom reply, suivi de la réponse et de l’état du processus. Il est aussi possible de ne pas répondre en retournant un tuple avec noreply.

handle_call(Message, From, Etat) ->
    io:format("call message: ~p~n", [Message]),
    io:format("call from: ~p~n", [From]),
    io:format("call etat: ~p~n", [Etat]),
    {reply, {Message, From, Etat}, Etat}.

La fonction handle_info/2 est similaire à handle_cast/2. La seule différence est qu’elle permet de récupérer les messages provenant d’une communication non supportée par le behaviour, provenant généralement de la fonction erlang:send/2. Le premier argument contient le message reçu et le second contient l’état du processus. Cette fonction, tout comme les précédentes, peut retourner les mêmes valeurs que la fonction handle_cast/2.

handle_info(Message, Etat) ->
    io:format("info message: ~p~n", [Message]),
    io:format("info etat: ~p~n", [Etat]),
    {noreply, Etat}.

Ces trois handlers permettent de répondre à une grande partie des besoins d’un développeur. Tout comme un module classique, le module créé peut-être enregistré dans un fichier et est maintenant prêt à être compilé en vue d’être démarré. Pour produire un processus basé sur ce module, les fonctions gen_server:start/3 ou gen_server:start_link/3 peuvent être utilisées. En cas de réussite, ces fonctions retourneront un tuple contenant le PID du processus démarré. Le code suivant montre comment démarrer trois nouveaux processus, à partir de ces fonctions.

1> c(exemple_server).
{ok, exemple_server}
 
2> {ok, Pid} = gen_server:start(exemple_server, [], []).
Argument: []
{ok,<0.111.0>}
 
3> {ok, PidLink} = gen_server:start_link(exemple_server, [], []).
Argument: []
{ok,<0.116.0>}
 
4> {ok, PidRegister} = gen_server:start_link({local, exemple_server}, exemple_server, [], []).
Argument: []
{ok,<0.118.0>}

Pour communiquer avec ces nouveaux processus, les fonctions gen_server:cast/2 et gen_server/3 offrent les fonctionnalités que nous avons vu lors de la présentation de communication synchrone et asynchrone. Le transfert du message se fait via des processus créés.

5> gen_server:cast(Pid, message).ok
cast message: message
cast etat: []
 
6> gen_server:call(PidLink, message).call message: message
call from: {<0.114.0>,#Ref<0.1624960413.2511339522.255265>}
call etat: []
{message,{<0.114.0>,#Ref<0.1624960413.2511339522.255265>},
         []}
 
7> gen_server:call(exemple_server, message, 1000).call message: message
call from: {<0.126.0>,#Ref<0.1624960413.2511339521.254999>}
call etat: []
{message,{<0.126.0>,#Ref<0.1624960413.2511339521.254999>},
         []}
 
8> erlang:send(Pid, message).info message: message
message
info etat: []

Pour arrêter le processus, les fonctions gen_server:stop/1 et gen_server:stop/3 peuvent être utilisées. Il est à noter que le démarrage d’un processus lié fera crasher le shell en cours d’utilisation, ce n’est pas un bug, et cette fonctionnalité sera expliquée lors de la présentation du behaviour supervisor.

9> gen_server:stop(Pid).arret raison: normal
arret etat: []
ok
 
10> gen_server:stop(PidLink, {normal, test}, 1000).gen_server:stop(PidLink, {normal, test}, 1000).
arret raison: {normal,test}
arret etat: []ok
 
11> gen_server:stop(exemple_server, {shutdown, crash}, 1000).arret raison: {shutdown,crash}
arret etat: []
** exception exit: {shutdown,crash}

Le modèle client/serveur implémenté avec gen_server est utilisé dans de nombreux domaines et offre une solution clés en main pour répondre aux besoins des développeurs. Il donne d’ores et déjà une bonne idée de l’étendue des possibilités offertes par Erlang/OTP et les behaviours.

6. Gen_Statem

Le behaviour gen_statem [3] est le digne remplaçant de l’ancien behaviour gen_fsm, qui sera probablement supprimé dans les prochaines releases. Ce modèle permet d’utiliser un design pattern basé sur les machines à état fini. gen_statem supporte plusieurs modes de fonctionnement définis par la fonction callback_mode/0. Dans le cadre de cet article, le mode handle_event_function est présenté. La documentation officielle présente un second mode de fonctionnement nommé state_functions.

-module(exemple_statem).
-behaviour(gen_statm).
-export([init/1, terminate/3]).
-export([callback_mode/0]).
-export([handle_event/4]).

callback_mode/0 est une fonction qui n’attend aucun argument retournant simplement le mode utilisé par le processus qui sera lancé. Le retour de cette fonction peut-être un atom ou une liste d’atoms, ce qui permet de configurer des modes de fonctionnements complémentaires.

callback_mode() -> [handle_event_function].

Tout comme gen_server, les fonctions init/1 et terminate/3 doivent être créées et retournent les données qui vont permettre d’initialiser ou de détruire le processus. Le code suivant va simuler un interrupteur branché à une lampe. Le processus démarre dans un état ouvert contenant la donnée eteint. La fonction terminate/3 ne fait rien de particulier, elle affiche simplement les informations du processus lors de son arrêt.

init(_Arguments) ->
    io:format("interrupteur ouvert~n"),
    {ok, ouvert, eteint}.
terminate(Raison, Etat, Donnee) ->
    io:format("interrupteur détruit.~n"),
    io:format("raison: ~p~n", [Raison]),
    io:format("état: ~p~n", [Etat]),
    io:format("lampe: ~p~n", [Donnee]),
    ok.

La fonction handle_event/4 est exportée obligatoirement dans le mode de fonctionnement handle_event_function. Le premier argument contient le type d’événement (cast, call ou info). Le second argument contient le message envoyé par un processus externe (nommé aussi événement). Le troisième argument contient l’état du processus. Le dernier argument contient les données associées à l’état. Comme tout callback, ces fonctions doivent retourner un type de données spécifié. Dans le cas de gen_statem, la FSM peut garder son état avec keep_state, changer d’état avec next_state, et donne la possibilité de faire des actions de transition dans la dernière valeur du tuple.

handle_event(cast, appuyer, ferme, allume) ->
    io:format("lampe éteinte.~n"),
    {next_state, ouvert, eteint};
handle_event(cast, appuyer, ouvert, eteint) ->
    io:format("lampe allumée.~n"),
    {next_state, ferme, allume};
handle_event({call, From}, lampe, _Etat, Donnee) ->
    {keep_state, Donnee, [{reply, From, Donnee}]};
handle_event(TypeEvenement, Evenement, Etat, Donnee) ->
    io:format("Type événement: ~p~n", [TypeEvenement]),
    io:format("Contenu événement: ~p~n", [Evenement]),
    io:format("État: ~p~n", [Etat]),
    io:format("État (données) processus: ~p~n", [Donnee]),
    {keep_state, Donnee}.

Au lieu de réutiliser constamment les fonctions gen_statem:call/2 ou gen_statem:cast/2, nous pouvons créer une interface qui viendra simplifier la tâche de communication avec le processus en créant une API. Dans cet exemple, la fonction appuyer/1 permettra d’appuyer sur l’interrupteur et la fonction lampe/1 permettra de voir l’état de la lampe.

appuyer(Pid) ->
    gen_statem:cast(Pid, appuyer).
 
lampe(Pid) ->
    gen_statem:call(Pid, lampe, 1000).

Tout comme gen_server, gen_statem offre plusieurs fonctions telles que gen_statem:start/3 ou gen_statem:start_link/3 pour démarrer le processus. Les comportements sont les mêmes que pour gen_server.

1> c(exemple_statem).
{ok,exemple_statem}
 
2> {ok, Pid} = gen_statem:start(exemple_statem, [], []).
interrupteur ouvert
{ok,<0.83.0>}
 
3> {ok, PidLink} = gen_statem:start_link(exemple_statem, [], []).
interrupteur ouvert
{ok,<0.85.0>}
 
4> {ok, PidRegister} = gen_statem:start_link({local, exemple_statem}, exemple_statem, [], []).
interrupteur ouvert
{ok,<0.87.0>}

Pour communiquer avec les processus précédemment créés, les fonctions gen_statem:call/2, gen_statem:cast/2 permettent d’envoyer les messages, et se comportent de la même façon que pour gen_server. La fonction classique erlang:send/2 peut aussi être utilisée.

6> gen_statem:call(Pid, lampe).
eteint
 
7> gen_statem:cast(PidLink, appuyer).
lampe allumée.
ok
 
8> erlang:send(PidRegister, message).
Type événement: info
message
Contenu événement: message
État: ferme
État (données) processus: allume
 
9> exemple_statem:appuyer(Pid).
lampe éteinte.
ok
 
10> exemple_statem:lampe(Pid).
Eteint
 
11> exemple_statem:appuyer(Pid).
lampe allumée.
ok
 
12> exemple_statem:lampe(Pid).
allume

Pour arrêter ce processus, les fonctions gen_statem:stop/1 et gen_statem:stop/2 sont généralement utilisées.

13> gen_statem:stop(Pid).interrupteur détruit.
raison: normal
état: ferme
lampe: allume
ok
 
14> gen_statem:stop(PidLink, Raison, 1000).
ok
 
15> gen_statem:stop(PidRegister).
ok

gen_statem est une fonction arrivée relativement tôt avec la version OTP-19 et vient combler de nombreux manques de l’ancien behaviour gen_fsm en offrant une plus grande souplesse de développement ainsi que de nombreuses fonctionnalités supplémentaires.

7. Supervisor

Le behaviour supervisor [4] est probablement l’un des plus critiques, car il permet de superviser les processus en cours d’exécution. Dans le dernier article, nous avions vu la fonction de monitoring via la fonction erlang:monitor/2 qui permet d’envoyer un message quand l’état d’un processus change, généralement quand il meurt, mais nous n’avions encore pas vu la création de liens via erlang:link/2. Un processus peut être fortement lié à un autre processus. Si l’un des deux crash, un signal est envoyé au processus encore vivant. Pour éviter que l’autre processus crash, la fonction erlang:process_flag/2 est utilisée pour attraper le signal émis et agir en conséquence. Sachant que ce signal est plus rapide qu’un message classique, il est parfait pour redémarrer rapidement un ou des processus qui auraient eu des soucis. Le behaviour supervisor permet de manager ces différents liens.

-module(exemple_supervisor).
-behaviour(supervisor).
-export([init/1]).

init/1 est le seul callback à devoir être défini dans le cas d’un superviseur. Cette fonction retourne deux informations fondamentales, la configuration du superviseur et la liste des processus qu’il devra surveiller. La configuration d’un superviseur définit sa stratégie de supervision. Au nombre de quatre, elle permet de gérer les différents comportements en fonction des processus-enfants supervisés. La stratégie one_for_one redémarre automatiquement un processus qui crash ; one_for_all redémarre tous les autres processus si un des processus supervisé crash ; rest_for_one redémarre les autres processus dans l’ordre si l’un d’eux s’arrête ; simple_one_for_one permet de démarrer dynamiquement des instances d’un processus à la demande et ayant un cycle de vie court. Les deux autres valeurs à configurer, intensity et period permettent de définir un nombre limite de redémarrages pendant une période définie, permettant ainsi de rendre le superviseur tolérant aux boucles infinies.

La spécification d’un processus-enfant est relativement simple. Elle permet de configurer un identifiant interne au sein du superviseur, puis la méthode à utiliser pour démarrer cedit processus, contenant le module, la fonction et les arguments d’initialisation. Dans le cas de l’identifiant, il n’est pas à confondre avec le fait d’enregistrer un processus sur la machine virtuelle. Il sert ici de référence en cas de crash pour pouvoir le redémarrer manuellement ou automatiquement via le superviseur.

init(Arguments) -> SupervisorConf = #{ strategy => one_for_one,
                      intensity => 1,
                      period => 5 },
SpecStatem = #{id => exemple_statem, start => {exemple_statem, start_link, []}},
SpecServer = #{id => exemple_server, start => {exemple_server, start_link, []}},
{ok, {SupervisorConf, [SpecStatem, SpecServer]}}.

Un superviseur ne peut être démarré qu’avec la fonction supervisor:start_link/2 ou supervisor:start_link/3. Aucune fonction stop n’existe au sein du module supervisor, mais il est possible d’utiliser les fonctions stop/1 et stop/3 présentes dans le behaviour gen_server.

1> {ok, Pid} = supervisor:start_link(exemple_supervisor, []).
interrupteur ouvert
Argument: []
{ok,<0.133.0>}

En tant que superviseur, ce processus est garant de la vie et de la mort des différents enfants qu’il a à gérer et dont il a l’entière responsabilité. Il est donc possible de démarrer, redémarrer ou arrêter un de ces processus ou encore de les lister en utilisant les fonctions supervisor:which_children/1 et supervisor:start_child/2.

2> supervisor:which_children(Pid).
[{exemple_server,<0.135.0>,worker,[exemple_server]},
{exemple_statem,<0.134.0>,worker,[exemple_statem]}]
 
3> Specification = #{id => exemple_statem2, start => {exemple_statem, start_link, []}}.
#{id => exemple_statem2, start => {exemple_statem, start_link, []}}
 
4> supervisor:start_child(Pid, Specification).
interrupteur ouvert
{ok,<0.140.0>}
 
6> supervisor:terminate_child(Pid, exemple_statem2).
ok

Le behaviour supervisor est un bon exemple de la flexibilité d’Erlang/OTP. Effectivement, ce module est basé sur le design pattern client/serveur et le behaviour gen_server.

8. Réimplémentation du système de cache

Lors de l’écriture du précédent article, le projet sélectionné était une gestion de cache, un système de stockage de clés/valeurs basé sur les maps. Cet exemple est parfait, car il est aussi la base de fonctionnement de nombreux autres outils codés en Erlang. Pour ce faire, le behaviour utilisé sera gen_server : il correspond parfaitement au besoin et est suffisamment simple pour avoir quelque chose de fonctionnel rapidement. La première partie consiste à définir le nom du module ainsi que les fonctions exportées. L’utilisation du behaviour gen_server se fait via l’attribut -behaviour() du préprocesseur Erlang.

-module(cache).
-behaviour(gen_server).
-export([init/1, terminate/2]).
-export([handle_cast/2, handle_call/3]).
-export([add/3, delete/2, get/2, get_keys/1, get_values/1]).

Les fonctions init/1 et terminate/2 permettent d’initialiser et de terminer le système de cache. La structure de données utilisée sera une map et sera l’état du processus lors de son démarrage. Aucune action n’est réalisée lors de la destruction du processus, mais une fonctionnalité d’alerting ou de sauvegarde pourrait très bien être utilisée ici, voire de sauvegarde de l’état.

init(_Args) ->
   Etat = #{},
   {ok, Etat}.
 
terminate(_Raison, _Etat) ->
   ok.

La fonction handle_cast/2 va permettre au cache d’effectuer des actions sans garantie de service, basé sur une communication asynchrone. Le rajout d’une clé et d’une valeur ou la suppression d’une clé ainsi que sa valeur associée n’ont pas besoin d’offrir un retour particulier. Pour le rajout d’une clé/valeur, la fonction maps:puts/3 sera utilisée. Pour la suppression d’une clé, la fonction maps:remove/2 sera utilisée. Ces deux fonctions permettent d’altérer l’état du processus en retournant la map modifiée contenue dans l’état.

handle_cast({add, Key, Value}, Etat) ->
   {noreply, maps:put(Key, Value, Etat)};
handle_cast({delete, Key}, Etat) ->
  {noreply, maps:remove(Key, Etat).

La fonction handle_call/3 va permettre de demander au processus de retourner une valeur, telle que la liste des clés, la liste des valeurs ou la valeur associée à une clé au travers d’une communication synchrone. Pour ce faire, les fonctions maps:keys/1, maps:values/1 et maps:get/3 seront utilisées et retourneront au client la ou les valeurs demandées.

handle_call(get_keys, _From, Etat) ->
   {reply, maps:keys(Etat), Etat)};
handle_call(get_values, _From, Etat) ->
   {reply, maps:values(Etat), Etat};
handle_call({get, Key}, _From, Etat) ->
   {reply, maps:get(Key, Etat, undefined), Etat}.

Maintenant que les handlers ont été définis, il est nécessaire d’offrir une API pour simplifier la communication aux différentes fonctionnalités. Certes, un développeur pourrait utiliser gen_server:cast/2 ou gen_server:call/2, mais en cas de changement des handlers, tout le code serait impacté. Une API permet donc d’offrir une méthode d’accès simple à un développeur et une potentielle garantie qu’il n’aura pas besoin de changer son code en utilisant ces méthodes. La fonction add/3 permet de rajouter une clé et une valeur associée à un PID faisant référence au processus cache. Ce dernier argument sera présent dans toutes les autres fonctions.

add(Pid, Key, Value) ->
   gen_server:cast(Pid, {add, Key, Value}).

La fonction cache:delete/2 offre une interface pour supprimer une clé et sa valeur.

delete(Pid, Key) ->
   gen_server:cast(Pid, {delete, Key}).

La fonction cache:get_keys/1 offre la possibilité de lister toutes les clés stockées.

get_keys(Pid) ->
   gen_server:call(Pid, get_keys).

La fonction cache:get_values/1 permet de lister les valeurs stockées.

get_values(Pid) ->
   gen_server:call(Pid, get_values).

Finalement, cache:get/2 permet de récupérer une clé associée à une valeur.

get(Pid, Key) ->
   gen_server:call(Pid, {get, Key}).

Une application ou une release, deux termes qui seront mis en avant dans un prochain article, utilisent le behaviour supervisor pour contrôler tous les processus lancés et ainsi créer un arbre de supervision. Par convention, un tel processus se nomme avec le nom du module suivi du postfixe _sup. Dans le cas du module cache, le nom du superviseur sera donc cache_sup, enregistré dans un fichier cache_sup.erl.

-module(cache_sup).
-behaviour(supervisor).
-export([init/1]).
 
init(_Args) ->
   SupervisorConf = #{ strategy => one_for_one,
                       intensity => 1,
                       period => 5 },
  CacheName = cache,
  CacheStart = { gen_server, start_link, [{local, CacheName}, cache, [], []]},
  CacheSpec = #{ id => cache,
                 start => CacheStart },
  {ok, {SupervisorConf, [CacheSpec]}}.

Les deux éléments primordiaux de notre système de cache sont maintenant prêts à être compilés et exécutés. Le superviseur supervisera le processus travailleur cache et, en cas de crash, le relancera avec son état initial, c’est-à-dire une map vide. Il est à noter que la fonction erlang:process_flag/2 est exécutée, cette fonction permet d’activer l’interception des signaux émis lors du crash d’un processus. Dans le cadre de cet exemple, cela permettra d’éviter de redémarrer prématurément le shell en cas d’erreur.

1> erlang:process_flag(trap_exit, true).
False
 
2> c(cache_sup).
{ok,cache_sup}
 
3> c(cache).
{ok,cache}
 
4> {ok, Pid} = supervisor:start_link(cache_sup, []).
{ok,<0.83.0>}
 
5> supervisor:which_children(Pid).
[{cache,<0.96.0>,worker,[gen_server]}]
 
6> cache:add(cache, cle, valeur).
ok
 
7> cache:get_keys(cache).
[cle]
 
8> cache:get_values(cache).
[valeur]
 
9> cache:get(cache, cle).
valeur
 
10> cache:delete(cache, cle).
ok
 
11> cache:get(cache, cle).
undefined

Maintenant qu’un processus cache est démarré et possède un état particulier, le principe du superviseur peut-être testé. Pour ce faire, une valeur volontairement fausse est envoyée sur le processus en question via gen_server:cast/2 ou toute autre fonction. Vu que le module cache n’est pas permissif, ce dernier crashera, car aucun handler n’aura été défini pour le mauvais message. Ce même processus redémarrera automatiquement grâce au superviseur cache_sup, qui recevra un signal informant d’un arrêt non prévu.

12> gen_server:cast(cache, crash_test).
=CRASH REPORT==== 12-Mar-2020::12:00:00.00000 ===
  crasher:
    initial call: cache:init/1
    pid: <0.114.0>
    registered_name: cache
    exception error: no function clause matching
                     cache:handle_cast(crash_test,#{}) (cache.erl, line 18)            
      in function gen_server:try_dispatch/4 (gen_server.erl, line 637)            
      in call from gen_server:handle_msg/6 (gen_server.erl, line 711)            
    ancestors: [<0.113.0>,<0.111.0>]
    message_queue_len: 0
    messages: []
    links: [<0.113.0>]
    dictionary: []
    trap_exit: false
    status: running
    heap_size: 1598
    stack_size: 27
    reductions: 23914
  neighbours:
=SUPERVISOR REPORT==== 12-Mar-2020::12:00:00.00000 ===           
    supervisor: {<0.113.0>,cache_sup}
    errorContext: child_terminated
    reason: {function_clause,
                [{cache,handle_cast,
                     [crash_test,#{}],
                     [{file,"cache.erl"},{line,18}]},
                 {gen_server,try_dispatch,4,
                     [{file,"gen_server.erl"},{line,637}]},            
                 {gen_server,handle_msg,6,
                     [{file,"gen_server.erl"},{line,711}]},            
                 {proc_lib,init_p_do_apply,3,
                     [{file,"proc_lib.erl"},{line,249}]}]}            
    offender: [{pid,<0.114.0>},
               {id,cache},
               {mfargs,{gen_server,start_link,[{local,cache},cache,[],[]]}},            
               {restart_type,permanent},
               {shutdown,5000},
               {child_type,worker}]

Comme désiré, le processus nommé cache s’est arrêté en crashant. Les deux messages informent le développeur qu’un processus cache s’est crashé. Le superviseur prend alors la responsabilité de redémarrer le processus. En utilisant la fonction supervisor:get_children/1 sur ce même superviseur, le processus cache est bien présent, mais avec un PID différent, preuve du redémarrage.

13> supervisor:get_children(Pid).
[{cache,<0.117.0>,worker,[gen_server]}]

Par ailleurs, vu qu’il a retrouvé son état initial, les informations contenues dans le précédent processus ont disparu. Il ne serait pas forcément très compliqué de mettre en place une solution pour conserver l’état du processus et le récupérer lors d’un crash, en externalisant les données dans un autre processus tolérant aux erreurs.

Conclusion

Les design patterns fournis avec Erlang/OTP font partie des briques essentielles du langage. Ils permettent de créer un environnement modulaire et cohérent. Il n’en reste pas moins que ce ne sont que les morceaux d’un iceberg beaucoup plus grand. Hérités de concepts mathématiques et d’observations pratiques, tous ces principes permettent donc de faciliter la communication entre les différents éléments du système, mais aussi d’aider les différents membres de la communauté à avoir une méthode de communication et de compréhension commune.

Au travers de cet article, vous aurez donc été initié à de nombreux concepts peu orthodoxes qui donnèrent naissance à la philosophie « Let It Crash », une méthode de programmation basée sur le prédicat suivant : votre application est vouée à crasher un jour, autant le faire le plus tôt possible. Ce mode de pensée n’est pas récent et peut être, d’ailleurs, rencontré dans bien d’autres domaines. Pour les plus curieux, sachez que cette technique porte aussi le nom de « Crash-Only Software ». Mais ceci est une autre histoire, que nous prendrons le temps d’approfondir dans un prochain article.

Tout le code présent dans cet article est disponible sur un Gist [5].

Références

[1] Site officiel d’Erlang : https://erlang.org

[2] Page de manuel du behaviour gen_server : http://erlang.org/doc/man/gen_server.html

[3] Page de manuel du behaviour gen_statem : http://erlang.org/doc/man/gen_statem.html

[4] Page de manuel du behaviour supervisor : http://erlang.org/doc/man/supervisor.html

[5] Code source des exemples stocké sur Gist : https://frama.link/linux-magazine-erlang-otp

Pour aller plus loin

La majorité des livres sur Erlang survolent Erlang/OTP, mais la référence reste le livre de Francesco Cesarini, « Designing for Scalability with Erlang/OTP ». Si vous vous sentez tout de même un peu perdu, sachez que vous n’êtes pas seul, et un sujet comme Erlang/OTP n’est pas forcément des plus simples à appréhender. Outre la documentation, la littérature et les blogs, vous pourrez trouver des réponses à vos questions grâce aux différents membres actifs de la communauté. Voici quelques références :

  • l’Erlang Connection (https://erlang-connection.eu), association française à but non lucratif, a vu le jour récemment et aura le plaisir de vous accueillir, de vous conseiller ou de répondre à vos questions, que vous soyez un particulier ou une entreprise ;
  • la Fondation Erlang (https://erlef.org) qui regroupe de nombreux acteurs ainsi que des groupes de travail pour étendre les fonctionnalités du langage ;
  • différentes communautés indépendantes ont vu le jour sur IRC et Slack ou sur la Toile avec de nombreux forums, que ce soit pour Erlang, Elixir ou les autres langages présents sur la BEAM. N’hésitez pas à envoyer vos questions sur ces différents canaux.


Article rédigé par

Par le(s) même(s) auteur(s)

Introduction à l’écriture de tests avec Erlang/OTP

Magazine
Marque
GNU/Linux Magazine
HS n°
Numéro
113
Mois de parution
mars 2021
Spécialité(s)
Résumé

Les tests ont toujours été un facteur non négligeable de réussite lors de la conception d’un projet de développement. Ces différentes procédures, accompagnant généralement le code source, permettent d’augmenter grandement la qualité d’une application, qu’elle soit libre ou fermée. Même s’il est vrai qu’un programme ne peut-être testé dans son intégralité, les tests mis en place permettront de livrer à l’utilisateur final un système stable ainsi qu’une garantie de fonctionnement. Bien entendu, Erlang/OTP n’est pas en reste, et fournit aux développeurs tout un arsenal d’outils agissant sur l’excellence du produit.

Gestion de projets avec Erlang/OTP

Magazine
Marque
GNU/Linux Magazine
Numéro
245
Mois de parution
février 2021
Spécialité(s)
Résumé

Un langage de programmation se doit d’être facile d’accès, que ce soit pour son apprentissage, la réalisation de concepts ou de produits finaux. La création de projets en Erlang se fait via les notions d’application et de release. Couplés à différents outils internes ou fournis par la communauté, ces principes permettent de créer un environnement de production flexible et maintenable sur le long terme, tout en facilitant la diffusion et le partage des modules conçus par les créateurs.

Netcat, l’histoire d’un couteau suisse pour le réseau

Magazine
Marque
Linux Pratique
Numéro
123
Mois de parution
janvier 2021
Spécialité(s)
Résumé

Lier le monde de l’administration système et celui du réseau n’est pas chose aisée, ni donné à tout le monde. De nombreux outils présents issus du monde de l’open source essaient désespérément d’y trouver une place. L’un d’entre eux a par ailleurs une longueur d’avance. Permettant de jouer avec la création de socket sur la couche transport du modèle OSI, Netcat rayonne dans le monde underground depuis déjà de nombreuses années. Rien de tel qu’une petite histoire pour parler de ce programme légendaire...

Les derniers articles Premiums

Les derniers articles Premium

Cryptographie : débuter par la pratique grâce à picoCTF

Magazine
Marque
Contenu Premium
Spécialité(s)
Résumé

L’apprentissage de la cryptographie n’est pas toujours évident lorsqu’on souhaite le faire par la pratique. Lorsque l’on débute, il existe cependant des challenges accessibles qui permettent de découvrir ce monde passionnant sans avoir de connaissances mathématiques approfondies en la matière. C’est le cas de picoCTF, qui propose une série d’épreuves en cryptographie avec une difficulté progressive et à destination des débutants !

Game & Watch : utilisons judicieusement la mémoire

Magazine
Marque
Contenu Premium
Spécialité(s)
Résumé

Au terme de l'article précédent [1] concernant la transformation de la console Nintendo Game & Watch en plateforme de développement, nous nous sommes heurtés à un problème : les 128 Ko de flash intégrés au microcontrôleur STM32 sont une ressource précieuse, car en quantité réduite. Mais heureusement pour nous, le STM32H7B0 dispose d'une mémoire vive de taille conséquente (~ 1,2 Mo) et se trouve être connecté à une flash externe QSPI offrant autant d'espace. Pour pouvoir développer des codes plus étoffés, nous devons apprendre à utiliser ces deux ressources.

Raspberry Pi Pico : PIO, DMA et mémoire flash

Magazine
Marque
Contenu Premium
Spécialité(s)
Résumé

Le microcontrôleur RP2040 équipant la Pico est une petite merveille et malgré l'absence de connectivité wifi ou Bluetooth, l'étendue des fonctionnalités intégrées reste très impressionnante. Nous avons abordé le sujet du sous-système PIO dans un précédent article [1], mais celui-ci n'était qu'une découverte de la fonctionnalité. Il est temps à présent de pousser plus loin nos expérimentations en mêlant plusieurs ressources à notre disposition : PIO, DMA et accès à la flash QSPI.

Les listes de lecture

9 article(s) - ajoutée le 01/07/2020
Vous désirez apprendre le langage Python, mais ne savez pas trop par où commencer ? Cette liste de lecture vous permettra de faire vos premiers pas en découvrant l'écosystème de Python et en écrivant de petits scripts.
11 article(s) - ajoutée le 01/07/2020
La base de tout programme effectuant une tâche un tant soit peu complexe est un algorithme, une méthode permettant de manipuler des données pour obtenir un résultat attendu. Dans cette liste, vous pourrez découvrir quelques spécimens d'algorithmes.
10 article(s) - ajoutée le 01/07/2020
À quoi bon se targuer de posséder des pétaoctets de données si l'on est incapable d'analyser ces dernières ? Cette liste vous aidera à "faire parler" vos données.
Voir les 53 listes de lecture

Abonnez-vous maintenant

et profitez de tous les contenus en illimité

Je découvre les offres

Déjà abonné ? Connectez-vous