Créer votre propre Protocole de Communication Client-Serveur

Nous allons découvrir comment utiliser les signaux UNIX pour communiquer entre différents programmes. Par la suite, nous créerons une communication textuelle entre un client et un serveur.

Niveau Débutant
39 minutes de lecture
Comment utiliser les signaux UNIX

Que signifie Inter-Process Communication ?

Description Définition: L'Inter-Process Communication (IPC), ou communication inter-processus en français, désigne l'ensemble des mécanismes permettant à des processus de communiquer entre eux et de partager des données. Eh bien sachez que c’est ce que nous allons créer ! En somme, nous créerons un mini protocole de communication Client - Server. Alors nous n’allons pas révolutionner le game mais cela nous permettra de comprendre comment cela fonctionne et… entre nous… c’est stylé comme projet non ? Dans un premier temps je vais indiquer le sujet complet avec ce qu’il faut faire et ce qu’il ne faut pas faire afin de permettre aux lecteurs souhaitant réaliser le projet seul de ne pas être spoil.

Sujet: Créer votre propre Protocole de Communication Client-Serveur

Si vous êtes arrivés jusqu’ici, Bravo ! Maintenant je vais énoncer le sujet du projet final de ce cours. Vous devrez créer un protocole de communication entre client et serveur. Le principe est que le client puisse envoyer un message au server qui le recevra et l’affichera. Vous aurez donc deux programmes (client et server), le server devra être exécuté en premier et afficher son PID pour le client.

  • La communication entre vos programmes doit se faire uniquement à l’aide de signaux UNIX.
  • Les deux seuls signaux autorisés sont SIGUSR1 & SIGUSR2 car ils n’ont pas de fonctions de base. Le programme client devra:
  • Lors du lancement, le client prend deux arguments (Le PID du server, Le message)
  • Chiffrer le message (N’oubliez pas que vous n’avez que deux signaux)
  • Envoyer le message au serveur Le programme server devra:
  • Afficher son PID
  • Créez une boucle infini pour que le serveur puisse recevoir des signaux à tout moment
  • Recevoir des signaux
  • Décrypter les signaux
  • Pour chaque signal reçu (SIGUSR1 & SIGUSR2), il doit effectuer une certaine action
  • Afficher le message complet du client

Commençons notre projet

Dans cette phase de création je vais essayer de détailler au plus les étapes afin que vous puissiez bien comprendre la réalisation de ce projet. Description Dans cet exemple, le client veut envoyer le message « Hello » mais il ne peut pas être envoyé directement au serveur. Le client doit chiffrer le message en envoyant une série de signaux et le serveur doit le décrypter/interpréter avant qu'il puisse être affiché. Pourquoi ne peut-on pas simplement envoyer un message directement ? Un signal est une sorte de notification envoyée à un processus. Dans ce projet nous pourrons utiliser seulement deux signaux : SIGUSR1 et SIGUSR2. Mais, savez vous ce qui fonctionne également par deux ? Le binaire bien sûr ! Donc si on transforme notre message en chaîne de 0 et de 1 et qu’on attribue 0 au signal SIGUSR1 et 1 au signal SIGUSR2, nous pouvons donc envoyer un message complet au serveur qui quant à lui pourra faire la même opération a l’inverse pour obtenir le message. Bien, maintenant que nous avons vu comment nous allons pouvoir envoyer notre message afin qu'il soit compris par le serveur, codons: Dans un premier temps, on aura besoin de trois fichiers:

  • client.c (Le code de notre programme client)
  • server.c (Le code de notre programme server)
  • ipc.h (Le header de nos programmes) Nous allons commencer par créer notre header afin de bien l'utiliser et d’y intégrer nos premières bibliothèques dont la bibliothèque standard, input output, signal, string etc... Fichier ipc.h

Bien, maintenant que nous avons la base de notre header, nous allons commencer la base du fichier server.c, nous commencerons par afficher son PID. Ficher server.c

On affiche le PID du processus server avec la fonction getpid(). Maintenant qu’on a la base du header et du server.c nous allons créer la base du client.c, celui-ci doit prendre en argument le PID du serveur et le message a envoyer. Ficher client.c

