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
-
Comment écrire le code d'un << serveur générique >> pour
l'instancier en notre service particulier de mise en majuscules.
- Comment tester ce serveur, sans avoir à écrire de client, en
utilisant la commande telnet.
- 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é.