Mis à jour le 31/10/2013
  • Facile
Connectez-vous ou inscrivez-vous gratuitement pour bénéficier de toutes les fonctionnalités de ce cours !

Introduction du cours

Ce tutoriel a pour but de vous apprendre à vous servir des sockets en Erlang. Par sockets on désigne l'interface permettant la communication entre un système unique et un réseau utilisant le protocole TCP/IP. Contrairement à d'autres langages de programmation (tels que le C) les sockets sont inclus dans la bibliothèque standard du langage, vous n'aurez donc rien de plus à télécharger ou installer pour vous en servir.

Pour suivre ce tutoriel il est impératif que vous connaissiez le langage Erlang, aucune autre connaissance n'est requise.

À la fin de ce tutoriel vous serez capable de créer un serveur de communication semblable à celui de M@teo21 dans son tutoriel sur l'utilisation des sockets avec la bibliothèque Qt (notez d'ailleurs qu'il utilise une bibliothèque à part, car en C++ non plus les sockets ne sont pas fournis avec le langage).

Présentation

Communiquer : un problème, deux solutions

Comme vous le savez certainement, Erlang se présente comme un langage de référence pour tout ce qui touche à la communication entre processus, que ce soit en local ou en réseau. Pour cela deux options s'offrent à vous :

  1. distribuer vos programmes, grâce au système de noeuds offert par Erlang ;

  2. utiliser les sockets.

La distribution par l'intermédiaire des noeuds est sans aucun doute un système très puissant, et intéressant. Pour en résumer le principe : vos processus vont être répartis sur les nœuds que vous spécifiez, et peuvent sans problème communiquer entre différents noeuds. Là où ça devient intéressant c'est que vous pouvez disséminer vos nœuds un peu partout sur Internet, tout en gardant la même facilité de conversation entre vos processus.

La deuxième possibilité, l'utilisation des sockets, est la plus accessible dans la plupart des langages de programmation actuels. C'est ce qui a motivé le choix de cette solution plutôt que de la première pour le sujet du tutoriel. En effet grâce aux sockets vous pourrez faire communiquer vos programmes avec des programmes écrits dans d'autre langages (par exemple en OCaml ou encore en Python), alors que le système de nœuds est propre à Erlang.

Les sockets, vue détaillée

Dans ce tutoriel nous allons nous intéresser aux protocoles TCP et UDP. UDP permet aux applications de s'envoyer de courts messages, sans garanties. C'est-à-dire que vous ne pouvez jamais être sûr qu'un message ne se perdra pas, ou n'arrivera pas en retard. Utiliser TCP, par contre, vous assure que tous les messages seront envoyés et/ou reçus, tant que la connexion est établie.

En Erlang deux modules sont offerts pour utiliser ces protocoles, ce sont gen_udp et gen_tcp.

Pour en savoir plus sur les sockets, référez-vous à ce tutoriel.

Le protocole TCP

Dans cette partie nous allons voir comment utiliser le module gen_tcp, qui va nous permettre de communiquer en suivant le protocole TCP.

Les bases

Connexion et déconnexion

La première fonction du module que vous devrez utiliser lorsque vous voudrez vous connecter à un serveur est la fonction connect, qui va établir la communication avec le correspondant souhaité.

Cette fonction s'utilise ainsi : connect(Host, Port, Options)

Host est une variable qui peut contenir soit une chaîne de caractères, soit un atome, soit une adresse ip. En Erlang les adresses ip sont représentées sous forme de tuples, je vous invite à consulter la documentation du module inet pour plus d'informations (erl -man int ).

Port est une variable de type integer, qui contient le numéro du port sur lequel vous voulez ouvrir la connexion. La variable Options est une liste d'options, qui sont quant à elles sont très nombreuses et diverses. Vous avez par exemple list et binary, qui servent à déterminer si vous voulez recevoir les données sous formes de listes (donc des chaînes de caractères) ou de binaires. Une autre option intéressante est le couple {packet, PacketType} , où plusieurs choix s'offrent à vous pour PacketType :

  • raw (ou 0) signifie que vous ne souhaitez pas empaqueter les données ;

  • 1, 2 ou encore 4, qui spécifient le nombres d'octets par paquet ;

  • line, avec cette option le paquet est une ligne terminée par un retour à la ligne (\n) ;

  • d'autres options qu'il n'est pas nécessaire de détailler ici, si jamais vous voulez plus d'informations référez-vous à la documentation de la fonction setopts du module inet.

Une fois la tentative de connexion effectuée la fonction va vous renvoyer un couple du type {ok, Socket} si la connexion a réussi, {error, Raison} sinon.

