Précédent Index Suivant

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 12 ' ' in
for i=0 to (String.length l)-1 do buffer.[i] <- l.[i] done ;
ignore (ThreadUnix.write fd buffer 0 12) ;
ignore (ThreadUnix.write fd mes 0 (String.length mes))

let receive fd =
let buffer = String.make 12 ' '
in
ignore (ThreadUnix.read fd buffer 0 12) ;
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 :

# 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" 12345 (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.


Précédent Index Suivant