Boîte à outils client-serveur
Nous présentons un ensemble de modules pour la construction
de client-serveur entre programmes Objective CAML. Cette boîte à outils
est ensuite utilisée dans les deux applications suivantes.
Une application se distingue d'une autre par le protocole qu'elle
utilise et par les traitements qu'elle y associe. Pour le reste, à
savoir les mécanismes d'attente de connexion, de détachement du
traitement de la connexion sur un autre processus, les lectures et
écritures sur une socket, les applications sont très
semblables les unes aux autres.
Profitant de la possibilité de mélanger la généricité modulaire et
l'extension des objets que nous offre Objective CAML, nous
allons réaliser un ensemble de foncteurs prenant comme argument un
protocole de communication et engendrant des classes génériques
implantant les mécanismes des clients et des serveurs. Il ne nous restera
ensuite qu'à les sous-classer pour obtenir des traitements
particuliers.
Protocoles
Un protocole de communication est un type de données qu'il est
possible de traduire sous forme de chaînes de caractères afin de
faire transiter d'une machine à une autre des données par une socket.
Ceci peut se traduire sous la forme d'une signature.
# module
type
PROTOCOL
=
sig
type
t
val
to_string
:
t
->
string
val
of_string
:
string
->
t
end
;;
La signature impose que le type de données soit monomorphe, mais hormis
cette restriction, du moment qu'il est possible de le traduire en
chaîne de caractères et inversement, nous pouvons choisir comme structure
de données des valeurs aussi complexes que l'on souhaite. En
particulier, rien ne nous interdit d'avoir comme donnée un objet.
# module
Integer
=
struct
class
integer
x
=
object
val
v
=
x
method
x
=
v
method
str
=
string_of_int
v
end
type
t
=
integer
let
to_string
o
=
o#str
let
of_string
s
=
new
integer
(int_of_string
s)
end
;;
En faisant quelques restrictions sur les types de données
manipulables, nous pouvons utiliser le module Marshal, décrit page
??, pour
définir les fonctions de traduction.
# module
Make_Protocole
=
functor
(
T
:
sig
type
t
end
)
->
struct
type
t
=
T.t
let
to_string
(x:
t)
=
Marshal.to_string
x
[
Marshal.
Closures]
let
of_string
s
=
(Marshal.from_string
s
0
:
t)
end
;;
Communication
Puisqu'un protocole est une donnée qu'il est possible de traduire sous
la forme d'une chaîne de caractères, nous pouvons en faire un
persistant et le stocker dans un fichier.
La seule difficulté pour lire une valeur depuis un fichier quand on ne
connaît pas son type est qu'a priori nous ne connaissons pas la taille
de la donnée en question. Et puisque le fichier en question sera en
fait une socket, nous ne pouvons pas nous fier au marqueur de fin de
fichier. Pour résoudre ce problème, nous faisons précéder la donnée
par la taille en nombre de caractères à lire. Les douze premiers
caractères contiennent sa taille et des espaces.
Le foncteur Com prend en paramètre un module de signature PROTOCOL
et définit les fonctions d'émission et de réception des valeurs codées dans le protocole.
# module
Com
=
functor
(P
:
PROTOCOL)
->
struct
let
send
fd
m
=
let
mes
=
P.to_string
m
in
let
l
=
(string_of_int
(String.length
mes))
in
let
buffer
=
String.make
1
2
' '
in
for
i=
0
to
(String.length
l)-
1
do
buffer.[
i]
<-
l.[
i]
done
;
ignore
(ThreadUnix.write
fd
buffer
0
1
2
)
;
ignore
(ThreadUnix.write
fd
mes
0
(String.length
mes))
let
receive
fd
=
let
buffer
=
String.make
1
2
' '
in
ignore
(ThreadUnix.read
fd
buffer
0
1
2
)
;
let
l
=
let
i
=
ref
0
in
while
(buffer.[!
i]<>
' '
)
do
incr
i
done
;
int_of_string
(String.sub
buffer
0
!
i)
in
let
buffer
=
String.create
l
in
ignore
(ThreadUnix.read
fd
buffer
0
l)
;
P.of_string
buffer
end
;;
module Com :
functor(P : PROTOCOL) ->
sig
val send : Unix.file_descr -> P.t -> unit
val receive : Unix.file_descr -> P.t
end
Notons que nous utilisons les fonctions read et
write du module ThreadUnix et non celles du module
Unix; cela nous permettra d'utiliser les fonctions du module
dans un thread sans bloquer l'exécution des autres processus.
Serveur
Un serveur est réalisé comme une classe abstraite paramétrée par le
type de données du protocole. Son constructeur prend comme argument
le numéro du port et le nombre de connexions simultanées
acceptables. La méthode de traitement d'une requête est abstraite;
elle doit être implantée dans une sous-classe de server pour
obtenir une classe concrète.
# module
Server
=
functor
(P
:
PROTOCOL)
->
struct
module
Com
=
Com
(P)
class
virtual
[
'a]
server
p
np
=
object
(s)
constraint
'a
=
P.t
val
port_num
=
p
val
nb_pending
=
np
val
sock
=
ThreadUnix.socket
Unix.
PF_INET
Unix.
SOCK_STREAM
0
method
start
=
let
host
=
Unix.gethostbyname
(Unix.gethostname())
in
let
h_addr
=
host.
Unix.h_addr_list.
(0
)
in
let
sock_addr
=
Unix.
ADDR_INET(h_addr,
port_num)
in
Unix.bind
sock
sock_addr
;
Unix.listen
sock
nb_pending
;
while
true
do
let
(service_sock,
client_sock_addr)
=
ThreadUnix.accept
sock
in
ignore
(Thread.create
s#treat
service_sock)
done
method
send
=
Com.send
method
receive
=
Com.receive
method
virtual
treat
:
Unix.file_descr
->
unit
end
end
;;
Afin de fixer les idées, nous reprenons le service majuscule comme
illustration mais en donnant la possibilité d'envoyer des listes de
mots.
# type
message
=
Str
of
string
|
LStr
of
string
list
;;
# module
Maj_Protocol
=
Make_Protocole
(struct
type
t=
message
end)
;;
# module
Maj_Server
=
Server
(Maj_Protocol)
;;
# class
maj_server
p
np
=
object
(self)
inherit
[
message]
Maj_Server.server
p
np
method
treat
fd
=
match
self#receive
fd
with
Str
s
->
self#send
fd
(Str
(String.uppercase
s))
;
Unix.close
fd
|
LStr
l
->
self#send
fd
(LStr
(List.map
String.uppercase
l))
;
Unix.close
fd
end
;;
class maj_server :
int ->
int ->
object
val nb_pending : int
val port_num : int
val sock : Unix.file_descr
method receive : Unix.file_descr -> Maj_Protocol.t
method send : Unix.file_descr -> Maj_Protocol.t -> unit
method start : unit
method treat : Unix.file_descr -> unit
end
Le traitement se décompose en la réception de la requête, son
filtrage, son traitement et l'émission du résultat. Le foncteur permet
de se concentrer sur le service pour réaliser le serveur, le reste est
générique. Cependant, si on souhaite avoir un mécanisme différent,
comme par exemple gérer des acquittements, rien n'interdit de
redéfinir les méthodes de communication héritées.
Client
Pour réaliser des clients utilisant un protocole donné, nous
définissons trois fonctions généralistes :
-
connect : établit une connexion avec un serveur, elle
prend son adresse (adresse IP et numéro de port) et rend un
descripteur de fichier correspondant à une socket connectée au
serveur.
- emit_simple : ouvre une connexion, envoie un message
et referme la connexion.
- emit_answer : idem que la précédente, mais attend la
réponse du serveur avant de refermer la connexion.
# module
Client
=
functor
(P
:
PROTOCOL)
->
struct
module
Com
=
Com
(P)
let
connect
addr
port
=
let
sock
=
ThreadUnix.socket
Unix.
PF_INET
Unix.
SOCK_STREAM
0
and
in_addr
=
(Unix.gethostbyname
addr).
Unix.h_addr_list.
(0
)
in
ThreadUnix.connect
sock
(Unix.
ADDR_INET(in_addr,
port))
;
sock
let
emit_simple
addr
port
mes
=
let
sock
=
connect
addr
port
in
Com.send
sock
mes
;
Unix.close
sock
let
emit_answer
addr
port
mes
=
let
sock
=
connect
addr
port
in
Com.send
sock
mes
;
let
res
=
Com.receive
sock
in
Unix.close
sock
;
res
end
;;
module Client :
functor(P : PROTOCOL) ->
sig
module Com :
sig
val send : Unix.file_descr -> P.t -> unit
val receive : Unix.file_descr -> P.t
end
val connect : string -> int -> Unix.file_descr
val emit_simple : string -> int -> P.t -> unit
val emit_answer : string -> int -> P.t -> P.t
end
Les deux dernières fonctions sont de plus haut niveau que la première.
Le vecteur de la liaison entre le client et le serveur n'apparaît pas.
L'utilisateur de emit_answer n'a même pas besoin de savoir
que le calcul qu'il demande est effectué sur une machine distante. Pour
lui, il invoque une fonction qui est représentée par une adresse et un
port avec un argument qui est le message envoyé, et une valeur lui est
retournée. Le côté distribué peut lui paraître anecdotique.
Un client du service majuscule est excessivement aisé à réaliser.
En supposant que la machine boulmich
héberge ce service sur le port 12345; la fonction list_uppercase peut
se définir par un appel au service.
# let
list_uppercase
l
=
let
module
Maj_client
=
Client
(Maj_Protocol)
in
match
Maj_client.emit_answer
"boulmich"
1
2
3
4
5
(LStr
l)
with
Str
x
->
[
x]
|
LStr
x
->
x
;;
val list_uppercase : string list -> string list = <fun>
Pour en faire plus
La première amélioration à apporter à notre boîte à outils est une
gestion des erreurs qui ici est totalement absente. Une récupération
des exceptions qui surviennent lors de la rupture d'une connexion et
un mécanisme de <<nouvel essai>> seraient les bienvenus.
Dans la même veine, le client et le serveur gagneraient à être munis
d'un mécanisme de timeout permettant de borner le temps
d'attente d'une réponse.
Le fait d'avoir réalisé le serveur générique comme une classe, qui de
surcroît est paramétrée par le type de données qui transitent sur le
réseau, permet de l'étendre facilement pour augmenter ou modifier
son comportement afin de réaliser les améliorations souhaitées.
Une autre approche est d'enrichir les protocoles de communication.
On peut par exemple ajouter des requêtes d'acquittement au protocole,
ou encore accompagner chaque requête d'un checksum permettant
de vérifier que le réseau n'a pas corrompu les données.