Quand vous aurez fini de communiquer, il vous faudra fermer la connexion si votre correspondant ne le fait pas pour vous, pour cela vous avez la fonction close/1 à votre disposition. L'argument attendu est la socket retournée par la fonction connect.

Envoi et réception

Une fois la connexion effectuée, vous pouvez commencer à vous amuser à envoyer des messages et en recevoir.

Pour envoyer un message il faut utiliser la fonction send/2. Elle attend deux arguments, le premier est la socket renvoyée par connect lors de l'établissement de la connexion. La seconde est un paquet, dont le type est un binaire ou une liste, en fonction des paramètres de connexion que vous avez choisis.

Pour la réception deux méthodes s'offrent à vous, la première est l'utilisation de la fonction recv/2. En premier paramètre elle attend la socket, en deuxième la longueur du paquet à recevoir. Cette longueur n'a d'importance que si vous êtes en mode raw. Si vous mettez cet argument à 0, tous les octets contenus dans le paquet seront réceptionnés, si par contre la valeur est supérieure à 0, seul un nombre limité d'octets sera reçu, celui fixé.

L'autre méthode possible est d'utiliser la structure receive ... end que vous avez probablement l'habitude d'utiliser lorsque vous développez des applications concurrentes. Pour distinguer les messages normaux des messages envoyés par le protocole TCP, il vous faudra filtrer vos messages en suivant le motif {tcp, Socket, Paquet} . L'autre type de message à repérer est si la connexion a été fermée par votre interlocuteur, vous pouvez vérifier cela grâce au format {tcp_closed, Socket} .

Exemple

Vous en savez désormais assez pour créer un petit client qui va envoyer une requête HTTP à une adresse donnée et recevoir la réponse. Voici le code :

-module(exemple1_client).
-export([get_url/1]).

get_url(Host) ->
    case gen_tcp:connect(Host, 80, [list, {packet, line}]) of
	{error, Reason} -> io:format("Erreur: ~w~n", [Reason]);
	{ok, Socket} ->
	    gen_tcp:send(Socket, "GET / HTTP/1.0\r\n\r\n"),
	    wait_for_answer(Socket, [])
    end.

wait_for_answer(Socket, Acc) ->
    receive
	{tcp, Socket, Packet} ->
	    wait_for_answer(Socket, [Packet|Acc]);
	{tcp_closed, Socket} ->
	    io:put_chars(lists:reverse(Acc));
	_ ->
	    wait_for_answer(Socket, Acc)
    end.

Voici ce que ça donne quand je teste de mon côté :

Erlang (BEAM) emulator version 5.6.3 [source] [async-threads:0] [hipe]
[kernel-poll:false]

Eshell V5.6.3  (abort with ^G)
1> c(exemple1_client).
{ok,exemple1_client}
2> exemple1_client:get_url("www.google.fr").
HTTP/1.0 302 Found
Location: http://www.google.fr/
Cache-Control: private
Content-Type: text/html; charset=UTF-8
Set-Cookie:
PREF=ID=b1ad4e2e00926034:TM=1222866614:LM=1222866614:S=-zYQ1v-uiG6a1_zC;
expires=Fri, 01-Oct-2010 13:10:14 GMT; path=/; domain=.google.com
Date: Wed, 01 Oct 2008 13:10:14 GMT
Server: gws
Content-Length: 218
Connection: Close

<HTML><HEAD><meta http-equiv="content-type" content="text/html;charset=utf-8">
<TITLE>302 Moved</TITLE></HEAD><BODY>
<H1>302 Moved</H1>
The document has moved
<A HREF="http://www.google.fr/">here</A>.
</BODY></HTML>
ok
3>

Écoute

Maintenant que vous savez créer un client, intéressons-nous à la création d'un serveur. Contrairement au client, un serveur ne se charge pas d'effectuer la connexion. Tout ce qu'il a à faire c'est d'attendre que quelqu'un essaye de se connecter, et ensuite décider s'il accepte la connexion ou non.

Pour la phase d'écoute vous devrez utiliser la fonction listen/2, qui en premier argument attend le port sur lequel écouter. En deuxième argument elle attend une liste d'options semblable à celle de la fonction connect que nous avons vu précédemment. Cette fonction renverra soit un couple {error, Raison} , soit {ok, ListenSocket} .

Une fois que listen aura fini de s'exécuter, vous utiliserez ListenSocket en association avec la fonction accept/1, qui va elle-même vous retourner un couple {ok, Socket} , ou bien entendu {error, Raison} en cas d'erreur.

Quand le temps vous manque

Il arrive parfois qu'on veuille laisser un temps limité à une action pour s'effectuer, comme par exemple : « t'as 30 secondes pour te connecter, si ça prend plus de temps laisse tomber », ou encore « si tu reçois pas de message dans les deux prochaines minutes ferme la connexion » (oui, parce que je suis persuadé que vous aussi vous parlez à vos programmes :p ).

