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.