Previous Up Next

Chapter 6  Communications modernes: les prises

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


Figure 6.1: Modèle Client-Serveur

  1. Le serveur U créé une prise s et la branche sur un port p connu des clients puis attend les connexions sur sa prise (1).
  2. Un client A créé une prise et se connecte au serveur sur le port p (2). Le système alloue alors une nouvelle prise pour communiquer en privé avec le client A (3). Dans le schéma choisi ici, il se duplique en un serveur auxiliaire V (4), ferme sa prise avec le client A (en trait haché), et laisse son fils V traiter la connexion avec A (5).
  3. Le serveur peut alors accepter un nouveau client B, établir une autre connexion en parallèle servie par un autre clone W (6), et ainsi de suite.
  4. Le serveur peut fermer son service en fermant le descripteur associé à la prise s. Au bout d’un certain temps le système libère le port p qui peut alors être réutilisé, par exemple pour y installer un autre service.

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.

6.1  Les prises

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:

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:

 FlotsDatagrammePaquet segmentés
Fiableouinonoui
Forme des donnéesflot d’octetspaquetspaquets

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.

6.2  Création d’une prise

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_UNIXle domaine Unix
PF_INETle domaine Internet

Le deuxième argument indique la sémantique de communication désirée:

SOCK_STREAMflot d’octets, fiable
SOCK_DGRAMpaquets, non fiable
SOCK_RAWaccès direct aux couches basses du réseau
SOCK_SEQPACKETpaquets, 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.

6.3  Adresses

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:

     
   val inet_addr_of_string : string -> inet_addr
   val string_of_inet_addr : inet_addr -> string

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)

6.4  Connexion à un serveur

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.

6.5  Déconnexion

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_RECEIVEferme la prise en lecture; write sur l’autre bout de la connexion va déclencher un signal SIGPIPE
SHUTDOWN_SENDferme la prise en écriture; read sur l’autre bout de la connexion va renvoyer une marque de fin de fichier
SHUTDOWN_ALLferme 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é.

6.6  Exemple complet: Le client universel

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 /~remyHTTP/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_addrport_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.

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.

6.7  Établissement d’un service

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 sraise 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_sockclient_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 serverservice clientexit 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 serverservice clientexit 0
       | k ->
           ignore (restart_on_EINTR (waitpid []) kin
     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.

6.8  Réglage des prises

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.

6.9  Exemple complet: le serveur universel

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 (hostportin
     let treat sock (client_sockclient_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 stdindup2 s stdoutdup2 s stderrclose 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.

Précautions

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.

6.10  Communication en mode déconnecté

Les lectures/écritures en mode déconnecté

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.

     
   val recvfrom :
     file_descr -> string -> int -> int -> msg_flag list -> int * sockaddr
   val sendto :
     file_descr -> string -> int -> int -> msg_flag list -> sockaddr -> int

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 lectures/écritures de bas niveau

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).

     
   val recv : file_descr -> string -> int -> int -> msg_flag list -> int
   val send : file_descr -> string -> int -> int -> msg_flag list -> int

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.

6.11  Primitives de haut niveau

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).

6.12  Exemples de protocoles

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

Protocoles “binaires”

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:

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:

     
   val output_valueout_channel -> 'a -> unit
   val input_valuein_channel -> 'a

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 * intarray * 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(verticesdrawablegcshapemode) ->
             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(verticesdrawablegcshapemode));;
   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.

Appel de procédure distant (Remote Procedure Call)


Figure 6.2: Remote Procedure Call

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).

Protocoles “texte”

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:

SMTP (Simple Mail Transfert Protocol)RFC 821courrier électronique
FTP (File Transfert Protocol)RFC 959transferts de fichiers
NNTP (Network News Transfert Protocol)RFC 977lecture des News
HTTP-1.0 (HyperText Transfert Protocol)RFC 1945navigation sur la toile
HTTP-1.1 (HyperText Transfert Protocol)RFC 2068navigation sur la toile

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.

     
       pomtelnet 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.frpleased 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 mailend with "." on a line by itself
   >>  Fromgod@heavens.sky.com (Himself)
   >>  Toxleroy@margaux.inria.fr
   >>  Subjectsalut!
   >>
   >>  Ca se passe bienen 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é.

6.13  Exemple complet: requêtes http

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

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.

Récupération d’une url

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 errexit 2
   
   let default_port = "80";;
   
   type regexp = { regexp : Str.regexpfields : (int * string optionlist; }
   let regexp_match r string =
     let get (posdefault) =
       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 :: _) -> hostint_of_string port
     | _ -> error host "Ill formed host";;
   let parse_url url =
     match regexp_match url_regexp url with
       Some (host :: path :: _) -> parse_host hostpath
     | _ -> 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 (hostnameport), path =
       match proxy with
         None -> parse_url url
       | Some host -> parse_host hosturl 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 (hostaddrport));
         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 proxyurl =
         if len > 2 then Some Sys.argv.(1), Sys.argv.(2)
         else NoneSys.argv.(1) in
       get_url proxy url stdout;;
   
   handle_unix_error (handle_error geturl) ();;

Relais HTTP


Figure 6.3: Relais HTTP

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_anyhttp_portin
     Misc.tcp_server treat_connection addr;;
   
   handle_unix_error (handle_error proxy) ();;

Le Protocole HTTP/1.1

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 /~remyHTTP/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

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
   DateSun, 10 Nov 2002 09:14:09 GMT\r
   ServerApache/1.2.6\r
   Last-ModifiedMon, 21 Oct 2002 13:06:21 GMT\r
   ETag"359-e0d-3db3fbcd"\r
   Content-Length: 3597\r
   Accept-Rangesbytes\r
   Content-Typetext/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.

Exercice 15   Écrire un relais qui fonctionne avec le protocole HTTP/1.1.
Exercice 16   Ajouter un cache au relais: les pages sont sauvegardées sur le disque. Lorsqu’une page demandée est disponible dans le cache, la page du cache est servie, sauf si elle est trop ancienne, auquel cas le serveur est interrogé (et le cache est mis à jour).
Exercice 17   Écrire un programme wget telle que wget u1 … un effectue les requêtes ui et sauve les réponses dans des fichiers ./mi"/"pimi 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:

1
Le réseau Internet se compose de réseaux locaux, généralement du type Ethernet, reliés par des liaisons spécialisées. Il relie des millions de machines dans le monde entier. À l’intérieur du domaine Internet, il n’y a pas de différence au niveau des programmes entre communiquer avec la machine voisine, branchée sur le même câble Ethernet, et communiquer avec une machine à l’autre bout du monde, à travers une dizaine de routeurs et une liaison satellite.
2
Les RFC sont disponibles par FTP anonyme sur de nombreux sites. En France: ftp.inria.fr, dans le répertoire rfc. Le site de référence étant http://www.faqs.org/rfcs/.
3
Il suffit de lancer 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.).
4
La description complète peut-être trouvée à l’URL http://www.doclib.org/rfc/rfc1945.html.
5
La description complète peut-être trouvée à l’URL http://www.doclib.org/rfc/rfc2068.html.
6
Le protocole HTTP/1.0 permet déjà ce type de requêtes en plus des requêtes simplifiées, mais nous préférons décrire le protocole HTTP/1.1 qui traite exclusivement des requêtes complexes.

Previous Up Next