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
1
4
0
0
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é.