Cours programmation réseau en C++

TCP - Un premier serveur : miniserveur

Voyons une implémentation d'un premier miniserveur et les évolutions de code nécessaires pour y parvenir.

19 commentaires Donner une note à l'article (5) 

Article lu   fois.

L'auteur

Liens sociaux

Viadeo Twitter Facebook Share on Google+   

I. De quel serveur s'agit-il ?

Il s'agira d'un « miniserveur » : un serveur destiné à gérer un faible nombre de connexions, de l'ordre de la dizaine ou centaine - mais cela peut varier selon le matériel et la puissance possédés.

Le serveur tournera sur une machine cliente, un joueur fera office de serveur, et ne sera pas exécuté sur une machine distante dédiée. Il devra donc pouvoir tourner en parallèle du programme principal et ne devra pas bloquer celui-ci. Il s'agit du cas où un joueur fait office d'hôte.

Nous utiliserons le mode non bloquant pour y parvenir.

Si le besoin devient de déplacer le programme serveur sur sa propre machine dédiée, aucun changement ne sera nécessaire - tant que son but (accueillir une centaine de joueurs) ne change pas. Vous devriez être capable d'extraire le code spécifique au serveur dans un programme à part et supprimer l'affichage (par exemple) inutile dans ce cas.

Si le serveur devient plus gros et doit accueillir une population plus importante, l'architecture mise en place dans ce chapitre ne sera pas adaptée.

II. Représentation des clients sur le serveur

Quelle est la différence entre un client - la machine cliente - tel que nous l'avons vu et mis en place dans le chapitre précédent et un client - la représentation locale de la machine cliente sur un serveur - ? Quasiment aucune !

Souvenez-vous que dans le chapitre traitant de l'envoi et réception des données depuis le serveur, nous faisons appel aux mêmes fonctions recv et send pour la réception et l'envoi. Donc l'échange de données est identique.

La seule différence est que notre client serveur (appelons ainsi le socket qui représente un client sur le serveur) ne va pas créer la connexion entre le client et le serveur, mais plutôt accepter la connexion entrante sur le serveur, via un appel à accept du serveur (voir chapitre 4) et non socket pour créer le socket suivi de connect (voir chapitre 1). C'est donc uniquement l'initialisation du socket qui diffère.

Nous utiliserons donc logiquement la même classe Client, en modifiant comme nécessaire son fonctionnement interne et son interface afin qu'on puisse l'utiliser indifféremment comme client - comme code client sur une machine cliente - et client serveur - représentation du client sur une machine serveur.

II-A. Initialisation depuis le serveur

L'initialisation sera en fait bien plus simple : accept retourne directement un socket valide qu'il suffira donc de passer à notre classe Client afin qu'elle l'utilise.

Ajoutons donc simplement une fonction init à cette fin que l'on implémentera ainsi :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
bool ClientImpl::init(SOCKET&& sckt)
{
	assert(sckt != INVALID_SOCKET);
	if (sckt == INVALID_SOCKET)
		return false;

	assert(mState == State::Disconnected);
	assert(mSocket == INVALID_SOCKET);
	if (mSocket != INVALID_SOCKET)
		disconnect();

	mSocket = sckt;
	if (!SetNonBlocking(mSocket))
	{
		disconnect();
		return false;
	}
	mSendingHandler.init(mSocket);
	mReceivingHandler.init(mSocket);
	mState = State::Connected;
	return true;
}

Puisque notre socket ainsi initialisé est déjà connecté, il suffit de le passer en mode non bloquant uniquement - c'est le mode nécessaire à l'utilisation de notre classe Client suite au chapitre précédent. Si l'initialisation du mode non bloquant est réussie, alors on passe notre client directement en état connecté et initialisons la réception et l'envoi de données, sinon on déconnecte le client.

Le socket en paramètre pourrait être passé par copie, mais le passer par déplacement permet de renforcer l'idée que la classe prend possession du socket lors de l'appel à cette fonction.

III. Interface du serveur

Il est maintenant temps d'avoir une classe Server à utiliser dans un programme principal.

L'utilisation retenue sera la même que pour notre classe Client : initialisation et extraction des messages via appel à poll.

Notre interface devrait donc ressembler à :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
class Server
{
	public:
		Server();
		Server(const Server&) = delete;
		Server& operator=(const Server&) = delete;
		Server(Server&&);
		Server& operator=(Server&&);
		~Server();

