Implémentation simpliste d'un SCS par sockets TCP bruts sous Microsoft Windows

Ce qui suit propose une implémentation simpliste de deux programmes, l'un client et l'autre serveur au sens des sockets bruts de type flux (sockets TCP). L'idée de cet exemple est de vous aider à démarrer si vous avez de la difficulté, pas de montrer de bonnes pratiques de programmation (d'autres exemples seront proposés à cette fin).

Des archives zip pour Visual Studio 2017 sont mise à votre disposition si vous souhaitez expérimenter :

Consignes d'ordre général

Les sockets bruts ne sont pas aussi naturels sous Microsoft Windows que sous Linux. Pour cette raison :

Structure de l'exemple

Cet exemple se présente en trois morceaux :

Il y aura donc deux programmes distincts (client et serveur); vous pourrez copier le code commun dans chacun des programmes, ou simplement en faire une petite bibliothèque que les deux utiliseront, à votre convenance. Les exemples qui suivent présument que vous avez procédé par simple copier / coller.

Pour tester les programmes, vous devrez d'abord lancer le serveur, puis lancer le client. Si vous procédez dans l'ordre inverse, le client constatera probalement un Timeout avant que le serveur ne soit démarré, faute d'avoir eu une réponse dans un délai raisonnable de la part de ce dernier.

Code commun aux deux programmes

Le code commun aux deux programmes consiste en une très mince couche pour isoler les fonctions de base des sockets TCP et faciliter (légèrement) la migration du code vers une autre plateforme au besoin. Il ne comporte aucune optimisation etr son « traitement d'erreur » est d'une naïveté extrême, soit afficher un message et terminer l'exécution du programme.

Je ne saurais trop insister sur la naïveté de ce qui suit : le code est lent, inefficace, et limité. Ne le prenez pas pour autre chose qu'un point de départ pour fins de compréhension des fonctions de l'API des sockets bruts.

Le code naïf que nous examinons ici utilisera des sockets et de l'affichage de texte sur les flux d'entrée/ sortie standards.

Pour faciliter l'utilisation de la bibliothèque Ws2_32.lib sous Microsoft Windows, j'ai utilisé une directive #pragma, non-portable, ce qui importe peu ici du fait que c'est précisément pour un détail non-portable que j'y ai recours.

#include <winsock2.h>
#include <WS2tcpip.h>
#include <iostream>
#include <string>
using namespace std;
#pragma comment(lib,"Ws2_32.lib")

Pour découpler légèrement le code des plateformes, j'utiliserai un alias socket_t pour le type représentant un socket (nommé SOCKET avec Microsoft Windows, alors qu'il s'agit d'un int avec Linux) et un type socket_operation_t pour les codes retournés par les fonctions sur des sockets.

using socket_t = SOCKET;
bool est_invalide(socket_t sck) {
   return sck == INVALID_SOCKET;
}
using socket_operation_t = int;
bool est_erreur(socket_operation_t op) {
   return op == SOCKET_ERROR;
}

Lorsqu'une erreur surviendra, une fonction erreur() sera appelée. Celle-ci obtiendra le code de l'erreur (WSAGetLastError() avec Microsoft Windows, variable globale errno avec Linux) et l'affichera, en plus d'un message descriptif fourni par le code appelant.

C'est, on le comprendra, une « gestion d'erreurs » qui n'en a que le nom (et encore).

L'affichage des messages est fait avec des templates variadiques pour faire en sorte qu'ils puissent comprendre autant que composants que souhaité.

template <class T>
   void print(ostream &os, T && arg) {
      os << arg;
   }
template <class T, class ... Args>
   void print(ostream &os, T && arg, Args && ... args) {
      print(os, std::forward<T>(arg));
      print(os, std::forward<Args>(args)...);
   }
template <class ... Args>
   auto erreur(Args && ... args) {
      const auto code = WSAGetLastError();
      print(cerr, std::forward<Args>(args)...);
      cerr << "; erreur " << code << endl;
      return code;
   }

