Programmation Avancée
TP9 (suite)
Après avoir découvert lwt en première partie, nous attaquons l'implémentation de notre chat pair-à-pair avec cette librairie. Je lancerai un serveur en début de séance: j'attends vos messages!
Pour cette deuxième partie, vous repartirez d'une base de code propre, en travaillant de le répertoire obtenu par extraction de cette archive.
Partie 3: client-serveur basique
L'exécution de make
devrait provoquer la compilation des
exécutables client
et server
à partir des fichiers
client.ml
et server.ml
. Nous allons prendre rapidement
connaissance de ces fichiers: en attendant un vrai cours
de réseau, le but est de comprendre la structure globale
et de s'en inspirer dans la suite.
Client
En réseau, on communique via des sockets. Une fois connectée, une socket n'est essentiellement pas différente d'un descripteur de fichier usuel, on peut y lire et écrire de la même façon.
Le seul point nouveau est donc cette opération de connection.
Pour le type de communication que nous allons utiliser (TCP)
un client doit se connecter à un serveur.
Pour cela il doit l'identifier, par un nom d'hôte
(exemple: www.google.com
) et un port (exemple: 80
).
Le nom d'hôte doit être résolu en une adresse IP; c'est fait
dans le bout de code let host = ...
de client.ml
, qu'on
ne cherchera pas à comprendre. Enfin, host
et port
permettent de définir addr
et d'exécuter Lwt_io.open_connection
pour tenter de se connecter au serveur.
Si la connection réussit, dix lignes sont envoyées sur la socket, et le client termine.
Serveur
Du côté du serveur, on va créer une socket TCP, la lier (bind
)
au port sur lequel on souhaite recevoir des clients,
et enfin démarrer l'écoute (listen
). Les clients peuvent
alors être reçus par des appels successifs à accept
,
effectués dans la fonction loop
de server.ml
.
Chaque appel crée une nouvelle socket, dédiée à la communication
avec ce client.
Lancez ./server
, et (dans un autre terminal) deux ./client
.
Dans la version actuelle du serveur, les clients sont traités séquentiellement: il faut qu'un client termine pour que le suivant soit traité. Modifier (très légèrement) le serveur pour qu'il traite ses clients de façon concurrente.
Partie 4: un chat pair à pair
Je présenterai brièvement au tableau le protocole à mettre en place pour notre modeste système de messagerie décentralisée.
Version 0
Dans le fichier chat.ml
,
créez une classe chat
qui prenne deux arguments:
un port
sur lequel un serveur devra être ouvert, et
un remote : (string*port) option
qui indique si le noeud
doit se connecter à un serveur pour rejoindre
un réseau existant.
Ces deux opérations seront exécutées par le biais
d'une method run : unit Lwt.t
(qui comportera la
boucle serveur).
Le traitement d'un client sera fait dans un méthode
#read_from_peer : Lwt_io.input_channel -> Lwt_io.output_channel -> unit
Lwt.t
(on obtiendra ces deux canaux à partir de la socket client
via self#read_from_peer (Lwt_io.of_fd Lwt_io.input client) (Lwt_io.of_fd
Lwt_io.output client)
).
Le serveur devra accepter des clients et afficher les
lignes qu'ils envoient. Si le noeud se connecte à un
serveur il lui enverra simplement la ligne
"Bonjour\n"
.
Ajoutez chat
à la cible default
dans le Makefile
.
Lancez make
.
Faites en sorte que quand on exécute ./chat <port>
un serveur soit créé sur le port indique, et que
quand on exécute ./chat <port> <host> <port>
le noeud
se connecte aussi à un noeud existant.
Version 1: on symétrise
Ajoutez une variable d'instance mutable
peers_out : Lwt_io.output_channel list
où vous stockerez la liste des canaux vers
chaque noeud connu.
Le noeud auquel on se connecte initialement ne devra ensuite
pas être traité différement: il sera aussi renseigné dans
cette liste.
Créez une méthode #broadcast
qui permet d'envoyer une
chaîne à tous ces noeuds.
On pourra utiliser la fonction Lwt_list.iter_p
.
L'utiliser pour envoyer régulièrement un message de ping à tous les noeuds connus. Tester.
Version 2: un peu d'interaction
Lire des lignes sur l'entrée standard. Pour chaque ligne lue, l'envoyer à tous les pairs.
Le résultat devrait être assez inutilisable. Afin de trier
entrées et sorties, faites hériter votre classe de la classe
Io.io
. Cette classe permet de séparer proprement la saisie
pendant que des lignes s'affichent à l'écran.
La classe io
prend en paramètre un terminal. Celui-ci
devra être créé via LTerm.create Lwt_unix.stdin Lwt_io.stdin
Lwt_unix.stdout Lwt_io.stdout
.
Elle fournit les méthodes #put_line
pour afficher une ligne dans le terminal et #on_input
pour traiter une ligne saisie.
Enfin, la méthode #run
de la classe io
devra être appelée
pour que tout ceci soit bien mis en place.
Adaptez votre classe pour faire passer les entrées-sorties
par ces deux méthodes.
On veillera à ce que le programme appelle toujours la méthode
#cleanup
avant de terminer, sans quoi votre terminal sera
inutilisable. En cas de problème, taper à l'aveugle stty sane
.
La méthode la plus sûre pour appeler #cleanup
est via un
finalize
, par exemple la méthode chat#run
pourrait
terminer par l'exécution des tâches principales,
avec un #cleanup
en cas de terminaison (brutale ou pas):
Lwt.finalize (fun () -> Lwt.choose [ io#run ; self#ping_peers ; server_loop () ]) (fun () -> io#cleanup)
Tester. Pour quitter, faites Control-C
.
Version 3: vers un protocole
Au lieu d'envoyer et recevoir de simples lignes, on enverra
des messages structurés, représentés de façon (plus) compacte
et bas niveau. Chaque message sera composé de: le nick
de
celui qui émet le message, un identifiant id
unique parmi
les messages de nick
, et enfin le contenu du message msg
.
Concrètement,
le message commencera par l'envoi de l'entier id
sur le
canal, dans sa représentation 32 bits little-endian,
en utilisant simplement Lwt_io.LE.write_int
(cf. doc).
Ensuite on enverra, de la même façon, les longueurs des
chaînes nick
et s
, dans cet ordre.
Enfin les chaînes.
Implémenter ce format d'envoi dans une méthode
write_message :
Lwt_io.output_channel -> string * int * string -> unit Lwt.t
.
Implémenter ensuite
read_message :
Lwt_io.input_channel -> (string * int * string) Lwt.t
.
Ici, on s'appuiera sur Lwt_io.LE.read_int
,
et Lwt_io.read_into_exactly
qui permet de lire un nombre
de caractères fixés sur un canal.
Ajouter un argument nick
à votre classe pour donner un nom
à l'utilisateur qui s'exprime à travers ce noeud. (Vous pourrez
par exemple prendre pour pseudonyme
votre login via Sys.getenv "LOGNAME"
.)
Modifiez votre application pour que tous les envois et
réceptions de messages utilisent
le nouveau format. Pour les identifiants, on utilisera simplement
un compteur qu'on incrémentera à chaque nouveau message.
Pour les messages de ping, on utilisera le contenu "/ping"
par convention.
A ce stade, vous devriez pouvoir communiquer avec mon serveur...
Version 4: gossip
Quand un nouveau message est reçu, le faire suivre à tous les pairs connectés. Pour éviter les rejeux, on mémorisera l'identifiant maximal associé à chaque nick déja rencontré, et on considèrera un message comme nouveau s'il a un identifiant supérieur à ce maximum.
A ce stade, on devrait pouvoir se parler à distance supérieure à 1...
Pour aller plus loin
Gérer les déconnections de pairs.
Quand on rejoint un réseau en se connectant à un noeud existant, gérer l'indisponibilité momentanée de ce noeud, en temporisant avec une nouvelle tentative de connection.
Compléter le système de ping en répondant par des messages "/pong"
.
Mettre en place un système de timeout pour éliminer les pairs morts.
Afficher des lignes en couleur en fonction du nom d'utilisateur.
Gérer la saisie de longues lignes dans la classe io
.
Rendre le réseau plus robuste: un pair ne doit pas être connecté au réseau par un unique autre pair. Pour cela il faut changer le protocole pour qu'un pair obtienne l'adresse d'autres pairs et puisse ainsi décider de s'y connecter.