		bool start(unsigned short _port);
		void stop();
		void update();
		std::unique_ptr<Messages::Base> poll();

	private:
		std::unique_ptr<ServerImpl> mImpl;
};

Tout comme notre Client, un Server sera déplaçable, mais non copiable. Le pattern pimpl sera réutilisé puisqu'il est toujours un bon candidat pour cet exercice.

Pour le démarrer, nous utiliserons la méthode start en passant en paramètre le port souhaité. La fonction stop servira à arrêter le serveur et déconnecter tous les clients.

update servira à mettre à jour le serveur, l'état de chaque client connecté et enfin poll retournera le message suivant à traiter.

Le serveur sera accessible uniquement depuis une connexion IPv4 pour le moment. Le port demandé sera initialisé avec un socket IPv4.

L'utilisation d'IPv6 sera étudiée lors d'un prochain chapitre dans lequel le code Client et Server sera mis à jour.

III-A. Pourquoi une fonction update ?

Notre serveur aura une liste de clients connectés. Que se passerait-il si nous n'avions qu'une fonction poll pour récupérer les données des clients ? Le serveur itérerait sur sa collection de clients, et retournerait le premier message complet du premier client.

Avec une simple collection type vector, l'itération serait toujours dans le même ordre, donc notre serveur retournerait les messages des premiers clients en priorité. Potentiellement le dernier client arrivé n'aurait jamais aucun message traité par le serveur.

Avec l'ajout d'une fonction update, on s'assure de gérer tous les clients, puis de mettre en file les messages reçus lors de ce traitement. Ainsi, sans être parfaitement égaux dans la file, la priorité des premiers clients connectés est moindre sur les derniers. Une autre astuce est de limiter à un message par client à chaque appel à update par exemple.

Ceci est préférable à avoir une collection plus intelligente qui garderait en mémoire où l'itération s'est arrêtée la dernière fois afin de traiter le client suivant : si vous avez un grand nombre de clients, ou particulièrement rapides, vous pourriez recevoir un nouveau message du premier avant d'avoir traité le dernier, l'appel à poll retournerait alors toujours un message valide et vous ne sortiriez jamais de la boucle d'appel à poll dans votre programme principal.

On aura un problème similaire sur l'acceptation de nouveaux clients : si notre serveur se fait spammer de connexions, il passera son temps à accepter des clients sans traiter les existants. De la même manière, nous pouvons limiter, par exemple, à dix nouveaux clients par appel à update.

L'établissement d'une connexion est un processus réputé relativement lent, donc retarder le traitement des connexions entrantes de quelques millisecondes est acceptable et n'entraînera pas la grogne des utilisateurs. Par contre une fois connectés, ils sont en général plutôt impatients de pouvoir communiquer avec le serveur.

IV. Implémentation de la classe Server

IV-A. Liste de clients connectés

Les clients connectés seront sauvegardés dans une collection type std::vector pour le moment. Dans les codes suivants, mClients est défini comme std::vector<Client> mClients; où Client est la classe Client introduite dans le chapitre précédent.

IV-B. bool start(unsigned short _port);

L'appel à start déclenchera l'initialisation du serveur telle qu'on la connaît depuis le chapitre 5.

Son implémentation sera donc juste une adaptation de ce que l'on avait alors vu, en ajoutant l'étape pour rendre le socket non bloquant :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
bool ServerImpl::start(unsigned short _port)
{
	assert(mSocket == INVALID_SOCKET); 
	if (mSocket != INVALID_SOCKET)
		stop();
	mSocket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
	if (mSocket == INVALID_SOCKET)
		return false; 

	if (!SetReuseAddr(mSocket) || !SetNonBlocking(mSocket))
	{
		stop();
		return false;
	}

	sockaddr_in addr;
	addr.sin_addr.s_addr = INADDR_ANY;
	addr.sin_port = htons(_port);
	addr.sin_family = AF_INET;
	if (bind(mSocket, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) != 0)
	{
		stop();
		return false;
	}
	if (listen(mSocket, SOMAXCONN) != 0)
	{
		stop();
		return false;
	}
	return true;
}

IV-B-1. Adresse réutilisable ?

Un serveur utilisera généralement un port spécifique sur l'ensemble des adresses disponibles. Il n'est pas rare également que la machine serveur ait des mécanismes au niveau du système pour relancer automatiquement le programme en cas de problème, erreur ou crash, type cron sur Unix ou les services Windows.