Pour faire cela on utilise ce qu'on appelle des timeouts. Parmi toutes les fonctions que l'on a vues précédemment, celles acceptant un timeout sont : connect, accept, et recv. Pour toutes ces fonctions il suffit d'ajouter un argument lors de l'appel, la durée (en millisecondes) que vous voulez laisser à la fonction pour s'exécuter.

Si jamais vous utilisez la structure receive ... end , vous allez pouvoir utiliser un autre mot clé, à savoir : « after ». La structure est la suivante :

receive
    {tcp, Socket, Request} -> foo;
    {tcp_closed, Socket} -> bar
after Timeout ->
    baz
end

Où Timeout est un entier.

Applications

Un serveur basique

Nous allons désormais nous servir de ce que vous venez d'apprendre pour créer un serveur basique. Basique dans le sens où il ne va gérer qu'une seule connexion à la fois.

Pour cet exercice je vais vous demander de créer un serveur qui va servir de lien entre le client et un serveur IRC. Ça peut vous sembler bizarre, mais l'idée m'est venue quand j'ai voulu me connecter à IRC depuis mon lycée. Il est en effet apparu que tous les ports du réseau sauf quelques ports particuliers étaient bloqués. J'ai donc eu l'idée de créer un programme qui servira de passerelle entre deux ports.

L'idée c'est de créer un serveur qui attend une connexion sur un port quelconque (mettons le port 8484), et qui une fois la connexion effectuée va ouvrir une connexion avec un serveur IRC et va ensuite transférer tous les messages que lui envoie le client vers le serveur et inversement.

Cet exercice est particulièrement intéressant car il va à la fois vous faire créer un serveur, mais aussi un client. Vous mettrez ainsi en application tout ce que nous venons de voir.

Les informations dont vous avez besoin sont l'adresse du serveur IRC, mettons qu'on veuille se connecter à EpiKnet, ce sera : irc.epiknet.org. Le port associé étant le port 6667.

Voici le code :

-module(serveur).
-export([start/0]).

start() ->
    case gen_tcp:listen(8484, [list, {packet, line}]) of
        {ok, Listen} ->
	    {ok, Socket} = gen_tcp:accept(Listen),
	    gen_tcp:close(Listen),
	    {ok, Irc} = gen_tcp:connect("irc.epiknet.org", 6667, [list, {packet, line}]),
	    loop(Socket, Irc);
	{error, Reason} ->
            io:format("Erreur: ~s~n", [Reason])
    end.

loop(Socket, Irc) ->
    receive
        {tcp, Socket, Request} ->
            gen_tcp:send(Irc, Request),
            loop(Socket, Irc);
        {tcp, Irc, Request} ->
            gen_tcp:send(Socket, Request),
            loop(Socket, Irc);
        {tcp_closed, Irc} ->
            io:format("Connexion à l'hôte perdue.~n"),
            gen_tcp:close(Socket);
	{tcp_closed, Socket} ->
            io:format("Fin de la connexion.~n"),
            gen_tcp:close(Irc)
    end.

Un serveur parallèle

En testant le code précédent, vous aurez pu remarquer qu'il fonctionne très bien pour la première connexion, mais si vous souhaitez lancer une deuxième connexion pendant que la première est toujours ouverte, vous allez échouer lamentablement. Pourquoi cela ? Eh bien car vous avez créé un serveur séquentiel, c'est-à-dire qu'il va effectuer les actions les unes après les autres. Pour gérer plusieurs connexions en même temps il vous faut développer un serveur parallèle, ainsi nommé parce qu'il parallélise les actions.

Pour passer d'un serveur séquentiel, comme le précédent, à un serveur parallèle il s'agit de créer un nouveau processus dès que accept reçoit une nouvelle connexion. Ainsi au lieu de faire :

init() ->
    {ok, Listen} = gen_tcp:listen(...),
    wait_for_connect(Listen).

wait_for_connect(Listen) ->
    {ok, Socket} = gen_tcp:accept(Socket),
    loop(Socket),
    wait_for_connect(Listen).

Il faudra faire :

init() ->
    {ok, Listen} = gen_tcp:listen(...),
    spawn(fun() -> wait_for_connect(Listen) end).

wait_for_connect(Listen) ->
    {ok, Socket} = gen_tcp:accept(Socket),
    spawn(fun() -> wait_for_connect(Listen) end),
    loop(Socket).

Facile non ? :)

Le protocole UDP

