TCP - Envoi et réception depuis le serveur en C++
I. Envoyer et recevoir des données depuis un socket
Pour
envoyer des données à un client connecté et depuis le client connecté, on
utilisera la même fonction que dans le code client : send.
De
même, la réception de données se fait via recv.
Pour
rappel, les prototypes de ces fonctions sont int send(int socket, const void* datas, size_t len, int flags);et int recv(int socket, void* buffer, size_t len, int flags);.
Nous
savons que le paramètre socket est
le socket auquel nous voulons envoyer les données ou duquel nous voulons les
recevoir. Ici il s'agira donc du socket du client que nous avons créé via
l'appel à accept.
Afin
de gérer ses clients, notre serveur va maintenant devoir maintenir une liste de
clients connectés en enregistrant les retours de accept qui représente chaque client effectivement
connecté à notre serveur, et en supprimant ceux dont le socket retourne une
erreur indiquant qu'ils ont été déconnectés.
« Gérer
ses clients » signifie recevoir et traiter les données qu'ils envoient,
les requêtes, puis envoyer d'éventuelles réponses.
Il faut
savoir que send et surtout recv sont bloquants. La première solution à laquelle
on pense est généralement d'avoir un thread par client. Commençons donc par
celle-ci.
La
première option sera donc de créer un thread par client, tandis que le thread
principal servira à l'initialisation et à accepter les connexions entrantes.
Voilà
grossièrement à quoi devrait ressembler notre programme :
Pour
ce qui est du client, nous réutiliserons le client de la partie 2 Envoi et réception.
Boucle d'acceptation des nouveaux
clients et lancement du thread pour chacun :
for (;;)
{
sockaddr_in from = { 0 };
socklen_t addrlen = sizeof(from);
SOCKET newClient = accept(server, (SOCKADDR*)(&from), &addrlen);
if (newClient != INVALID_SOCKET)
{
std::thread([newClient,
from]() {
const std::string clientAddress = Sockets::GetAddress(from);
const unsigned short clientPort = ntohs(from.sin_port);
std::cout
<< "Connexion de " << clientAddress.c_str() << ":" << clientPort << std::endl;
bool connected = true;
for(;;)
{
char buffer[200] = { 0 };
int ret = recv(newClient, buffer, 199, 0);
if (ret == 0 || ret == SOCKET_ERROR)
break;
std::cout
<< "[" << clientAddress << ":" << clientPort << "]" << buffer << std::endl;
ret = send(newClient, buffer, ret, 0);
if (ret == 0 || ret == SOCKET_ERROR)
break;
}
std::cout
<< "Deconnexion de [" << clientAddress << ":" << clientPort << "]" << std::endl;
}).detach();
}
else
break;
}
Lignes
8 à 25, se trouvent les changements et le code qui permet de lancer un thread
reproduisant le comportement du schéma précédent : chaque client exécutera
son recv puis son send dans son propre thread.
En
termes de traitement de la requête, nous faisons au plus simple : il n'y a
aucun traitement et nous nous contentons de retourner à l'expéditeur ce qu'il
nous a envoyé.
Une
autre approche du serveur est d'avoir l'ensemble des traitements sur un unique
thread.
D'abord,
créons une structure très simple pour agréger les sockets de chaque client avec
leur adresse :
struct Client {
SOCKET sckt;
sockaddr_in addr;
};
Qui
nous permettra de facilement avoir l'information d'IP et port du client en
question.
Pour
garder en mémoire nos clients connectés, ayons recours à un std::vector :
std ::vector<Client> clients ;
I-B-2-aPermet
de récupérer le statut d'écriture, lecture ou erreur d'un ou plusieurs sockets,
sous Windows, ou descripteurs de fichiers, sous Unix.
·
nfds est l'identifiant
du descripteur le plus élevé, plus un
o
ce paramètre est ignoré sous Windows, mais
présent pour compatibilité.
·
readfds est un pointeur
vers un ensemble de sockets pour lesquelles tester le statut de lecture.
·
writefds est
un pointeur vers un ensemble de sockets pour lesquelles tester le statut
d'écriture.
·
exceptfds est
un pointeur vers un ensemble de sockets pour lesquelles tester le statut
d'erreur.
·
timeout est un pointeur
vers une structure pour le temps maximum que select doit attendre et bloquer
avant de retourner, une valeur de nullptr permet de bloquer jusqu'à ce qu'un
des sockets soit prêt à lire ou écrire.
Pour
les fd_set, on utilisera les
macros de FD_ZERO pour l'initialiser
et FD_SET pour mettre les
valeurs des sockets, de cette forme :
fd_set set;
FD_ZERO(&set);
FD_SET(server, &set);
Où server est notre socket serveur.
select retourne le nombre de sockets qui sont prêts à
lire, écrire ou ayant une erreur, peut retourner 0 si aucun socket n'est prêt.
Retourne -1 en cas d'erreur.
Pour
vérifier qu'un socket, ou descripteur de fichier, particulier a été défini dans
la structure, on utilisera la macro FD_ISSET.
Pour
vérifier qu'une connexion entrante est en attente, que l'appel à accept ne sera pas bloquant, on doit vérifier que notre
socket serveur est prêt en écriture :
fd_set set;
timeval timeout = { 0 };
FD_ZERO(&set);
FD_SET(server, &set);
int selectReady = select(server + 1, &set, nullptr, nullptr, &timeout);
if (selectReady == -1)
{
std::cout << "Erreur select pour accept : " << Sockets::GetError() << std::endl;
break;
}
else if (selectReady > 0)
{
// notre socket server est prêt à être
lu
sockaddr_in from = { 0 };
socklen_t addrlen = sizeof(from);
SOCKET newClientSocket = accept(server, (SOCKADDR*)(&from), &addrlen);
if (newClientSocket != INVALID_SOCKET)
{
Client newClient;
newClient.sckt = newClientSocket;
newClient.addr = from;
const std::string clientAddress = Sockets::GetAddress(from);
const unsigned short clientPort = ntohs(from.sin_port);
std::cout << "Connexion de " << clientAddress.c_str() << ":" << clientPort << std::endl;
}
}
Pour
vérifier qu'un de nos clients est prêt à recevoir des données, l'utilisation
sera identique, mais notre structure fd_setsera utilisé pour vérifier tous les clients via un
seul appel à select :
fd_set setReads;
fd_set setWrite;
fd_set setErrors;
int highestFd = 0;
timeval timeout = { 0 };
for (auto& client : clients)
{
FD_SET(client.sckt, &setReads);
FD_SET(client.sckt, &setWrite);
FD_SET(client.sckt, &setErrors);
if (client.sckt > highestFd)
highestFd = client.sckt;
}
int selectResult = select(highestFd + 1, &setReads, &setWrite, &setErrors, &timeout);
if (selectResult == -1)
// erreur
else if (selectResult > 0)
// au moins 1 client a une action à
exécuter
Si selectResult est strictement positif, au moins un de nos
clients est prêt à recevoir ou envoyer des données, ou a une erreur :
auto itClient = clients.begin();
while (itClient != clients.end())
{
const std::string clientAddress = Sockets::GetAddress(itClient->addr);
const unsigned short clientPort = ntohs(itClient->addr.sin_port);
bool hasError = false;
if (FD_ISSET(itClient->sckt, &setErrors))
{
std::cout << "Erreur" << std::endl;
hasError = true;
}
else if (FD_ISSET(itClient->sckt, &setReads))
{
char buffer[200] = { 0 };
int ret = recv(itClient->sckt, buffer, 199, 0);
if (ret == 0 || ret == SOCKET_ERROR)
{
std::cout
<< "Erreur reception" << std::endl;
hasError = true;
}
else
{
std::cout
<< "[" << clientAddress << ":" << clientPort << "]" << buffer << std::endl;
if (FD_ISSET(itClient->sckt, &setWrite))
{
ret = send(itClient->sckt, buffer, ret, 0);
if (ret == 0 || ret == SOCKET_ERROR)
{
std::cout
<< "Erreur envo" << std::endl;
hasError
= true;
}
}
}
}
if (hasError)
{
//!< Déconnecté
std::cout << "Deconnexion de [" << clientAddress << ":" << clientPort << "]" << std::endl;
itClient = clients.erase(itClient);
}
else
{
++itClient;
}
}
Ce
code nécessitera quelques static_cast selon
la plateforme et les options de compilation.
Puisque select permet de connaître l'état de plusieurs sockets
à la fois, nous ne pouvons pas utiliser notre Sockets::GetError() pour déterminer l'erreur d'un socket en
particulier.
Pour
connaître l'erreur d'un socket en particulier, il faudra utiliser la
fonction getsockopt.
Permet
de récupérer certaines informations d'un socket, dont l'erreur qui l'affecte.
·
sckt est le socket en
question.
·
level est le niveau
relatif à l'option que nous voulons récupérer. Pour les erreurs il s'agira
de SOL_SOCKET.
·
optname est le nom de
l'option à récupérer. Pour les erreurs il s'agira de SO_ERROR.
·
optval est un tampon pour
récupérer la valeur de l'option.
·
optlen est un pointeur
vers la taille du tampon.
Son
utilisation sera donc :
getsockopt
int err;
int errsize = sizeof(err);
getsockopt(sckt, SOL_SOCKET, SO_ERROR, reinterpret_cast<char*>(&err), &errsize);
Retourne
0 si aucune erreur est survenue, SOCKET_ERROR sinon.
La
principale différence vient du type des paramètres, un int remplace le SOCKET pour le descripteur de socket, le tampon de la
valeur de l'option sera un void* et la taille du tampon sera représentée par
un socklen_t.
Puisque
notre code possède déjà de quoi faire abstraction du SOCKET et socklen_t, et que le langage permet de convertir
automatiquement un char* vers
un void*,
l'appel pourra être uniformisé entre Windows et Unix sous cette forme :
getsockopt cross-platform
socklen_t err;
int errsize = sizeof(err);
if (getsockopt(sckt, SOL_SOCKET, SO_ERROR, reinterpret_cast<char*>(&err), &errsize) != 0)
{
// erreur lors de la recuperation
d'erreur…
std::cout << "Erreur lors de la determination de
l'erreur : " << Sockets::GetError() << std::endl;
}
Les
systèmes plus récents (Windows Vista et supérieurs) proposent une alternative
à select avec poll. Leur fonctionnement est identique : vérifier
l'état d'un ensemble de descripteurs. Le principal avantage qui aura un intérêt
est que poll peut gérer plus
que 1024 descripteurs à la fois.
Permet
de récupérer l'état d'un ensemble de descripteurs de sockets.
·
fdarray est un tableau de
structures WSAPOLLFD (voir
détails ci-dessous).
·
nfds est le nombre de
structures WSAPOLLFD dans fdarray.
·
timeout est la durée
maximale d'attente avant retour.
o
timeout < 0 indique une attente infinie : un appel
bloquant.
o
timeout == 0 indique un appel non bloquant.
o
timeout > 0 pour définir un temps d'attente en
millisecondes.
La
structure WSAPOLLFD possède
trois champs :
·
fd de type SOCKET pour accueillir le descripteur de socket ;
·
events de type short servant de champ de bits des états à
vérifier ;
·
revents de type short qui sera modifié par l'appel avec les flags des
états trouvés pour le socket en question.
Version
Unix de WSAPoll.
·
fds est un tableau de
structure pollfd (voir détails
ci-dessous).
·
nfds est la taille du
tableau fds.
·
timeout est la durée
maximale d'attente avant retour.
o
timeout < 0 indique une attente infinie : un appel
bloquant.
o
timeout == 0 indique un appel non bloquant.
o
timeout > 0 pour définir un temps d'attente en
millisecondes.
La
structure pollfd possède également
trois champs :
·
fd de type int pour accueillir le descripteur de fichier ;
·
events de type short servant de champ de bits des états à
vérifier ;
·
revents de type short qui sera modifié par l'appel avec les flags des
états trouvés pour le socket en question.
Malgré
les différences de prototypes et types, les fonctions sont suffisamment
similaires pour que la portabilité soit simple. Les structures WSAPOLLFD et pollfd sont identiques, et Windows définit d'ailleurs
lui-même une struct pollfd.
Inutile de passer par la déclaration WSAPOLLFD donc.
Le
second paramètre nfds est déclaré
comme unsigned long sur Windows, et est un typedef vers unsigned intsur Unix. Ayons recours à la technique habituelle, et
définissons nfds_t pour Windows afin
d'utiliser ce type dans notre code indépendamment de la plateforme.
Sockets.hpp
#ifdef _WIN32
…
typedef unsigned long nfds_t;
#define poll WSAPoll
…
#else
…
#include <poll.h>
…
#endif
Les
noms des flags de chaque état sont quelque peu différents d'une plateforme à
l'autre, mais Windows s'en sort bien en définissant les valeurs que l'on
s'attend à avoir en Posix. Les valeurs qui nous intéressent sont :
·
POLLIN pour vérifier que
le socket est prêt en lecture ;
·
POLLOUT pour vérifier que
le socket est prêt en écriture.
Les
valeurs intéressantes à vérifier dans revents au retour de poll sont :
·
POLLERR pour vérifier
qu'une erreur est survenue ;
·
POLLNVAL si
le socket n'était pas initialisé ;
·
POLLHUP si la connexion a
été interrompue ;
·
POLLIN si l'écriture est
possible.
o
Notez que si vous envoyez plus de données
que la place disponible, le send sera
toujours bloquant ;
·
POLLOUT si des données
sont disponibles en lecture.
Nous
pouvons modifier le code précédent utilisant select pour utiliser poll :
poll remplace select
std::map<SOCKET, Client> clients;
std::vector<pollfd> clientsFds;
for (;;)
{
{
pollfd pollServerFd;
pollServerFd.fd = server;
pollServerFd.events = POLLIN;
int pollReady = poll(&pollServerFd, 1, 0);
if (pollReady == -1)
{
std::cout
<< "Erreur poll pour accept : " << Sockets::GetError() << std::endl;
break;
}
if (pollReady > 0)
{
sockaddr_in
from = { 0 };
socklen_t
addrlen = sizeof(from);
SOCKET
newClientSocket = accept(server,
(SOCKADDR*)(&from), &addrlen);
if (newClientSocket != INVALID_SOCKET)
{
Client
newClient;
newClient.sckt
= newClientSocket;
newClient.addr
= from;
const std::string clientAddress = Sockets::GetAddress(from);
const unsigned short clientPort = ntohs(from.sin_port);
std::cout
<< "Connexion de " << clientAddress.c_str() << ":" << clientPort << std::endl;
clients[newClientSocket]
= newClient;
pollfd
newClientPollFd;
newClientPollFd.fd
= newClientSocket;
newClientPollFd.events
= POLLIN | POLLOUT;
clientsFds.push_back(newClientPollFd);
}
}
}
if (!clients.empty())
{
int pollResult = poll(clientsFds.data(), static_cast<nfds_t>(clientsFds.size()), 0);
if (pollResult == -1)
{
std::cout
<< "Erreur poll pour clients : " << Sockets::GetError() << std::endl;
break;
}
else if (pollResult > 0)
{
auto itPollResult = clientsFds.cbegin();
while (itPollResult != clientsFds.cend())
{
const auto clientIt =
clients.find(itPollResult->fd);
if (clientIt == clients.cend())
{
itPollResult
= clientsFds.erase(itPollResult);
continue;
}
const auto& client = clientIt->second;
const std::string clientAddress = Sockets::GetAddress(client.addr);
const unsigned short clientPort = ntohs(client.addr.sin_port);
bool disconnect = false;
if (itPollResult->revents & POLLERR)
{
socklen_t
err;
int errsize = sizeof(err);
if (getsockopt(client.sckt, SOL_SOCKET, SO_ERROR, reinterpret_cast<char*>(&err), &errsize) != 0)
{
std::cout
<< "Impossible de determiner l'erreur : " << Sockets::GetError() << std::endl;
}
if (err != 0)
std::cout
<< "Erreur : " << err << std::endl;
disconnect
= true;
}
else if (itPollResult->revents & (POLLHUP | POLLNVAL))
{
disconnect
= true;
}
else if (itPollResult->revents & POLLIN)
{
char buffer[200] = { 0 };
int ret = recv(client.sckt, buffer, 199, 0);
if (ret == 0)
{
std::cout
<< "Connexion terminee" << std::endl;
disconnect
= true;
}
else if (ret == SOCKET_ERROR)
{
std::cout
<< "Erreur reception : " << Sockets::GetError() << std::endl;
disconnect
= true;
}
else
{
std::cout
<< "[" << clientAddress << ":" << clientPort << "]" << buffer << std::endl;
if (itPollResult->revents & POLLOUT)
{
ret
= send(client.sckt, buffer, ret, 0);
if (ret == 0 || ret == SOCKET_ERROR)
{
std::cout
<< "Erreur envoi : " << Sockets::GetError() << std::endl;
disconnect
= true;
}
}
}
}
if (disconnect)
{
std::cout
<< "Deconnexion de " << "[" << clientAddress << ":" << clientPort << "]" << std::endl;
itPollResult
= clientsFds.erase(itPollResult);
clients.erase(clientIt);
}
else
{
++itPollResult;
}
}
}
}
}
Notez
également le changement de clients pour un std::map<SOCKET, Client>. Ce changement peut également être fait dans
l'exemple utilisant select.
Un
avantage de poll est la
différenciation entre les flags d'entrée, à vérifier, le champ events de la structure, et les flags de sortie,
retournés, le champ revents de la structure.
Ça permet de conserver une collection de sockets et flags à appeler sans
nécessiter la moindre réinitialisation après appel à poll.
Un
autre point fort est que poll peut
être utilisé sur plus de 1024 descripteurs à la fois, là où select est limité à 1024.
En
dehors de ça, select est surtout le
premier implémenté historiquement et l'utilisation de l'un ou l'autre est le
plus souvent interchangeable. Pour un développement récent, si vous ne comptez
pas avoir un code portable sur d'anciens systèmes, autant utiliser poll à mon avis. Sauf si celui-ci n'est pas
disponible sur la plateforme ciblée.
D'autres
systèmes existent aujourd'hui tel epoll ou kqueue. Ils feront sans doute l'objet d'articles plus
avancée par la suite.
Une
autre façon de faire, qui peut aller de pair avec l'utilisation de select, est de déclarer explicitement le socket comme non
bloquant.
Cette
option a ma préférence dans le cadre d'un serveur, parce que plus simple dans
l'écriture et les traitements, selon moi.
Permet
de changer le mode d'entrée/sortie d'un socket.
·
socket est le socket sur
lequel appliquer la modification.
·
command est l'identifiant
de la commande à appliquer au socket.
·
parameter est
un pointeur vers un paramètre à appliquer à la commande.
Retourne
-1 en cas d'échec, 0 sinon.
Dans
le cas qui nous intéresse, rendre le socket non bloquant, l'appel sera :
Rendre un socket non bloquant sous
Windows
ioctlsocket(s, FIONBIO, &mode);
Permet
de changer une propriété d'un descripteur de fichier.
·
fd est le descripteur
de fichier auquel appliquer la modification, dans notre cas le socket.
·
cmd la commande à
appliquer.
·
un éventuel dernier paramètre selon la
commande souhaitée.
La
valeur de retour dépendra de la commande à exécuter.
fcntl(socket, F_SETFL, O_NONBLOCK);
Dans
ce cas retournera -1 en cas d'erreur, autre chose sinon.
Que
se passe-t-il désormais pour les fonctions auparavant bloquantes ?
accept retournera INVALID_SOCKET si aucune nouvelle connexion n'est arrivée au
lieu d'attendre la prochaine connexion, le nouveau socket sinon.
recv retournera une erreur (-1 ou SOCKET_ERROR). Il faudra alors récupérer le code erreur de la
bibliothèque socket afin de vérifier s'il s'agit d'une erreur légitime à
traiter en tant que telle ou la valeur de WSAEWOULDBLOCK (pour Windows) ou EWOULDBLOCK (pour Unix) indiquant que recv a retourné sans lire de données au lieu de
bloquer.
send aura le même comportement que recv si la mise en file d'envois aurait dû être
bloquante : retour d'une valeur d'erreur, puis il faudra vérifier si
l'erreur de la bibliothèque socket est WSAEWOULDBLOCK/EWOULDBLOCK ou
non.
Puisque
Windows et Unix sont ici encore différents, l'un utilisant WSAEWOULDBLOCK l'autre EWOULDBLOCK, commençons par uniformiser ceci dans notre
bibliothèque. Ajoutons une énumération d'erreurs à notre code, par exemple dans
un nouveau fichier :
Errors.hpp
#ifndef BOUSK_DVP_COURS_ERRORS_HPP
#define BOUSK_DVP_COURS_ERRORS_HPP
#pragma once
#ifdef _WIN32
#include <WinSock2.h>
#else
#include <cerrno>
#define SOCKET int
#define INVALID_SOCKET ((int)-1)
#define SOCKET_ERROR (int(-1))
#endif
namespace Sockets
{
int GetError();
enum class Errors {
#ifdef _WIN32
WOULDBLOCK = WSAEWOULDBLOCK
#else
WOULDBLOCK = EWOULDBLOCK
#endif
};
}
#endif // BOUSK_DVP_COURS_ERRORS_HPP
J'ai
également déplacé int GetError(); de Sockets.cpp vers Errors.cpp afin de centraliser tout ce qui est lié aux
erreurs dans ces nouveaux fichiers.
Nous
avons maintenant une façon élégante et portable de vérifier l'aspect
« erreur, opération bloquante qui n'a pas bloqué » via cette nouvelle
valeur Sockets::Errors::WOULDBLOCK.
Il
est maintenant temps de modifier notre programme principal.
D'abord,
il faut définir notre socket serveur comme non bloquant :
if (!Sockets::SetNonBlocking(server))
{
std::cout << "Erreur settings non bloquant : " << Sockets::GetError();
return -3;
}
Ensuite,
la modification de la boucle principale qui aura maintenant cette forme :
Boucle principale : acceptation des
nouveaux clients et gestion des connectés et déconnectés
std::vector<Client> clients;
for (;;)
{
{
sockaddr_in
from = { 0 };
socklen_t
addrlen = sizeof(from);
SOCKET
newClientSocket = accept(server,
(SOCKADDR*)(&from), &addrlen);
if (newClientSocket != INVALID_SOCKET)
{
if (!Sockets::SetNonBlocking(newClientSocket))
{
std::cout
<< "Erreur settings nouveau socket non bloquant :
" << Sockets::GetError() << std::endl;
Sockets::CloseSocket(newClientSocket);
continue;
}
Client
newClient;
newClient.sckt
= newClientSocket;
newClient.addr
= from;
const std::string clientAddress = Sockets::GetAddress(from);
const unsigned short clientPort = ntohs(from.sin_port);
std::cout
<< "Connexion de " << clientAddress.c_str() << ":" << clientPort << std::endl;
clients.push_back(newClient);
}
}
{
auto itClient = clients.begin();
while ( itClient != clients.end() )
{
const unsigned short clientPort = ntohs(itClient->addr.sin_port);
char buffer[200] = { 0 };
bool disconnect = false;
int ret = recv(itClient->sckt, buffer, 199, 0);
if (ret == 0)
{
//!< Déconnecté
disconnect
= true;
}
if (ret == SOCKET_ERROR)
{
int error = Sockets::GetError();
if (error != static_cast<int>(Sockets::Errors::WOULDBLOCK))
{
disconnect
= true;
}
//!< il n'y avait juste rien à recevoir
}
std::cout
<< "[" << clientAddress << ":" << clientPort << "]" << buffer << std::endl;
ret = send(itClient->sckt, buffer, ret, 0);
if (ret == 0 || ret == SOCKET_ERROR)
{
disconnect
= true;
}
if (disconnect)
{
std::cout
<< "Deconnexion de [" << clientAddress << ":" << clientPort << "]" << std::endl;
itClient
= clients.erase(itClient);
}
else
++itClient;
}
}
}
Notez
qu'il faut définir chaque socket accepté comme non bloquant également.
En
théorie, il faudrait également vérifier que l'erreur retournée par send ne soit pas WOULDBLOCK. En pratique il est très difficile de faire
bloquer send, il faudrait vouloir
envoyer une très grande quantité de données, ce que ne fait pas ce programme.
Commentaires
Enregistrer un commentaire