En cas d'arrêt intempestif du programme serveur, à fortiori si des clients étaient toujours connectés, il est fort à parier que des échanges étaient toujours en cours. Afin de terminer proprement les connexions, le socket TCP sera maintenu par le système dans un état nommé TIME_WAIT. Le socket dans cet état attendra que les derniers échanges se terminent afin que sa connexion soit terminée correctement du point de vue du protocole TCP, ou qu'il arrive à expiration (timeout), généralement après deux minutes.

Si l'on essaye de rouvrir un socket sur ce port, ce qui arrive généralement plus rapidement que les deux minutes d'expiration, à fortiori si c'est le système qui le gère, le système va retourner une erreur EADDRINUSE ou EACCESS, respectivement adresse déjà utilisée ou erreur d'accès, tant que notre socket fantôme est dans cet état, empêchant un nouveau socket d'être créé à cette adresse et utilisant le port souhaité.

Pour contrer ça, il existe le flag SO_REUSEADDR qui permet entre autres d'indiquer au système qu'il est autorisé à réutiliser une adresse et un port d'un socket dans l'état TIME_WAIT.

Pour des informations plus en détail et spécificités de chaque plateforme, voici un excellent post sur StackOverflow.

IV-B-1-a. Unix - int setsockopt(int sckt, int level, int optname, const void* optval, socklen_t optlen);

Permet d'effectuer une opération sur un socket. Il s'agit de l'inverse de getsockopt que nous avions utilisé pour récupérer l'erreur spécifique d'un socket dans le chapitre 5.

  • sckt est le socket sur lequel agir.
  • level est le niveau relatif à l'opération que nous voulons effectuer. Pour un socket, il s'agira toujours de SOL_SOCKET.
  • optname est le nom de l'opération à effectuer. Dans notre cas présent SO_REUSEADDR.
  • optval est un pointeur vers la valeur à appliquer à l'opération.
  • optlen la taille de la valeur pointée par optval.
 
Sélectionnez
1.
2.
int optval = 1;
setsockopt(socket, SOL_SOCKET, SO_REUSEADDR, &optval, sizeof(optval));

Retourne -1 en cas d'échec, 0 sinon.

IV-B-1-b. Windows - int setsockopt(SOCKET sckt, int level, int optname, const char* optval, int optlen);

La signature change à peine, optval est passé en tant que const char* plutôt que const void* et optlen est un int et non un socklen_t.

Ce qui permettrait d'écrire ceci avec notre code actuel pour avoir un appel portable :

 
Sélectionnez
1.
2.
int optval = 1;
setsockopt(socket, SOL_SOCKET, SO_REUSEADDR, reinterpret_cast<const char*>(&optval), sizeof(optval));

Retourne SOCKET_ERROR en cas d'erreur, 0 sinon.

IV-B-1-b-1. Doit-on définir un socket comme réutilisable sur Windows ?

Cette option n'a que peu d'intérêt sur Windows, dû à l'implémentation de celle-ci par le système.

En effet, Windows définit SO_REUSEADDR comme permettant au socket de s'associer (bind) de force à un port déjà ouvert par un autre socket. Un programme peut donc usurper l'utilisation d'un port sur une adresse locale, ou toutes les adresses locales. Puis on tombe dans un indéterminisme des plus complets : vous avez deux sockets qui utilisent le même port et travaillent sur la même adresse, les données vont aller à l'un ou l'autre sans aucune garantie et aléatoirement - mais toujours à l'un ou l'autre - sans que vous puissiez rien y faire.

L'aspect usurpation de socket a depuis été rectifié en ajoutant l'option SO_EXCLUSIVEADDRUSE, qui permet d'empêcher toute autre ouverture du même port. Rien qui ne nous intéresse dans notre cas pour rouvrir un socket sur un port qui serait dans l'état TIME_WAIT suite au crash de notre serveur. Rien d'utile à notre niveau donc.

Lire les sujets sur la MSDN à ce sujet ici et ici.

IV-C. void stop();

stop aura pour effet de déconnecter les clients existants et libérer le socket du serveur.

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
void ServerImpl::stop()
{
	for (auto& client : mClients)
		client.disconnect();
	mClients.clear();
	if (mSocket != INVALID_SOCKET)
		CloseSocket(mSocket);
	mSocket = INVALID_SOCKET;
}

IV-D. void update();