Ce qu'il est important de noter sur UDP, c'est que contrairement au protocole TCP il fonctionne sans connexion. C'est-à-dire que vous n'avez pas besoin de vous connecter à un hôte pour lui envoyer des messages, vous envoyez des messages à qui vous voulez, quand vous voulez. C'est aussi pour ça que vous n'avez aucune garantie que vos messages arriveront bien à destination.

Il peut au premier abord sembler inintéressant d'utiliser un tel système de communication, n'apportant aucune garantie. Mais ça reste néanmoins utilisé dans des applications où l'on a besoin d'envoyer des petits messages très régulièrement, et qu'il importe peu s'il y en a quelques-uns qui se perdent dans le tas. C'est par exemple le cas des jeux multi-joueurs, à l'image d'Urban Terror, jeu qui a de nombreux adeptes sur ce site si j'ai bien compris. :) De plus, l'envoi de message est bien plus rapide en UDP qu'en TCP.

Vous pourrez constater que créer une application utilisant UDP est bien plus facile que d'en créer une avec TCP. En effet, nous n'avons désormais plus à nous occuper de maintenir la connexion ! :)

En pratique

Tout d'abord, avant de pouvoir recevoir ou envoyer un message, il vous faut ouvrir le port sur lequel se feront vos communications, pour cela vous disposez des fonctions suivantes :

gen_udp:open(Port) -> {ok, Socket} | {error, Raison}
gen_udp:open(Port, Options) -> {ok, Socket} | {error, Raison}

Toutes ces variables vous sont désormais connues, inutile donc de s'y attarder.

Ensuite, la fonction pour envoyer un message se complexifie un peu. Il faudra en effet préciser l'adresse à laquelle vous voulez envoyer le message, et le port sur lequel l'envoyer. Voyez plutôt :

gen_udp:send(Socket, Address, Port, Packet) -> ok | {error, Reason}

Et enfin pour recevoir, vous bénéficiez des même fonctions que précédemment, seulement la valeur renvoyée sera différente, ce sera en effet un couple de la forme {ok, Tuple} , où Tuple est un triplet de la forme {Address, Port, Packet} . Le motif pour la structure receive ... end est modifié en conséquence, exemple :

receive
    {udp, Socket, Adress, Port, Packet} -> foo
end

Notez qu'il vous faudra ensuite fermer le port sur lequel s'effectuent les communications, et ce à l'aide de la fonction close/1. L'argument à lui passer est la socket retournée par open.

Application

Plutôt que de vous faire créer un logiciel communiquant avec Urban Terror, j'ai décidé qu'il serait plus rapide et facile de créer un client DAYTIME. L'intérêt de cet exemple c'est qu'il n'y a nul besoin de connaître un protocole, puisque quel que soit le message envoyé le serveur enverra toujours la même réponse. :D

Si vous n'avez pas lu la RFC, il vous faut simplement savoir que les communications s'effectuent sur le port 13. Vous aurez aussi probablement envie de tester votre client, pour cela référez-vous à la liste des serveurs daytime. Voilà, vous avez tout ce qu'il faut ! :)

Au final vous avez :

-module(daytime).
-export([ask/1]).

ask(Server) ->
    {ok, Socket} = gen_udp:open(8888),
    gen_udp:send(Socket, Server, 13, "hello\r\n"),
    receive_date(Socket),
    gen_udp:close(Socket).

receive_date(Socket) ->
    receive
        {udp, Socket, _, _, Date} ->
	    io:put_chars(Date);
	_ -> receive_date(Socket)
    end.

Pour finir

Voilà, vous savez désormais utiliser les sockets en Erlang !
Et plus particulièrement :

  • ouvrir une connexion TCP avec gen_tcp:connect/3-4 ;

  • envoyer un message en TCP avec gen_tcp:send/2-3 ;

  • recevoir un message en TCP soit avec gen_tcp:recv\2-3 soit avec la structure receive ... end et le motif adapté : {tcp, _, _} ;

  • écouter un port dans l'attente d'une connexion, à l'aide des fonctions gen_tcp:listen/2 et gen_tcp:accept/1 ;

  • fermer une connexion avec gen_tcp:close/1 ;

  • utiliser des timeouts.

Mais aussi :

  • ouvrir un port en prévision d'une communication UDP avec gen_udp:open/1-2 ;

  • envoyer un message à l'aide de gen_udp:send/4 ;

  • recevoir des messages avec gen_udp:recv/2-3 ou receive {udp, _, _, _, _,} -> ... end ;

  • fermer le port ouvert précédemment avec gen_udp:close/1 .

Voilà, c'est tout ! Si jamais vous avez des questions n'hésitez pas à passer sur les forums !

Image utilisateur

Un tutoriel signé PHM

Exemple de certificat de réussite
Exemple de certificat de réussite