Client-server
Interprocess communication between processes on the same machine
or on different machines through TCP/IP sockets
is a mode of point-to-point asynchronous communication.
The reliability of such transmissions is assured by the
TCP protocol. It is nonetheless possible to simulate
the broadcast to a group of processes through
point-to-point communication to all receivers.
The roles of different processes communicating in an application are
asymmetric, as a general rule.
That description holds for client-server architectures.
A server is a process (or several processes) accepting
requests and trying to respond to them. The client, itself
a process, sends a request to the server, hoping for a response.
Client-server Action Model
A server provides a service on a given port
by waiting for connections from future clients.
Figure 20.1 shows the sequence of principal tasks
for a server and a client.
Figure 20.1: Model of a server and client
A client can connect to a service once the server is ready to
accept connections (accept).
In order to make a connection, the client must know the
IP number of the server machine and the port number
of the service. If the client does not know the IP number,
it needs to request name/number resolution using the function
gethostbyname.
Once the connection is accepted by the server, each program can
communicate via input-output channels over the sockets created at both ends.
Client-server Programming
The mechanics of client-server programming
follows the model described in Figure 20.1.
These tasks are always performed.
For these tasks, we write generic functions parameterized
by particular functions for a given server.
As an example of such a program, we describe a server that
accepts a connection from a client, waits on a socket
until a line of text has been received, converting the line to CAPITALS,
and sending back the converted text to the client.
Figure 20.2 shows the communication between the
service and different clients1.
Figure 20.2: CAPITAL service and its clients
Certain tasks run on the same machine as the server, while others are
found on remote machines.
We will see
-
How to write the code for a ``generic server'' and
instantiate it for our particular capitalization service.
- How to test the server, without writing the client, by
using the telnet program.
- How to create two types of clients:
-
a sequential client, which waits for a response after sending a request;
-
a parallel client, which separates the send and receive tasks.
Therefore, there are two processes for this client.
Code for the Server
A server may be divided into two parts: waiting for a connection
and the following code to handle the connection.
A Generic Server
The generic server function establish_server described below
takes as its first argument a function
for the service (server_fun) that handles requests,
and as its second argument, the address of the socket
in the Internet domain that listens for requests.
This function uses the auxiliary function domain_of,
which extracts the domain of a socket from its address.
In fact, the function establish_server is made up
of high-level functions from the Unix library.
This function sets up a connection to a server.
# 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>
To finish building a server with a standalone executable that takes
a port number parameter, we write a function
main_server which takes a parameter indicating a service.
The function uses the command-line parameter as the port number of
a service. The auxiliary function get_my_addr,
returns the address of the local machine.
# let
get_my_addr
()
=
(Unix.gethostbyname(Unix.gethostname())).
Unix.h_addr_list.
(0
)
;;
val get_my_addr : unit -> Unix.inet_addr = <fun>
# let
main_server
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
my_address
=
get_my_addr()
in
establish_server
serv_fun
(Unix.
ADDR_INET(my_address,
port))
with
Failure("int_of_string"
)
->
Printf.eprintf
"serv_up : bad port number\n"
;;
val main_server : (in_channel -> out_channel -> 'a) -> unit = <fun>
Code for the Service
The general mechanism is now in place. To illustrate how it works,
we need to define the service we're interested in.
The service here converts strings to upper-case.
It waits for a line of text over an input channel,
converts it, then writes it on the output channel,
flushing the output buffer.
# 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
"End of text\n"
;
flush
stdout
;
exit
0
;;
val uppercase_service : in_channel -> out_channel -> unit = <fun>
In order to correctly recover from exceptions
raised in the Unix library,
we wrap the initial call to the service
in an ad hoc function from the Unix library:
# let
go_uppercase_service
()
=
Unix.handle_unix_error
main_server
uppercase_service
;;
val go_uppercase_service : unit -> unit = <fun>
Compilation and Testing of the Service
We group the functions in the file serv_up.ml,
adding an actual call to the function go_uppercase_service.
We compile this file, indicating that the Unix library is
linked in
ocamlc -i -custom -o serv_up.exe unix.cma serv_up.ml -cclib -lunix
The transcript from this compilation (using the option -i) gives:
val establish_server :
(in_channel -> out_channel -> 'a) -> Unix.sockaddr -> unit
val main_server : (in_channel -> out_channel -> 'a) -> unit
val uppercase_service : in_channel -> out_channel -> unit
val go_uppercase_service : unit -> unit
We launch the server by writing:
serv_up.exe 1400
The port chosen here is 1400. Now the machine where
the server was launched will accept connections on this
port.
Testing with telnet
We can now begin to test the server by using an existing client
to send and receive lines of text. The telnet utility,
which normally is a client of the telnetd service
on port 23, and used to control a remote connection,
can be diverted from this role by passing a machine name
and a different port number.
This utility exists on several operating systems.
To test our server under Unix, we type:
$ telnet boulmich 1400
Trying 132.227.89.6...
Connected to boulmich.ufr-info-p6.jussieu.fr.
Escape character is '^]'.
The IP address for boulmich is 132.227.89.6
and its complete name, which contains its domain name, is
boulmich.ufr-info-p6.jussieu.fr.
The text displayed by telnet indicates a successful
connection to the server.
The client waits for us to type on the keyboard, sending
the characters to the server that we have launched on boulmich on
port 1400.
It waits for a response from the server and displays:
The little cat is dead.
THE LITTLE CAT IS DEAD.
We obtained the expected result.
WE OBTAINED THE EXPECTED result.
The phrases entered by the user are in lower-case and those
sent by the server are in upper-case. This is exactly
the role of this service, to perform this conversion.
To exit from the client, we need to close the window where it was
run, by executing the kill command.
This command will close the client's socket, causing the
server's socket to close as well.
When the server displays the message ``End of text,''
the process associated with the service terminates.
The Client Code
While the server is naturally parallel (we would like
to handle a particular request while accepting others,
up to some limit), the client may or may not be so, according
to the nature of the application.
Below we give two versions of the client. Beforehand, we present
two functions that will be useful for writing these clients.
The function open_connection from the Unix library
allows us to obtain a couple of input-output channels for a socket.
The following code is contained in the language distribution.
# 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>
Similarly, the function shutdown_connection closes
down a socket.
# let
shutdown_connection
inchan
=
Unix.shutdown
(Unix.descr_of_in_channel
inchan)
Unix.
SHUTDOWN_SEND
;;
val shutdown_connection : in_channel -> unit = <fun>
A Sequential Client
From these functions, we can write the main function
of a sequential client. This client takes as its argument a
function for sending requests and receiving responses.
This function analyzes the command line arguments to obtain
connection parameters before actual processing.
# let
main_client
client_fun
=
if
Array.length
Sys.argv
<
3
then
Printf.printf
"usage : client server port\n"
else
let
server
=
Sys.argv.
(1
)
in
let
server_addr
=
try
Unix.inet_addr_of_string
server
with
Failure("inet_addr_of_string"
)
->
try
(Unix.gethostbyname
server).
Unix.h_addr_list.
(0
)
with
Not_found
->
Printf.eprintf
"%s : Unknown server\n"
server
;
exit
2
in
try
let
port
=
int_of_string
(Sys.argv.
(2
))
in
let
sockaddr
=
Unix.
ADDR_INET(server_addr,
port)
in
let
ic,
oc
=
open_connection
sockaddr
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>
All that is left is to write the function for client
processing.
# let
client_fun
ic
oc
=
try
while
true
do
print_string
"Request : "
;
flush
stdout
;
output_string
oc
((input_line
stdin)^
"\n"
)
;
flush
oc
;
let
r
=
input_line
ic
in
Printf.printf
"Response : %s\n\n"
r;
if
r
=
"END"
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>
The function client_fun enters an infinite loop
which reads from the keyboard, sends a string to the server,
gets back the transformed upper-case string, and displays it.
If the string is "END"
, then the exception Exit
is raised in order to exit the loop. If another exception is
raised, typically if the server has shut down, the function
ceases its calculations.
The client program thus becomes:
# let
go_client
()
=
main_client
client_fun
;;
val go_client : unit -> unit = <fun>
We place all these functions in a file named
client_seq.ml, adding a call to the function
go_client. We compile the file with the following
command line:
ocamlc -i -custom -o client_seq.exe unix.cma client_seq.ml -cclib -lunix
We run the client as follows:
$ client_seq.exe boulmich 1400
Request : The little cat is dead.
Response: THE LITTLE CAT IS DEAD.
Request : We obtained the expected result.
Response: WE OBTAINED THE EXPECTED RESULT.
Request : End
Response: END
The Parallel Client with fork
The parallel client mentioned divides its tasks between
two processes: one for sending, and the other for receiving.
The processes share the same socket. The functions associated
with each of the processes are passed to them as parameters.
Here is the modified program:
# let
main_client
client_parent_fun
client_child_fun
=
if
Array.length
Sys.argv
<
3
then
Printf.printf
"usage : client server port\n"
else
let
server
=
Sys.argv.
(1
)
in
let
server_addr
=
try
Unix.inet_addr_of_string
server
with
Failure("inet_addr_of_string"
)
->
try
(Unix.gethostbyname
server).
Unix.h_addr_list.
(0
)
with
Not_found
->
Printf.eprintf
"%s : unknown server\n"
server
;
exit
2
in
try
let
port
=
int_of_string
(Sys.argv.
(2
))
in
let
sockaddr
=
Unix.
ADDR_INET(server_addr,
port)
in
let
ic,
oc
=
open_connection
sockaddr
in
match
Unix.fork
()
with
0
->
if
Unix.fork()
=
0
then
client_child_fun
oc
;
exit
0
|
id
->
client_parent_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>
The expected behavior of the parameters is: the (grand)child sends
the request and the parent receives the response.
This architecture has the effect that if the child needs to send
several requests, then the parent receives the responses
to requests as each is processed. Consider again the preceding
example for capitalizing strings, modifying the client side program.
The client reads the text from one file, while writing
the response to another file. For this we need a function
that copies from one channel, ic, to another, oc,
respecting our little protocol (that is, it recognizes the
string "END"
).
# let
copy_channels
ic
oc
=
try
while
true
do
let
s
=
input_line
ic
in
if
s
=
"END"
then
raise
End_of_file
else
(output_string
oc
(s^
"\n"
);
flush
oc)
done
with
End_of_file
->
()
;;
val copy_channels : in_channel -> out_channel -> unit = <fun>
We write the two functions for the child and parent
using the parallel client model:
# let
child_fun
in_file
out_sock
=
copy_channels
in_file
out_sock
;
output_string
out_sock
("FIN\n"
)
;
flush
out_sock
;;
val child_fun : in_channel -> out_channel -> unit = <fun>
# let
parent_fun
out_file
in_sock
=
copy_channels
in_sock
out_file
;;
val parent_fun : out_channel -> in_channel -> unit = <fun>
Now we can write the main client function. It must
collect two extra command line parameters:
the names of the input and output files.
# let
go_client
()
=
if
Array.length
Sys.argv
<
5
then
Printf.eprintf
"usage : client_par server port filein fileout\n"
else
let
in_file
=
open_in
Sys.argv.
(3
)
and
out_file
=
open_out
Sys.argv.
(4
)
in
main_client
(parent_fun
out_file)
(child_fun
in_file)
;
close_in
in_file
;
close_out
out_file
;;
val go_client : unit -> unit = <fun>
We gather all of our material into the file client_par.ml
(making sure to include a call to go_client), and compile it.
We create a file toto.txt containing the text to be converted:
The little cat is dead.
We obtained the expected result.
We can test the client by typing:
client_par.exe boulmich 1400 toto.txt result.txt
The file result.txt contains the text:
$ more result.txt
THE LITTLE CAT IS DEAD.
WE OBTAINED THE EXPECTED RESULT.
When the client finishes, the server always displays the message
"End of text"
.
Client-server Programming with Lightweight Processes
The preceding presentation of code for a generic server and
a parallel client created processes via the fork primitive
in the Unix library. This works well under Unix; many
Unix services are implemented by this technique.
Unfortunately, the same cannot be said for Windows.
For portability, it is preferable to write client-server
code with lightweight processes, which were presented in
Chapter 19. In this case, it becomes necessary
to examine the interactions among different server processes.
Threads and the Unix Library
The simultaneous use of lightweight processes and the Unix
library causes all active threads to block if a system call does
not return immediately. In particular, reads on file descriptors,
including those created by socket, are blocking.
To avoid this problem, the ThreadUnix library
reimplements most of the input-output functions from the
Unix library. The functions defined in that library will only
block the thread which is actually making the system call.
As a consequence, input and output is handled with the
low-level functions read and write found
in the ThreadUnix library.
For example, the standard function for reading a string of characters,
input_line, is redefined in such a way that it does not
block other threads while reading a line.
# 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 for a Server with Threads
Now let us recycle the example of the CAPITALIZATION service, this
time giving a version using lightweight processes. Shifting
to threads poses no problem for our little application
on either the server side or the client side, which start
processes independently.
Earlier, we built a generic server parameterized over a service
function. We were able to achieve this kind of abstraction by relying on
the functional aspect of the Objective CAML language. Now we are about to
use the object-oriented extensions to the language to show how objects
allow us to achieve a comparable abstraction.
The server is organized into two classes:
serv_socket and connection. The first of these
handles the service startup, and the second, the service itself.
We have introduced some print statements to trace the main
stages of the service.
The serv_socket class.
has two instance variables:
port, the port number for the service, and socket,
the socket for listening. When constructing such an object, the initializer
opens the service and creates this socket. The run method
accepts connections and creates a new connection object for handling
requests. The serv_socket uses the connection class described
in the following paragraph. Usually, this class must be defined before the
serv_socket class.
# class
serv_socket
p
=
object
(self)
val
port
=
p
val
mutable
sock
=
ThreadUnix.socket
Unix.
PF_INET
Unix.
SOCK_STREAM
0
initializer
let
my_address
=
get_my_addr
()
in
Unix.bind
sock
(Unix.
ADDR_INET(my_address,
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
connection
=
new
connection(sd,
sa)
in
Printf.printf
"TRACE.serv: new connection from %s\n\n"
(self#client_addr
sa)
;
ignore
(connection#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
It is possible to refine the server by inheriting from this class and redefining
the run method.
The connection class.
The instance variables in this class,
s_descr and s_addr, are
initialized to the descriptor and the address of the socket created
by accept. The methods are start, run,
and stop. The start creates a thread calling the
two other methods, and returns its thread identifier, which can be
used by the calling instance of serv_socket.
The run method contains the core functionality of the
service. We have slightly modified the termination condition for
the service: we exit on receipt of an empty string.
The stop service just closes the socket descriptor
for the service.
Each new connection has an associated number obtained by
calling the auxiliary function gen_num when
the instance is created.
# let
gen_num
=
let
c
=
ref
0
in
(fun
()
->
incr
c;
!
c)
;;
val gen_num : unit -> int = <fun>
# exception
Done
;;
exception Done
# class
connection
(sd,
sa)
=
object
(self)
val
s_descr
=
sd
val
s_addr
=
sa
val
mutable
number
=
0
initializer
number
<-
gen_num();
Printf.printf
"TRACE.connection : object %d created\n"
number
;
print_newline()
method
start
()
=
Thread.create
(fun
x
->
self#run
x
;
self#stop
x)
()
method
stop()
=
Printf.printf
"TRACE.connection : object finished %d\n"
number
;
print_newline
()
;
Unix.close
s_descr
method
run
()
=
try
while
true
do
let
line
=
my_input_line
s_descr
in
if
(line
=
""
)
or
(line
=
"\013"
)
then
raise
Done
;
let
result
=
(String.uppercase
line)^
"\n"
in
ignore
(ThreadUnix.write
s_descr
result
0
(String.length
result))
done
with
Done
->
()
|
exn
->
print_string
(Printexc.to_string
exn)
;
print_newline()
end
;;
class connection :
Unix.file_descr * 'a ->
object
val mutable number : 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
Here again, by inheritance and redefinition of the run method,
we can define a new service.
We can test this new version of the server by running the
protect_serv function.
# let
go_serv
()
=
let
s
=
new
serv_socket
1
4
0
0
in
s#run
()
;;
# let
protect_serv
()
=
Unix.handle_unix_error
go_serv
()
;;
Multi-tier Client-server Programming
Even though the client-server relation is asymmetric, nothing prevents
a server from being the client of another service.
In this way, we have a communication hierarchy.
A typical client-server application might be the following:
-
a mail client presents a friendly user interface;
- a word-processing program is run, followed by an interaction with the user;
- the word-processing program accesses a database.
One of the goals of client-server applications is to alleviate the processing
of centralized machines. Figure 20.3 shows two client-server
architectures with three tiers.
Figure 20.3: Different client-server architectures
Each tier may run on a different machine.
The user interface runs on the machine running the user mail application.
The processing part is handled by a machine shared by a collection
of users, which itself sends requests to a remote database server.
With this application, a particular piece of
data may be sent to the user mail application or to the database server.
Some Remarks on the Client-server Programs
In the preceding sections, we constructed servers for a simple CAPITALIZATION
service. Each server used a different approach for its implementation.
The first such server used the Unix fork mechanism. Once we
built that server, it became possible to test it with the telnet
client supplied with the Unix, Windows, and MacOS operating
systems. Next, we built a simple first client. We were then able
to test the client and server together. Clients may have tasks
to manage between communications. For this purpose, we built the
client_par.exe client, which separates reading from writing
by using forks. A new kind of server was built using threads
to clearly show the relative independence of the server and the client,
and to bring input-output into this setting. This server was
organized into two easily-reused classes. We note that both functional
programming and object-oriented programming support the separation
of ``mechanical,'' reusable code from code for specialized processing.