La société Ericsson, pionnière dans le domaine des télécommunications, a été à l’origine de la création du langage Erlang. La bibliothèque standard offre toutes les méthodes pour gérer les connexions réseau, que ce soit pour créer son protocole au-dessus de TCP, UDP ou encore TLS, mais aussi en réutilisant des implémentations d’ores et déjà existantes au sein de l’écosystème fourni avec la release.
Un système distribué se doit de supporter nativement une ou plusieurs méthodes pour communiquer avec ses pairs. Il est donc normal qu’un tel environnement puisse supporter les primitives essentielles pour gérer des connexions réseau. Erlang/OTP n’est pas en reste de ce côté, et comme de nombreux autres langages présents sur le marché, il offre la possibilité d’utiliser nativement les interfaces réseau fournies par le système d’exploitation hôte, que ce soit un dérivé libre d’UNIX ou un système propriétaire comme Microsoft Windows.
Le monde du réseau est vaste, bouillonnant, imparfait et par conséquent, complexe. Un tel article ne peut être vu que comme une introduction aux principes de base permettant d’utiliser Erlang et sa machine virtuelle comme outil pour la création de clients ou de serveurs. De plus, comme tout système automatisé, une simple question ou un problème ne se limitera pas qu’à une unique réponse. Ce principe est évidemment exacerbé par la rudesse du réseau, enclin à s’accroître avec des applications interdépendantes, mais aussi par des implémentations protocolaires plus ou moins bien respectées, en fonction des constructeurs ou développeurs.
Cet article est le premier d’une série qui donnera la possibilité au lecteur de se familiariser avec la programmation réseau, mais aussi avec la suite d’outils mis à disposition avec l’environnement Erlang/OTP. Le texte qui va suivre permettra de se concentrer sur le protocole UDP, le second article présentera TCP et le dernier article parlera de SSL/TLS. Ces différents articles permettront aussi d’étendre les fonctionnalités du projet cache créé pour illustrer, mais aussi mettre en pratique les principes antérieurement acquis. Le code présenté ici a été testé avec la dernière version d’Erlang en date, la version R23, mais devrait être fonctionnel avec les autres versions actuellement maintenues.
1. Introduction
La gestion des connexions réseau en Erlang se fait au moyen de deux modules présents par défaut sur toutes les releases de la BEAM : le module gen_udp [1] permettant de gérer des connexions UDP, et le module gen_tcp [2], permettant de gérer quant à lui les connexions TCP. Ces deux protocoles de transport sont les fondements de l’Internet d’aujourd’hui. Utilisés par chacun de ses utilisateurs, mais fondamentalement opposés, car utilisant deux principes de fonctionnement très différents. Avant de rentrer dans le vif du sujet, quelques rappels essentiels sur la programmation réseau et système. Les systèmes d’exploitation UNIX utilisent généralement des descripteurs de fichiers, ou file descriptors en anglais, fréquemment nommé fd en anglais. Cette donnée aura ici bien souvent la forme d’un nombre entier unique, permettant de partager l’état d’une fonctionnalité contrôlée par le noyau du système et un programme utilisateur. Cette valeur permet de s’abstraire de la complexité inhérente à un ou plusieurs protocoles. Erlang cache cette information au sein d’un processus spécial nommé « port ».
Le noyau du système d’exploitation est ici responsable des couches basses issues du modèle OSI. La couche 1 ou « physique » représente le support de transmission, comme un câble ou des ondes électromagnétiques. La couche 2 ou « liaison de données », représente le support des connexions locales. La couche 3 ou « réseau » ainsi que la couche 4 ou « transport » permettent respectivement d’accéder à des réseaux distants et d’offrir un moyen de réception des informations envoyées ou reçues. L’article est centré sur les protocoles TCP et UDP, appartenant à cette dernière couche : « transport ». Le descripteur de fichier s’appellera ici « socket » et permettra de gérer une connexion réseau comme un fichier classique.
Les sockets UDP et TCP ont par ailleurs des caractéristiques communes, mais se comportent de façons différentes, suivant des règles imposées par lesdits protocoles. Évidemment, ces informations seront décrites lors de l’utilisation de chaque fonctionnalité dans les paragraphes suivants. Comme toute communication réseau, certaines informations sont nécessaires, et se retrouveront à pratiquement tous les niveaux du code. Une connexion réseau doit posséder au moins quatre attributs, d’une part les informations relatives à l’émetteur, une adresse source et un port source, d’autre part les informations liées au receveur, une adresse de destination et un port de destination. Si utilisées avec le protocole réseau IPv4, les adresses sont codées sur 32 bits. Utilisées avec le protocole réseau IPv6, les adresses sont codées sur 128 bits. Les ports, quant à eux, sont codés sur 16 bits, offrant la possibilité d’utiliser 65536 interfaces. Évidemment, en fonction du système d’exploitation utilisé, différentes conventions peuvent venir s’appliquer.
2. Gestion de flux sans connexion avec UDP
User Datagram Protocol ou UDP est un protocole de transport sans connexion défini dans la RFC 768 [3] et standardisé dans les années 80. La particularité de ce protocole tient dans le fait qu’aucune garantie de service n’est offerte. Les données transmises entre deux entités utilisant ce protocole se doivent de créer leurs propres règles pour garantir la bonne réception des informations partagées. Généralement utilisé dans des environnements ayant des contraintes de performance en autorisant la perte de paquets ainsi que la dégradation de services, ce protocole se retrouve couramment dans le monde du streaming audio ou vidéo, tel que la visioconférence, mais aussi dans le monde du jeu vidéo.
La meilleure façon de comprendre le fonctionnement d’UDP est de l’utiliser au travers d’un des nombreux services l’ayant pris comme base pour leur communication. Malheureusement, un grand nombre d’entre eux ne sont pas textuels et nécessitent donc la mise en place d’échange de données au format binaire. Dans le cadre de cet exemple, le protocole DNS, défini par la RFC 1035 [4], sera présenté. Ce protocole connu et utilisé par tous les internautes est le cœur de l’Internet. Il permet entre autres de lier une adresse IP à une chaîne de caractères, nommée aussi nom de domaine.
Les développeurs d’Erlang ayant été confrontés à l’implémentation de ce protocole lors de la création du langage, ils ont d’ores et déjà intégré un module interne portant le nom d’inet_dns. Aucune documentation n’existe pour ce module, et ce, pour une simple raison : il n’a pas pour vocation d’être utilisé par des modules utilisateurs, mais par des modules critiques du système comme le noyau Erlang. Par ailleurs, les fonctions de ce module étant exportées et visibles par les autres modules, il est tout à fait possible de l’utiliser pour des tâches bien précises ou pour de l’expérimentation. Attention tout de même, ces interfaces n’étant pas disponibles dans la documentation officielle, elles peuvent être susceptibles de changer à tout moment en fonction des versions. Le code suivant sera donc probablement à adapter à l’avenir.
Après le lancement du Shell Erlang au moyen de la commande erl, il est possible de charger les différents records pour simplifier la compréhension du retour des différentes fonctions qui vont suivre. Ce fichier se trouve normalement au niveau du code source du noyau. Pour récupérer le chemin utilisé au sein de la machine virtuelle, la fonction code:lib_dir/1 est utilisée, concaténée au nom du fichier contenant les records : inet_dns.hrl. Ce document est normalement livré avec toutes les versions d’Erlang. Pour rappel, un record est une structure de données fixe fonctionnant sur le modèle de clé/valeur. C’est en quelque sorte une version primitive des maps, construite autour d’un tuple. Après avoir récupéré le chemin, la fonction rr/1, propre au shell Erlang, est utilisée pour charger le fichier et ainsi pouvoir utiliser le sucre syntaxique associé aux records.
Une requête DNS est constituée de plusieurs champs qu’il est nécessaire de paramétrer. L’en-tête permet de configurer et de caractériser la requête envoyée ou reçue. Un identifiant aléatoire correspondant à un nombre entier est habituellement généré à chaque requête par le client. Le type de paquet est défini par plusieurs champs booléens expliqués dans la RFC. Pour générer cette première structure de données, la fonction inet_dns:make_header/1 sera ici utilisée. Elle récupère une liste de tuples, ou proplists, correspondant à chaque élément de l’en-tête à configurer.
La seconde étape consiste à concevoir la charge utile de la requête : l’information demandée au serveur distant. Dans cet exemple, la demande consistera à récupérer l’adresse IP liée au domaine erlang-punch.eu. Une adresse IP est stockée dans un enregistrement de type A et fait partie de la classe IN, un raccourci pour « Internet ». La création de la structure de données utilisée pour la requête se fait au moyen de la fonction inet_dns:make_dns_query/1. Cette dernière attend comme unique argument une proplist.
Pour éviter d’avoir une réponse contenant tous les enregistrements liés au domaine, il est aussi possible de demander au serveur de la filtrer avec seulement les informations voulues. Ce filtre est à rajouter au niveau du champ de requête additionnelle et la création de la structure de données se fait au moyen de la fonction inet_dns:make_rr/1. Tout comme les précédentes fonctions, elle attend comme argument une proplist.
Maintenant que les différentes parties du message comme l’en-tête, la charge utile de la requête ainsi que le filtre additionnel ont été créés, il est possible de concevoir la structure de données globale en utilisant la fonction inet_dns:make_msg/1. Cette fonction, tout comme les précédentes, attend en entrée une liste de tuples, chaque tuple correspondant à une partie du message utilisé pour concevoir la résolution DNS.
La structure de données à ce stade est complète, mais uniquement utilisable au sein de la BEAM. Pour pouvoir l’envoyer en dehors de la machine virtuelle, il est alors nécessaire de la sérialiser, faire en sorte que la structure de données ici abstraite soit convertie en une valeur binaire, compréhensible par le socket réseau ouvert. Pour ce faire, la fonction inet_dns:encode/1 permet de récupérer la structure de données stockée dans la variable ChargeUtile pour en faire un message de type bitstring.
La charge utile pourra donc être envoyée sur un socket UDP, créé au moyen de la fonction gen_udp:open/2. Le premier argument attendu par cette fonction est le port de réception à utiliser, 31415 dans cet exemple. Le second argument correspond aux options à passer au socket, le paramètre mode correspond au type de donnée attendu et renvoyé par l’interface. Le paramètre active permet de contrôler le comportement du socket, en le configurant avec la valeur false, le socket devient alors bloquant et attend obligatoirement une action extérieure pour retransmettre les informations reçues. S’il avait été configuré à true, le processus en charge du socket aurait alors retransmis les données reçues à un autre processus sous forme de messages Erlang.
Le socket stocké dans la variable Socket étant normalement ouvert et prêt à recevoir ou émettre des informations, il est dorénavant possible d’utiliser la fonction gen_udp:send/4. Cette fonction attend en premier lieu le socket utilisé, puis l’adresse de destination ainsi que le port. Le dernier argument correspond au contenu du message binaire stocké dans la variable ChargeUtile. Dans l’exemple suivant, le message sera alors envoyé au DNS public de l’association FDN (French Data Network) ayant comme adresse 80.67.169.12, en écoute sur le port UDP 53, le port utilisé par défaut pour le protocole DNS.
Après l’envoi de la charge utile, un client DNS s’attend normalement à recevoir une réponse avec les informations demandées. Pour ce faire, la fonction gen_udp:recv/3 doit être utilisée, et ce, uniquement avec un socket utilisant un mode de fonctionnement {active, false}. Le premier argument correspond au socket, le second argument correspond à la taille d’un buffer, 1024 octets dans cet exemple. Le dernier argument est une valeur d’expiration, configurée ici à 1000 ms.
Si un message est reçu, la fonction gen_udp:recv/3 doit retourner un tuple composé de la source de la réponse ainsi que du contenu de cette dernière. La charge utile de réponse est bien évidemment au format binaire et nécessite d’être décodée. Pour ce faire, la fonction inet_dns:decode/1 sera alors utilisée.
Utilisant le principe du pattern matching propre à Erlang, la réponse de la requête est décomposée et se trouve alors stockée dans la variable ReponseDNS. La structure de données récupérée correspond au record nommé #dns_rec{}, ou un tuple dont la première valeur correspondra à l’atom dns_rec, dans le cas où le fichier inet_dns.hrl n’aurait pas été préalablement chargé. La réponse du serveur DNS ayant été convertie dans un format facilement compréhensible, il est alors possible d’afficher la réponse du serveur distant. Pour ce faire, la méthode la plus simple est d’utiliser la fonction io:format/2 et d’imprimer sur la sortie standard le contenu de la variable Resultat.
Pour un œil profane, la réponse retournée à l’écran semble complexe, mais il n’en est rien. Le résultat est une liste de tuples contenant l’information demandée : le nom de domaine erlang-punch.eu, de type A et de classe IN, suivi de la valeur du TTL, suivi de l’adresse IP liée au nom de domaine, qui se trouve être 80.67.190.222. Si plusieurs adresses IP avaient été configurées pour le nom de domaine erlang-punch.eu, elles auraient été rajoutées à la suite de cette liste.
Maintenant que l’échange de données a été correctement orchestré, et si le socket en question n’a plus vocation à être utilisé, il revient au développeur le lourd fardeau de restituer ce descripteur de fichier au système hôte, en demandant explicitement sa fermeture. Pour ce faire, la fonction gen_udp:close/1 pourra être utilisée, ayant comme seul argument la variable contenant le socket à fermer.
Pour les lecteurs curieux, ou ceux désirant implémenter leur propre client DNS en Erlang, il est possible d’utiliser quelques techniques d’ingénierie inverse. Effectivement, le protocole DNS est généralement non chiffré, et donne la possibilité d’avoir directement le contenu des requêtes en clair. Le simple fait d’utiliser la commande système dig sous UNIX/Linux (ou nslookup sous Windows) sur le port UDP en écoute permettra de recevoir les données binaires convertibles directement dans la structure de données pour le DNS. Évidemment, sans réponse de l’applicatif Erlang, la commande dig se terminera avec une expiration, mais la charge utile sera quant à elle stockée, permettant ainsi d’imiter le comportement de cet outil.
Couplée à une commande comme tcpdump, la valeur des différents éléments de la requête devrait s’afficher et permettre de comprendre plus facilement, ou du moins, de s’inspirer des différents patterns. Erlang a été conçu aussi pour construire et déconstruire facilement des motifs, autant en profiter !
2.1 Conception d’un serveur UDP
La création d’un service utilisant le protocole UDP pour communiquer est triviale. Effectivement, UDP étant un protocole de transport sans connexion, aucune garantie d’acheminement de données ni état en particulier n’est à gérer. La création d’un serveur UDP se fait donc via les fonctions gen_udp:open/1 ou gen_udp:open/2. Le premier argument de cette fonction correspond au port d’écoute sur le système sous forme d’un entier positif compris entre 0 et 65535. Ces deux fonctions retournent alors une référence pour pouvoir gérer les différentes informations transmises.
Comme tout programme en Erlang, ce dernier commence par un en-tête permettant de définir le nom du module lié au nom du fichier, les fonctions qui seront exportées et potentiellement, le type de comportement (behavior) adopté par le module en cours de conception.
Le code précédent définit le module udp_server, il exporte les fonctions essentielles relatives à l’utilisation du behavior gen_server [5] qui sont init/1, handle_cast/2, handle_call/3 ainsi que la fonction handle_info/2. Pour rappel, handle_cast/2 permet la gestion des messages asynchrones, handle_call/3 permet de gérer les appels synchrones et handle_info/2 permet de recevoir les messages ne suivant pas le modèle défini par OTP, tel que les messages bruts envoyés par d’autres processus. La dernière partie permet d’intégrer un en-tête pour le système de logging intégré dans Erlang depuis la version 21, nommé logger [6]. Le rajout de cet en-tête permet au développeur d’utiliser différentes macros permettant de gérer l’envoi de messages d’événements, activables individuellement au niveau d’une application ou d’un module.
La fonction init/1 permet d’initialiser l’état d’un processus utilisant le behavior gen_server, et plus généralement les autres types de behaviors supportés par OTP. La fonction init/1 est triviale, elle attend en entrée un argument, dans ce cas-ci un nombre entier représentant le port UDP utilisé par le receveur. Cette valeur est alors utilisée pour demander au système d’allouer le descripteur de fichier au moyen de la fonction gen_udp:open/1. La valeur du processus de contrôle, appelé aussi « port » dans le vaste univers Erlang, sera appelée ici processus de contrôle pour éviter une confusion évidente. Cette donnée est alors récupérée, et affichée via logger lorsque celui-ci est configuré avec un niveau debug. Finalement, la fonction init/1 retourne l’état du processus, c’est-à-dire la donnée qu’il conservera durant son exécution, qui correspondra ici à la valeur du processus de contrôle.
La fonction terminate/2 est exécutée lors de l’arrêt du processus. Dans ce cas-ci, le socket UDP doit être systématiquement libéré en utilisant la fonction gen_udp:close/1 après utilisation du processus. Il serait tout à fait possible de définir plusieurs actions en fonction de la raison de l’arrêt, mais ici les effets de bord se trouvent au niveau du socket UDP, dont le processus à la responsabilité.
La fonction handle_cast/2 ne sera pas utilisée dans cette partie de l’article. Tous les messages récupérés par ce callback seront simplement affichés par le logger. Le lecteur pourra modifier à sa guise cette partie du code pour pouvoir, par exemple, modifier l’état du serveur, et ainsi altérer son comportement.
Tout comme la fonction handle_cast/2, la fonction handle_call/3 ne sera pas utilisée pour le moment et se contentera d’afficher le message reçu en le transférant au module logger.
La partie réellement utile du module udp_server est directement liée à la fonction handle_info/2, qui aura pour charge de récupérer les messages en provenance du processus de contrôle. Effectivement, lors de l’initialisation du processus via la fonction init/1, et plus particulièrement au moment de demander l’accès en lecture et écriture du port UDP au moyen de la fonction gen_udp:open/1, un processus de contrôle est lancé, puis directement lié au processus utilisant le behavior gen_server. Le processus de contrôle transfère alors tous les messages dans un format spécifique, sous forme d’un tuple composé de 5 valeurs. La première valeur est un atom ayant pour valeur udp, le second élément contient le processus de contrôle sous forme d’un PID, et le troisième élément contient la source du message, c’est-à-dire l’adresse IP de l’émetteur sous forme d’un tuple. La quatrième valeur correspond au port utilisé par l’émetteur pour envoyer le paquet UDP. Finalement, le cinquième et dernier élément contient le message sous forme d’un bitstring. Voici un exemple de la structure de données complète générée pour l’occasion :
La fonction udp_server:handle_info/2 récupère donc un message en provenance d’un client, affiche les informations du message sur la sortie standard au moyen de la macro ?LOG_DEBUG/2, puis renvoie une réponse au client en utilisant le message reçu et en y rajoutant le préfixe « echo: ». La fonction ne retourne aucune information et ne modifie en aucun cas l’état du processus, qui reste le processus de contrôle.
Après enregistrement de ce code, il est nécessaire de le compiler. Assumant qu’un shell Erlang est en cours de fonctionnement chez le lecteur, il est devient alors possible de compiler le programme au moyen de la fonction c/1. Pour pouvoir voir les données reçues par le module, le module logger doit être alerté de la granularité de l’affichage des messages, en utilisant la fonction logger:set_module_level/2. le premier argument faisant référence au module à déboguer, et le second à son niveau de verbosité. Le serveur peut alors être lancé au moyen de la fonction gen_server:start/3, prenant en argument le nom du module à démarrer, suivi de l’argument de la fonction udp_server:init/1, puis d’une liste vide pour la partie argument du behavior. Elle retourne le PID du processus actif, qui se chargera de faire office de serveur UDP.
Il est évidemment possible d’utiliser plusieurs méthodes pour valider le bon fonctionnement de ce serveur. Le lancement d’un outil comme netcat dans une autre console peut, par exemple, permettre d’offrir un premier diagnostic. Pour rappel, netcat est un outil permettant de gérer les sockets réseau sur les systèmes UNIX.
netcat, dont le rôle a été ici fixé comme client, récupère le contenu du message à transmettre via son entrée standard, et la retransmet en utilisant le protocole UDP à l’adresse 127.0.0.1 au niveau du port 31415. La réponse donnée par le serveur est celle attendue, le message « test » précédé du terme « echo: ». Qu’en est-il pour la gestion de plusieurs connexions UDP ? Pour cette première partie, ce code se base simplement sur la linéarisation des requêtes reçues au moyen de la boite mail, mais aussi en s’appuyant sur l’implémentation du module gen_udp.
Encore une fois, netcat, couplé à la commande xargs, permet de générer simplement un flux de données pour voir comment se comporte le serveur UDP précédemment démarré. Le retour de cette commande permet de voir que les messages sont correctement reçus par le serveur, et retransmis au client avec l’altération qui a été voulue. Une autre méthode est d’utiliser directement Erlang avec le module gen_udp, et de l’utiliser comme client...
2.2 Conception d’un client UDP
La création d’un client UDP est très proche de celle de la création d’un serveur. Comme vu lors de l’introduction, un client a besoin d’un port d’émission pour pouvoir envoyer un message à un destinataire, possédant lui aussi un port. Contrairement à l’utilisation d’un protocole d’ores et déjà utilisé, et ce, au moyen du shell Erlang, cette partie permettra de créer un client UDP classique, tout en utilisant une nouvelle fois le behavior gen_server. Ce morceau de code agira comme une interface haut niveau, permettant de gérer par exemple des temps d’expiration de réponses.
L’en-tête reste conventionnel pour une utilisation du behavior gen_server, et est similaire à la version proposée pour le module du serveur UDP précédemment créé. Le nom du module est défini, suivi de l’export des fonctions, du rajout du behavior, puis de l’inclusion de l’en-tête pour logger.
La fonction init/1, quant à elle, a beaucoup évolué. Tout d’abord, l’argument unique attendu n’est plus un nombre, mais une structure de données map. Plusieurs clés sont attendues, telles que l’adresse et le port de destination, le message à envoyer au destinataire ainsi qu’un délai d’expiration de la connexion. Pourquoi utiliser une structure de données sous forme d’une map ? En cas d’oubli d’une des valeurs attendues, la fonction init/1 s’arrêtera brusquement en générant une exception, permettant ainsi de mettre en pratique la philosophie « Let It Crash ».
Un client UDP a aussi besoin d’être configuré avec un port d’émission, ce dernier doit-être un entier compris, si possible, entre 1024 et 65535, pour éviter d’avoir un problème de droit avec un utilisateur lambda. L’algorithme présenté ici est élémentaire, l’utilisation d’un nombre aléatoire via la fonction rand:uniform/0, multiplié par la différence entre 65535 et 1024. Le port est alors généré en additionnant la valeur plafond du calcul précédent avec 1024. Cet algorithme pourrait très bien être externalisé dans une bibliothèque commune, réutilisable par les autres services utilisant les mêmes caractéristiques.
Après avoir récupéré toutes les informations nécessaires, le socket UDP est créé en utilisant un mode binaire et actif. Le message est directement envoyé en utilisant gen_udp:send/4. Un temporisateur est créé en utilisant la fonction timer:apply_after/4, cette fonction permettra d’envoyer un message d’alerte sous forme d’un atom au processus lancé en cas de non-réponse du destinataire. Finalement, l’état renvoyé par le module est le socket retourné par gen_udp:open/2. Comme vu dans les précédents articles, Erlang/OTP regorge de modules très utiles, pouvant être utilisés dans de nombreuses situations.
En cas d’arrêt du processus, la fonction terminate/2 se charge de fermer le socket créé dans le cadre de l’utilisation du client. À noter qu’il n’y a pas de gestion fine de l’arrêt, tout type de raisons non gérées aura pour résultat la fermeture du socket en question et ainsi de le retourner au système. Ce principe se marie parfaitement, encore une fois, avec le concept du « Let It Crash ».
La fonction handle_cast/2 réagit aux messages contenant l’atom timeout. Lors de l’obtention d’un tel message, le processus s’arrête. Ce type de message est généré par la fonction timer:apply_after/4.
La fonction handle_call/3 ne fait que recevoir les messages utilisant les appels synchrones, retournant à chaque fois l’atom ok sans aucune altération de l’état du processus.
Contrairement à la fonction init/1, la fonction handle_info/2 est bien plus simple que celle qui avait été présentée lors de la création du module udp_server. Lors de la réception d’une réponse, cette fonction s’arrêtera alors d’elle-même en affichant la réponse du serveur via le module logger. La structure de données reçue par le client est identique à celle reçue par le serveur, correspondant à un tuple dont la première valeur est un atom udp.
L’utilisation du client pourra se faire, une fois de plus, au moyen du shell Erlang. Tout comme le serveur, le module udp_client sera compilé à l’aide de la fonction c/1, logger sera informé du niveau de verbosité désiré pour le module, puis une connexion sera initialisée vers un serveur prédéfini. Le serveur utilisé dans l’exemple fait évidemment référence au serveur udp_server conçu dans la partie précédente.
Un message de debug contenant l’information reçue du serveur devrait alors s’afficher sur l’écran, contenant le message altéré. Évidemment, il faudra d’abord s’assurer que le module udp_server créé lors de la précédente partie soit correctement démarré et configuré.
Finalement, une autre méthode peut-être mise en avant au moyen de la commande netcat, qui recevra le contenu envoyé et les affichera sur la sortie standard du shell courant. La commande est une fois de plus triviale, mais permettra d’avoir un aperçu rapide du fonctionnement du client, sans pour autant passer par le shell Erlang.
Ce module udp_client reste un exemple, il est loin d’être utilisable en production, mais permet de voir la logique derrière le développement d’une application réseau utilisant UDP. Une nouvelle fois, le lecteur est invité à faire les modifications qu’il désire pour créer le client qui lui convient.
3. Intégration d’UDP dans un projet
Un rappel est probablement nécessaire pour les nouveaux lecteurs. Lors de la création de cette série d’articles sur Erlang, le module cache fut créé. Ce dernier permettait d’illustrer les fonctionnalités d’Erlang/OTP, évoluant au gré des différents besoins comme n’importe quel projet dans un contexte réel. Le principe de ce module est d’offrir une méthode pour le stockage d’information entre plusieurs processus, fonctionnement similaire à celui d’un service comme Redis ou MongoDB. Cette application est malheureusement cloisonnée à la communication interprocessus au sein de la BEAM. Pourquoi ne pas alors lui permettre de communiquer avec l’extérieur, en lui rajoutant le support du réseau, tout en profitant d’UDP comme protocole de transport ?
3.1 Création du module pour le serveur UDP
Quelle forme pourrait donc prendre un tel module ? Tout d’abord, par souci de clarté, le nom du module utilisant UDP devra être calqué sur celui des autres modules. Il sera baptisé cache_udp_listener, et permettra de router les connexions UDP depuis le client vers le module de cache interne.
Le code présent ici est identique à celui présenté dans l’exemple sur UDP. Les fonctions init/1, terminate/2, handle_cast/2 et handle_call/3 sont exportées de la même façon, ne changeant pas de comportement. Pour plus de détails, le lecteur est invité à relire la partie sur ce sujet.
Une fois de plus, les messages en provenance du client UDP seront reçus par la fonction handle_info/2 dans le format de données vu précédemment. Cette fonction, pour des soucis de flexibilité, envoie le contenu des messages vers la fonction command/2, qui est interne au module cache_udp_listener, mais qui pourra être exportée, ou mieux encore, placée dans un module dédié pour utiliser cette fonctionnalité avec d’autres protocoles, comme TCP ou TLS.
Le cœur de la gestion des requêtes UDP se trouve dans la fonction command/2. Celle-ci étend en quelque sorte la fonction handle_info/2 en récupérant les mêmes informations, mais en isolant les effets de bord. En cas de soucis, c’est la partie de ce code qui risquera de crasher. L’algorithmie est assez simple, la première étape de ce code permet de parser le message au moyen d’une expression rationnelle via la fonction re:split/3. Le message reçu est découpé en 3 parties séparées par un espace unique, la donnée retournée et stockée dans la variable Parse est alors une liste de binary.
La deuxième étape permet de définir l’action en fonction du type de commande reçu. La commande add suivie d’une clé et d’une valeur exécutera la fonction cache:add/3 et rajoutera en conséquence l’information au niveau de l’application cache. La commande get exécutera la fonction cache:get/2 et retournera la réponse à l’utilisateur au moyen de la fonction gen_udp:send/4. Les commandes delete, get_keys et get_values fonctionnent sur le même principe et appelleront respectivement les fonctions cache:delete/2, cache:get_keys/1 et cache:get_values/1. En cas de réception d’une commande non supportée, la fonction affiche un message d’alerte et retourne un atom ok.
Si l’application cache est démarrée, le service UDP devrait alors maintenant être fonctionnel et permettre à des clients d’insérer des données dans le système de cache. Malheureusement, ce code n’est pas à l’épreuve des balles, et pourrait causer quelques problèmes à des utilisateurs non avertis. Un exercice supplémentaire laissé ici pour le lecteur, mais aussi un exemple concret pour la mise en place d’un processus de supervision, permettant de redémarrer le service UDP en cas de crash. Ce nouveau module utilisera le behavior supervisor et s’appellera cache_udp_sup. Il sera démarré directement avec le processus de supervision de l’application cache. Pour rappel, un processus de supervision a la lourde tâche de s’assurer que ses processus enfants soient bien vivants. Si ce n’est pas le cas, il a la responsabilité de les redémarrer.
Un module de supervision ne diffère pas des autres modules. L’en-tête contient le nom du module à utiliser, le nom du behavior à utiliser, puis la seule et unique fonction à exporter : init/1.
La fonction init/1 est responsable de l’initialisation du processus de supervision. Cette dernière doit retourner le type de comportement attendu par ce dernier, ainsi que la spécification du ou des enfants à contrôler. Ce superviseur utilise une stratégie one_for_one : si un enfant meurt, il est redémarré automatiquement. Le niveau d’intensité et la période de redémarrage sont définis arbitrairement. L’unique enfant à gérer est le processus démarré via le module cache_udp_listener. Ce module utilisant le behavior gen_server, il est démarré au moyen de la fonction gen_server:start_link/4. Pour rappel, le tuple présenté ici est constitué du module à utiliser pour le démarrage, suivi de la fonction à utiliser, puis de ses arguments au format d’une liste. Ce tuple est donc équivalent à la commande suivante :
Le module cache_udp_listener sera donc mis automatiquement en écoute sur le port UDP 31415 lors du lancement du superviseur.
Le dernier élément de ce programme à modifier est le superviseur applicatif, celui directement lancé lors du démarrage de l’application cache. Il a été créé lors des derniers articles et se nomme cache_sup. La modification est triviale, mais vitale pour pouvoir profiter du service UDP lors de l’initialisation :
La partie serveur est maintenant complète ! Le lecteur impatient pourra démarrer l’application via le shell Erlang, lancé avec rebar3. Après le lancement du shell, l’application doit normalement être démarrée, puis écoutée sur toutes les interfaces du système au niveau du port UDP 31415.
Même sans avoir de client sous la main, il est toujours possible de tester l’application au moyen de la célèbre commande netcat, d’ores et déjà utilisée dans les parties précédentes.
Utiliser netcat pour la partie développement est accessible au début, mais un client est nécessaire pour pouvoir réellement profiter de toutes les fonctionnalités d’Erlang. Accessoirement, l’utilisation d’une interface dédiée est bien plus facile à gérer que d’utiliser une commande externe.
3.2 Création du module pour le client UDP
Une interface en ligne de commande serait du plus bel effet pour ce programme de cache… Une telle commande permettrait d’interagir facilement avec l’application d’une manière portable et scriptable. Une nouvelle épreuve à développer dans un futur article. Pour le moment, il est nécessaire d’avoir un client UDP minimaliste, permettant de gérer l’intégration des nouvelles fonctionnalités et de valider le fonctionnement de l’application. Ce module sera nommé cache_udp_client. Le client présenté dans la partie d’introduction permettait de gérer des cas complexes d’échange de données en dédiant un processus à cet échange. Dans le cas du service de cache, le client pourra être bien plus simple, et n’offrir que quelques fonctions permettant de manager les données au niveau du serveur. Il n’y aura donc ici aucun behavior utilisé.
Le module utilise un en-tête très simplifié, en donnant accès à des fonctions dont le nom est explicite. Comme dit précédemment, le client se doit d’être simple, étant donné qu’il sera le premier outil utilisé pour communiquer avec le service, le maximum de confusion doit être évité.
La première catégorie de fonctions exportées, send_async/3, send_sync/3 et send_sync/4, sont utilisées pour faciliter le transfert des informations vers le service. Étant donné qu’UDP ne possède aucune gestion d’état, seul un délai de réponse permettra de valider si les informations ont été correctement reçues.
La seconde catégorie de fonctions correspond directement aux commandes d’ores et déjà supportées par le service de cache, telles que add/4, delete/3, get/3, get_keys/2 et get_values/2. Ces fonctions permettent d’envoyer directement les commandes au moyen des 3 fonctions de la première catégorie.
Finalement, le dernier groupe de fonctions n’est pas exporté, et permet de faciliter la création des différentes commandes ou d’éviter la répétition de code. La fonction source_port/0 permet de retourner un nombre entier compris entre 1024 et 65535. Les fonctions join/1 et join/2 permettent de concaténer les différentes commandes au format binaire, séparées par un espace. Il ne reste plus qu’à tester ce client. Après avoir démarré le service au moyen de rebar3 et de sa sous-commande shell, les fonctions du module exportées devraient alors être utilisables. Si le shell est déjà démarré, la fonction r3(compile) pourra être utilisée pour gagner du temps.
Le client UDP est fonctionnel. Des données peuvent être facilement rajoutées sans passer par un outil externe.
3.3 Création des tests fonctionnels
« Extra Periclitor nulla salus », soit, en français, « En dehors du Test, il n’y a pas de salut », cette expression latine serait attribuée à Cyprien de Carthage, lors de la relecture d’un programme dont la couverture de test était proche de zéro. Il serait alors fortement déplacé de ne pas honorer sa mémoire en écrivant au moins une suite de tests pour valider le bon fonctionnement des différents modules écrits dans cette partie de l’article, et en particulier celui du service. Erlang/OTP est livré avec une application permettant de facilement créer des tests d’intégration, nommés common_test, d’ores et déjà présentés dans un précédent article.
Le module cache_udp_SUITE a besoin d’exporter obligatoirement certaines fonctions pour pouvoir exécuter correctement les tests.
La fonction all/0 permet de lister les différents tests à exécuter au sein de la suite de tests. Ici, le seul test sera nommé udp et fera donc référence à la fonction udp/1.
La fonction suite/0 permet de configurer le comportement du module common_test lors de son exécution. Rien d’exceptionnel à rajouter ici, la configuration par défaut est amplement suffisante pour pouvoir exécuter sereinement les différents tests.
Les fonctions init_per_suite/1 et end_per_suite/1 permettent respectivement d’initialiser la suite de tests et de la clôturer. La première fonction aura pour responsabilité de s’assurer que l’application cache est correctement démarrée, alors que la seconde aura pour tâche de l’arrêter.
Les fonctions inet_per_testcase/2 et end_per_testcase/2 permettent d’initialiser ou de finaliser les différents tests. Ces fonctions ne seront pas utilisées ici.
La dernière fonction à présenter n’est autre que udp/1, qui déroulera un scénario d’ores et déjà vu lors du test manuel du client UDP. Une clé associée à une valeur est ajoutée, celle-ci est récupérée, ainsi que toutes les clés et toutes les valeurs. Finalement, la clé est supprimée et le client UDP doit retourner un code d’erreur, car il ne reçoit aucune réponse du service.
Conclusion
La programmation réseau n’est pas des plus aisées, mais une fois de plus, Erlang réussit là où de nombreux langages échouent. Le très haut niveau d’abstraction et le système de messages permettent d’agir sur les données reçues comme un routeur recevrait des paquets réseau. La flexibilité d’Erlang/OTP permet aussi de connecter rapidement une application, originalement prévue pour un usage interne, à des outils externes utilisant d’autres langages.
Il est bien rare de commencer un cours sur le réseau en utilisant UDP. Pourtant, ce protocole est simple d’accès, il n’a pas de fonctionnalités très avancées, mais permet de concevoir des outils assez rapidement. Évidemment, un tel programme n’est pas à utiliser en production. De nombreux problèmes de stabilité ont été laissés au lecteur.
Ce premier article sur la création d’applications réseau touche à sa fin. Il n’est que la partie visible d’un gigantesque iceberg. La simplicité d’intégration est incroyable, mais d’autres problèmes bien plus complexes arriveront dans les prochains épisodes de la série.
Pour finir, le code présent dans ces quelques pages est disponible sur le dépôt GitHub de l’auteur [7].
Références
[1] Documentation de gen_udp : https://erlang.org/doc/man/gen_udp.html
[2] Documentation de gen_tcp : https://erlang.org/doc/man/gen_tcp.html
[3] Spécification du protocole UDP (RFC-768) : https://tools.ietf.org/html/rfc768
[4] Spécification du protocole DNS (RFC-1053) : https://tools.ietf.org/html/rfc1053
[5] Documentation du behavior gen_server : https://erlang.org/doc/man/gen_server.html
[6] Documentation du module logger : https://erlang.org/doc/man/logger.html
[7] Dépôt des sources de l’auteur : https://github.com/niamtokik/articles
Pour aller plus loin
Pour les lecteurs curieux désireux d’en savoir plus sur UDP ou de voir comment intégrer ce protocole à d’autres services, voici une liste non exhaustive de projets utilisant UDP :
- erldns : https://github.com/dnsimple/erldns
- reliable_udp : https://github.com/loguntsov/reliable_udp
- quic : https://github.com/voxoz/quic
Bonne programmation et bonne lecture !