Author: Christian.Queinnec@lip6.fr

## Client-serveur

La communication entre processus sur une même machine ou sur des machines différentes à travers des sockets TCP/IP est un mode de communication point-à-point asynchrone. La fiabilité des transmissions est assurée par le protocole TCP. Il est néanmoins possible de simuler une diffusion à un ensemble de processus en effectuant des communications point-à-point sur tous les récepteurs.
Le rôle des différents processus entrant en jeu dans la communication d'une application est en règle générale asymétrique. C'est le cas pour les architectures client-serveur. Un serveur est un processus (ou plusieurs) acceptant des requêtes et tâchant d'y répondre. Le client, lui-même un processus, envoie une requête au serveur en espérant une réponse.

### Schéma d'actions d'un client-serveur

Un serveur ouvrira un service sur un port donné et se mettra en attente de connexions de la part de futurs clients. La figure 20.1 montre le déroulement des principales tâches d'un serveur et d'un client.

> Figure 20.1 : schéma des actions d'un serveur et d'un client

Un client peut se connecter à un service à partir du moment où le serveur est à la phase d'acceptation de connexions (`accept`). Pour cela il devra connaître le numéro IP de la machine serveur et le numéro de port du service. S'il ne connaît pas le numéro IP, il devra demander une résolution nom/numéro par la fonction `gethostbyname`. Une fois la connexion acceptée par le serveur, chaque programme pourra communiquer via les canaux d'entrées-sorties de la socket créée de part et d'autre.

### Programmation d'un client-serveur

La mécanique de programmation d'un client-serveur va donc suivre le schéma décrit à la figure 20.1. Ces tâches sont à réaliser dans tous les cas. Pour cela nous écrirons les fonctions génériques d'agencement de ces tâches, paramétrées par les fonctions particulières à un serveur donné. On illustrera ces programmes par un premier serveur qui accepte une connexion à partir d'un client, attend sur cette prise de communication qu'une ligne soit passée, la convertit en MAJUSCULES et la renvoie convertie au client.
La figure 20.2 montre la communication entre ce service et différents clients.

> Figure 20.2 : service MAJUSCULE et des clients

Certains tournent sur la même machine que le serveur et d'autres se trouvent sur des machines distantes.
Dans la suite de ce paragraphe nous verrons

1. Comment écrire le code d'un << serveur générique >> pour l'instancier en notre service particulier de mise en majuscules.
2. Comment tester ce serveur, sans avoir à écrire de client, en utilisant la commande `telnet`.
3. Comment implanter deux types de clients :
   - le premier, séquentiel, c'est à dire qu'après l'envoi d'une requête, il attend la réponse ;
   - le second, parallèle, qui sépare les tâches d'envoi et de réception. Il y aura donc deux processus pour ce client.

### Code du serveur

Un serveur se découpe en deux parties : l'attente d'une connexion et le traitement suite à une connexion.

#### Serveur générique

