La communication par tuyaux présente certaines insuffisances. Tout d’abord, la communication est locale à une machine: les processus qui communiquent doivent tourner sur la même machine (cas des tuyaux nommés), voire même avoir le créateur du tuyau comme ancêtre commun (cas des tuyaux normaux). D’autre part, les tuyaux ne sont pas bien adaptés à un modèle particulièrement utile de communication, le modèle dit par connexions, ou modèle client-serveur. Dans ce modèle, un seul programme (le serveur) accède directement à une ressource partagée; les autres programmes (les clients) accèdent à la ressource par l’intermédiaire d’une connexion avec le serveur; le serveur sérialise et réglemente les accès à la ressource partagée. (Exemple: le système de fenêtrage X-window — les ressources partagées étant ici l’écran, le clavier, la souris et le haut-parleur.) Le modèle client-serveur est difficile à implémenter avec des tuyaux. La grande difficulté, ici, est l’établissement de la connexion entre un client et le serveur. Avec des tuyaux non nommés, c’est impossible: il faudrait que les clients et le serveur aient un ancêtre commun ayant alloué à l’avance un nombre arbitrairement grand de tuyaux. Avec des tuyaux nommés, on peut imaginer que le serveur lise des demandes de connexions sur un tuyau nommé particulier, ces demandes de connexions pouvant contenir le nom d’un autre tuyau nommé, créé par le client, à utiliser pour communiquer directement avec le client. Le problème est d’assurer l’exclusion mutuelle entre les demandes de connexion provenant de plusieurs clients en même temps.
Les prises (traduction libre de sockets) sont une généralisation des tuyaux qui pallie ces faiblesses. Le modèle du client serveur avec prises (en mode connecté) est décrit dans la figure 6.1
Il est essentiel dans ce modèle que le serveur U et le client A aient établi une connexion privée (3) pour dialoguer jusqu’à la fin de la connexion, sans interférence avec d’autres requêtes provenant d’autres clients. Pour cette raison, on dit que l’on fonctionne en mode connecté. Si le service est court, le serveur pourrait lui-même servir la requête directement (sans se cloner) au travers de la connexion (3). Dans ce cas, le client suivant doit attendre que le serveur soit libre, soit parce qu’il a effectivement fini de traiter la connexion (3), soit parce qu’il gère explicitement plusieurs connexions par multiplexage.
Les prises permettent également un modèle client-serveur en mode déconnecté. Dans ce cas, moins fréquent, le serveur n’établit pas une connexion privée avec le client, mais répond directement à chaque requête du client. Nous verrons brièvement comment procéder selon ce modèle dans la section 6.10.
Le mécanisme des prises, qui étend celui des tuyaux, a été introduit en BSD 4.2, et se retrouve maintenant sur toutes les machines Unix connectées à un réseau (Ethernet ou autre). Tout d’abord, des appels systèmes spéciaux sont fournis pour établir des connexions suivant le modèle client-serveur. Ensuite, les prises permettent la communication locale ou à travers le réseau entre processus tournant sur des machines différentes de façon (presque) transparente. Pour cela, plusieurs domaines de communication sont pris en compte. Le domaine de communication associé à une prise indique avec qui on peut communiquer via cette prise; il conditionne le format des adresses employées pour désigner le correspondant. Deux exemples de domaines:
129.199.129.1
, par exemple), plus un numéro de service à l’intérieur
de la machine. La communication est possible entre processus tournant
sur deux machines quelconques reliées au réseau Internet.1
Enfin, plusieurs sémantiques de communication sont prises en compte. La sémantique indique en particulier si la communication est fiable (pas de pertes ni de duplication de données), et sous quelle forme les données se présentent au récepteur (flot d’octets, ou flot de paquets — petits blocs d’octets délimités). La sémantique conditionne le protocole utilisé pour la transmission des données. Voici trois exemples de sémantiques possibles:
Flots | Datagramme | Paquet segmentés | |
Fiable | oui | non | oui |
Forme des données | flot d’octets | paquets | paquets |
La sémantique par “flot” est très proche de celle de la communication
par tuyaux. C’est la plus employée, en particulier lorsqu’il s’agit de
retransmettre des suites d’octets sans structure particulière
(exemple: rsh
). La sémantique par “paquets segmentés”
structure les données transmises en paquets: chaque écriture délimite
un paquet, chaque lecture lit au plus un paquet. Elle est donc bien
adaptée à la communication par messages. La sémantique par “datagrammes”
correspond au plus près aux possibilités hardware d’un réseau de type
Ethernet: les transmissions se font par paquets, et il n’est pas
garanti qu’un paquet arrive à son destinataire. C’est la
sémantique la plus économique en termes d’occupation du réseau.
Certains programmes l’utilisent pour transmettre des données sans
importance cruciale (exemple: biff
); d’autres, pour tirer plus de
performances du réseau, étant entendu qu’ils doivent gérer eux-mêmes
les pertes.
L’appel système socket permet de créer une nouvelle prise:
val socket : socket_domain -> socket_type -> int -> file_descr |
Le résultat est un descripteur de fichier qui représente la nouvelle
prise. Ce descripteur est initialement dans l’état dit “non
connecté”; en particulier, on ne peut pas encore faire read
ou
write
dessus.
Le premier argument indique le domaine de communication auquel la prise appartient:
PF_UNIX | le domaine Unix |
PF_INET | le domaine Internet |
Le deuxième argument indique la sémantique de communication désirée:
SOCK_STREAM | flot d’octets, fiable |
SOCK_DGRAM | paquets, non fiable |
SOCK_RAW | accès direct aux couches basses du réseau |
SOCK_SEQPACKET | paquets, fiable |
Le troisième argument est le numéro du protocole de communication à
utiliser. Il vaut généralement 0, ce qui désigne le protocole par
défaut,
généralement déterminé
à partir du type de communication (typiquement, SOCK_DGRAM
et SOCK_STREAM
sont associés aux protocoles udp
et tcp
).
D’autres valeurs donnent accès à des protocoles spéciaux.
Exemple typique: le protocole ICMP (Internet Control Message
Protocol), qui est le protocole utilisé par la commande ping
pour
envoyer des paquets avec retour automatique à l’envoyeur. Les numéros
des protocoles spéciaux se trouvent dans le fichier /etc/protocols
ou dans la table protocols
du système d’informations réparti NIS
(Network Information Service), le cas échéant.
L’appel système getprotobyname permet de consulter cette
table de manière portable:
val getprotobyname : string -> protocol_entry |
L’argument est le nom du protocole désiré. Le résultat est un type
record comprenant, entre autres, un champ p_proto
qui est le numéro
du protocole.
Un certain nombre d’opérations sur les prises utilisent des adresses
de prises. Ce sont des valeurs du type concret sockaddr
:
type sockaddr = ADDR_UNIX of string | ADDR_INET of inet_addr * int |
L’adresse ADDR_UNIX
(f) est une adresse dans le domaine Unix. La
chaîne f est le nom du fichier correspondant dans la hiérarchie de
fichiers de la machine.
L’adresse ADDR_INET
(a,p) est une adresse dans le domaine Internet.
Le premier argument, a, est l’adresse Internet d’une machine; le
deuxième argument, p, est un numéro de service (port number) à
l’intérieur de cette machine.
Les adresses Internet sont représentées par le type abstrait
inet_addr
. Deux fonctions permettent de convertir des chaînes de
caractères de la forme 128.93.8.2
en valeurs du type inet_addr
, et
réciproquement:
Une autre manière d’obtenir des adresses Internet est par consultation de la
table /etc/hosts
, qui associe des adresses Internet aux noms de machines.
On peut consulter cette table ainsi que la base de donnée NIS par la
fonction de bibliothèque gethostbyname. Sur les machines
modernes, cette fonction interroge les “name servers” soit en cas
d’échec, soit au contraire de façon prioritaire, n’utilisant alors le
fichier /etc/hosts
qu’en dernier recours.
val gethostbyname : string -> host_entry |
L’argument est le nom de la machine désirée. Le résultat est un type
record comprenant, entre autres, un champ h_addr_list
, qui est un
vecteur d’adresses Internet: les adresses de la machine. (Une même
machine peut être reliée à plusieurs réseaux, sous des adresses
différentes.)
Pour ce qui est des numéros de services (port numbers), les
services les plus courants sont répertoriés dans la table
/etc/services
. On peut la consulter de façon portable par la fonction
val getservbyname : string -> string -> service_entry |
Le premier argument est le nom du service (par exemple, ftp
pour le
serveur FTP, smtp
pour le courrier, nntp
pour le serveur de News,
talk
et ntalk
pour les commandes du même nom, etc.). Le deuxième
argument est le nom du protocole: généralement, tcp
si le service
utilise des connexions avec la sémantique “stream”, ou udp
si le
service utilise des connexions avec la sémantique “datagrams”. Le
résultat de getservbyname
est un type record dont le champ s_port
contient le numéro désiré.
Exemple:
pour obtenir l’adresse du serveur FTP de
pauillac.inria.fr
:
ADDR_INET((gethostbyname "pauillac.inria.fr").h_addr_list.(0), (getservbyname "ftp" "tcp").s_port) |
L’appel système connect permet d’établir une connexion avec un serveur à une adresse donnée.
val connect : file_descr -> sockaddr -> unit |
Le premier argument est un descripteur de prise. Le deuxième argument est l’adresse du serveur auquel on veut se connecter.
Une fois connect
effectué, on peut envoyer des données au serveur en
faisant write sur le descripteur de la prise, et lire les
données en provenance du serveur en faisant read sur le
descripteur de la prise. Lectures et écritures sur une prise se comportent
comme sur un tuyau: read
bloque tant qu’aucun octet n’est disponible, et
peut renvoyer moins d’octets que demandé; et si le serveur a fermé la
connexion, read
renvoie zéro et write
déclenche un signal SIGPIPE
.
Un effet de connect
est de brancher la prise à une adresse locale qui est
choisie par le système. Parfois, il est souhaitable de choisir soi-même
cette adresse, auquel cas il est possible d’appeler l’opération bind
(voir ci-dessous) avant connect
.
Pour suivre les connexions en cours cours on peut utiliser la commande
netstat
depuis un shell.
Il y a deux manières d’interrompre une connexion. La première est de faire close sur la prise. Ceci ferme la connexion en écriture et en lecture, et désalloue la prise. Ce comportement est parfois trop brutal. Par exemple, un client peut vouloir fermer la connexion dans le sens client vers serveur, pour transmettre une fin de fichier au serveur, mais laisser la connexion ouverte dans le sens serveur vers client, pour que le serveur puisse finir d’envoyer les données en attente. L’appel système shutdown permet ce genre de coupure progressive de connexions.
val shutdown : file_descr -> shutdown_command -> unit |
Le premier argument est le descripteur de la prise à fermer. Le deuxième argument peut être:
SHUTDOWN_RECEIVE | ferme la prise en lecture; write sur l’autre
bout de la connexion va déclencher un signal SIGPIPE |
SHUTDOWN_SEND | ferme la prise en écriture; read sur l’autre bout de
la connexion va renvoyer une marque de fin de fichier |
SHUTDOWN_ALL | ferme la prise en lecture et en écriture; à la
différence de close , la prise elle-même n’est pas désallouée |
En fait, la désallocation d’une prise peut prendre un certain temps que celle-ci soit faite avec politesse ou brutalité.
On va définir une commande client
telle que client
host port établit
une connexion avec le service de numéro port sur la machine de nom host,
puis envoie sur la connexion tout ce qu’il lit sur son entrée standard, et
écrit sur sa sortie standard tout ce qu’il reçoit sur la connexion.
Par exemple, la commande
echo -e 'GET /~remy/ HTTP/1.0\r\n\r\n' | ./client pauillac.inria.fr 80 |
se connecte sur le port 80
et pauillac.inria.fr
et envoie la commande
HTTP
qui demande la page web d’accueil /~remy/
sur ce serveur.
Cette commande constitue une application
client “universel”, dans la mesure où elle regroupe le code
d’établissement de connexions qui est commun à beaucoup
de clients, tout en déléguant la partie implémentation
du protocole de communication, propre à chaque application au programme qui
appelle client
.
Nous utilisons une fonction de bibliothèque retransmit
qui recopie les données
arrivant d’un descripteur sur un autre descripteur. Elle termine lorsque la
fin de fichier est atteinte sur le descripteur d’entrée, sans refermer
les descripteurs. Notez que retransmit
peut-être interrompue par un
signal.
let retransmit fdin fdout = let buffer_size = 4096 in let buffer = String.create buffer_size in let rec copy() = match read fdin buffer 0 buffer_size with 0 -> () | n -> ignore (write fdout buffer 0 n); copy() in copy ();; |
Les choses sérieuses commencent ici.
open Sys;; open Unix;; let client () = if Array.length Sys.argv < 3 then begin prerr_endline "Usage: client <host> <port>"; exit 2; end; let server_name = Sys.argv.(1) and port_number = int_of_string Sys.argv.(2) in let server_addr = try (gethostbyname server_name).h_addr_list.(0) with Not_found -> prerr_endline (server_name ^ ": Host not found"); exit 2 in let sock = socket PF_INET SOCK_STREAM 0 in connect sock (ADDR_INET(server_addr, port_number)); match fork() with 0 -> Misc.retransmit stdin sock; shutdown sock SHUTDOWN_SEND; exit 0 | _ -> Misc.retransmit sock stdout; close stdout; wait();; handle_unix_error client ();; |
On commence par déterminer l’adresse Internet du serveur auquel se
connecter. Elle peut être donnée (dans le premier argument de la commande)
ou bien sous forme numérique, ou bien sous forme d’un nom de machine. La
commande gethostbyname
traite correctement les deux cas de figure. Dans le
cas d’une adresse symbolique, la base /etc/hosts
est interrogée et on
prend la première des adresses obtenues. Dans le cas d’une adresse numérique
aucune vérification n’est effectuée: une structure est simplement créée
pour l’adresse demandée.
Ensuite, on crée une prise dans le domaine Internet, avec la sémantique “stream” et le protocole par défaut, et on la connecte à l’adresse indiquée.
On clone alors le processus par fork
. Le processus fils recopie les
données de l’entrée standard vers la prise; lorsque la fin de fichier est
atteinte sur l’entrée standard, il ferme la connexion en écriture,
transmettant ainsi une fin de fichier au serveur, et termine. Le
processus père recopie sur la sortie standard les données lues sur la
prise. Lorsqu’une fin de fichier est détectée sur la prise, il
se synchronise avec le processus fils par wait
, et termine.
La fermeture de la connexion peut se faire à l’initiative du client ou du serveur.
sock
, le client (père) reçoit
finalement une fin de fichier sur la connexion et termine normalement.sock
fermée reçoit le signal sigpipe
ce
qui par défaut tue le client. C’est la sémantique attendue. Toutefois,
le client meurt immédiatement, sans pouvoir indiqué que la connexion a été
coupée. Pour récupérer cette information, on peut ignorer le signal
SIGPIPE
avec pour effet d’envoyer au client l’erreur EPIPE
qui sera
alors traitée par le handler handler_unix_error
: il suffit d’ajouter
la ligne suivante entre les lignes 19 et 20 du client.
ignore (signal sigpipe Signal_ignore) |
Si le client, fils ou père, termine prématurément la prise sera fermée en écriture ou en lecture. Si le serveur détecte cette information, il ferme l’autre bout de la prise, ce que l’autre partie du client va détecter. Sinon, le serveur termine normalement en fermant la connexion. Dans les deux cas, on se retrouve également dans l’un des scénarios précédents.
On vient de voir comment un client se connecte à un serveur; voici maintenant comment les choses se passent du côté du serveur. La première étape est d’associer une adresse à une prise, la rendant ainsi accessible depuis l’extérieur. C’est le rôle de l’appel bind:
val bind : file_descr -> sockaddr -> unit |
Le premier argument est le descripteur de la prise; le second, l’adresse à
lui attribuer. La commande bind
peut aussi utiliser une adresse spéciale
inet_addr_any
représentant toutes les adresses internet possibles sur la
machine (qui peut comporter plusieurs sous-réseaux).
Dans un deuxième temps, on déclare que la prise peut accepter les connexions avec l’appel listen:
val listen : file_descr -> int -> unit |
Le premier argument est le descripteur de la prise. Le second indique combien de demandes de connexion incomplètes peuvent être mises en attente. Sa valeur, souvent de l’ordre de quelques dizaines peut aller jusqu’à quelques centaines pour des serveurs très sollicités. Lorsque ce nombre est dépassé, les demandes de connexion excédentaires échouent.
Enfin, on reçoit les demandes de connexion par l’appel accept:
val accept : file_descr -> file_descr * sockaddr |
L’argument est le descripteur de la prise. Le premier résultat est un
descripteur sur une prise nouvellement créée, qui est reliée au
client: tout ce qu’on écrit sur ce descripteur peut être lu sur la
prise que le client a donné en argument à connect
, et
réciproquement. Du côté du serveur, la prise donnée en argument à
accept
reste libre et peut accepter d’autres demandes de connexion.
Le second résultat est l’adresse du client qui se connecte. Il peut
servir à vérifier que le client est bien autorisé à se connecter;
c’est ce que fait le serveur X par exemple (xhost
permettant d’ajouter de
nouvelles autorisations) ou à établir une seconde connexion du serveur
vers le client (comme le fait ftp
pour chaque demande de transfert de
fichier).
Le schéma général d’un serveur tcp
est donc de la forme suivante
(nous définissons ces fonctions dans la bibliothèque Misc
).
let install_tcp_server_socket addr = let s = socket PF_INET SOCK_STREAM 0 in try bind s addr; listen s 10; s with z -> close s; raise z;; |
let tcp_server treat_connection addr = ignore (signal sigpipe Signal_ignore); let server_sock = install_tcp_server_socket addr in while true do let client = restart_on_EINTR accept server_sock in treat_connection server_sock client done;; |
La fonction install_tcp_server_socket
commence par créer une prise dans le
domaine Internet, avec la sémantique «stream» et le protocole par défaut
(ligne 2), puis il la prépare à recevoir des demandes de
connexion sur le port indiqué sur la ligne de commande par les appels
bind
et listen
des lignes 4 et 5. Comme il
s’agit d’une fonction de bibliothèque, nous refermons proprement la prise en
cas d’erreur lors de l’opération bind
ou listen
.
La fonction tcp_server
crée la prise avec la fonction précédente, puis
entre dans une boucle infinie, où elle attend une demande de connexion
(accept
, ligne 12) et traite celle-ci (ligne 13).
Comme il s’agit d’une fonction de bibliothèque, nous avons pris soin de
relancer l’appel système accept
(bloquant) en cas d’interruption. Notez
qu’il appartient à la fonction treat_connection
de fermer le descripteur
client
en fin de connexion y compris lorsque la connexion se termine de
façon brutale. Nous ignorons le signal sigpipe
afin qu’une déconnexion
prématurée d’un client lève une exception EPIPE
récupérable par
treat_connection
plutôt que de tuer le processus brutalement.
La fonction treat_connection
reçoit également le descripteur du serveur
car dans le cas d’un traitement par fork
ou double_fork
, celui-ci devra
être fermé par le fils.
Le traitement d’une connexion peut se faire séquentiellement,
i.e. par le serveur lui même. Dans ce cas, treat_connection
se contente
d’appeler une fonction service
, entièrement dépendante de l’application,
qui est le corps du serveur et qui exécute effectivement le service demandé
et se termine par la fermeture de la connexion.
let service (client_sock, client_addr) = (* Communiquer avec le client sur le descripteur descr *) (* Puis quand c'est fini: *) close client_descr;; |
D’où la fonction auxiliaire (que nous ajoutons à la bibliothèque Misc
):
let sequential_treatment server service client = service client |
Comme pendant le traitement de la connexion le serveur ne peut pas traiter
d’autres demandes, ce schéma est en général réservé à des services rapides,
où la fonction service
s’exécute toujours en un temps cours et borné (par
exemple, un serveur de date
).
La plupart des serveurs sous-traitent le service à un processus fils: On
appelle fork
immédiatement après le retour de accept
. Le processus
fils traite la connexion. Le processus père recommence aussitôt à faire accept
.
Nous obtenons la fonction de bibliothèque suivante:
let fork_treatment server service (client_descr, _ as client) = let treat () = match fork() with | 0 -> close server; service client; exit 0 | k -> () in try_finalize treat () close client_descr;; |
Notez qu’il est essentiel de fermer le descripteur client_descr
du père,
sinon sa fermeture par le fils ne suffira pas à terminer la connexion; de
plus, le père va très vite se retrouver à court de descripteurs.
Cette fermeture doit avoir lieu dans le cas normal, mais aussi si
pour une raison quelconque le fork échoue—le programme peut éventuellement
décider que ce n’est pas une erreur fatale et maintenir éventuellement le
serveur en service.
De façon symétrique, le fils ferme le descripteur sock
sur lequel le
service à été établi avant de réaliser le service. D’une part, il n’en a
plus besoin. D’autre part, le père peut terminer d’être serveur alors que le
fils n’a pas fini de traiter la connexion en cours. La commande exit 0
est importante pour que le fils meurt après l’exécution du service et ne se
mette pas à exécuter le code du serveur.
Nous avons ici ignoré pour l’instant la récupération des fils qui vont devenir zombis, ce qu’il faut bien entendu faire. Il y a deux approches. L’approche simple est de faire traiter la connexion par un petit-fils en utilisant la technique du double fork.
let double_fork_treatment server service (client_descr, _ as client) = let treat () = match fork() with | 0 -> if fork() <> 0 then exit 0; close server; service client; exit 0 | k -> ignore (restart_on_EINTR (waitpid []) k) in try_finalize treat () close client_descr;; |
Toutefois, cette approche fait perdre au serveur tout contrôle sur son
petit-fils. En général, il est préférable que le processus qui traite un
service appartienne au même groupe de processus que le serveur, ce qui
permet de tuer tous les services en tuant les processus du même groupe que
le serveur. Pour cela, les serveurs gardent en général le modèle précédent
et ajoute une gestion des fils, par exemple en installant une procédure
Misc.free_children
sur le signal sigchld
.
Les prises possèdent de nombreux paramètres internes qui peuvent être
réglés: taille des tampons de transfert, taille minimale des transferts,
comportement à la fermeture, etc.. Ces paramètres sont de type booléen,
entier, entier optionnel ou flottant. Pour des raisons de typage, il
existe donc autant de primitives getsockopt
, getsockopt_int
,
getsockopt_optint
, getsockopt_float
, qui permettent de consulter ces
paramètres et autant de variantes de la forme setsockopt
, etc..
On pourra consulter
l’appendice ?? pour avoir la liste détaillée des
options et le manuel Unix (getsockopt) pour leur sens exact.
A titre d’exemple, voici deux types de réglages, qui ne s’appliquent qu’à des
prises dans le domaine INET
de type SOCK_STREAM
. La déconnexion des
prises au protocole TCP
est négociée, ce qui peut prendre un certain temps.
Normalement l’appel close
retourne immédiatement, alors que le système
négocie la fermeture.
setsockopt_optint sock SO_LINGER (Some 5);; |
Cette option rend l’opération close
bloquante sur la socket sock
jusqu’à
ce que les données déjà émises soient effectivement transmises ou qu’un délai
de 5 secondes se soit écoulé.
setsockopt sock SO_REUSEADDR;; |
L’effet principal de l’option SO_REUSEADDR
est de permettre à l’appel
système bind de réallouer une prise à une adresse locale sur laquelle
toutes les communications sont en cours de déconnexion. Le risque est
alors qu’une nouvelle connexion capture les paquets destinés à l’ancienne
connexion. Cette option permet entre autre d’arrêter un serveur et de le
redémarrer immédiatement, très utile pour faire des tests.
On va maintenant définir une commande server
telle que
server
port cmd arg1 … argn reçoit les demandes de
connexion au numéro port, et à chaque connexion lance la commande
cmd avec arg1 … argn comme arguments, et la connexion comme
entrée et sortie standard.
Par exemple, si on lance
./server 8500 grep foo |
sur la machine pomerol
, on peut ensuite faire depuis n’importe
quelle machine
./client pomerol 8500 < /etc/passwd |
en utilisant la commande client
écrite précédemment,
et il s’affiche la même chose que si on avait fait
./grep foo < /etc/passwd |
sauf que grep
est exécuté sur pomerol
, et non pas sur la machine
locale.
La commande server
constitue une application serveur “universel”, dans
la mesure où elle regroupe le code d’établissement de service qui est commun
à beaucoup de serveurs, tout en déléguant la partie implémentation du
service et du protocole de communication, propre à chaque application ou
programme lancé par server
.
open Sys;; open Unix;; let server () = if Array.length Sys.argv < 2 then begin prerr_endline "Usage: client <port> <command> [arg1 ... argn]"; exit 2; end; let port = int_of_string Sys.argv.(1) in let args = Array.sub Sys.argv 2 (Array.length Sys.argv - 2) in let host = (gethostbyname(gethostname())).h_addr_list.(0) in let addr = ADDR_INET (host, port) in let treat sock (client_sock, client_addr as client) = (* log information *) begin match client_addr with ADDR_INET(caller, _) -> prerr_endline ("Connection from " ^ string_of_inet_addr caller); | ADDR_UNIX _ -> prerr_endline "Connection from the Unix domain (???)"; end; (* connection treatment *) let service (s, _) = dup2 s stdin; dup2 s stdout; dup2 s stderr; close s; execvp args.(0) args in Misc.double_fork_treatment sock service client in Misc.tcp_server treat addr;; handle_unix_error server ();; |
L’adresse fournie à tcp_server
contient l’adresse Internet de la machine
qui fait tourner le programme; la manière habituelle de l’obtenir
(ligne 11) est de chercher le nom de la machine (renvoyé par
l’appel gethostname
) dans la table /etc/hosts
.
En fait, il existe en général plusieurs adresses pour accéder
à une machine. Par exemple, l’adresse de la machine pauillac
est 128.93.11.35
, mais on peut également y accéder en local (si l’on est
déjà sur la machine pauillac) par l’adresse 127.0.0.1
.
Pour offrir un service sur toutes les adresses désignant la machine, on peut
utiliser l’adresse inet_addr_any
.
Le traitement du service se fera ici par un «double fork» après avoir émis quelques informations sur la connexion. Le traitement du service consiste à rediriger l’entrée standard et les deux sorties standard vers la prise sur laquelle est effectuée la connexion puis d’exécuter la commande souhaitée. (Notez ici que le traitement du service ne peut pas se faire de façon séquentielle.)
Remarque: la fermeture de la connexion se fait sans intervention du
programme serveur
. Premier cas: le client ferme la connexion dans le sens
client vers serveur. La commande lancée par le serveur reçoit une fin de
fichier sur son entrée standard. Elle finit ce qu’elle a à faire, puis
appelle exit
. Ceci ferme ses sorties standard, qui sont les derniers
descripteurs ouverts en écriture sur la connexion. (Le client recevra alors
une fin de fichier sur la connexion.) Deuxième cas: le client termine
prématurément et ferme la connexion dans le sens serveur vers client. Le
serveur peut alors recevoir le signal sigpipe
en essayant d’envoyer des
données au client, ce qui peut provoquer la mort anticipée par signal
SIGPIPE
de la commande du côté serveur; ça convient parfaitement, vu que
plus personne n’est là pour lire les sorties de cette commande.
Enfin, la commande côté serveur peut terminer (de son plein gré ou par un
signal) avant d’avoir reçu une fin de fichier. Le client recevra alors un
fin de fichier lorsqu’il essayera de lire et un signal SIGPIPE
(dans ce
cas, le client meurt immédiatement) ou une exception EPIPE
(si le
signal est ignoré) lorsqu’il essayera d’écrire sur la connexion.
L’écriture d’un serveur est en général plus délicate que celle d’un client. Alors que le client connaît le serveur sur lequel il se connecte, le serveur ignore tout de son client. En particulier, pour des services publics, le client peut être «hostile». Le serveur devra donc se protéger contre tous les cas pathologiques.
Un attaque typique consiste à ouvrir des connexions puis les laisser ouvertes sans transmettre de requête: après avoir accepté la connexion, le serveur se retrouve donc bloqué en attente sur la prise et le restera tant que le client sera connecté. L’attaquant peut ainsi saturer le service en ouvrant un maximum de connexions. Il est important que le serveur réagisse bien à ce genre d’attaque. D’une part, il devra prévoir un nombre limite de connexions en parallèle et refuser les connexions au delà afin de ne pas épuiser les ressources du système. D’autre part, il devra interrompre les connexions restées longtemps inactives.
Un serveur séquentiel qui réalise le traitement lui même et sans le déléguer à un de ses fils est immédiatement exposé à cette situation de blocage: le serveur ne répond plus alors qu’il n’a rien à faire. Une solution sur un serveur séquentiel consiste à multiplexer les connexions, mais cela peut être complexe. La solution avec le serveur parallèle est plus élégante, mais il faudra tout de même prévoir des «timeout», par exemple en programmant une alarme.
Le protocole tcp
utilisé par la plupart des connexions de type
SOCK_STREAM
ne fonctionne qu’en mode connecté. Inversement, le protocole
udp
utilisé par la plupart des connexions de type SOCK_DGRAM
fonctionne toujours de façon interne en mode déconnecté. C’est-à-dire qu’il
n’y a pas de connexion établie entre les deux machines.
Ce type de prise peut être utilisé sans établir de connexion au préalable. Pour cela on utilise les appels système recvfrom et sendto.
Chacun des appels retourne la taille des données transférées.
L’appel recvfrom
retourne également l’adresse du correspondant.
Une prise de type SOCK_DGRAM
peut également être branchée avec connect
,
mais il s’agit d’une illusion (on parle de pseudo-connexion). L’effet de la
pseudo-connexion est purement local. L’adresse passée en argument est
simplement mémorisée dans la prise et devient l’adresse utilisée pour
l’émission et la réception (les messages venant d’une autre adresse sont
ignorés).
Les prises de ce type peuvent être connectées plusieurs fois pour changer
leur affectation et déconnectées en les reconnectant sur une
adresse invalide, par exemple 0. (Par opposition, la reconnexion d’une
prise de type SOCK_STREAM
produit en général un erreur.)
Les appels systèmes recv et send généralisent les
fonctions read
et write
respectivement (mais ne s’appliquent qu’aux
descripteurs de type prise).
Leur interface est similaire à read
et write
mais elles prennent chacune
en plus une liste de drapeaux dont la signification est la suivante:
MSG_OOB
permet d’envoyer une valeur exceptionnelle;
MSG_DONTROUTE
indique de court-circuiter les tables de routage par défaut;
MSG_PEEK
consulte les données sans les lire.
Ces primitives peuvent être utilisées en mode connecté à la place de read
et write
ou en mode pseudo-connecté à la place de recvfrom
et
sendto
.
L’exemple du client-serveur universel est suffisamment fréquent pour que la
bibliothèque Unix
fournisse des fonctions de plus haut niveau permettant
d’établir et d’utiliser un service de façon presque transparente.
val open_connection : sockaddr -> in_channel * out_channel val shutdown_connection : Pervasives.in_channel -> unit |
La fonction open_connection ouvre une prise à l’adresse reçue en
argument et crée une paire de tampons (de la bibliothèque Pervasives
)
d’entrée-sortie sur cette prise. La communication avec le serveur se fait
donc en écrivant les requêtes dans le tampon ouvert en écriture et en lisant
les réponses dans celui ouvert en lecture. Comme les écritures sont
temporisées, il faut vider le tampon pour garantir qu’une requête est émise
dans sa totalité.
Le client peut terminer la connexion brutalement en fermant l’un ou l’autre
des deux canaux (ce qui fermera la prise) ou plus “poliment” par un appel
à shutdown_connection. (Si le serveur ferme la connexion, le
client s’en apercevra lorsqu’il recevra une fin de fichier dans le tampon
ouvert en lecture.)
De façon symétrique, un service peut également être établi par la fonction establish_server.
val establish_server : (in_channel -> out_channel -> unit) -> sockaddr -> unit |
Cette primitive prend en argument une fonction f, responsable du
traitement des requêtes, et l’adresse de la prise sur laquelle le service
doit être établi.
Chaque connexion au serveur crée une nouvelle prise (comme le fait la
fonction accept
); après avoir été cloné, le processus fils créé une paire
de tampons d’entrée-sortie (de la bibliothèque Pervasives
) qu’il passe à la
fonction f pour traiter la connexion. La fonction f lit les requêtes
dans dans le tampon ouvert en lecture et répond au client dans celui ouvert
en écriture. Lorsque le service est rendu (c’est-à-dire
lorsque f a terminé), le processus fils ferme la prise et termine.
Si le client ferme la connexion gentiment,
le fils recevra une fin de fichier sur le tampon ouvert en lecture.
Si le client le fait brutalement, le fils peut recevoir un
SIGPIPE
lorsqu’il essayera de d’écrire sur la prise fermée.
Quant au père, il a déjà sans doute servi une autre requête!
La commande establish_server
ne termine pas normalement, mais seulement en
cas d’erreur (par exemple, du runtime OCaml ou du système pendant
l’établissement du service).
Dans les cas simples (rsh
, rlogin
, …), les données à
transmettre entre le client et le serveur se présentent naturellement
comme deux flux d’octets, l’un du client vers le serveur, l’autre du
serveur vers le client. Dans ces cas-là, le protocole de communication
est évident. Dans d’autres cas, les données à transmettre sont plus
complexes, et nécessitent un codage avant de pouvoir être transmises
sous forme de suite d’octets sur une prise. Il faut alors que le
client et le serveur se mettent d’accord sur un protocole de
transmission précis, qui spécifie le format des requêtes et des
réponses échangées sur la prise. La plupart des protocoles employés
par les commandes Unix sont décrits dans des documents appelés
“RFC” (request for comments): au début simple propositions ouvertes
à la discussion, ces documents acquièrent valeur de norme au cours du
temps, au fur et à mesure que les utilisateurs adoptent le protocole
décrit.2
La première famille de protocoles vise à transmettre les données sous une forme compacte la plus proche possible de leur représentation en mémoire, afin de minimiser le travail de conversion nécessaire, et de tirer parti au mieux de la bande passante du réseau. Exemples typiques de protocoles de ce type: le protocole X-window, qui régit les échanges entre le serveur X et les applications X, et le protocole NFS (RFC 1094).
Les nombres entiers ou flottants sont généralement transmis comme les 1, 2, 4 ou 8 octets qui constituent leur représentation binaire. Pour les chaînes de caractères, on envoie d’abord la longueur de la chaîne, sous forme d’un entier, puis les octets contenus dans la chaîne. Pour des objets structurés (n-uplets, records), on envoie leurs champs dans l’ordre, concaténant simplement leurs représentations. Pour des objets structurés de taille variable (tableaux), on envoie d’abord le nombre d’éléments qui suivent. Le récepteur peut alors facilement reconstituer en mémoire la structure transmise, à condition de connaître exactement son type. Lorsque plusieurs types de données sont susceptibles d’être échangés sur une prise, on convient souvent d’envoyer en premier lieu un entier indiquant le type des données qui va suivre.
Exemple:
L’appel XFillPolygon
de la bibliothèque X, qui dessine
et colorie un polygone, provoque l’envoi au serveur X d’un message de
la forme suivante:
FillPoly
)
Dans ce type de protocole, il faut prendre garde aux différences d’architecture entre les machines qui communiquent. En particulier, dans le cas d’entiers sur plusieurs octets, certaines machines mettent l’octet de poids fort en premier (c’est-à-dire, en mémoire, à l’adresse basse) (architectures dites big-endian), et d’autres mettent l’octet de poids faible en premier (architectures dites little-endian). Par exemple, l’entier 16 bits 12345 = 48 × 256 + 57 est représenté par l’octet 48 à l’adresse n et l’octet 57 à l’adresse n+1 sur une architecture big-endian, et par l’octet 57 à l’adresse n et l’octet 48 à l’adresse n+1 sur une architecture little-endian. Le protocole doit donc spécifier que tous les entiers multi-octets sont transmis en mode big-endian, par exemple. Une autre possibilité est de laisser l’émetteur choisir librement entre big-endian et little-endian, mais de signaler dans l’en-tête du message quelle convention il utilise par la suite.
Le système OCaml facilite grandement ce travail de mise en forme des données (travail souvent appelé marshaling ou encore sérialisation dans la littérature) en fournissant deux primitives générales de transformation d’une valeur OCaml quelconque en suite d’octets, et réciproquement:
Le but premier de ces deux primitives est de pouvoir sauvegarder n’importe quel objet structuré dans un fichier disque, puis de le recharger ensuite; mais elles s’appliquent également bien à la transmission de n’importe quel objet le long d’un tuyau ou d’une prise. Ces primitives savent faire face à tous les types de données OCaml à l’exception des fonctions; elles préservent le partage et les circularités à l’intérieur des objets transmis; et elles savent communiquer entre des architectures d’endianness différentes.
Exemple:
Si X-window était écrit en OCaml, on aurait un
type concret request
des requêtes pouvant être envoyées au serveur,
et un type concret reply
des réponses éventuelles du serveur:
type request = ... | FillPolyReq of (int * int) array * drawable * graphic_context * poly_shape * coord_mode | GetAtomNameReq of atom | ... and reply = ... | GetAtomNameReply of string | ... |
Le coeur du serveur serait une boucle de lecture et décodage des requêtes de la forme suivante:
(* Recueillir une demande de connexion sur le descripteur s *) let requests = in_channel_of_descr s and replies = out_channel_of_descr s in try while true do match input_value requests with ... | FillPoly(vertices, drawable, gc, shape, mode) -> fill_poly vertices drawable gc shape mode | GetAtomNameReq atom -> output_value replies (GetAtomNameReply(get_atom_name atom)) | ... done with End_of_file -> (* fin de la connexion *) |
La bibliothèque X, liée avec chaque application, serait de la forme:
(* Établir une connexion avec le serveur sur le descripteur s *) let requests = out_channel_of_descr s and replies = in_channel_of_descr s;; let fill_poly vertices drawable gc shape mode = output_value requests (FillPolyReq(vertices, drawable, gc, shape, mode));; let get_atom_name atom = output_value requests (GetAtomNameReq atom); match input_value replies with GetAtomNameReply name -> name | _ -> fatal_protocol_error "get_atom_name";; |
Il faut remarquer que le type de input_value
donné ci-dessus est
sémantiquement incorrect, car beaucoup trop général: il n’est pas vrai
que le résultat de input_value
a le type 'a
pour tout type 'a
.
La valeur renvoyée par input_value
appartient à un type bien précis,
et non pas à tous les types possibles; mais le type de cette valeur ne
peut pas être déterminé au moment de la compilation, puisqu’il dépend
du contenu du fichier qui va être lu à l’exécution. Le typage correct
de input_value
nécessite une extension du langage ML connue sous le
nom d’objets dynamiques: ce sont des valeurs appariées avec une
représentation de leur type, permettant ainsi des vérifications de
types à l’exécution. On se reportera à [13] pour une
présentation plus détaillée.
Une autre application typique de ce type de protocole est l’appel de procédure distant, courremment appelé RPC (pour «Remote Procedure Call»). Un utilisateur sur une Machine A veut exécuter un programme f sur une machine B. Ce n’est évidemment pas possible directement. Ce pourrait être programmé au cas par cas, en passant par le système pour ouvrir une connexion vers la machine B exécuter l’appel, relayer la réponse vers la machine A puis l’utilisateur.
En fait, comme c’est une situation typique, il existe un service RPC qui fait cela. C’est un client-serveur (client sur la machine A, serveur sur la machine B, dans notre exemple) qui reçoit des requêtes d’exécution sur une machine distante (B) de la part d’un utilisateur, se connecte au serveur RPC sur la machine distante B qui exécute l’appel f et retourne la réponse au client RPC A qui a son tour la renvoie à l’utilisateur. L’intérêt est qu’un autre utilisateur peut appeler un autre programme sur la machine B (ou une autre) en passant par le même serveur RPC. Le travail a donc été partagé par le service RCP installé sur les machines A et B.
Du point de vue du programme utilisateur, tout se passe virtuellement comme s’il faisait un simple appel de fonction (flèches hachurées).
Les services réseaux où l’efficacité du protocole n’est pas cruciale utilisent souvent un autre type de protocoles: les protocoles “texte”, qui sont en fait un petit langage de commandes. Les requêtes sont exprimées sous forme de lignes de commandes, avec le premier mot identifiant le type de requête, et les mots suivants les arguments éventuels. Les réponses sont elles aussi sous forme d’une ou plusieurs lignes de texte, commençant souvent par un code numérique, pour faciliter le décodage de la réponse. Quelques protocoles de ce type:
|
Le grand avantage de ce type de protocoles est que les échanges entre
le client et le serveur sont immédiatement lisibles par un être
humain. En particulier, on peut utiliser telnet
pour dialoguer “en
direct” avec un serveur de ce type3: on tape les requêtes comme le ferait un client, et on voit
s’afficher les réponses. Ceci facilite grandement la mise au point.
Bien sûr, le travail de codage et de décodage des requêtes et des
réponses est plus important que dans le cas des protocoles binaires;
la taille des messages est également un peu plus grande; d’où une
moins bonne efficacité.
Exemple:
Voici un exemple de dialogue interactif avec un serveur SMTP. Les lignes précédées par → vont du client vers le serveur, et sont donc tapées par l’utilisateur. Les lignes précédées par ← vont du serveur vers le client.
pom: telnet margaux smtp Trying 128.93.8.2 ... Connected to margaux.inria.fr. Escape character is '^]'. << 220 margaux.inria.fr Sendmail 5.64+/AFUU-3 ready at Wed, 15 Apr 92 17:40:59 >> HELO pomerol.inria.fr << 250 Hello pomerol.inria.fr, pleased to meet you >> MAIL From:<god@heavens.sky.com> << 250 <god@heavens.sky.com>... Sender ok >> RCPT To:<xleroy@margaux.inria.fr> << 250 <xleroy@margaux.inria.fr>... Recipient ok >> DATA << 354 Enter mail, end with "." on a line by itself >> From: god@heavens.sky.com (Himself) >> To: xleroy@margaux.inria.fr >> Subject: salut! >> >> Ca se passe bien, en bas? >> . << 250 Ok >> QUIT << 221 margaux.inria.fr closing connection Connection closed by foreign host. |
Les commandes HELO
, MAIL
et RCPT
transmettent le nom de la
machine expéditrice, l’adresse de l’expéditeur, et l’adresse du
destinataire. La commande DATA
permet d’envoyer le texte du message
proprement dit. Elle est suivie par un certain nombre de lignes (le
texte du message), terminées par une ligne contenant le seul caractère
“point”. Pour éviter l’ambiguïté, toutes les lignes du message qui
commencent par un point sont transmises en doublant le point initial;
le point supplémentaire est supprimé par le serveur.
Les réponses sont toutes de la forme « un code numérique en trois
chiffres plus un commentaire ». Quand le client est un programme, il
interprète uniquement le code numérique; le commentaire est à l’usage
de la personne qui met au point le système de courrier. Les réponses
en 5xx
indiquent une erreur; celles en 2xx
, que tout s’est bien
passé.
Le protocole HTTP (HyperText Transfert Protocol) est utilisé essentiellement pour lire des documents sur la fameuse “toile”. Ce domaine est une niche d’exemples client-serveur: entre la lecture des pages sur la toile ou l’écriture de serveurs, les relais se placent en intermédiaires, serveurs virtuels pour le vrai client et clients par délégation pour le vrai serveur, offrant souvent au passage un service additionnel tel que l’ajout de caches, de filtres, etc.
Il existe plusieurs versions du protocole HTTP. Pour aller plus rapidement à l’essentiel, à savoir l’architecture d’un client ou d’un relais, nous utilisons le protocole simplifié, hérité des toutes premières versions du protocole. Même s’il fait un peu poussiéreux, il reste compris par la plupart des serveurs. Nous décrivons à la fin une version plus moderne et plus expressive mais aussi plus complexe, qui est indispensable pour réaliser de vrais outils pour explorer la toile. Cependant, nous laisserons la traduction des exemples en exercices.
La version 1.0 du protocole http décrite dans la norme RFC 19454 permet les requêtes simplifiées de la forme:
GET sp Request-URI crlf
où sp représente un espace et crlf la chaîne de caractères
\r\n
(return suivi de linefeed). La réponse à une
requête simplifiée est également simplifiée: le contenu de l’url est envoyé
directement, sans entête, et la fin de la réponse est signalée par la fin de
fichier, qui termine donc la connexion. Cette forme de requête, héritée du
protocole 0.9, limite de fait la connexion à la seule requête en cours.
Nous proposons d’écrire une commande geturl
qui prend un seul argument,
une URL, recherche sur la toile le document qu’elle désigne et
l’affiche.
La première tâche consiste à analyser l’URL pour en extraire le nom du
protocole (ici nécessairement http
) l’adresse du serveur, le port
optionnel et le chemin absolu du document sur le serveur.
Pour ce faire nous utilisons la bibliothèque d’expressions régulières Str
.
Nous passons rapidement sur cette partie du code peu intéressante,
mais indispensable.
open Unix;; exception Error of string let error err mes = raise (Error (err ^ ": " ^ mes));; let handle_error f x = try f x with Error err -> prerr_endline err; exit 2 let default_port = "80";; type regexp = { regexp : Str.regexp; fields : (int * string option) list; } let regexp_match r string = let get (pos, default) = try Str.matched_group pos string with Not_found -> match default with Some s -> s | _ -> raise Not_found in try if Str.string_match r.regexp string 0 then Some (List.map get r.fields) else None with Not_found -> None;; let host_regexp = { regexp = Str.regexp "\\([^/:]*\\)\\(:\\([0-9]+\\)\\)?"; fields = [ 1, None; 3, Some default_port; ] };; let url_regexp = { regexp = Str.regexp "http://\\([^/:]*\\(:[0-9]+\\)?\\)\\(/.*\\)"; fields = [ 1, None; 3, None ] };; let parse_host host = match regexp_match host_regexp host with Some (host :: port :: _) -> host, int_of_string port | _ -> error host "Ill formed host";; let parse_url url = match regexp_match url_regexp url with Some (host :: path :: _) -> parse_host host, path | _ -> error url "Ill formed url";; |
Nous pouvons maintenant nous attaquer à l’envoi de la requête qui, dans le protocole simplifié, est une trivialité.
let send_get url sock = let s = Printf.sprintf "GET %s\r\n" url in ignore (write sock s 0 (String.length s));; |
Remarquons que l’url peut ne contenir que le chemin sur le serveur, ou bien être complète, incluant également le port et l’adresse du serveur.
La lecture de la réponse est encore plus facile, puisque le document est
simplement envoyé comme réponse, sans autre forme de politesse. Lorsque la
requête est erronée, un message d’erreur est encodé dans un document
HTML. Nous nous contentons ici de faire suivre la réponse sans distinguer si
elle indique une erreur ou correspond au document recherché. La transmission
utilise la fonction de bibliothèque Misc.retransmit
.
Le cœur du programme établit la connexion avec le serveur.
let get_url proxy url fdout = let (hostname, port), path = match proxy with None -> parse_url url | Some host -> parse_host host, url in let hostaddr = try inet_addr_of_string hostname with Failure _ -> try (gethostbyname hostname).h_addr_list.(0) with Not_found -> error hostname "Host not found" in let sock = socket PF_INET SOCK_STREAM 0 in Misc.try_finalize begin function () -> connect sock (ADDR_INET (hostaddr, port)); send_get path sock; retransmit sock fdout end () close sock;; |
Nous terminons, comme d’habitude, par l’analyse de la ligne de commande.
let geturl () = let len = Array.length Sys.argv in if len < 2 then error "Usage:" (Sys.argv.(0) ^ " [ proxy [:<port>] ] <url>") else let proxy, url = if len > 2 then Some Sys.argv.(1), Sys.argv.(2) else None, Sys.argv.(1) in get_url proxy url stdout;; handle_unix_error (handle_error geturl) ();; |
Nous nous proposons maintenant d’écrire un relais HTTP (proxy en anglais), c’est-à-dire un serveur de requêtes HTTP qui permet de traiter toutes les requêtes HTTP en les redirigeant vers la machine destinataire (ou un autre relais...) et fait suivre les réponses vers la machine appelante. Nous avons schématisé le rôle d’un relais dans la figure 6.3. Lorsqu’un client HTTP utilise un relais, il adresse ses requêtes au relais plutôt que de les adresser directement aux différents serveurs HTTP localisés un peu partout dans le monde. L’avantage du relais est multiple. Un relais peut mémoriser les requêtes les plus récentes ou les plus fréquentes au passage pour les resservir ultérieurement sans interroger le serveur, soit pour ne pas le surcharger, soit en l’absence de connexion réseau. Un relais peut aussi filtrer certaines pages (retirer la publicité ou les images, etc.). L’utilisation d’un relais peut aussi simplifier l’écriture d’une application en lui permettant de ne plus voir qu’un seul serveur pour toutes les pages du monde.
La commande proxy
lance le serveur sur le port passé en argument, ou
s’il est omis, sur le port par défaut du service HTTP.
Nous récupérons bien entendu le code réalisé par la fonction get_url
(nous supposons que les fonctions ci-dessus, hormis le lancement
de la commande, sont disponibles dans un module Url
). Il ne reste qu’à
écrire l’analyse des requêtes et mettre en place le serveur.
open Unix open Url let get_regexp = { regexp = Str.regexp "^[Gg][Ee][Tt][ \t]+\\(.*[^ \t]\\)[ \t]*\r"; fields = [ 1, None ] } let parse_request line = match regexp_match get_regexp line with | Some (url :: _) -> url | _ -> error line "Ill formed request" |
Nous allons établir le service avec la commande establish_server
. Il
suffit donc de définir le traitement d’une connexion.
let proxy_service (client_sock, _) = let service() = try let in_chan = in_channel_of_descr client_sock in let line = input_line in_chan in let url = parse_request line in get_url None url client_sock with End_of_file -> error "Ill formed request" "End_of_file encountered" in Misc.try_finalize (handle_error service) () close client_sock |
Le reste du programme n’a plus qu’à établir le service.
let proxy () = let http_port = if Array.length Sys.argv > 1 then try int_of_string Sys.argv.(1) with Failure _ -> error Sys.argv.(1) "Incorrect port" else try (getservbyname "http" "tcp").s_port with Not_found -> error "http" "Unknown service" in let treat_connection s = Misc.double_fork_treatment s proxy_service in let addr = ADDR_INET(inet_addr_any, http_port) in Misc.tcp_server treat_connection addr;; handle_unix_error (handle_error proxy) ();; |
Les requêtes simplifiées obligent à créer une connexion par requête, ce qui est inefficace, car il est fréquent que plusieurs requêtes se suivent sur le même serveur (par exemple, le chargement d’une page WEB qui contient des images va entraîner dans la foulée le chargement des images correspondantes). Le temps d’établissement de la connexion peut facilement dépasser le temps passé à traiter la requête proprement dite. Nous verrons dans le chapitre 7 comment réduire celui-ci en faisant traiter les connexions par des coprocessus plutôt que par des processus. Nous proposons dans les exercices ci-dessous l’utilisation du protocole HTTP/1.15 qui utilise des requêtes complexes permettant de servir plusieurs requêtes par connexion6.
Dans les requêtes complexes, le serveur précède chaque réponse par une entête indiquant le format de la réponse et le cas échéant la taille du document transmis. La fin du document n’est plus indiquée par une fin de fichier, puisqu’elle peut être déduite de la taille. La connexion peut ainsi rester ouverte pour servir d’autres requêtes.
Celles-ci sont de la forme suivante:
GET sp Uri sp HTTP/1.1 crlf Header crlf
L’entête Header définit une suite de paires champ-valeur avec la syntaxe suivante:
field : value crlf
Des espaces superflus sont également permis autour du séparateur “":"”. En fait, un espace sp peut toujours être remplacé par une tabulation ou une suite d’espaces. Les champs de l’entête peuvent également s’étendre sur plusieurs lignes: dans ce cas et dans ce cas uniquement le lexème de fin de ligne crlf est immédiatement suivi d’un espace sp. Enfin, majuscules et minuscules sont équivalentes dans les mots-clés des champs, ainsi que dans les valeurs de certains champs composés de listes de mots-clé.
Selon le type de requête, certains champs sont obligatoires, d’autres sont optionnels. Par exemple, une requête GET comporte forcément un champ qui indique la machine destinataire:
Host colon Hostname crlf
Pour ce type requête, on peut aussi demander, en utilisant le champ optionnel If-Modified que le document ne soit retourné que s’il a été modifié depuis une certaine date.
If-Modified colon Date crlf
Le nombre de champs du Header n’est donc pas fixé par avance mais indiqué par la fin de l’entête qui consiste en une ligne réduite aux seuls caractères crlf.
Voici une requête complète (toutes les lignes se terminant par le
caractère \n
laissé implicite et qui suit immédiatement le \r
):
GET /~remy/ HTTP/1.1\r Host:pauillac.inria.fr\r \r |
Une réponse à une requête complexe est également une réponse complète. Elle comporte une ligne de statut, une entête, puis le corps de la réponse, le cas échéant.
HTTP/1.0 SP status SP message crlf Header crlf Body
Les champs de l’entête d’une réponse ont une syntaxe analogue à celle d’une requête mais les champs permis et obligatoires sont différents (ils dépendent du type de la requête ou du statut de la réponse—voir la documentation complète du protocole).
Le corps de la réponse Body peut-être vide, transmis en un seul bloc, ou par tranches. Dans le second cas, l’entête comporte un champ Content-Length indiquant le nombre d’octets en notation décimale ASCII. Dans le troisième cas, l’entête comporte une champ Transfer-Encoding avec la valeur chuncked. Le corps est alors un ensemble de tranches et se termine par une tranche vide. Une tranche est de la forme:
Size [ semicolon arg ] crlf Chunk crlf
où Size est la taille de la tranche en notation hexadécimale (la partie entre “[” et “]” est optionnelle et peut être ignorée ici) et Chunk est une partie du corps de la réponse de la taille indiquée. La dernière tranche de taille nulle est toujours de la forme suivante:
0 crlf Header crlf crlf
Enfin, le corps de la réponse Body est vide lorsque la réponse
n’est pas tranchée et ne contient pas de champ Content-Length
(par exemple, une requête de type HEAD ne répond que par une
entête).
Voici un exemple de réponse:
HTTP/1.1 200 OK\r Date: Sun, 10 Nov 2002 09:14:09 GMT\r Server: Apache/1.2.6\r Last-Modified: Mon, 21 Oct 2002 13:06:21 GMT\r ETag: "359-e0d-3db3fbcd"\r Content-Length: 3597\r Accept-Ranges: bytes\r Content-Type: text/html\r \r <html> ... </html> |
Le statut 200
indique que la requête a réussi. Un statut 301
ou 302
signifie que l’URL a été redirigée vers une autre URL définie dans le champ
Location
de la réponse. Les statuts de la forme 400
, 401
,
etc. indique des erreurs dans la forme ou l’aboutissement de la requête et
ceux de la forme 500
, 501
, etc. des erreurs, plus grave, dans le
traitement de la requête.
wget
telle que wget
u1 … un effectue les
requêtes ui et sauve les réponses dans des fichiers ./
mi"/"pi où
mi et pi sont respectivement le nom de la machine et le chemin absolu
de la requête ui. On profitera du protocole complet pour n’effectuer
qu’une seule connexion sur la machine m lorsque celle-ci est la même pour
plusieurs requêtes consécutives. De plus, on suivra une URL lorsque
celle-ci est une redirection temporaire ou définitive.
On pourra ajouter les options suivantes:
telnet
machine service, où machine est le nom de la machine sur laquelle tourne le serveur, et
service est le nom du service (smtp
, nntp
, etc.).