Précédent Index Suivant

Clients-Serveurs

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 clients-serveurs. Un serveur est un processus (ou plusieurs) acceptant des requêtes et tachant d'y répondre. Le client, lui même un processus, envoie une requête (une tâche à effectuer) au serveur en espérant une réponse.

Schémas 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 5.1 montre le déroulement des principales taches d'un serveur et d'un client.



Figure 5.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ée-sortie 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 les schémas décrits à la figure figures 5.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 5.2 montre la communication entre ce service et différents clients.


Figure 5.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 majuscule.
  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 taches 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ête 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 donne l'adresse de la machine locale.

# 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 exn -> 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 l'appel à 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 25 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 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 >> et la fonction de service, et donc le processus associé terminent.

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. Celle-ci lira une chaîne de caractères au clavier, l'enverra vers le serveur et récupérera le résultat pour l'afficher. Si le résultat vaut la chaîne "FIN" alors il sortira de la fonction de traitement et se déconnectera de la socket liée au serveur.

# 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 majuscule 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
Le texte après la chaîne "Requête" est le texte entré au clavier par l'utilisateur du client. La texte après la chaîne "Réponse" est celle retournée par le serveur. Si la réponse vaut "FIN" alors le client clôt la connexion, ce qui entraînera la fermeture de la connexion auprès du serveur avec l'affichage du texte "Fin de traitement".

Client parallèle avec fork

Le client parallèle proposé ici répartie 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 de 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 coté 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 la ligne d'appel à 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 doit contenir 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".

Clients-serveurs avec processus légers

La présentation précédente du code d'un serveur générique et d'un client parallèle utilisent 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é et on écrira de préférence les clients-serveurs avec des processus légers qui ont été présentés au chapitre 4. 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ée 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ère, 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épendament.

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 étape 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éé 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 pourra définir un nouveau service.

On testera cette nouvelle version du serveur en exécutant le programme protect_serv ();; après avoir écrit les fonctions suivantes :

# 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 des buts des applications client-serveur est de déchargé les machines centrales d'une partie du traitement. La figure 5.3 montre deux architectures client-serveur à 3 niveaux (ou tiers en anglais).


Figure 5.3 : Différentes architectures de clients-serveurs


Chaque niveau peut être implanté sur des machines différentes. L'interface utilisateur s'exécute sur les machines des utilisateurs de l'application. La partie traitement est localisée sur une machine commune qui elle-même envoie des requêtes à un serveur de base de données. 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 clients-serveurs réalisés

Nous avons, dans les sections précédentes, élaborer 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 du service (serveur-client). Les clients eux-aussi peuvent avoir des taches à 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. Pour bien montrer, une certaine indépendance entre le serveur et le client, une nouvelle mouture du serveur a été réalisé en utilisant des threads en montrant l'attention à porter aux entrées-sorties dans ce cadre là. Ce serveur d'autre part a été organisé sous forme de deux classes facilement réutilisables. On note que, et la programmation fonctionnelle, et la programmation objet permettent de bien séparer la partie << mécanique >> réutilisable de la partie traitement spécialisé. Néanmoins le protocole utilisé est fort simple et les traitements aussi.


Précédent Index Suivant