Notre fonction aura deux objectifs : accepter les nouvelles connexions et gérer les clients existants.

Afin de limiter l'inégalité des clients face à leur ordre de connexion, nous limiterons la réception des messages à un par client.

De même, afin de limiter l'éventuel spam de connexions et continuer de traiter les clients déjà connectés, nous limiterons à dix les nouveaux clients par appel à update.

Il suffit de dérouler cet algorithme pour obtenir une implémentation :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
37.
38.
39.
40.
41.
void ServerImpl::update()
{
	if (mSocket == INVALID_SOCKET)
		return;

	//!< accept jusqu'à 10 nouveaux clients
	for (int accepted = 0; accepted < 10; ++accepted)
	{
		sockaddr_in addr = { 0 };
		socklen_t addrlen = sizeof(addr);
		SOCKET newClientSocket = accept(mSocket, reinterpret_cast<sockaddr*>(&addr), &addrlen);
		if (newClientSocket == INVALID_SOCKET)
			break;
		Client newClient;
		if (newClient.init(std::move(newClientSocket)))
		{ 
			auto message = std::make_unique<Messages::Connection>(Messages::Connection::Result::Success); 
			mMessages.push_back(std::move(message));
			mClients.push_back(std::move(newClient));
		}
	}

	//!< mise à jour des clients connectés
	//!< réceptionne au plus 1 message par client
	//!< supprime de la liste les clients déconnectés
	for (auto itClient = mClients.begin(); itClient != mClients.end(); )
	{
		auto& client = *itClient;
		auto msg = client.poll();
		if (msg)
		{
			if (msg->is<Messages::Disconnection>())
				itClient = mClients.erase(itClient);
			else
				++itClient;
			mMessages.push_back(std::move(msg));
		}
		else
			++itClient;
	}
}

N'oubliez pas de générer un Message::Connection lorsqu'un nouveau client est accepté par le serveur pour que l'application puisse avoir l'information.

IV-E. std::unique_ptr<Messages::Base> poll();

Ici poll sera des plus simples à implémenter. Puisque update se charge de mettre en file les messages, poll n'aura qu'à les dépiler afin de les retourner à l'application.

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
std::unique_ptr<Messages::Base> ServerImpl::poll()
{
	if (mMessages.empty())
		return nullptr;

	auto msg = std::move(mMessages.front());
	mMessages.pop_front();
	return msg;
}

Dans cette implémentation, les fonctions update et poll sont supposées appelées depuis le même thread.

Si vous souhaitez appeler update et poll depuis différents threads, il vous faudra synchroniser l'accès à mMessages à l'aide de mutex par exemple.

V. Qui est l'expéditeur de ce message ?

Vous vous serez sans doute déjà fait la réflexion : j'extrais un message via poll, mais je n'ai aucune idée de qui l'a envoyé !

En effet, notre hiérarchie de messages était utilisée jusque-là par le client uniquement, qui ne pouvait recevoir un message que du serveur auquel il était connecté. Mais notre serveur peut recevoir des messages de plusieurs clients, il faut donc ajouter quelques informations supplémentaires quant à l'origine du message reçu.

V-A. Identifiant de client

L'idée est d'ajouter un identifiant unique pour chaque client.

La bonne nouvelle c'est que nous avons déjà un tel identifiant à disposition, généré par le système et assuré unique pour chaque connexion : le socket !

Sur Unix, il s'agit d'un int, généralement ayant une valeur plutôt faible qui plus est.

Sur Windows c'est un type plus opaque, un entier non signé, mais qui sur toutes les versions jusque-là se retrouve être un typedef sur un entier non signé 64 bits ou plus petit.

Pour couvrir ces deux cas, notre identifiant sera un uint64_t.

V-A-1. uint64_t id() const ;

Ajoutons donc cette très simple fonction à notre interface Client :

 
Sélectionnez
class ClientImpl
{
…
	uint64_t id() const { return static_cast<uint64_t>(mSocket); }};

uint64_t Client::id() const { return mImpl ? mImpl->id() : (uint64_t)(-1); }

Notez que tous les clients non initialisés partageront l'identifiant -1. Heureusement un client n'est pas censé être utilisé non initialisé très longtemps vous ne devrez donc avoir aucun problème à cause de cela.

Si un client est déplacé, son identifiant sera également la valeur non initialisée suite au déplacement. Mais ici encore, vous ne devriez pas avoir à traiter des clients déplacés très longtemps. Une fois déplacé, vous aurez vite fait de vous en débarrasser et le libérer.

