Programmation Avancée
TP9: un chat dans une monade
Aujourd'hui, nous allons construire une petite application de messagerie instantanée pair-à-pair, en utilisant la bibliothèque de threads coopératifs Lwt.
Avant de commencer,
ouvrez le manuel lwt
et récupérez le Makefile qui vous permettra de faire
make foo
pour construire un binaire à partir d'un unique fichier
foo.ml
utilisant les modules et l'extension de syntaxe de Lwt.
Partie 1: se familiariser avec lwt
Les threads coopératifs lwt forment une monade, dont les opérations de
base sont Lwt.bind
et Lwt.return
. Dans le modèle lwt, un thread
s'exécute potentiellement dès qu'il est créé. Cependant, il y a quand
même une fonction Lwt_main.run
qui sert à activer le scheduler lwt
jusqu'à ce que le thread passé en argument se termine.
Sur ce noyau, on a des opérations
comme Lwt.join
pour exécuter deux threads en parallèle. La bibliothèque
fournit aussi des tâches primitives, notamment Lwt_io.printf
, version
non-bloquante / coopérative de printf
. (Toutes
ces fonctions sont documentées dans le manuel, et je vous invite à ouvrir
les pages correspondant à ces modules.)
Dans le fichier hello.ml
définir une fonction
repete : string -> int -> unit Lwt.t
telle que repete s n
affiche n
lignes numérotées
et contenant le message s
,
avec un délai aléatoire de moins d'une seconde entre chaque
ligne.
Exécuter en parallèle deux instances de repete
pour dire bonjour
dans deux langues de votre choix. Votre application ne devra pas
terminer avant que les deux threads aient effectué tous leurs
outputs.
Réécrire votre programme en utilisant l'extension syntaxique documentée
brièvement sur la première page du manuel lwt. On utilisera notamment
le notation lwt x = .. in ..
pour le bind, >>
pour la séquence,
et éventuellement un petit for_lwt
.
Partie 2: un jeu palpitant
Les threads lwt peuvent (en général) être annulés, c'est à dire interrompus.
Cela peut être fait via la fonction Lwt.cancel
explicitement, mais aussi
implicitement par exemple avec la fonction pick
qui, comme join
,
exécute plusieurs tâches en parallèle, mais dès qu'une tâche termine annule
les autres.
Ecrire une fonction
timeout : delay:float -> kill:(unit -> 'a t) -> 'a t -> 'a t
qui se comporte comme la tâche passée en argument si celle-ci termine
avant de délai imposé, et sinon se comporte comme la tâche kill
.
Tester avec le jeu suivant (sûrement une super idée de jeu pour téléphone mobile) où l'utilisateur doit répétitivement entrer une ligne avant un timeout d'une seconde:
let kill () = Lwt_io.printf "Game over!\n" >> Lwt.fail (Failure "over") let rec f () = Lwt_io.printf "Vite, une ligne!\n> " >> lwt l = timeout 1. ~kill (Lwt_io.read_line Lwt_io.stdin) in Lwt_io.printf "Merci pour ces %d caractères...\n\n" (String.length l) >> f () let () = Lwt_main.run (f ())
Partie 3: interface utilisateur
Notre application de messagerie instantanée aura une interface très primitive en mode texte. Cette interface permettra à l'utilisateur de saisir des messages, et affichera les messages des autres utilisateurs. Malheureusement, ces deux tâches sont concurrentes, et l'affichage d'une nouvelle ligne au milieu d'une saisie a un effet assez moche. Récupérer la base io_messy.ml pour le vérifier.
Votre mission est de modifier ce fichier pour instaurer un fonctionnement plus
propre. La dernière ligne devra toujours afficher la saisie en cours, et une
saisie partielle ne devra s'afficher que là. Pour cela on maintiendra un
Buffer
contenant la saisie en cours, on effacera la saisie en cours avant l'affichage
d'une nouvelle ligne, et on la restaurera ensuite. On utilisera
les fonctions Lwt_term.clear_line
et Lwt_term.goto_beginning_of_line
(doc dans /usr/lib/ocaml/lwt/lwt_term.mli
, car c'est cassé en ligne...)
ainsi que Lwt_term.read_key
pour capturer en temps-réel les évènements
clavier de l'utilisateur.
L'utilisation de read_key
nous prive de fonctionalités usuelles du
shell, notamment les réactions à Ctrl-Z
, Ctrl-C
et l'effacement
que nous allons simuler un minimum:
lwt key = Lwt_term.read_key () in if key = Lwt_term.key_enter then (* Saisie complète, à valider. *) ... else match key with | Lwt_term.Key s -> (* Nouveau caractère à prendre en compte. *) ... | Lwt_term.Key_control '?' -> (* Effacement *) Buffer.clear buffer ; ... | Lwt_term.Key_control 'c' -> Lwt.fail (Failure "byebye") | Lwt_term.Key_control 'z' -> (* Dodo *) Unix.kill 0 Sys.sigstop ; ... | _ -> (* On ignore le reste *) ...
Une fois un premier prototype réalisé, re-organisez votre code pour
séparer l'interface (fonctionalités de saisie et affichage de lignes)
du reste du code (dans le cas d'io_messy
, affichage de Tic et Tac
et simple affichage des lignes saisies).
Par exemple on pourrait extraire la fonctionalité comme une fonction
init : (string -> unit Lwt.t) -> (string -> unit Lwt.t) * unit Lwt.t
qui prend en argument la fonction de traitement des lignes saisies,
et renvoie (1) la fonction à appeler pour afficher proprement une ligne
et (2) la tâche de gestion des I/O.
L'utilisation de cet exemple devrait vous convaincre que c'est un
choix de design un peu pénible et qu'on peut améliorer en utilisant
d'autres traits du langage OCaml.
Correction: solution ci-dessus io.ml et version OO io_oo.ml.
Partie 4: client-serveur
Client
Dans un fichier client.ml
écrivez un client qui se connecte à un
serveur et lui envoie dix lignes de texte avec un délai d'une
demi-seconde après chaque ligne.
Le client devra se connecter par défaut à 127.0.0.1
sur le port 1234
,
ou permettre le choix de ces paramètres sur la ligne de commande:
./client <host> <port>
.
Les fonctions Unix.gethostbyname
et Lwt_io.open_connection
vous
seront a priori utiles.
Un modèle possible est disponible
ici.
Serveur
Réalisez ensuite le serveur qui va avec, il doit accepter un nombre
arbitraire de connections et les traiter en parallèle: pour chaque
client connecté le serveur reçoit des lignes et les affiche sur
sa sortie standard.
Le serveur devra fonctionner sur le port 1234
par défaut,
et autoriser la spécification d'un autre port via la ligne de
commande.
Vous pourrez utiliser les fonctions
bind
, listen
, accept
du module Lwt_unix
.
Un modèle possible est disponible
ici
(à simplifier car dans le modèle les messages sont re-diffusés
aux autres clients).
Corrigé: client.ml, server.ml.
Partie 5: un chat pair à pair
Nous allons réaliser une première version de notre messagerie pair à pair.
Je donnerai au tableau des indications sur le style conseillé pour
écrire le code, en guise de corrigé de la partie 3. Je donnerai aussi une
description du fonctionnement attendu de l'application, ainsi que du
format des messages, qui seront sérialisé en utilisant notamment Lwt_io.LE
.
On pourra utiliser la fonction Lwt_list.iter_p
.
Bonus
On veut éviter qu'un pair disparaisse. Mettre en place un système de ping et un timeout pour éliminer les pairs morts. Utiliser le système de ping pour avoir une liste des pairs actifs.
Gérer les échecs de connection au pair initial. Votre client pourrait par exemple re-essayer de se connecter, après un délai.
Afficher des lignes en couleur en fonction du nom d'utilisateur.
Gérer la saisie de longues lignes.
Mettre en place un système de tolérance aux pannes: un pair ne doit pas être connecté au réseau par un unique autre pair. Pour cela il faut changer le protocol pour qu'un pair obtienne l'adresse d'autres pairs et puisse ainsi décider de s'y connecter.