Le chargement et le déchargement de la DLL implémentant les services des sockets doivent être faits explicitement et une fois par programme (charger au début, décharger à la fin) avec Microsoft Windows. Sous Linux, ces fonctions seraient vides, tout simplement.

void charger_sockets() {
   const long VERSION_DEMANDEE = MAKEWORD(2,2);
   WSAData configWinsock;
   if (WSAStartup(VERSION_DEMANDEE, &configWinsock) != 0)
      exit(erreur("Demarrage"));
}
void decharger_sockets() noexcept {
   WSACleanup();
}

Créer un socket de type flux se fait à l'aide de la fonction socket(), en lui passant une combinaison de paramètres appropriée.

socket_t creer_socket() {
   auto sck = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
   if (est_invalide(sck))
      exit(erreur("Creation"));
   return sck;
}

Fermer un socket se fait avec la fonction closesocket() sous Microsoft Windows, et avec la fonction close() sous Linux.

Puisque les communications par sockets passent par des canaux du système d'exploitation et peuvent ne pas avoir été complétées au moment de la fermeture, j'ai inséré un appel à shutdown() avant cette fermeture (même si on parle du socket serveur, dans quel cas le shutdown est essentiellement un no-op). Notez que les paramètres exacts à shutdown() dépendent de la plateforme.

void fermer_socket(socket_t sck) noexcept {
   shutdown(sck, SD_BOTH);
   closesocket(sck);
}

La connexion est une tâche qui incombe au socket qui souhaite jouer le rôle d'un client, et se fait par un appel à la fonction connect().

void connecter(socket_t sck, const string &adresse, unsigned short port) {
   sockaddr_in dest = { };
   dest.sin_family= AF_INET;
   inet_pton(AF_INET, adresse.c_str(), &dest.sin_addr.s_addr);
   dest.sin_port = htons(port);
   auto resultat = connect(
      sck, reinterpret_cast<const sockaddr*>(&dest), sizeof(sockaddr_in)
   );
   if (est_erreur(resultat))
      exit(erreur("Connexion a ", adresse, ":", port));
}

Réserver un port et y créer une file d'attente pour accueillir les demandes de connexion sont des tâches qui incombent au socket qui souhaite jouer le rôle de serveur, et se font respectivement par des appels aux fonctions bind() et listen(), dans l'ordre.

void reserver_port(socket_t sck, unsigned short port) {
   sockaddr_in nom = { };
   nom.sin_family = AF_INET;
   nom.sin_port = htons(port);
   auto resultat = bind(
      sck, reinterpret_cast<const sockaddr*>(&nom), sizeof(sockaddr_in)
   );
   if (est_erreur(resultat))
      exit(erreur("Port ", port, ", obtention"));
   int taille_file = 16; // arbitraire
   if (est_erreur(listen(sck, taille_file)))
      exit(erreur("Creer file d'attente de taille ", taille_file));
}

Accepter une demande de connexion se fait par la fonction accept().

socket_t accepter_client(socket_t srv) {
   sockaddr_in homologue = { };
   int taille = sizeof(sockaddr_in);
   auto sck = accept(
      srv, reinterpret_cast<sockaddr *>(&homologue), &taille
   );
   if (est_invalide(sck))
      exit(erreur("Accepter connexion"));
   return sck;
}

Envoyer des données (ici, du texte, mais ce n'est qu'un exemple banal) se fait à l'aide de la fonction send(), qui retourne le nombre de bytes réellement émis.

La fonction proposée ici annexe un délimiteur de fin au message à envoyer, puis itère jusqu'à ce qu'il ait été complètement émis. C'est une manière extrêmement inefficace de procéder : si on n'a pas réussi à envoyer un message entier lors d'une itération, réessayer immédiatement entraîne une faible probabilité que l'envoi suivant fonctionne (pourquoi aurait-on déjà plus de ressources à notre disposition, après tout?).