V-B. Mise à jour des Messages

Il faut maintenant mettre à jour nos Messages pour ajouter cet identifiant.

Pas besoin de chercher compliqué pour le moment, et ajoutons juste un champ à la structure de Base uint64_t idFrom;.

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
namespace Network
{
	namespace Messages
	{
		class Base
		{
			public:
				template<class M>
				bool is() const { return mType == M::StaticType; }
				template<class M>
				const M* as() const { return static_cast<const M*>(this); }

				uint64_t idFrom;

			protected:
				enum class Type {
					Connection,
					Disconnection,
					UserData,
				};
				Base(Type type)
					: mType(type)
				{}
			private:
				Type mType;
		};
	}
}

Le mettre dans la portée publique simplifie les modifications à apporter au reste de la bibliothèque. Si vous préférez l'inclure dans le constructeur et le mettre en privé, il vous faudra modifier tous les constructeurs et leur appel, puis penser à mettre un accesseur public.

Ensuite modifiez les générations de messages afin d'ajouter l'identifiant du client correspondant à chaque fois où nécessaire.

Ou bien vous pouvez l'ajouter à un seul emplacement : dans le poll du serveur. Après tout il n'est pas vraiment utile d'ajouter l'identifiant du client si on utilise directement le client : le message vient forcément du serveur auquel il est connecté.

V-C. Coordonnées du client

Parmi les informations utiles que nous n'avons pas encore retrouvées, se trouvent l'adresse IP et le port du client.

Cette information est récupérée lors de l'appel à accept, mais directement oubliée puisque nous n'initialisons le client qu'avec le socket.

Mettons donc à jour notre fonction init pour également passer l'information d'adresse du client :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
class ClientImpl
{bool init(SOCKET&& sckt, const sockaddr_in& addr);
		…
		const sockaddr_in& destinationAddress() const { return mAddress; }
		…
		sockaddr_in mAddress{ 0 };
};
bool ClientImpl::init(SOCKET&& sckt, const sockaddr_in& addr)
{
	assert(sckt != INVALID_SOCKET);
	if (sckt == INVALID_SOCKET)
		return false;

	mSocket = sckt;
	mAddress = addr;
	if (!SetNonBlocking(mSocket))
	{
		disconnect();
		return false;
	}
	onConnected();
	return true;
}

N'oubliez pas de réinitialiser l'adresse dans la fonction disconnect avec un memset(&mAddress, 0, sizeof(mAddress));.

V-C-1. Coordonnées du serveur

Afin de garder une utilisation cohérente lorsque notre classe Client est utilisée en tant que client de connexion à un serveur, utilisons cette variable mAddress pour sauvegarder les informations du serveur auquel le client est connecté. destinationAddress() retournera ainsi toujours l'adresse de destination du client : du client derrière le client serveur dans le cadre d'un serveur, et du serveur auquel est connecté le client dans le cadre d'un client.

Comme il s'agit de l'adresse à laquelle le client se connecte, le code qui génère cette information se retrouvera dans le ConnectionHandler :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
class ConnectionHandler
{const sockaddr_in& getConnectedAddress() const { return mConnectedAddress; }
		…
		sockaddr_in mConnectedAddress;
};
bool ConnectionHandler::connect(SOCKET sckt, const std::string& address, unsigned short port)
{
	assert(sckt != INVALID_SOCKET);
	mAddress = address;
	mPort = port;
	mFd.fd = sckt;
	mFd.events = POLLOUT;
	inet_pton(AF_INET, mAddress.c_str(), &mConnectedAddress.sin_addr.s_addr);
	mConnectedAddress.sin_family = AF_INET;
	mConnectedAddress.sin_port = htons(mPort);
	if (::connect(sckt, (const sockaddr*)&mConnectedAddress, sizeof(mConnectedAddress)) != 0)
	{
		int err = Errors::Get();
		if (err != Errors::INPROGRESS && err != Errors::WOULDBLOCK)
			return false;
	}
	return true;
}

Dans connect, nous avons juste remplacé le sockaddr_in server auparavant local par la variable membre mConnectedAddress.

