Previous Contents Next

Client-server Toolbox

We present a collection of modules to enable client-server interactions among Objective CAML programs. This toolbox will be used in the two applications that follow.

A client-server application differs from others in the protocol that it uses and in the processing that it associates with the protocol. Otherwise, all such applications use very similar mechanisms: waiting for a connection, starting a separate process to handle the connection, and reading and writing sockets.

Taking advantage of Objective CAML's ability to combine modular genericity and extension of objects, we will create a collection of functors which take as argument a communications protocol and produce generic classes implementing the mechanisms of clients and of servers. We can then subclass these to obtain the particular processing we need.

Protocols

A communications protocol is a type of data that can be translated into a sequence of characters and transmitted from one machine to another via a socket. This can be described using a signature.

# module type PROTOCOL =
sig
type t
val to_string : t -> string
val of_string : string -> t
end ;;


The signature requires that the data type be monomorphic; yet we can choose a data type as complex as we wish, as long as we can translate it to a sequence of characters and back. In particular, nothing prevents us from using objects as our data.

# 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 ;;


By making some restrictions on the types of data to be manipulated, we can use the module Marshal, described on page ??, to define the translation functions.

# module Make_Protocol = 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

Since a protocol is a type of value that can be translated into a sequence of characters, we can make these values persistent and store them in a file.

The only difficulty in reading such a value from a file when we do not know its type is that a priori we do not know the size of the data in question. And since the file in question is in fact a socket, we cannot simply check an end of file marker. To solve this problem, we will write the size of the data, as a number of characters, before the data itself. The first twelve characters will contain the size, padded with spaces.

The functor Com takes as its parameter a module with signature PROTOCOL and defines the functions for transmitting and receiving values encoded using the protocol.


# 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
Note that we use the functions read and write from module ThreadUnix and not those from module Unix; this will permit us to use our functions in a thread without blocking the execution of other processes.

Server

A server is built as an abstract class parameterized by the type of data in the protocol. Its constructor takes as arguments a port number and the maximum number of simultaneous connections allowed. The method for processing a request is abstract; it must be implemented in a subclass of server to obtain a concrete class.


# 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#process service_sock)
done
method send = Com.send
method receive = Com.receive
method virtual process : Unix.file_descr -> unit
end
end ;;


In order to show these ideas in use, let us revisit the capital service, adding the capability of sending lists of strings.

# type message = Str of string | LStr of string list ;;
# module Cap_Protocol = Make_Protocol (struct type t=message end) ;;
# module Cap_Server = Server (Cap_Protocol) ;;

# class cap_server p np =
object (self)
inherit [message] Cap_Server.server p np
method process 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 cap_server :
int ->
int ->
object
val nb_pending : int
val port_num : int
val sock : Unix.file_descr
method process : Unix.file_descr -> unit
method receive : Unix.file_descr -> Cap_Protocol.t
method send : Unix.file_descr -> Cap_Protocol.t -> unit
method start : unit
end


The processing consists of receiving a request, examining it, processing it and sending the result. The functor allows us to concentrate on this processing while constructing the server; the rest is generic. However, if we wanted a different mechanism, such as for example using acknowledgements, nothing would prevent us from redefining the inherited methods for communication.

Client

To construct clients using a given protocol, we define three general-purpose functions:

# 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
The last two functions are of a higher level than the first: the mechanism linking the client and the server does not appear. The caller of emit_answer does not even need to know that the computation it is requesting is carried out by a remote machine. As far as the caller is concerned, it invokes a function that is represented by an address and port, with an argument which is the message to be sent, and a value is returned to it. The distributed aspect can seem entirely hypothetical.

A client of the capital service is extremely easy to construct. Assume that the boulmich machine provides the service on port number 12345; then the function list_uppercase can be defined by means of a call to the service.

# let list_uppercase l =
let module Cap_client = Client (Cap_Protocol)
in match Cap_client.emit_answer "boulmich" 12345 (LStr l)
with Str x -> [x]
| LStr x -> x ;;
val list_uppercase : string list -> string list = <fun>


To Learn More

The first improvement to be made to our toolbox is some error handling, which has been totally absent so far. Recovery from exceptions which arise from a broken connection, and a mechanism for retrying, would be most welcome.

In the same vein, the client and the server would benefit from a timeout mechanism which would make it possible to limit the time to wait for a response.

Because we have constructed the generic server as a class, which moreover is parameterized by the type of data to be transmitted over the network, it is easy to extend it to augment or modify its behavior in order to implement any desired improvements.

Another approach is to enrich the communication protocols. One can for example add requests for acknowledgement to the protocol, or accompany each request by a checksum allowing verification that the network has not corrupted the data.
Previous Contents Next