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
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
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:
-
connect: establishes a connection with a server; it takes
the address (IP address and port number) and returns a file descriptor
corresponding to a socket connected to the server.
- emit_simple: opens a connection, sends a message and closes
the connection.
- emit_answer: same as emit_simple, but waits for the server's
response before closing the connection.
# 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"
1
2
3
4
5
(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.