L'accesseur est présent pour pouvoir initialiser ClientImpl::mAddress lorsque la connexion est réussie. J'opte pour factoriser les initialisations qui suivent une connexion réussie, que ce soit depuis init ou le gestionnaire de connexion :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
37.
38.
39.
40.
41.
42.
43.
44.
45.
46.
47.
bool ClientImpl::init(SOCKET&& sckt, const sockaddr_in& addr)
{
	assert(sckt != INVALID_SOCKET);
	if (sckt == INVALID_SOCKET)
		return false;

	mSocket = sckt;
	if (!SetNonBlocking(mSocket))
	{
		disconnect();
		return false;
	}
	onConnected(addr);
	return true;
}

std::unique_ptr<Messages::Base> ClientImpl::poll()
{
	switch (mState)
	{
		case State::Connecting:
		{
			auto msg = mConnectionHandler.poll();
			if (msg)
			{
				if (msg->result == Messages::Connection::Result::Success)
				{
					onConnected(mConnectionHandler.getConnectedAddress());
				}
				else
				{
					disconnect();
				}
			}
			return msg;
		} break;
	…
	}
}

void ClientImpl::onConnected(const sockaddr_in& addr)
{
	mAddress = addr;
	mSendingHandler.init(mSocket);
	mReceivingHandler.init(mSocket);
	mState = State::Connected;
}

V-C-1-a. Récupérer le port d'une adresse

Nous avons déjà une fonction GetAddress qui retourne l'adresse IP depuis une structure sockaddr_in.

Voici une fonction pour récupérer le port depuis une telle structure, que vous aurez facilement devinée puisqu'elle est le miroir de l'initialisation du sockaddr_in pour l'appel à connect :

 
Sélectionnez
1.
2.
3.
4.
unsigned short GetPort(const sockaddr_in& addr)
{
	return ntohs(addr.sin_port);
}

V-D. Ajouter les coordonnées dans le message pour le serveur

Dans les précédents chapitres, nous affichions toujours les informations de connexion du client sur le serveur à chaque réception de message.

Avec l'identifiant seul, il faudrait ajouter une interface pour récupérer un client ou ses informations à partir de l'identifiant introduit plus haut. Ce qui n'est pas très élégant et source de problème si notre code n'est plus monothreadé.

Au lieu de modifier l'interface du serveur, ajoutons cette information à nos messages, tout comme nous avons déjà ajouté l'identifiant :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
class Base
{
		…
		sockaddr_in from;
		…
};

Tout comme pour idFrom, il sera dans la portée publique et utilisée uniquement dans le cadre d'un serveur. Ce sera le serveur qui mettra à jour ce champ lors de la réception de données d'un client :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
for (auto itClient = mClients.begin(); itClient != mClients.end(); )
{
	auto& client = *itClient;
	auto msg = client.poll();
	if (msg)
	{
		msg->from = client.destinationAddress();
		msg->idFrom = client.id();
		if (msg->is<Messages::Disconnection>())
		{
			itClient = mClients.erase(itClient);
		}
		else
			++itClient;
		mMessages.push_back(std::move(msg));
	}
	else
		++itClient;
}

V-E. Comment joindre un client ?

Notre serveur est capable de réceptionner les données de nos clients, mais pas encore de leur envoyer quoi que ce soit.

Mais nous avons désormais un identifiant pour nos clients.

V-E-1. Mise à jour de la collection de clients

Puisque nous avons un identifiant unique et allons être amenés à retrouver un client dans notre collection, le seul vector est désormais remplacé par une map. Il sera ainsi plus simple de retrouver un client par son identifiant.

Désormais la définition de mClients est std::map<uint64_t, Client> mClients;. Le code à jour des parties implémentées plus haut est disponible en fin de chapitre.

V-E-2. Envoyer des données à un client spécifique

Ajoutons donc deux méthodes pour envoyer des données à nos clients : l'une d'elles pour envoyer des données à un client précis, selon son identifiant, l'autre pour envoyer une même donnée à tous nos clients connectés - une opération toujours pratique dans le cadre d'un serveur :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
bool sendTo(uint64_t clientid, const unsigned char* data, unsigned int len);
bool sendToAll(const unsigned char* data, unsigned int len);

bool ServerImpl::sendTo(uint64_t clientid, const unsigned char* data, unsigned int len)
{
	auto itClient = mClients.find(clientid);
	return itClient != mClients.end() && itClient->second.send(data, len);
}
bool ServerImpl::sendToAll(const unsigned char* data, unsigned int len)
{
	bool ret = true;
	for (auto& client : mClients)
		ret &= client.second.send(data, len);
	return ret;
}