Le serveur générique `establish_server` décrit ci-dessous est une fonction qui prend en premier argument la fonction de service (`server_fun`) chargée de traiter les requêtes et, en second, l'adresse de la socket dans le domaine Internet qui sera à l'écoute des requêtes. Cette fonction utilise la fonction auxiliaire `domain_of` qui extrait le domaine d'une socket à partir de son adresse.
En fait, la fonction `establish_server` fait partie des fonctions de haut niveau du module `Unix`. Nous en donnons l'implantation de la distribution.

    # let establish_server server_fun sockaddr =
       let domain = domain_of sockaddr in
       let sock = Unix.socket domain Unix.SOCK_STREAM 0
       in Unix.bind sock sockaddr ;
          Unix.listen sock 3;
          while true do
            let (s, caller) = Unix.accept sock
            in match Unix.fork() with
                   0 -> if Unix.fork() <> 0 then exit 0 ;
                        let inchan = Unix.in_channel_of_descr s
                        and outchan = Unix.out_channel_of_descr s
                        in server_fun inchan outchan ;
                           close_in inchan ;
                           close_out outchan ;
                           exit 0
                 | id -> Unix.close s; ignore(Unix.waitpid [] id)
          done ;;
    val establish_server :
      (in_channel -> out_channel -> 'a) -> Unix.sockaddr -> unit = <fun>

Pour construire complètement un serveur en tant qu'exécutable autonome paramétré par le numéro de port, on écrit la fonction `main_serveur` qui prend toujours en paramètre la fonction de service. Elle utilise le paramètre de la ligne de commande comme numéro de port du service. On utilise la fonction auxiliaire `get_my_addr` qui retourne l'adresse de la machine locale.

    # let get_my_addr () =
       (Unix.gethostbyname(Unix.gethostname())).Unix.h_addr_list.(0) ;;
    val get_my_addr : unit -> Unix.inet_addr = <fun>

    # let main_serveur  serv_fun =
       if Array.length Sys.argv < 2 then Printf.eprintf "usage : serv_up port\n"
       else try
              let port =  int_of_string Sys.argv.(1) in
              let mon_adresse = get_my_addr()
              in establish_server serv_fun  (Unix.ADDR_INET(mon_adresse, port))
            with
              Failure("int_of_string") ->
                Printf.eprintf "serv_up : bad port number\n" ;;
    val main_serveur : (in_channel -> out_channel -> 'a) -> unit = <fun>

#### Code du service

La mécanique générale est en place. Pour l'illustrer, il reste à définir le service. Celui-ci est un convertisseur de chaînes en majuscules. Il attend une ligne sur le canal d'entrée, la convertit et l'écrit sur le canal de sortie en vidant le tampon.

    # let uppercase_service ic oc =
       try while true do
             let s = input_line ic in
             let r = String.uppercase s
             in output_string oc (r^"\n") ; flush oc
           done
       with _ -> Printf.printf "Fin du traitement\n" ; flush stdout ; exit 0 ;;
    val uppercase_service : in_channel -> out_channel -> unit = <fun>

Pour récupérer correctement les exceptions provenant du module `Unix`, on encapsule l'appel au démarrage du service dans la fonction ad hoc du module `Unix` :

    # let go_uppercase_service () =
       Unix.handle_unix_error main_serveur uppercase_service ;;
    val go_uppercase_service : unit -> unit = <fun>

#### Compilation et test du service

On regroupe ces fonctions dans le fichier `serv_up.ml` auquel on ajoute un appel effectif à la fonction `go_uppercase_service`. On compile ce fichier en précisant l'utilisation du module `Unix`.

    ocamlc -i -custom -o serv_up.exe unix.cma serv_up.ml -cclib -lunix

L'affichage de la compilation (option `-i`) donne :

    val establish_server :
      (in_channel -> out_channel -> 'a) -> Unix.sockaddr -> unit
    val main_serveur : (in_channel -> out_channel -> 'a) -> unit
    val uppercase_service : in_channel -> out_channel -> unit
    val go_uppercase_service : unit -> unit

On lance le serveur en écrivant :

    serv_up.exe 1400

Le port choisi est ici 1400. Maintenant la machine où a été lancée cette commande accepte les connexions sur ce port.

### Tester avec `telnet`

On peut d'ores et déjà tester le serveur en utilisant un client existant d'envoi et de réception de lignes de caractères. L'utilitaire `telnet`, qui normalement est un client du service `telnetd` sur le port 23 et que l'on utilise alors comme commande de connexion distante peut être détourné de son rôle si on lui passe en argument une machine et un autre numéro de port. Cet utilitaire existe sur les différents systèmes d'exploitation. Pour tester notre serveur, sous Unix, on tapera :

    $ telnet boulmich 1400
    Trying 132.227.89.6...
    Connected to boulmich.ufr-info-p6.jussieu.fr.
    Escape character is '^]'.

L'adresse IP de `boulmich` est 132.227.89.6 et son nom complet, qui contient son nom de domaine, est `boulmich.ufr-info-p6.jussieu.fr`. C'est bien ce qu'affiche `telnet`. Le client attend une frappe au clavier et l'envoie au serveur que nous avons lancé sur `boulmich` avec le port 1400. Il attendra la réponse du serveur et l'affichera :

    Le petit chat est mort.
    LE PETIT CHAT EST MORT.
    On obtient bien le résultat escompté.
    ON OBTIENT BIEN LE RÉSULTAT ESCOMPTÉ.

Les phrases entrées par l'utilisateur sont en minuscules et celles renvoyées par le serveur sont en majuscules. C'est justement le rôle de ce mini-service que d'assurer cette conversion.
Pour sortir de ce client il sera nécessaire soit de fermer la fenêtre d'où il a été exécuté, soit d'utiliser la commande `kill`. La socket de communication du client sera alors fermée, ce qui provoquera du côté serveur la disparition de la socket de service. À ce moment là le serveur affiche le message << `Fin de traitement` >>, puis le processus associé à la fonction de service termine.

### Code du client

Autant le serveur est naturellement parallèle (on désire traiter une requête tout en en acceptant d'autres, jusqu'à une certaine limite), autant le client peut l'être ou ne pas l'être selon la nature de l'application à développer. Nous donnerons ci-dessous deux versions de client. Mais auparavant, nous présentons deux fonctions utiles pour l'écriture de ces clients.
La fonction `open_connection` du module `Unix` permet à partir d'une socket INET d'obtenir un couple de canaux classiques d'entrées-sorties sur cette socket.
Le code suivant est issu de la distribution du langage.

    # let open_connection sockaddr =
       let domain = domain_of sockaddr in
       let sock = Unix.socket domain Unix.SOCK_STREAM 0
       in try Unix.connect sock sockaddr ;
              (Unix.in_channel_of_descr sock , Unix.out_channel_of_descr sock)
         with exn -> Unix.close sock ; raise exn ;;
    val open_connection : Unix.sockaddr -> in_channel * out_channel = <fun>

De même, la fonction `shutdown_connection` effectue la fermeture en envoi de la socket.

    # let shutdown_connection inchan =
       Unix.shutdown (Unix.descr_of_in_channel inchan) Unix.SHUTDOWN_SEND ;;
    val shutdown_connection : in_channel -> unit = <fun>

#### Client séquentiel

À partir de ces fonctions on peut écrire la fonction principale du client prenant en argument la fonction d'envoi de requêtes et de réception des réponses. Elle analyse les arguments de la liste de commande pour obtenir les paramètres de connexion avant de lancer le traitement.

    # let main_client client_fun  =
       if Array.length Sys.argv < 3
       then Printf.printf "usage :  client serveur port\n"
       else let serveur = Sys.argv.(1) in
            let serveur_adr =
              try  Unix.inet_addr_of_string serveur
              with Failure("inet_addr_of_string") ->
                     try  (Unix.gethostbyname serveur).Unix.h_addr_list.(0)
                     with Not_found ->
                            Printf.eprintf "%s : serveur inconnu\n" serveur ;
                            exit 2
            in try
                 let port = int_of_string (Sys.argv.(2)) in
                 let sockadr = Unix.ADDR_INET(serveur_adr,port) in
                 let ic,oc = open_connection sockadr
                 in client_fun ic oc ;
                    shutdown_connection ic
               with Failure("int_of_string") -> Printf.eprintf "bad port number";
                                                exit 2 ;;
    val main_client : (in_channel -> out_channel -> 'a) -> unit = <fun>

Il ne reste plus qu'à écrire la fonction de traitement du client.

    # let client_fun ic oc =
       try
         while true do
           print_string  "Requête : " ;
           flush stdout ;
           output_string oc ((input_line stdin)^"\n") ;
           flush oc ;
           let r = input_line ic
           in Printf.printf "Réponse : %s\n\n" r;
              if r = "FIN" then ( shutdown_connection ic ; raise Exit) ;
         done
       with
           Exit -> exit 0
         | exn -> shutdown_connection ic ; raise exn  ;;
    val client_fun : in_channel -> out_channel -> unit = <fun>

La fonction `client_fun` entre dans une boucle a priori sans fin qui lit le clavier, envoie la chaîne au serveur, récupère la chaîne transformée en majuscules et l'affiche. Si la chaîne vaut `"FIN"` l'exception `Exit` est déclenchée pour sortir de la boucle. Si une autre exception est déclenchée, typiquement si le serveur disparaît, la fonction interrompt son calcul.
Le programme client devient donc :

    # let go_client () = main_client client_fun ;;
    val go_client : unit -> unit = <fun>

On regroupe toutes ces fonctions dans un fichier nommé `client_seq.ml` en ajoutant l'appel à la fonction `go_client`. On le compile ensuite avec la ligne de commande suivante :

    ocamlc -i -custom -o client_seq.exe unix.cma client_seq.ml -cclib -lunix

L'exécution du client est alors la suivante :

    $ client_seq.exe boulmich 1400
    Requête : Le petit chat est mort.
    Réponse : LE PETIT CHAT EST MORT.

    Requête : On obtient le résultat escompté.
    Réponse : ON OBTIENT LE RÉSULTAT ESCOMPTÉ.

    Requête : fin
    Réponse : FIN

#### Client parallèle avec `fork`

Le client parallèle proposé ici répartit sa tâche sur deux processus : l'un d'émission et l'autre de réception. Ils partagent la même socket. Les fonctions associées à chacun des processus sont passées en paramètre.
Voici le texte du programme ainsi modifié.

    # let main_client client_pere_fun client_fils_fun  =
       if Array.length Sys.argv < 3
       then Printf.printf "usage :  client serveur port\n"
       else
         let serveur = Sys.argv.(1) in
         let serveur_adr =
           try  Unix.inet_addr_of_string serveur
           with Failure("inet_addr_of_string")
                  -> try  (Unix.gethostbyname serveur).Unix.h_addr_list.(0)
                     with Not_found ->
                            Printf.eprintf "%s : serveur inconnu\n" serveur ;
                            exit 2
         in try
              let port = int_of_string (Sys.argv.(2)) in
              let sockadr = Unix.ADDR_INET(serveur_adr,port) in
              let ic,oc = open_connection sockadr
              in match Unix.fork () with
                     0 -> if Unix.fork() = 0 then client_fils_fun oc ;
                          exit 0
                  | id -> client_pere_fun ic ;
                          shutdown_connection ic ;
                          ignore (Unix.waitpid [] id)
            with
               Failure("int_of_string") -> Printf.eprintf "bad port number" ;
                                           exit 2 ;;
    val main_client : (in_channel -> 'a) -> (out_channel -> unit) -> unit = <fun>

Le comportement attendu des paramètres est : le (petit-)fils envoie la requête et le père reçoit la réponse.
Cette architecture prend du sens si le fils doit envoyer plusieurs requêtes, le père recevra les réponses des premières requêtes au fur et à mesure de leur traitement. On reprend donc l'exemple précédent de conversion de chaînes en majuscules mais en modifiant le côté client. Celui-ci lit le texte à convertir dans un fichier et écrit la réponse dans un autre fichier. Pour cela nous aurons besoin d'une fonction de copie d'un canal (`ic`) dans un autre (`oc`) respectant notre petit protocole (c'est à dire reconnaissant la chaîne `"FIN"`).

    # let copie_canaux ic oc =
       try while true do
             let s = input_line ic
             in if s = "FIN" then raise End_of_file
                else (output_string oc (s^"\n"); flush oc)
           done
       with End_of_file -> () ;;
    val copie_canaux : in_channel -> out_channel -> unit = <fun>

On écrit les deux fonctions destinées au fils et au père du schéma de client parallèle :

    # let fils_fun in_file out_sock =
       copie_canaux in_file out_sock ;
       output_string out_sock ("FIN\n") ;
       flush out_sock ;;
    val fils_fun : in_channel -> out_channel -> unit = <fun>
    # let pere_fun out_file in_sock = copie_canaux in_sock out_file ;;
    val pere_fun : out_channel -> in_channel -> unit = <fun>

Cela permet d'écrire la fonction principale du client. Elle devra récupérer sur la ligne de commande deux paramètres supplémentaires : le nom du fichier d'entrée et le nom du fichier de sortie.

    # let go_client () =
      if Array.length Sys.argv < 5
        then Printf.eprintf "usage : client_par serveur port filein fileout\n"
      else let in_file = open_in Sys.argv.(3)
           and out_file = open_out Sys.argv.(4)
           in main_client (pere_fun out_file) (fils_fun in_file) ;
              close_in in_file ;
              close_out out_file ;;
    val go_client : unit -> unit = <fun>

On réunit tout notre matériel dans le fichier `client_par.ml` (sans oublier l'appel effectif à `go_client`), on compile. On crée alors le fichier `toto.txt` contenant le texte à convertir, disons :

    Le petit chat est mort.
    On obtient le résultat escompté.

On peut alors tester en tapant :

    client_par.exe boulmich 1400 toto.txt result.txt

Le fichier `result.txt` contient le texte :

    $ more result.txt
    LE PETIT CHAT EST MORT.
    ON OBTIENT LE RÉSULTAT ESCOMPTÉ.

Lorsque le client termine, le serveur affiche toujours le message `"Fin de traitement"`.

### Client-serveur avec processus légers

La présentation précédente du code d'un serveur générique et d'un client parallèle utilise la création de nouveaux processus grâce à la primitive `fork` du module `Unix`. Cela fonctionne bien sous Unix et de nombreux services Unix sont mis en oeuvre par cette technique. Ce n'est cependant pas le cas avec Windows. Pour la portabilité, on écrira de préférence les client-serveur avec les processus légers qui ont été présentés au chapitre 19. Il sera nécessaire de déterminer les interactions entre les différents processus du serveur.

#### Threads et bibliothèque `Unix`

L'utilisation conjointe de la bibliothèque de processus légers et de la bibliothèque `Unix` provoque le blocage de tous les threads actifs si un appel système ne répond pas immédiatement. En particulier, les lectures sur un descripteur de fichiers incluant donc ceux créés par `socket`, sont bloquantes.
Pour éviter ce désagrément, le module `ThreadUnix` réimplante la plupart des fonctions d'entrées-sorties du module `Unix`. Les fonctions définies dans ce module ne bloqueront que le thread qui effectue l'appel système. En conséquence, les entrées-sorties devront être implantées avec les fonctions de plus bas niveau `read` et `write` offertes par le module `ThreadUnix`. Par exemple, on redéfinit la fonction standard de lecture d'une chaîne de caractères, `input_line`, de façon à ce qu'elle ne bloque pas les autres threads pendant la lecture d'une ligne.

    # let my_input_line  fd =
       let s = " "  and  r = ref ""
       in while (ThreadUnix.read fd s 0 1 > 0) && s.[0] <> '\n' do r := !r ^s done ;
          !r ;;
    val my_input_line : Unix.file_descr -> string = <fun>

#### Classes pour un serveur avec threads

Nous reprenons l'exemple du service MAJUSCULE pour en donner une version utilisant les processus légers. Le passage aux threads ne pose pas de problème puisque notre petite application, aussi bien côté serveur que côté client, lance des processus fonctionnant indépendamment.
Nous avons précédemment implanté un serveur générique paramétré par une fonction de service. Nous avons réalisé cette abstraction en utilisant le caractère fonctionnel du langage Objective CAML. Nous proposons d'utiliser l'extension objet du langage pour illustrer comment les objets permettent de réaliser une abstraction analogue.
L'organisation du serveur repose sur deux classes : `serv_socket` et `connexion`. La première correspond à la mise en route du service, la seconde, à la fonction de service. Nous avons introduit quelques impressions traçant les principales étapes du service.

##### La classe `serv_socket`

possède deux variables d'instance : `port` et `socket` correspondant au numéro de port du service et à la socket d'écoute. À la construction d'un tel objet l'initialisateur effectue les opérations d'ouverture de service et crée cette socket. La méthode `run` se met en acceptation de connexions, et crée un nouvel objet `connexion` pour lancer le traitement de la requête. La classe serv_socket utilise la classe connexion présentée au paragraphe suivant. Cette dernière doit normalement être définie avant la classe serv_socket.

    # class serv_socket p =
       object (self)
         val port = p
         val mutable sock = ThreadUnix.socket Unix.PF_INET Unix.SOCK_STREAM 0

         initializer
           let mon_adresse = get_my_addr ()
           in Unix.bind sock (Unix.ADDR_INET(mon_adresse,port)) ;
              Unix.listen sock 3

         method private client_addr = function
             Unix.ADDR_INET(host,_) -> Unix.string_of_inet_addr host
           | _ -> "Unexpected client"

         method run () =
           while(true) do
             let (sd,sa) = ThreadUnix.accept sock in
             let connexion = new connexion(sd,sa)
             in Printf.printf "TRACE.serv: nouvelle connexion de %s\n\n"
                              (self#client_addr sa) ;
             ignore (connexion#start ())
           done
       end ;;
    class serv_socket :
      int ->
      object
        val port : int
        val mutable sock : Unix.file_descr
        method private client_addr : Unix.sockaddr -> string
        method run : unit -> unit
      end

Il est toujours possible d'affiner le serveur en héritant de cette classe et en redéfinissant la méthode `run`.

##### La classe `connexion`

Les variables d'instance de cette classe, `s_descr` et `s_addr`, seront initialisées avec le descripteur et l'adresse de la socket de service créés par `accept`. Les méthodes sont `start`, `run` et `stop`. La méthode `start` crée un thread appelant les deux autres et retourne son identificateur qui pourrait être manipulé par l'instance appelante de `serv_socket`. C'est dans la méthode `run` que l'on retrouve le corps de la fonction de service. Nous avons un peu modifié la condition de fin de service : on sort sur une chaîne vide. La méthode `stop` se contente de fermer le descripteur de la socket de service.
À chaque nouvelle connexion sera attribué un numéro obtenu par appel à la fonction auxiliaire `gen_num` lors de la création d'une instance.

    # let gen_num = let c = ref 0 in (fun () -> incr c; !c) ;;
    val gen_num : unit -> int = <fun>
    # exception Fin ;;
    exception Fin
    # class connexion (sd,sa) =
       object (self)
         val s_descr = sd
         val s_addr = sa
         val mutable numero = 0
         initializer
           numero <- gen_num();
           Printf.printf "TRACE.connexion : objet traitant %d créé\n" numero ;
           print_newline()

         method start () =  Thread.create (fun x -> self#run x ; self#stop x) ()

         method stop() =
           Printf.printf "TRACE.connexion : fin objet traitant %d\n" numero ;
           print_newline () ;
           Unix.close s_descr

        method run () =
          try
            while true do
              let ligne =  my_input_line s_descr
              in if (ligne = "") or (ligne = "\013") then raise Fin ;
                 let result = (String.uppercase ligne)^"\n"
                 in ignore (ThreadUnix.write s_descr result 0 (String.length result))
            done
          with
             Fin  -> ()
           | exn  -> print_string (Printexc.to_string exn) ; print_newline()
       end ;;
    class connexion :
      Unix.file_descr * 'a ->
      object
        val mutable numero : int
        val s_addr : 'a
        val s_descr : Unix.file_descr
        method run : unit -> unit
        method start : unit -> Thread.t
        method stop : unit -> unit
      end

Ici encore, par héritage et redéfinition de la méthode `run`, on peut définir un nouveau service.
On testera cette nouvelle version du serveur en exécutant la fonction `protect_serv`.

    # let go_serv () = let s = new serv_socket 1400 in s#run () ;;
    # let protect_serv () = Unix.handle_unix_error go_serv () ;;

### Client-serveur à plusieurs niveaux

Bien que la relation client-serveur soit asymétrique, rien n'empêche un serveur d'être lui-même client d'un autre service. On obtient ainsi une hiérarchie dans la communication. Une application client-serveur classique comporte bien souvent :

- un poste client muni d'une interface conviviale;
- un programme de traitement suite à une interaction de l'utilisateur;
- une base de données accessible par le programme de traitement.

Un des buts des applications client-serveur est de décharger les machines centrales d'une partie du traitement. La figure 20.3 montre deux architectures client-serveur à 3 niveaux (tiers).

> Figure 20.3 : différentes architectures de client-serveur

Chaque niveau peut être implanté sur des machines différentes. L'interface utilisateur s'exécute sur la machine d'un utilisateur de l'application. La partie traitement est localisée sur une machine commune à un ensemble d'utilisateurs qui elle-même envoie des requêtes à un serveur de base de données distant. Selon les caractéristiques de l'application, une partie des traitements peut être déportée soit sur le poste utilisateur, soit sur le serveur de base de données.

### Remarques sur les client-serveur réalisés

Nous avons, dans les sections précédentes, élaboré un service simple : le service MAJUSCULE. Différentes solutions ont été exposées. Tout d'abord le serveur a utilisé le mécanisme Unix des forks. Une fois ce premier serveur construit, il a été possible de le tester par le client telnet existant sous tous les systèmes (Unix, Windows et MacOS). Ensuite un premier client simple a été écrit. Nous avons pu alors tester l'ensemble de l'application client-serveur. Les clients eux-aussi peuvent avoir des tâches à gérer entre les communications. Pour cela le client `client_par.exe`, qui sépare la lecture de l'écriture en utilisant aussi des forks, a été construit. Une nouvelle mouture du serveur a été réalisée en utilisant des threads pour bien montrer l'indépendance relative entre le serveur et le client, et l'attention à apporter aux entrées-sorties dans ce cadre. Ce serveur a été organisé sous forme de deux classes facilement réutilisables. On note que la programmation fonctionnelle et la programmation objet permettent de séparer la partie << mécanique >> réutilisable de la partie traitement spécialisé.