Le mois dernier, nous avons vu comment écrire un client mail rudimentaire en Tcl/Tk. La gestion des sockets est relativement simple avec ce langage et il n'est pas nécessaire au programmeur de comprendre tous les appels de l'API socket. Finis les amuse-gueule, il est temps aujourd'hui de mettre les mains dans le cambouis ! Vive le langage C, ses chaînes de caractères et ses Segmentation Faults !
Nous allons tout d'abord étudier notre premier algorithme serveur. Afin de simplifier notre dure vie de programmeur, nous construirons une bibliothèque de fonctions dédiées aux communications TCP/IP. Enfin, nous illustrerons la théorie par un exemple d'école : le célèbre jeu du morpion, en client/serveur.
Algorithmes d'un serveur TCP
Nous aborderons, au cours de cet article et des suivants, trois algorithmes serveurs répondant à trois problèmes différents de communication. Le premier résout le problème d'une communication basique entre seulement deux processus à un instant donné (jeu à deux joueurs par exemple). Le second, plus général, accepte un nombre quelconque de clients (ftp, telnet). Le troisième accepte aussi un nombre quelconque de clients mais permet de plus une communication aisée entre les clients connectés (chat, jeu en réseau). Malgré leurs différences fonctionnelles, ces trois algorithmes ont en commun la création de socket serveur et l'acceptation des connexions clientes.
Les sockets serveur
La création d'une socket serveur se réalise en quatre étapes :
1 Création de la socket grâce à l'appel socket.
2 Liaison de la socket à un port déterminé. Le serveur, pour pouvoir être contacté, doit monopoliser un port de la machine sur laquelle il s'exécute. Par exemple, un serveur ftp monopolise normalement le port 21. Dans le cadre du développement d'un serveur propriétaire, on utilisera un port non sollicité par un autre service. Les 1024 premiers ports sont normalement réservés au système et le serveur doit posséder les droits de l'utilisateur root pour pouvoir les utiliser. La liaison d'une socket à un port se fait par la primitive bind.
3 Dans le cadre du protocole TCP, il convient de placer la socket dans un mode particulier appelé mode passif. Ce mode permet la connexion de clients sur le port défini en (2). La socket n'est alors pas une socket de communication mais est susceptible de réagir à chaque demande de connexion d'un client.
4 Chaque fois qu'un client en fait la demande, le serveur doit y répondre en acceptant la connexion par la fonction accept. Une nouvelle socket est alors créée, par laquelle la communication peut avoir lieu.
Ce fonctionnement, qui peut paraître complexe, est en fait très proche d'une technologie qui nous est familière : la téléphonie. En effet, on peut, de manière analogique, le décrire en ces termes :
1 Se procurer un central téléphonique (socket).
2 Le brancher sur une prise téléphonique (bind).
3 Le mettre en route, en sachant que l'on dispose d'un certain nombre de lignes et de postes téléphoniques, et rester à l'écoute (listen) des appels entrants.
4 Chaque fois qu'un appel a lieu, décrocher et mettre en place une ligne entre un poste et l'initiateur de l'appel (accept).
L'algorithme itératif à accès non concurrentiel
Cet algorithme n'est utilisable que dans la communication entre deux processus. Il sera mis en pratique dans le jeu du morpion. L'analogie avec la téléphonie est la suivante : une secrétaire attend que le téléphone sonne, répond au téléphone, traite la demande de l'appelant, raccroche puis revient en attente d'appel.
Créer une socket (socket)
Associer la socket à un numéro de port (bind)
Mettre la socket en mode passif (listen)
Faire à tout jamais :
Accepter les connexions clientes (accept)
Tantque le client est connecté faire :
Lire une requête sur la socket active (read)
Traiter la requête
Envoyer une réponse sur la socket active (write)
Fin Tantque
Fermer la socket active (close)
Fin Faire
L'API socket, encapsulée !
L'API socket donne au programmeur toutes les primitives de base pour établir des connexions TCP/IP. Mais, contrairement à Tcl/Tk, il n'existe pas de fonctions de haut niveau qui nous permettraient, en un seul appel, de créer une socket de communication en donnant simplement le nom de la machine serveur et le nom du port. Afin de simplifier l'écriture des programmes, il est utile de confectionner une bibliothèque définissant ce genre de fonctions.
Le code ci-dessous définit deux fonctions de haut niveau :
- client_socket(host, service, transport) crée une socket cliente en spécifiant le nom ou l'adresse IP de la machine serveur, le nom ou le numéro du service ainsi que le protocole de transport (udp ou tcp). Elle est l'équivalent de la commande socket de Tcl/Tk.
- server_socket(service, transport, lqueue) crée une socket serveur en spécifiant le nom ou le numéro du service, le protocole de transport et la longueur de la file d'attente des appels. Ce dernier paramètre permet de mettre en attente des connexions clientes alors que le serveur est occupé (vous savez le petit message « vous avez demandé le service X, ne quittez pas »). Elle est l'équivalente de la commande socket -server de Tcl/Tk.
Ces fonctions sont décrites, ainsi que quelques autres, dans un fichier header afin d'être incorporées aisément dans un programme :
/* Fichier csock.h */
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <netdb.h>
#include <string.h>
#include <stdio.h>
#include <stdarg.h>
#include <stdlib.h>
#ifndef INADDR_NONE
#define INADDR_NONE 0xffffffff
#endif
void error_socket(const char *format, ...);
int create_socket(const char *service, const char *trans port, struct sockaddr_in *sin);
int client_socket(const char *host, const char *service, const char *transport);
int server_socket(const char *service, const char *trans port, int lqueue);
Le code associé suit :
/* Fichier csock.c */
#include "csock.h"
extern int errno;
- error_socket sert à sortir brutalement du programme en affichant un message d'erreur quand ça se passe mal…
void error_socket(const char *format, ...) {
va_list args;
va_start(args, format);
vfprintf(stderr, format, args);
va_end(args);
exit(1);
}
- create_socket crée une socket en fonction du service et du protocole transport désirés. Elle n'est normalement pas appelée directement. Elle fait appel à getservbyname qui scrute le fichier /etc/services afin d'y trouver le numéro de port correspondant au nom du service (par exemple, la chaîne « smtp » est convertie en 25). Si le paramètre service est déjà un nombre, la recherche n'a pas lieu. La fonction getprotobyname fait un peu la même chose avec le fichier /etc/protocols : elle cherche le numéro de protocole par rapport à son nom. Le paramètre transport doit être nécessairement « tcp » ou « udp ». Enfin, un appel à socket crée effectivement la socket.
- create_socket renvoie deux informations : sa valeur de retour est le descripteur de la socket créée et le paramètre sin est une structure de données associée à la socket.
Remarque : La fonction htons signifie « Host TO Network, Short integer ». Elle fait partie du protocole XDR (Sun's eXternal Data Representation), qui définit une manière unique d'échanger des données typées par le réseau. En effet, toutes les machines, au sens hardware du terme, ne codent pas les données, et notamment les entiers, de la même façon. Pour qu'elles puissent tout de même communiquer, il a été nécessaire de définir un protocole qui unifie la représentation de ces données. Plusieurs auteurs placent XDR dans la couche Présentation du modèle ISO. On s'aperçoit que notre modèle à quatre couches ne suffit plus....
int create_socket(const char *service, const char *transport, struct sockaddr_in *sin) {
/* Structures permettant de récupérer des informations numériques à partir des noms de service (n° port) et de protocole transport (n° protocole) */
struct servent *iservice;
struct protoent *itransport;
int s, type, proto;
/* Initialisation de la structure socket */
memset(sin, 0, sizeof(*sin)); // Mise à zéro
sin->sin_family = AF_INET; // Famille Internet
/* Recherche du numéro de port */
sin->sin_port = htons(atoi(service));
if (!sin->sin_port) {
/* Si le service n'etait pas un numéro on le recherche dans /etc/services */
iservice = getservbyname(service, transport);
if (!iservice)
error_socket("Service non enregistré: %s", service);
sin->sin_port = iservice->s_port;
}
/* Recherche du numéro de protocole */
itransport = getprotobyname(transport);
if (!itransport)
error_socket("Protocole non enregistré: %s", transport);
proto = itransport->p_proto;
if (!strcmp(transport, "udp"))
type = SOCK_DGRAM; //Protocole UDP
else
type = SOCK_STREAM; // Protocole TCP
/* Création de la socket */
s = socket(AF_INET, type, proto);
if (s < 0)
error_socket("Création de socket impossible: %s", strerror(errno));
return s;
}
client_socket crée une socket par la fonction précédente, cherche l'adresse IP de la machine sur laquelle on désire se connecter en regard du paramètre host grâce à gethostbyname, puis se connecte avec connect. Gethostbyname met en route l'algorithme de résolution de noms en vigueur sur la machine, ce qui se traduit généralement par une recherche dans le fichier /etc/hosts puis, si le nom n'est pas trouvé, par le lancement d'une requête DNS (Domaine Name Service). Si le paramètre host est déjà une adresse IP, par exemple, 192.168.2.18, la fonction inet_addr se charge de la convertir dans un format interne et l'appel à gethostbyname n'a pas lieu.
Cette fonction renvoie le descripteur de la socket créée.
int client_socket(const char *host, const char *service, const char *transport) {
/* Structure décrivant la socket */
struct sockaddr_in sin;
struct hostent *ihost;
int s;
/* Creation d'une socket */
s = create_socket(service, transport, &sin);
/* Recherche de l'adresse IP de host */
sin.sin_addr.s_addr = inet_addr(host);
if (sin.sin_addr.s_addr == INADDR_NONE) {
/* Si host n'est pas une adresse IP mais un nom, on le recherche (/etc/hosts, dns) */
ihost = gethostbyname(host);
if (!ihost)
error_socket("Hôte inconnu: %s", host);
memcpy(&sin.sin_addr, ihost->h_addr, ihost->h_length);
}
/* Connexion de la socket */
if (connect(s, (struct sockaddr *)&sin, sizeof(sin)) < 0)
error_socket("Impossible de se connecter à %s[%s]: %s", host, service, strerror(errno));
return s;
}
- server_socket crée une socket passive prête à accepter des connexions clientes. Cette fonction lie la socket à un numéro de port grâce à bind puis, dans le cas du protocole TCP, la place en mode passif avec listen.
Cette fonction renvoie le descripteur de la socket créée.
int server_socket(const char *service, const char *transport, int lqueue) {
/* Structure décrivant la socket */
struct sockaddr_in sin;
int s;
/* Creation d'une socket */
s = create_socket(service, transport, &sin);
sin.sin_addr.s_addr = INADDR_ANY;
/* Allocation du numéro de port */
if (bind(s, (struct sockaddr *)&sin, sizeof(sin)) < 0)
error_socket("Impossible de lier la socket à %s: %s", service, strerror(errno));
/* Place la socket en mode passif si transport != "udp" */
if (strcmp(transport, "udp") && listen(s, lqueue) < 0)
error_socket("Mode passif impossible sur %s: %s", service, strerror(errno));
return s;
}
Pour compiler cette API, écrivez le makefile suivant :
libcsock.a : csock.h csock.c
gcc -Wall -c -o libcsock.a csock.c
IMPORTANT : Dans les exemples qui suivent, nous supposerons que les fichiers csock.h et libcsock.a se trouvent dans le même répertoire que les fichiers sources des exemples.
Notre premier logiciel client/serveur
Ce premier exemple met en pratique l'algorithme itératif à accès non concurrentiel. Il permet à deux utilisateurs de jouer au morpion sur un réseau TCP/IP (Internet par exemple).
Nous allons d'abord inclure notre bibliothèque de fonctions puis les fonctions Unix standard de manipulation de fichiers bas niveau (read, write, etc.)
/* Fichier morpion.c */
#include "csock.h"
#include <unistd.h>
Une seule fonction dans ce programme : elle gère la connexion puis déroule l'algorithme client/serveur. Elle est réduite à sa plus simple expression afin de se concentrer sur la partie communication. Il n'y a pas de code vérifiant la validité du coup ou la fin de partie.
la suite du code se trouve dans l'encadré suivant :
nt main(int argc, char *argv[]) { char *service = "5656"; // n° de port par défaut char *host = "localhost"; // machine par défaut int msock; // Socket passive pour le serveur int partenaire; // Socket de communication (active) struct sockaddr_in sin; // structure décrivant la socket active int lsin = sizeof(sin); int serveur; // booléen à 1 si on est en mode serveur int fin = 0; // booléen de fin d'exécution /* Variables de gestion du jeu */ int moi = 0; // Descripteur du joueur local (clavier = 0) int joueur; // Descripteur du celui qui doit jouer char plateau[18] = "- - -- -- -"; int position[10] = { 0, 12, 14, 16, 6, 8, 10, 0, 2, 4 } ; char *pions = "ox"; int pion = 0; // Les o commencent char buf[100]; // Buffer pour accueillir les caractères int pavenum; // Chiffre entré par le joueur /* Récupération des arguments */ switch (argc) { case 1: serveur = 0; // Pas de paramètres : // c'est un client break; case 3: // 2 paramètres : le // 2ème est le n° de port service = argv[2]; // Récupération n° de port case 2: // 1 ou 2 paramètres: le // premier est soit s, soit une machine serveur = !strcmp(argv[1], "-s"); if (!serveur) host = argv[1]; // Récupération nom machine break; default: fprintf(stderr, "Usage:%s -s [port] pour le mode serveur", argv[0]); fprintf(stderr, "%s [machine_serveur [port]] pour le client", argv[0]); return 1; } if (serveur) { // Mode SERVEUR // Création d’une socket serveur msock = server_socket(service, "tcp", 5); puts("Attente du joueur client..."); // Attente de la connexion d’un client et création de la socket // de communication ‘partenaire’ partenaire = accept(msock, (struct sockaddr *)&sin, &lsin); // Affichage de l’adresse IP du client printf("Connexion depuis: %s", inet_ntoa(sin.sin_addr)); // Fermeture de la socket serveur close(msock); joueur = moi; puts(plateau); } else { // Mode CLIENT puts("Attente du joueur serveur..."); // Connexion d’une socket de communication au serveur partenaire = client_socket(host, service, "tcp"); joueur = partenaire; } while (!fin) { if (joueur == moi) { /* Jeu du joueur local (clavier) */ puts("A vous de jouer !"); /* Lecture au clavier d'un chiffre entre 1 et 9 */ do { fin = read(moi, buf, 100) <= 0; if (!fin) { pavenum = buf[0]; if (pavenum >= '0' && pavenum <= '9') pavenum -= '0'; else pavenum = -1; } } while (!fin && pavenum == -1); /* Envoi du chiffre au partenaire */ if (!fin) { write(partenaire, &pavenum, sizeof(pavenum)); joueur = partenaire; fin = !pavenum; } } else { /* Réception du chiffre du partenaire */ puts("Attente du jeu du partenaire..."); fin = read(partenaire, &pavenum, sizeof(pavenum)) <= 0; fin = fin || (!pavenum); joueur = moi; } /* Mise à jour du plateau et affichage */ if (!fin) { plateau[position[pavenum]] = pions[pion]; puts(plateau); pion = !pion; } } puts("Connexion terminée."); if (serveur) sleep(1); close(partenaire); return 0; } Pour compiler ce programme, écrivez le makefile suivant : morpion : morpion.c libcsock.a gcc Wall o morpion morpion.c L. -lcsock libcsock.a : csock.h csock.c gcc -Wall -c -o libcsock.a csock.c |
On peut tester ce programme sur une machine isolée à condition que les couches réseau soient installées (c'est le cas par défaut). Il suffit de lancer morpion -s dans un xterm et morpion dans un autre xterm. L'option -s permet de fixer la variable booléenne serveur qui détermine si le programme doit se comporter comme un serveur ou comme un client.
Conclusion
Ce premier exemple montre combien il est simple d'écrire une application réseau sous Linux à partir du moment où l'on dispose de bons outils de programmation.
Le prochain article exposera un algorithme capable de gérer plusieurs connexions simultanées. Cet algorithme est utilisé par de nombreux serveurs comme, par exemple, ftp ou telnet. Pour le coder, nous devrons aborder des aspects fondamentaux de la programmation système sous Linux : la création de processus, la gestion des signaux et la destruction des processus zombies.
Alain Basty