sendTo sera utilisé pour envoyer des données à un client spécifique selon son identifiant et retournera true ou false selon que les données ont été mises en file d'envois avec succès ou non.

sendToAll tentera de mettre en file d'envois un même ensemble de données à tous les clients connus du serveur. Si la valeur de retour vaut true, tous les clients connus au moment de l'appel ont ces données en file d'envois. Sinon, au moins un client n'a pas pu les mettre en file et ne les recevra donc jamais.

Ici nous n'aurons aucune information sur quel(s) client(s) ne recevra(ont) pas les données en question. Si cette information vous importe, vous pourrez par exemple retourner un vector d'identifiants des clients qui ont échoué.

En pratique, puisque nous sommes en TCP, il n'y a aucune raison que les données ne soient pas mises en file d'envois, et donc reçues du moment que le client reste connecté assez longtemps pour les recevoir. En fait l'unique raison est que l'appel à notre Client::send retourne false, ce qui peut actuellement arriver pour deux raisons :

  • le client n'est pas ou plus implémenté, après un déplacement par exemple et auquel cas ce client ne devrait plus être dans notre liste et sera certainement purgé prochainement ;
  • SendingHandler::send retourne false, ce qui dans notre implémentation actuelle n'est possible que si la taille des données est trop élevée, et échouera donc pour tous les clients.

Vous aurez remarqué l'utilisation de mClients. Ces fonctions doivent donc être appelées sur le même thread que update.

Sinon, il vous faudra synchroniser mClients à l'aide d'un mutex par exemple.

VI. Code final du serveur

Nous avons maintenant un serveur fonctionnel prêt à accueillir nos utilisateurs.

VI-A. Server.hpp

TCP/Server.hpp
CacherSélectionnez

VI-B. Server.cpp

TCP/Server.cpp
CacherSélectionnez

VI-C. Exemple d'utilisation

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
37.
38.
39.
40.
41.
42.
43.
44.
45.
46.
47.
48.
49.
50.
#include "Sockets.hpp"
#include "TCP/Server.hpp"
#include "Messages.hpp"
#include "Errors.hpp"

#include <iostream>

int main()
{
	if (!Network::Start())
	{
		std::cout << "Erreur initialisation WinSock : " << Network::Errors::Get();
		return -1;
	}
	
	unsigned short port;
	std::cout << "Port ? ";
	std::cin >> port;

	Network::TCP::Server server;
	if (!server.start(port))
	{
		std::cout << "Erreur initialisation serveur : " << Network::Errors::Get();
		return -2;
	}

	while(1)
	{
		server.update();
		while (auto msg = server.poll())
		{
			if (msg->is<Network::Messages::Connection>())
			{
				std::cout << "Connexion de [" << Network::GetAddress(msg->from) << ":" << Network::GetPort(msg->from) << "]" << std::endl;
			}
			else if (msg->is<Network::Messages::Disconnection>())
			{
				std::cout << "Deconnexion de [" << Network::GetAddress(msg->from) << ":" << Network::GetPort(msg->from) << "]" << std::endl;
			}
			else if (msg->is<Network::Messages::UserData>())
			{
				auto userdata = msg->as<Network::Messages::UserData>();
				server.sendToAll(userdata->data.data(), static_cast<unsigned int>(userdata->data.size()));
			}
		}
	}
	server.stop();
	Network::Release();
	return 0;
}

Le while(1) représente la boucle principale du programme, si vous embarquez le serveur dans le code client avec un joueur qui fait office d'hôte. Si vous optez pour une machine dédiée, il s'agira vraisemblablement du main du programme comme dans cet exemple.

Ici le serveur se contente de transférer les données reçues à tous les clients connectés. Un programme plus significatif devrait désérialiser les données reçues, traiter la requête puis retourner un résultat ou non au(x) client(s).

VI-D. Code du client mis à jour

Le code de la classe Client a légèrement été mis à jour dans ce chapitre, voici une copie de l'implémentation actuelle.

VI-D-1. Client.hpp

TCP/Client.hpp
CacherSélectionnez

VI-D-2. Client.cpp

TCP/Client.cpp
CacherSélectionnez

Langue Télécharger le code source complet.

Vous avez aimé ce tutoriel ? Alors partagez-le en cliquant sur les boutons suivants : Viadeo Twitter Facebook Share on Google+   

  

Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright © 2017 Cyrille (Bousk) Bousquet. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.