Désormais notre server affiche son PID et le client enregistre dans des variables le PID et le Message. Nous avons donc la base de nos trois fichiers client, server et header. Nous allons donc terminer notre programme client. Celui-ci prends actuellement le PID du serveur et un message. Maintenant, nous allons creer la fonction qui va prendre le message, le crypter en Bits et envoyer chaque bits au serveur. Commençons par créer une fonction send_message qui prend en paramètre le pid du serveur et le message a envoyer. client.c

Nous initialisons deux entiers qui nous serviront d’index pour cette fonction. "Letter" et "I" “letter” représente l’index du caractère en cours de traitement dans la chaîne “message” que nous initialisons a 0. “i” représente l’index de chaque bit du caractère.

Nous créons maintenant un boucle qui va être exécutée tant que le message n’arrive pas a la fin représenter par un octet null “\0”. Dans cette boucle nous initialisons “i” a -1 qui sera incrémenté avant chaque itération dans une nouvelle boucle et comparer au nombre de bits d’un caractère (Un caractère fait 8 bits).

Désormais nous allons traiter chaque bits du caractère actuel afin de les envoyer un à un au serveur. Pour ce faire, nous créons une condition qui nous permettra d’identifier si le bits est un 1 ou un 0. Nous plaçons le bit que nous voulons vérifier à la position la plus à droite afin de pouvoir le comparer (et nous ferons cela avec chaque bit du caractère tant qu’on a pas atteint les 8 bits qu’il contient). Après vérification, si le bit est 0, on utilise la fonction kill pour envoyer le signal SIGUSR1 au serveur et à l'inverse, si le bit est 1, la fonction kill envoie le signal SIGUSR2 au serveur. A chaque envoie de bits nous utilisons la fonction usleep afin d’attendre 50 millisecondes que le signal s’envoie et laisser le temps en serveur de le recevoir.

Grâce à cette boucle, nous avons pu envoyer au serveur les 8 bits du premier caractère et nous devons désormais passer au suivant et recommencer, donc on incrémente “letter” en dehors de cette boucle. De ce fait, tant que le message contient un caractère, il envoie celui-ci bit par bit au serveur et ainsi de suite jusqu'à ce que le message soit entièrement envoyé. Néanmoins, il nous reste un point majeur à voir : la fin du message. En effet, en C, chaque chaîne de caractères doit se terminer par un octet nul "\0" (représenté par 8 bits "0"). Nous allons donc, en dehors de la boucle principale (c’est-à-dire à la fin du message), simplement envoyer 8 bits 0, c’est-à-dire 8 fois le signal SIGUSR1 afin de clôturer l'envoi du message complet.

Nous avons donc notre fonction send_signal qui prends le pid du serveur ainsi que le message du client afin d'envoyer bit par bit le message au serveur. Pour clôturer le programme client, il ne nous reste plus qu'à appeler notre nouvelle fonction au sein de la fonction main et de lui donner en paramètre le pid du serveur ainsi que notre message.

Description server.c Bien désormais notre client est terminé, passons au serveur et voyons comment nous pouvons recevoir les signaux du client et transformer les bits reçu en caractère pour reconstituer le message. Voici ce que devra faire le serveur: Le serveur reçoit les signaux envoyés par le client et utilise ces signaux pour reconstruire le message original bit par bit. Chaque caractère est reconstruit à partir de 8 bits, puis ajouté à la chaîne finale. Une fois que le caractère nul ('\0') est reçu, le serveur affiche le message complet. Le serveur utilise une boucle infinie pour rester en attente de nouveaux messages. Dans un premier temps nous reprenons le fichier server.c et nous y ajoutons une boucle infini contenant la fonction pause() afin que le programme puisse être mis en suspends le temps qu'il reçoive un signal. (PS: J'en profite pour ajouter un print "Welcome to Creators Area Server").

Ensuite, nous devons définir une fonction pour gérer les signaux reçus. Nous créons donc la fonction “signal_handler” qui prend en paramètre le signal reçu. Cette fonction ne contient rien pour l'instant mais elle nous servira plus tard.