void envoyer(socket_t sck, string msg, char fin) {
   msg += fin;
   while (!msg.empty()) {
      auto plus_recent = send(
         sck, &msg[0], static_cast<int>(msg.size()), 0
      );
      if (est_erreur(plus_recent))
         exit(erreur("Envoi"));
      msg = msg.substr(plus_recent);
   }
}

Recevoir des données (ici, du texte, mais ce n'est qu'un exemple banal) se fait à l'aide de la fonction recv(), qui retourne le nombre de bytes réellement reçus.

La fonction proposée ici consomme des données et les accumule dans une string jusqu'à ce que le dernière caractère reçu corresponde à un délimiteur de fin de message. C'est une manière risquée de procéder : les sockets ne connaissent pas les mesages, et il se pourrait qu'une réception obtienne la fin d'un envoi et une partie de l'envoi suivant, enfouissant en quelque sorte le délimiteur de fin au beau milieu de ce qui aura été reçu.

string recevoir(socket_t sck, char fin) {
   const int CAPACITE = 64; // arbitraire
   char tampon[CAPACITE];
   string message_recu;
   bool reception_completee = false;
   do {
      auto n = recv(sck, tampon, CAPACITE, 0);
      if (est_erreur(n))
         exit(erreur("Réception"));
      message_recu.append(tampon + 0, tampon + n);
      if (tampon[n-1] == fin)
         reception_completee = true;
   } while (!reception_completee);
   if (!message_recu.empty()) message_recu.pop_back();
   return message_recu;
}

Client très simple

Le code du client très simple suit.

Le déroulement de l'exécution de ce programme devrait être essentiellement évident.

// ... voir le code commun aux deux programmes, plus haut
int main() {
   const string ADRESSE_DU_SERVEUR = "127.0.0.1";
   const unsigned short PORT_DU_SERVEUR = 4321;
   const string MESSAGE = "Ca marche!";
   const char DELIMITEUR_FIN = '#';
   charger_sockets();
   socket_t client = creer_socket();
   connecter(client, ADRESSE_DU_SERVEUR, PORT_DU_SERVEUR);
   envoyer(client, MESSAGE, DELIMITEUR_FIN);
   cout << "Envoi reussi de " << MESSAGE.size() + 1
        << " bytes, delimiteur inclus. Message : \"" << MESSAGE << "\"\n";
   auto recu = recevoir(client, DELIMITEUR_FIN);
   cout << "Reception reussie de " << recu.size() + 1
        << " bytes, délimiteur inclus. Message : \"" << recu << '\"' << endl;
   fermer_socket(client);
   decharger_sockets();
}

Serveur très simple

Le code du serveur très simple suit.

Le déroulement de l'exécution de ce programme devrait être essentiellement évident.

// ... voir le code commun aux deux programmes, plus haut
int main() {
   const unsigned short PORT_A_RESERVER = 4321;
   const string MESSAGE = "Recu 5 sur 5";
   const char DELIMITEUR_FIN = '#';
   charger_sockets();
   auto serveur = creer_socket();
   reserver_port(serveur, PORT_A_RESERVER);
   auto pourJaser = accepter_client(serveur);
   auto recu = recevoir(pourJaser, DELIMITEUR_FIN);
   cout << "Reception reussie de " << recu.size() + 1
        << " bytes, delimiteur inclus. Message : \"" << recu << "\"\n";
   envoyer(pourJaser, MESSAGE, DELIMITEUR_FIN);
   cout << "Envoi reussi de " << MESSAGE.size() + 1
        << " bytes, delimiteur inclus. Message : \"" << MESSAGE << '\"' << endl;
   fermer_socket(pourJaser);
   fermer_socket(serveur);
   decharger_sockets();
}

Voilà!


Valid XHTML 1.0 Transitional

CSS Valide !