En attendant, nous allons retourner dans notre fonction main et initialiser la fameuse structure sigaction qu’on a vu plus tôt afin de pouvoir intercepter les signaux et les rediriger vers la fonction adéquate Nous définissons donc une “structure sigaction” nommée “action”.

Une fois la structure déclarée, nous pouvons configurer votre gestionnaire de signaux. Nous utilisons action.sa_handler = signal_handler; afin de définir la fonction a appeler lorsqu’on reçoit un signal. sigemptyset(&action.sa_mask); permet de définir un masque de signaux vide et action.sa_flags = 0 permet de gérer les potentielles erreurs. Nous indiquons également les fonctions: sigaction(SIGUSR1, &action, NULL); sigaction(SIGUSR2, &action, NULL); Ces fonctions associent les deux différents signaux à la fonction du gestionnaire. En gros, lorsque le programme serveur reçoit un signal SIGUSR1 ou SIGUSR2, il appelle la fonction signal_handler qui décidera de quoi faire (Mais nous verrons rapidement comment agit cette fonction a la réception de ces signaux).

Bien, passons désormais au code de la fonction signal_handler. Celle-ci va devoir recevoir chaque bit envoyer par le gestionnaire de signal et effectuer une action de decryptage afin d’afficher le message complet. Dans un premier temps, nous devrons avoir 4 variables. Une pour compter le nombre de bit reçu, une pour stocker la valeur du caractère en cours de reconstruction, une pour stocker la longueur actuelle de la chaîne finale et une qui sera un pointeur vers la chaîne finale reconstituer. Ces variables seront statiques car elles ne devront pas bouger a réception de chaque bits (Bah oui, sinon elle se réinitialise à chaque réception de bits et ce n'est pas l'idée).

On doit maintenant initialiser la variables final en utilisant la fonction strdup afin d’allouer de la mémoire sur cette chaîne vide. Si final n’existe pas, on la lui alloue. Si elle existe alors la fonction suit son cours.

Alors désormais nous allons pouvoir traiter nos signaux reçus, en gros, dès que SIGUSR1 est reçu le bit est un 0 et dès que SIGUSR2 est reçu le bit est 1. Mais vous allez voir que c’est un peu plus compliqué que cela et que nous allons devoir utiliser les puissances. Je vais déjà vous montrer la condition, puis je repondrais a la question: Pourquoi avons nous besoin d’utiliser les puissances.

Bien donc nous voyons que lorsque le signal SIGUSR1 est reçu, rien de bien mechant ne se passe car result est egal a 0. Lorsque le signal SIGUSR2 est reçu, la ca se complique, on appelle une fonction permettant de calculer la puissance d’un nombre. Alors, pourquoi nous utilisons la formule char_result = char_result + (1 * recursive_power(2, 7 - bits_counter)); ? Les caractères sont représentés en mémoire par leurs codes ASCII en binaire. Par exemple, le caractère 'A' a un code ASCII de 65, qui est représenté en binaire par 01000001. Pour reconstituer un caractère à partir de ses bits, chaque bit doit être placé à la position correcte. La formule char_result = char_result + (1 * recursive_power(2, 7 - bits_counter)); permet de placer chaque bit à sa position correcte en utilisant les puissances de 2. Dire simplement char_result = 1 ne fonctionnerait pas, car cela ne prend pas en compte la position du bit. Cela signifierait que chaque bit reçu remplace le résultat précédent au lieu de l'ajouter au bon emplacement. Maintenant que nous avons vu pourquoi nous devons utiliser la fonction recursive_power, nous devons maintenant l'écrire. Pour cela rien de plus simple la fonction prendra deux argument, nb et power. L’argument nb est un entier qui sera élevé à la puissance power.

On va initialiser une variable res qui va stocker le résultat intermédiaire de la puissance. Ensuite, nous commençons notre condition principale. Si Power est égal à 0 on return 1 car tout nombre élevé à la puissance 0 est 1. Si power est inférieur à 0 on return 0. Car on ne doit pas gérer les puissances négatives donc on return 0 par défaut. Ensuite nous utilisons la récursivité jusqu'à obtenir un des deux résultats ci- dessus. Nous avons donc réussi à obtenir un bit. Et il y a 8 bits dans un caractère, par conséquent nous allons incrémenter notre variable bits_counter pour répéter l'opération jusqu'à ce qu’on atteigne les 8 bits formant un caractère complet. Pour le vérifier cela, nous créons une condition qui dès qu’on a nos 8 bits (Donc un caractère complet), on l’ajoute a la chaine de caractère finale en utilisant une fonction letter_to_string qui nous allons écrire. final = letter_to_string(final, char_result); Appelle letter_to_string pour ajouter le caractère (représenté par char_result) à la chaîne final.

Et rebelote, allons écrire cette fonction letter_to_string. Celle ci doit prendre en paramètre un pointeur constant vers une chaîne de caractères (string) et un caractère letter. Elle return un pointeur vers une nouvelle chaîne de caractères.

Nous allons déclarer trois variables, deux index et une chaîne de caractère. "i" sera l’index de la chaîne d'entrée "string". "j" sera l’index de la nouvelle chaîne "temp". "*temp" sera un pointeur vers la nouvelle chaîne de caractères qui sera allouée dynamiquement. i et j sont initialisés à 0. i sera utilisé pour parcourir string et j pour parcourir temp.

Bien, nous allons maintenant allouer dynamiquement de la mémoire à la nouvelle chaîne temp en utilisant malloc. On va allouer à “temp” la taille de “string” + 2, un pour le nouveau caractère “letter” et un pour le caractère nul de fin (\0), tout cela multiplier à la taille d’un (char) obtenue par la fonction sizeof. Ensuite, comme après chaque allocation de mémoire, on vérifie si tout s’est bien passé, sinon, nous retournons NULL.

Maintenant que nous avons alloué la mémoire suffisante à notre chaîne de caractères “temp” nous allons copier le contenu de la chaîne “string” existante dans la chaîne “temp”. Une fois arrivée à la fin de la copie nous incluons le nouveau caractère “letter” a la fin de la chaîne “temp” en incrémentant “j”. Une fois le dernier caractère ajouté, nous terminons en ajoutant le caractère nul “\0” representer par 0.

Nous avons terminé, notre nouveau caractère “letter” a été ajouté à notre chaîne de caractère. Nous allons maintenant liberer la memoire de “string” en utilisant la fonction free() afin d'éviter un leaks de mémoire. Puis nous retournons “temp” qui est le pointeur vers la nouvelle chaîne de caractères qui contient les caractères de string suivis de letter.

Bien maintenant que notre fonction letter_to_string est opérationnelle, poursuivons l'exécution de notre fonction signal_handler, nous nous étions arrêter à l’appel de la fonction letter_to_string.

Nous allons désormais vérifier si “char_result” est le caractère nul ('\0'), cela signifiera que la chaîne entière a été reçue. Par conséquent, nous devrons l’afficher et réinitialiser toutes nos variables dans l’attente d’une nouvelle chaine de caractère envoyée par le client.

final = NULL; Réinitialise final à NULL pour préparer la réception d'une nouvelle chaîne. bits_counter = 0; Réinitialise le compteur bits_counter à 0 pour le prochain caractère. char_result = 0; Réinitialise char_result à 0 pour le prochain caractère. str_len += 1; Incrémente str_len pour suivre la longueur de la chaîne reçue (nombre de caractères reçus). Eh bien, nous pouvons dire que nous en avons terminé. Nous avons réussi à créer notre propre petit protocole de communication entre deux programmes grâce aux signaux UNIX. Félicitations à vous si vous êtes arrivé jusqu'ici, en espérant que ce cours vous aura plu. Je vous laisse une vidéo de démonstration de notre protocole ainsi que le code source sur GitHub. Demo: Video GitHub: Code Source

Rejoindre la communauté de développeurs

Rejoins notre communauté de développeurs pour progresser et t'améliorer