Cours programmation réseau en C++

TCP - Quelle architecture de client visée ?

Dans cette partie nous allons définir l'architecture du code client que nous souhaitons utiliser.

Pour cela nous allons mettre à jour notre protocole et nos interfaces.

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

Article lu   fois.

L'auteur

Liens sociaux

Viadeo Twitter Facebook Share on Google+   

I. Quel client voulons-nous ?

I-A. Propriétés souhaitées

Le client qui nous intéresse sera non bloquant pour l'application principale, et rendra les données qu'il possède disponibles à l'application sur simple requête.

Il s'agira d'un client TCP. La latence n'est donc pas une priorité et il s'adaptera plutôt pour une utilisation avec un serveur de lobby (discussions, textes…), gameplay non temps réel (Civilization, Blood Bowl) ou dans un cas où la latence n'est pas vraiment un souci et peut être absorbée. La plupart des MMO utilisent également un client TCP pour ne pas ajouter une couche supplémentaire de complexité sur la difficulté de réaliser un tel projet.

Son interface finale devrait être proche de :

Interface souhaitée pour le client
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
class TCPClient
{
	public:
		TCPClient();
		~TCPClient();

		bool connect(const std::string& ipaddress, unsigned short port);
		void disconnect();
		bool send(const unsigned char* data, unsigned int len);
		std::unique_ptr<Message> poll();
};

Par rapport à l'interface mise en place dans la partie 3, vous remarquerez que Recv a disparu, laissant sa place à poll.

poll sera appelé pour extraire un Message du socket. Tout le mécanisme de réception des données et création d'un paquet complet vu dans le chapitre 3 Mise en place du protocole sera désormais interne à la bibliothèque. L'utilisateur de cette classe ne s'occupera pas de réceptionner des données arbitraires, mais directement ce que nous avions appelé un paquet, sous la dénomination de message. En pratique, l'application devra appeler poll pour récupérer le dernier message, typiquement tant qu'il y en a un disponible et agir en fonction du message reçu.

Si l'utilisation via poll ne vous convient pas, vous pouvez opter pour un système de callbacks.

Ajoutez à l'interface du client une méthode par type de callback/message existant, et modifiez poll en update pour qu'il appelle ces callbacks en interne.

Veillez simplement à également fournir une fonction pour retirer une callback.

Nous resterons pour l'instant sur cette interface. La mise en place d'une solution à base de callbacks fera éventuellement l'objet d'un chapitre à part.

I-B. Un message ?

Notre Message sera la classe de base d'une hiérarchie de cette forme :

Messages réseau
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
class Message
{};
class Connection : public Message
{};
class Disconnection : public Message
{};
class UserData : public Message
{};

Nous pourrons ajouter autant de types de messages que nous le souhaitons au fur et à mesure de l'évolution de notre bibliothèque, ceux-ci ne représentent que les actions basiques nécessaires jusque-là.

II. Nouvel élément : les messages

Le protocole mis en place dans le chapitre correspondant est toujours valable et sera utilisé en interne de notre bibliothèque. Il s'agit d'un détail d'implémentation dont l'utilisateur n'a à priori pas à se soucier.

Les messages servent d'abstraction entre la couche réseau et l'application. L'utilisateur de la bibliothèque travaillera avec ces messages. Leur utilité sera d'autant plus évidente quand nous implémenterons les sockets UDP, qui utiliseront également ce système de messages, permettant ainsi de passer d'une implémentation TCP à UDP avec très peu, voire aucun changement dans le code utilisateur.

Grâce à cette couche d'abstraction, l'écriture de tests devient également possible très simplement et vous pouvez fournir une interface pour simuler n'importe quel message sans même établir de connexion ni initialiser de socket.

II-A. Comment savoir de quel Message il s'agit ?

Puisque nous mettons en place du polymorphisme, il n'y aura pas beaucoup de solutions possibles. Soit avoir recours au dynamic_cast, soit proposer une interface qui le permette.

C'est cette seconde option que l'on retiendra : elle permet plus de flexibilité d'implémentation, vous pouvez vous contenter d'appeler un dynamic_cast en interne au début puis changer quand vous trouvez une solution plus performante.

II-A-1. Interface de conversion

Nous aurons donc une interface template pour vérifier qu'un message est d'un type donné, et pouvoir l'y convertir.

L'interface aura cette forme :

Interface de conversion de Message
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
class Message
{
	public:
		template<class M>
		bool is() const { ... }
		template<class M>
		const M* as() const { ... }
};

Une implémentation utilisant dynamic_cast pourra être :

Conversion de Message utilisant dynamic_cast
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
class Message
{
	public:
		template<class M>
		bool is() const { return dynamic_cast<const M*>(this) != nullptr; }
		template<class M>
		const M* as() const { return static_cast<const M*>(this); }
};

Notez que c'est au client de s'assurer qu'une conversion est possible via un appel à is avant tout as. Vous pouvez opter pour une solution plus défensive et ajouter ce test en interne également. Une bonne pratique, qui n'apparaît pas ici, est d'ajouter ceci via un assert, afin que le test soit réalisé en configuration de débogage, puis retiré lors de la compilation de la version finale.

Je vous propose une autre implémentation qui n'utilise pas dynamic_cast, réputé lent et nécessitant le RTTI (RunTime Type Information) qui est parfois désactivé dans un souci de performance et dont l'implémentation dépend de la plateforme. (Voir cette discussion sur StackOverflow traitant du RTTI en C++.)

Vu le faible nombre de messages, tous connus et internes à la couche réseau (l'application cliente ne pourra pas en définir), j'utilise une implémentation toute simple à base d'énumération, et quelques macros d'aide pour la génération de code :

Implémentation à base d'énumération
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.
#define DECLARE_MESSAGE(name) friend class Message; static const Message::Type StaticType = Message::Type::name
class Message
{
	public:
		template<class M>
		bool is() const { return mType == M::StaticType; }
		template<class M>
		const M* as() const { return static_cast<const M*>(this); }

	protected:
		enum class Type {
			Connection,
			Disconnection,
			UserData,
		};
		Message(Type type)
			: mType(type)
		{}
	private:
		Type mType;
};
class Connection : public Message
{
	DECLARE_MESSAGE(Connection);
	public:
		Connection()
			: Message(Type::Connection)
		{}
};
class Disconnection : public Message
{
	DECLARE_MESSAGE(Disconnection);
public:
	Disconnection()
		: Message(Type::Disconnection)
	{}
};
class UserData : public Message
{
	DECLARE_MESSAGE(UserData);
public:
	UserData()
		: Message(Type::UserData)
	{}
};
#undef DECLARE_MESSAGE

Le constructeur n'est pas public afin que l'on ne puisse pas créer un Message basique, mais uniquement un Message dérivé qui eux sont correctement définis. De même les types sont dans la portée protected afin que les classes dérivées puissent utiliser ces valeurs dans leur appel au constructeur de base, mais qu'ils ne soient pas accessibles par l'utilisateur.

Enfin la macro se chargera de définir une variable statique StaticType qui contiendra le Type de message de cette classe et déclarer une amitié de la classe dérivée vers la classe de base afin que cette variable soit accessible dans la fonction is, sans être accessible publiquement. StaticType est donc dans la portée private de la classe dérivée.

Finalement, par pure préférence personnelle, plaçons le tout dans un espace de nommage (namespace) approprié Messages et renommons Message en Base. Ajoutons également dès à présent des données supplémentaires qui nous seront utiles plus tard :

Messages.hpp
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.
51.
52.
53.
54.
55.
56.
57.
58.
59.
60.
61.
62.
63.
64.
65.
66.
namespace Messages
{
#define DECLARE_MESSAGE(name) friend class Base; static const Base::Type StaticType = Base::Type::name
	class Base
	{
		public:
			template<class M>
			bool is() const { return mType == M::StaticType; }
			template<class M>
			M* as() { return static_cast<M*>(this); }
			template<class M>
			const M* as() const { return static_cast<const M*>(this); }

		protected:
			enum class Type {
				Connected,
				ConnectionFailed,
				Disconnected,
				UserData,
			};
			Base(Type type)
				: mType(type)
			{}
		private:
			Type mType;
	};
	class Connection : public Base
	{
		DECLARE_MESSAGE(Connection);
		public:
			enum class Result {
				Success,
				Failed,
			};
			Connection(Result r)
				: Base(Type::Connection) 
				, result(r)
			{}
			Result result;
	};
	class Disconnection : public Base
	{
		DECLARE_MESSAGE(Disconnection);
		public:
			enum class Reason {
				Disconnected,
				Lost,
			};
			Disconnection(Reason r)
				: Base(Type::Disconnection) 
				, reason(r)
			{}
			Reason reason;
	};
	class UserData : public Base
	{
		DECLARE_MESSAGE(UserData);
		public:
			UserData(std::vector<unsigned char>&& d)
				: Base(Type::UserData) 
				, data(std::move(d))
			{}
			std::vector<unsigned char> data;
	};
#undef DECLARE_MESSAGE
}

III. Comment rendre notre classe non bloquante ?

III-A. recv

recv est le plus simple aspect à traiter ici. Que l'on choisisse d'utiliser le mode non bloquant ou select ou poll, son comportement est très similaire et permet de réceptionner une partie des données demandées. Puisque les données peuvent être réceptionnées par fragments, on devra user d'un tampon interne pour les sauvegarder entre chaque appel.

Notre protocole utilise un en-tête indiquant la taille des données, avant d'envoyer les données elles-mêmes, et chacune de ces données peut être fragmentée. Une très simple machine à état sera utilisée pour indiquer si nous sommes en train de réceptionner un en-tête ou des données.

Pour simplifier la mise en place de ce mécanisme, ayons recours à une classe de cette forme :

Gestionnaire de réception des données
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
class ReceptionHandler
{
	enum class State {
		Header,
		Data,
	};
	public:
		ReceptionHandler(SOCKET sckt);
		std::unique_ptr<Messages::Base> recv(); 

	private:
		size_t missingData() const { return mBuffer.size() - mReceived; }

	private:
		std::vector<unsigned char> mBuffer; 
		unsigned int mReceived;
		SOCKET mSocket;
		State mState;
};

recv retourne directement le dernier message disponible, s'il y en a. En cas de déconnexion survenue, il sera capable de déterminer l'éventuelle erreur et retournera le message de déconnexion correspondant.

Tant que notre client est connecté, il suffira d'appeler cette méthode pour récupérer les données. Si un message est retourné, vérifiez s'il s'agit d'un message de déconnexion ou non pour modifier l'état de notre client avant de le retourner à l'application.

Toute la gestion de la taille du message, de la quantité de données manquantes, de la réception de l'en-tête et du passage du mode de réception de l'en-tête aux données sera interne à cette classe.

III-B. connect

Afin de rendre le connect non bloquant et aisément interruptible, nous utiliserons poll comme introduit au chapitre précédent.

Et pourquoi ne pas limiter et encapsuler cette gestion dans sa propre classe ? Par exemple un premier jet pourrait être :

Gestionnaire de connexion
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
class ConnectionHandler
{
	public:
		ConnectionHandler(SOCKET sckt);
		bool connect(const std::string& address, unsigned short port);
		std::unique_ptr<Messages::Connection> poll();

	private: 
		pollfd mFd;
		std::string mAddress;
		unsigned short mPort;
};

Cette interface permet de connecter un socket donné à une adresse et port. connect pouvant échouer dès son appel, la valeur de retour sera utilisée pour déterminer si la connexion a été démarrée ou non. Ensuite il faut vérifier régulièrement, typiquement à chaque frame, si la connexion est finalement établie, ou a échoué en vérifiant si poll retourne un message et le champ result de celui-ci.

Grâce à cette interface à utilisation asynchrone, il sera plus simple de faire évoluer notre code par la suite. Pour l'instant nous nous sommes toujours connectés à une adresse IP directement, et nous continuerons ainsi pour le moment. Si nous voulons utiliser un nom d'hôte à la place, il faudra retrouver l'IP derrière cet hôte, ce qui est une opération asynchrone. Comme notre interface l'est déjà, le résultat sera différé pour l'utilisateur, mais il n'aura pas, ou que très peu, à changer l'utilisation qu'il en fait.

La sauvegarde de l'adresse et du port est optionnelle. Ils seront directement utilisés dans l'appel à connect, mais il est pratique de les garder en mémoire si on a besoin d'y accéder par la suite.

Le socket n'est apparemment pas sauvegardé, mais sera en fait enregistré dans le champ fd de la structure mFd utilisée pour l'appel à poll. Inutile donc de dupliquer cette information.

III-C. send

Enfin la dernière partie nécessaire à l'utilisation de notre socket : l'envoi de données. La difficulté réside dans la gestion des envois partiels et des erreurs non bloquantes qui nécessitent de renvoyer respectivement les données manquantes et la totalité des données.

Afin de décharger l'application de ces vérifications, ce mécanisme sera également géré en interne. Ce qui signifie qu'on devra user d'un tampon interne pour stocker les données avant que leur envoi ne soit effectif s'il n'est pas possible de les envoyer directement. Toute la mise en place du protocole, symétrique à la réception (en-tête suivi des données) sera également gérée en interne.

Ceci permettra également d'avoir certaines informations sur notre connexion : nous pourrons déterminer à chaque instant la quantité de données en file d'attente, ce qui peut être pratique pour les contrôles et éviter de noyer notre couche réseau. Vous pouvez ainsi limiter la file et générer des erreurs si elle atteint une taille critique, et avoir une idée à tout moment de la quantité de données que vous envoyez.

Vous pourrez aller plus loin en ajoutant par exemple une notion de durée de vie à chaque envoi, si des données n'ont pas été envoyées après un temps donné vous pouvez les ignorer et passer directement aux suivantes. Mais ceci sort du cadre de ce chapitre.

La structure d'une telle classe pourrait être :

Gestionnaire d'envoi des données
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
class SendingHandler
{
	enum class State {
		Idle,
		Header,
		Data,
	};
	public:
		SendingHandler(SOCKET sckt);
		bool send(const unsigned char* data, unsigned int datalen);
		void update();
		size_t queueSize() const;

	private:
		std::list<std::vector<unsigned char>> mQueueingBuffers;
		std::vector<unsigned char> mSendingBuffer;
		State mState;
};

L'appel à send devra mettre en file d'attente les données à envoyer, si possible. Une valeur retour false indiquera que les données ont été rejetées et ne seront pas envoyées.

Ensuite il convient d'appeler update régulièrement, typiquement à chaque frame. Cette fonction se chargera de l'envoi effectif des données quand possible, d'envoyer l'en-tête avant les données réelles et prendra en charge les envois partiels éventuels.

Contrairement aux classes ConnectionHandler et ReceivingHandler, nous n'avons aucune gestion d'erreur explicite ici. La raison est que cette classe est faite pour travailler en binôme avec la classe ReceivingHandler, et ce sera cette dernière qui nous informera d'une erreur.

Si vous souhaitez utiliser cette classe seule, dans le cadre d'un socket amené uniquement à envoyer des données et ne jamais en recevoir, vous pourrez par exemple changer la signature de la fonction update pour retourner un bool indiquant que la connexion a été terminée s'il vaut false, ou est toujours valide s'il vaut true.

III-D. Le client

Avec toutes ces spécificités internes, le pattern pimpl (pointer to implementation : pointeur vers l'implémentation) est un bon candidat : il permettra de modifier les mécanismes internes à notre guise sans avoir à changer l'interface publique de notre classe client.

Voici donc à quoi devrait ressembler notre en-tête :

En-tête du client TCP
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.
#pragma once

#include <string>
#include <memory>

namespace Network
{
	namespace Messages {
		class Base;
	}
	namespace TCP
	{ 
		using HeaderType = uint16_t;
		static const unsigned int HeaderSize = sizeof(HeaderType);
		class ClientImpl;
		class Client
		{
			public:
				Client();
				~Client();

				bool connect(const std::string& ipaddress, unsigned short port);
				void disconnect();
				bool send(const unsigned char* data, unsigned int len);
				std::unique_ptr<Messages::Base> poll();

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

Les informations concernant l'en-tête d'un paquet sont également rendues disponibles publiquement dans ce fichier pour simplifier leur utilisation dans les différents modules et si un utilisateur souhaite y accéder pour quelconque raison (information de débogage…).

IV. Implémentation des différents modules

IV-A. Propriétés de la classe Client et son socket

Notre classe possédera un constructeur par défaut qui n'initialisera pas le socket.

Le socket sera initialisé sur appel à connect uniquement et pas avant. Si l'initialisation du socket échoue, connect retournera directement false sans tenter d'amorcer la connexion.

Notre classe ne sera pas copiable. Sa destruction entraînera la fermeture du socket interne s'il était utilisé. Elle sera par contre déplaçable.

Le socket de notre classe Client utilisera le mode non bloquant. Ceci par pure préférence personnelle, vous pouvez très bien utiliser select ou poll pour l'envoi et la réception également.

Notre en-tête de Client sera de cette forme :

En-tête du client TCP
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.
#pragma once

#include <string>
#include <memory>

namespace Network
{
	namespace Messages {
		class Base;
	}
	namespace TCP
	{ 
		using HeaderType = uint16_t;
		static const unsigned int HeaderSize = sizeof(HeaderType);
		class ClientImpl;
		class Client
		{
			public:
				Client();
				Client(const Client&) = delete;
				Client& operator=(const Client&) = delete;
				Client(Client&&);
				Client& operator=(Client&&);
				~Client();

				bool connect(const std::string& ipaddress, unsigned short port);
				void disconnect();
				bool send(const unsigned char* data, unsigned int len);
				std::unique_ptr<Messages::Base> poll();

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

Au niveau de l'implémentation, il s'agit juste de transférer les appels au pointeur mImpl qui se charge d'implémenter réellement les mécanismes :

Client.cpp
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
namespace Network
{
	namespace TCP
	{
		Client::Client() {}
		Client::~Client() {}
		Client::Client(Client&& other) : mImpl(std::move(other.mImpl) {}
		Client& Client::operator=(Client&& other) { mImpl = std::move(other.mImpl); return *this; }
		bool Client::connect(const std::string& ipaddress, unsigned short port)
		{
			if (!mImpl)
				mImpl = std::make_unique<ClientImpl>();
			return mImpl && mImpl->connect(ipaddress, port);
		}
		void Client::disconnect() { if (mImpl) mImpl->disconnect(); mImpl.reset(); }
		bool Client::send(const unsigned char* data, unsigned int len) { return mImpl && mImpl->send(data, len); }
		std::unique_ptr<Messages::Base> Client::poll() { return mImpl ? mImpl->poll() : nullptr; }
	}
}

Assurez-vous juste de vérifier que le pointeur mImpl est toujours valide avant d'y accéder. Puisque notre classe propose les interfaces de déplacement, il peut se retrouver invalidé si elle a été déplacée - ou non initialisée.

L'initialisation du pointeur interne sera faite sur appel à connect uniquement, afin de limiter la mémoire utilisée si notre client n'est pas initialisé et pouvoir réutiliser et reconnecter un client déplacé.

IV-B. Gestionnaire de connexion

IV-B-1. bool connect(SOCKET sckt, const std::string& address, unsigned short port);

Notre fonction connect prend en paramètre l'adresse, qui devra être, pour le moment, uniquement une adresse IP, et le port du serveur auquel se connecter.

Puisque notre classe Client initialise son socket tardivement, il n'est pas possible de créer directement un ConnectionHandler avec le socket sans user d'allocation dynamique. On ajoute donc le socket à utiliser en paramètre de connect.

Elle retournera true si la connexion a été amorcée, false sinon.

Point de vue implémentation, il s'agit d'une copie adaptée du chapitre précédent :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
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;
	sockaddr_in server;
	inet_pton(AF_INET, mAddress.c_str(), &server.sin_addr.s_addr);
	server.sin_family = AF_INET;
	server.sin_port = htons(mPort);
	if (::connect(sckt, (const sockaddr*)&server, sizeof(server)) != 0)
	{
		int err = Errors::Get();
		if (err != Errors::INPROGRESS && err != Errors::WOULDBLOCK)
			return false;
	}
	return true;
}

Avec cette implémentation, seule la connexion à une adresse IPv4 est possible.

Cette classe sera étendue par la suite pour permettre de se connecter à des adresses IPv6, mais également à un nom d'hôte en recherchant l'IP de l'hôte.

IV-B-2. std::unique_ptr<Messages::Connection> poll();

Notre fonction poll retournera nullptr tant que la connexion est en cours.

Une fois la connexion terminée, ou échouée, elle retournera le Messages::Connection correspondant en déterminant l'éventuelle erreur survenue afin de remplir le champ Reason du message.

Implémentation de poll
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.
std::unique_ptr<Messages::Connection> ConnectionHandler::poll()
{
	int res = ::poll(&mFd, 1, 0);
	if (res < 0)
		return std::make_unique<Messages::Connection>(Messages::Connection::Result::Failed);
	else if (res > 0)
	{
		if (mFd.revents & POLLOUT)
		{
			return std::make_unique<Messages::Connection>(Messages::Connection::Result::Success);
		}
		else if (mFd.revents & (POLLHUP | POLLNVAL))
		{
			return std::make_unique<Messages::Connection>(Messages::Connection::Result::Failed);
		}
		else if(mFd.revents & POLLERR)
		{
			return std::make_unique<Messages::Connection>(Messages::Connection::Result::Failed);
		}
	}
	//!< action non terminée
	return nullptr;
}

Pour l'instant très simple, Reason peut valoir uniquement Success ou Failed. Mais par la suite nous pourrons ajouter une durée limitée et avoir un TimeOut, ou bien échouer à traduire le nom d'hôte en IP. Libre à vous d'ajouter la granularité de vos possibilités sur les erreurs possibles selon votre plateforme par exemple.

IV-B-2-a. Changements liés à poll sous Windows

Dans le chapitre introduisant poll, nous nous étions contentés d'un #define poll WSAPoll sous Windows pour uniformiser la syntaxe d'appel avec les systèmes Unix.

Puisque poll est maintenant une fonction de notre interface, un simple define ne peut plus convenir. Ce n'était de toute façon qu'une écriture quick & dirty qui n'était pas faite pour durer : les macros de ce genre, à fortiori sur une fonction aussi banale et commune, peuvent vite dégénérer et modifier un code à l'insu de l'utilisateur.

Changeons donc dès à présent sa déclaration pour rediriger l'appel vers WSAPoll convenablement. #define poll WSAPoll devient donc inline int poll(pollfd fdarray[], nfds_t nfds, int timeout) { return WSAPoll(fdarray, nfds, timeout); }.

Je choisis de la déclarer inline par simplicité principalement. Vous pouvez tout aussi bien avoir le seul prototype dans l'en-tête int poll(pollfd fdarray[], nfds_t nfds, int timeout); et son implémentation dans un fichier cpp, en s'assurant qu'elle soit limitée à Windows :

Implémentation de poll pour Windows
Sélectionnez
1.
2.
3.
4.
5.
6.
#ifdef _WIN32
int poll(pollfd fdarray[], nfds_t nfds, int timeout)
{
	return WSAPoll(fdarray, nfds, timeout);
}
#endif

IV-B-3. Interface finale de ConnectionHandler

L'interface finale est très proche de celle introduite dans la première partie de ce chapitre. Le résultat avec l'initialisation des variables membres est :

Interface finale de ConnectionHandler
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
namespace Network
{
	namespace TCP
	{
		class ConnectionHandler
		{
			public:
				ConnectionHandler() = default;
				bool connect(SOCKET sckt, const std::string& address, unsigned short port);
				std::unique_ptr<Messages::Connection> poll();

			private:
				pollfd mFd{ 0 };
				std::string mAddress;
				unsigned short mPort;
		};
	}
}

IV-C. Gestionnaire de réception des données

IV-C-1. std::unique_ptr<Messages::Base> recv();

Notre fonction recv doit assurer la bonne mise en place de notre protocole.

Notre protocole se caractérise par l'envoi d'un en-tête qui contient la taille du paquet à suivre, codé sur 16 bits, puis les données.

Une fois la connexion effectuée, les premières données que nous recevrons seront forcément un en-tête. Après réception de l'en-tête complet, nous savons quelle quantité de données nous devons nous attendre à recevoir pour le paquet.

Une fois la réception des données terminées, nous avons un paquet entier disponible que nous pouvons transmettre à l'application via un Messages::UserData, et nous pouvons repasser en mode réception de l'en-tête suivant.

IV-C-1-a. Utilisation des membres mBuffer et mReceived

À chaque instant, mBuffer servira de tampon de réception. Puisque nous connaissons toutes les données à recevoir, un sizeof(uint16_t) pour l'en-tête et la valeur de l'en-tête pour les données, nous pouvons préparer ce tampon à la taille convenable en utilisant std::vector::resize.

mReceived enregistrera les données déjà reçues.

La quantité de données manquantes sera tout simplement la différence entre la taille de mBuffer et la valeur de mReceived : int missingDataLength() const { return static_cast<int>(mBuffer.size() - mReceived); }.

De la même manière, pour savoir où se situer dans le tampon pour la réception, il suffira de décaler de mReceived le pointeur de mBuffer : char* missingDataStartBuffer() { return reinterpret_cast<char*>(mBuffer.data() + mReceived); }.

IV-C-1-b. Implémentation de std::unique_ptr<Messages::Base> recv();

recv se chargera de la réception des données dans mBuffer, dont l'utilisation a été expliquée dans le paragraphe précédent et de retourner les données quand elles ont été intégralement reçues dans un Messages::UserData.

recv servira également à intercepter les déconnexions et retournera un Messages::Disconnection correspondant si cela se produit.

Finalement, si aucune donnée n'a été reçue ou que le paquet n'est pas encore prêt - pas encore réceptionné entièrement - et que la connexion est toujours valide, la valeur de nullptr sera retournée.

Implémentation de recv
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.
std::unique_ptr<Messages::Base> ReceptionHandler::recv()
{
	assert(mSckt != INVALID_SOCKET);
	int ret = ::recv(mSckt, missingDataStartBuffer(), missingDataLength(), 0);
	if (ret > 0)
	{
		mReceived += ret;
		if (mReceived == mBuffer.size())
		{
			if (mState == State::Data)
			{
				std::unique_ptr<Messages::Base> msg = std::make_unique<Messages::UserData>(std::move(mBuffer));
				startHeaderReception();
				return msg;
			}
			else
			{
				startDataReception();
				//!< si jamais les données sont déjà disponibles elles seront ainsi retournées directement
				return recv();
			}
		}
		return nullptr;
	}
	else if (ret == 0)
	{
		//!< connexion terminée correctement
		return std::make_unique<Messages::Disconnection>(Messages::Disconnection::Reason::Disconnected);
	}
	else // ret < 0
	{
		//!< traitement d'erreur
		int error = Errors::Get();
		if (error == Errors::WOULDBLOCK || error == Errors::AGAIN)
		{
			return nullptr;
		}
		else
		{
			return std::make_unique<Messages::Disconnection>(Messages::Disconnection::Reason::Lost);
		}
	}
}

Nous commençons par réceptionner des données, en limitant à la quantité que nous attendons. Si des données ont été reçues, nous mettons à jour mReceived.

Si nous avons reçu la quantité de données attendues, mReceived == mBuffer.size(), alors le paquet est complet. Commence alors le traitement du paquet en question.

Si nous étions en mode en-tête, mState == State::Header, nous commençons immédiatement à récupérer les données du paquet. Si nous étions déjà en mode données, mState == State::Data, nous retournons au mode réception du prochain en-tête et créons le message pour l'application.

IV-C-1-b-1. Initialiser la réception de l'en-tête

Notre en-tête sera une donnée comme une autre à réceptionner. La seule différence réside dans l'état de notre receveur qui sera en mode State::Header.

Sa taille est connue, puisque décidée par nous-mêmes lors de l'élaboration de la bibliothèque, et vaut 16 bits, sizeof(uint16_t) octets (2 octets sur à peu près toutes les machines standards).

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
void ReceptionHandler::startHeaderReception()
{
	mReceived = 0;
	mBuffer.clear();
	mBuffer.resize(HeaderSize, 0);
	mState = State::Header;
}
IV-C-1-b-2. Initialiser la réception des données

Après réception de l'en-tête, le tampon contiendra la valeur de la quantité de données à attendre pour le prochain paquet, enregistrée en boutisme réseau qu'il faudra donc convertir en boutisme local.

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
void ReceptionHandler::startDataReception()
{ 
	assert(mBuffer.size() == sizeof(HeaderType));
	HeaderType networkExpectedDataLength;
	memcpy(&networkExpectedDataLength, mBuffer.data(), sizeof(networkExpectedDataLength));
	const auto expectedDataLength = ntohs(networkExpectedDataLength);
	mReceived = 0;
	mBuffer.clear();
	mBuffer.resize(expectedDataLength, 0);
	mState = State::Data;
}

Vu la similitude, sans grande surprise, de ces deux fonctions, nous pouvons les factoriser ainsi afin de limiter les erreurs :

Factorisation de la réception des en-têtes et des données
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
void ReceptionHandler::startHeaderReception()
{
	startReception(HeaderSize, State::Header);
}
void ReceptionHandler::startDataReception()
{
	assert(mBuffer.size() == sizeof(HeaderType));
	HeaderType networkExpectedDataLength;
	memcpy(&networkExpectedDataLength, mBuffer.data(), sizeof(networkExpectedDataLength));
	const auto expectedDataLength = ntohs(networkExpectedDataLength);
	startReception(expectedDataLength, State::Data);
}
void ReceptionHandler::startReception(unsigned int expectedDataLength, State newState)
{
	mReceived = 0;
	mBuffer.clear();
	mBuffer.resize(expectedDataLength, 0);
	mState = newState;
}

Ce qui renforce la remarque initiale : l'en-tête est une donnée à récupérer comme une autre.

IV-C-2. Initialisation de la réception

Une fois notre socket connecté, nous devons initialiser la réception des données. Ajoutons donc une fonction init à notre ReceptionHandler afin d'indiquer le socket sur lequel travailler et démarrer la réception des données, ou plutôt de l'en-tête :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
void ReceptionHandler::init(SOCKET sckt)
{
	assert(sckt != INVALID_SOCKET);
	mSckt = sckt;
	startHeaderReception();
}

IV-C-3. Interface finale de ReceptionHandler

Interface finale de ReceptionHandler
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.
class ReceptionHandler
{
	enum class State {
		Header,
		Data,
	};
	public:
		ReceptionHandler() = default;
		void init(SOCKET sckt);
		std::unique_ptr<Messages::Base> recv();

	private: 
		inline char* missingDataStartBuffer() { return reinterpret_cast<char*>(mBuffer.data() + mReceived); }
		inline int missingDataLength() const { return static_cast<int>(mBuffer.size() - mReceived); }
		void startHeaderReception();
		void startDataReception();
		void startReception(unsigned int expectedDataLength, State newState);

	private:
		std::vector<unsigned char> mBuffer;
		unsigned int mReceived;
		SOCKET mSckt{ INVALID_SOCKET };
		State mState;
};

IV-D. Gestionnaire d'envoi des données

IV-D-1. bool send(const unsigned char* data, unsigned int datalen);

La fonction send validera les données avant de les mettre en file d'attente d'envoi et retourner true, ou les ignorer et retourner false. Les données seront dans une liste FIFO - First In, First Out - : elles seront envoyées dans l'ordre de leur passage à send.

Pour l'instant la seule limitation à l'envoi de nos données est que leur quantité ne dépasse pas la taille de l'en-tête du protocole : la valeur maximale stockable dans un uint16_t.

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
bool SendingHandler::send(const unsigned char* data, unsigned int datalen)
{
	if (datalen > std::numeric_limits<HeaderType>::max())
		return false;
	mQueueingBuffers.emplace_back(data, data + datalen);
	return true;
}

IV-D-2. void update();

update sera le cœur de l'implémentation de l'envoi des données. Il s'agira d'envoyer autant de données que possible à chaque appel.

Si un envoi est en cours, que l'envoi précédent n'a été que partiel, nous devons renvoyer les données manquantes.

Si aucune donnée n'est en cours d'envoi, nous envoyons les données suivantes dans la file d'envoi si elle n'est pas vide.

En cas d'erreur, nous l'ignorons et abandonnons l'opération en cours. L'erreur sera interceptée et traitée par le gestionnaire de réception.

IV-D-2-a. Utilisation de mSendingBuffer

mSendingBuffer sera à chaque instant notre tampon à envoyer. Quand nous voudrons envoyer des données, nous les placerons dans ce tampon. Pour connaître la quantité de données restant à envoyer, il s'agira de la taille du tampon. Après chaque envoi réussi, il faudra donc l'amputer d'autant afin de garder ses informations cohérentes.

IV-D-2-b. Implémentation de void update();

 
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.
void SendingHandler::update()
{
	assert(mSocket != INVALID_SOCKET);
	if (mState == State::Idle && !mQueueingBuffers.empty())
	{
		prepareNextHeader();
	}
	while (mState != State::Idle && sendPendingBuffer())
	{
		
		if (mState == State::Header)
		{
			prepareNextData();
		}
		else
		{
			if (!mQueueingBuffers.empty())
			{
				prepareNextHeader();
			}
			else
			{
				mState = State::Idle;
			}
		}
	}
}

Notre classe débute toujours en état Idle, la première partie commencera l'envoi des données après un arrêt et l'initialisation, si des données ont été mises en file d'envoi depuis.

Ensuite, tant que nous ne sommes pas en arrêt, nous envoyons les données en cours. sendPendingBuffer se charge d'envoyer les données en cours et retourne true si toutes les données ont été envoyées, et que nous pouvons passer à la suite, false sinon.

Si l'envoi en cours est terminé et que nous étions en mode d'envoi de l'en-tête, mState == State::Header, on démarre l'envoi des données actuellement mises en file et attendues par l'utilisateur. Si nous étions déjà en mode envoi des données, mState == State::Data, et qu'il reste des données en attente d'envoi, on repasse en mode en-tête et continuons l'envoi. Sinon on repasse à l'arrêt et en attente de données.

IV-D-2-b-1. bool sendPendingBuffer();

Cette fonction devra envoyer les données manquantes de l'envoi en cours et retournera true si toutes les données ont été envoyées, false si l'envoi n'a été que partiel et que des données restent à envoyer.

 
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.
bool SendingHandler::sendPendingBuffer()
{
	if (mSendingBuffer.empty())
		return true;
			
	//!< envoi des données restantes du dernier envoi
	int sent = ::send(mSocket, reinterpret_cast<char*>(mSendingBuffer.data()), static_cast<int>(mSendingBuffer.size()), 0);
	if (sent > 0)
	{
		if (sent == mSendingBuffer.size())
		{
			//!< toutes les données ont été envoyées
			mSendingBuffer.clear();
			return true;
		}
		else
		{
			//!< envoi partiel
			memmove(mSendingBuffer.data() + sent, mSendingBuffer.data(), sent);
			mSendingBuffer.erase(mSendingBuffer.cbegin() + sent, mSendingBuffer.cend());
		}
	}
	return false;
}

Petite astuce : plutôt que de supprimer les données en début de tampon après leur envoi, il est plus efficace de déplacer les données au début du vector afin d'en supprimer la fin.

IV-D-2-b-2. Initialisation de l'envoi de l'en-tête

D'après les propriétés de notre protocole, l'en-tête est un entier uint16_t qui représente la taille - la quantité de données - du paquet à venir. Le paquet à venir étant le premier paquet présent dans mQueueingBuffers.

La préparation de l'envoi de l'en-tête sera le miroir du code de leur réception du paragraphe précédent :

Préparation de l'envoi de l'en-tête
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
void SendingHandler::prepareNextHeader()
{
	assert(!mQueueingBuffers.empty());
	auto header = static_cast<HeaderType>(mQueueingBuffers.front().size());
	const auto networkHeader = htons(header);
	mSendingBuffer.clear();
	mSendingBuffer.resize(HeaderSize);
	memcpy(mSendingBuffer.data(), &networkHeader, sizeof(HeaderType));
	mState = State::Header;
}

On s'assure de convertir en boutisme réseau la taille avant de la copier dans le tampon d'envoi, puis on change l'état en envoi d'en-tête.

IV-D-2-b-3. Initialisation de l'envoi des données

Les données seront celles présentes en tête de mQueueingBuffer. Préparer les données à l'envoi reviendra à copier ces données dans le tampon d'envoi, puis modifier l'état de l'envoyeur en mode données :

Préparation de l'envoi des données
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
void SendingHandler::prepareNextData()
{
	assert(!mQueueingBuffers.empty());
	mSendingBuffer.swap(mQueueingBuffers.front());
	mQueueingBuffers.pop_front();
	mState = State::Data;
}

Si vous comptez appeler send et update dans des threads différents, il faudra synchroniser les opérations sur mQueueingBuffers avec un mutex.

IV-D-3. void init(SOCKET sckt);

Tout comme pour la réception, il faudra initialiser notre gestionnaire d'envoi après connexion.

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
void SendingHandler::init(SOCKET sckt)
{
	mSocket = sckt;
	mQueueingBuffers.clear();
	mSendingBuffer.clear();
	mState = State::Idle;
}

Cette version purgera toutes les données qui seraient toujours en file lors de l'appel. En cas de reconnexion par exemple.

Si vous préférez pouvoir garder les données précédemment mises en file, vous pouvez opter pour cette implémentation :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
void SendingHandler::init(SOCKET sckt)
{
	mSocket = sckt;
	if (mState == State::Header || mState == State::Data)
	{
		mSendingBuffer.clear();
	}
	mState = State::Idle;
}

Les données qui étaient en cours d'envoi ne peuvent malheureusement pas être conservées, on ignore ce qui a réellement été transmis ou non, il faudra donc les purger dans tous les cas.

De même, comme l'application n'a aucune idée de ce qui a été envoyé ou non, à moins de recevoir une réponse du serveur, il n'est pas forcément avantageux de garder les envois précédents en cas de reconnexion. Ce n'est donc pas l'implémentation que je conseille ou utiliserai dans ce cours.

IV-D-4. size_t queueSize() const;

Voici une implémentation de la fonction utilitaire présentée plus tôt afin de connaître la quantité de données en file d'envoi :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
#include <numeric>
size_t SendingHandler::queueSize() const
{
	size_t s = std::accumulate(mQueueingBuffers.cbegin(), mQueueingBuffers.cend(), static_cast<size_t>(0), [](size_t n, const std::vector<unsigned char>& queuedItem) {
		return n + queuedItem.size() + HeaderSize;
	});
	if (mState == State::Data)
		s += mSendingBuffer.size();
	return s;
}

Retourne la quantité de données actuellement en file d'envoi. N'oubliez pas d'ajouter la taille de l'en-tête pour chaque donnée. Si des données sont actuellement en train d'être envoyées, il faut aussi les ajouter au total.

Si le gestionnaire est en train d'envoyer un en-tête, cette implémentation retournera une valeur erronée d'une poignée d'octets, ce qui n'est pas bien grave étant donné la rareté de la chose (le gestionnaire d'envoi devrait se trouver dans un état en-tête qu'extrêmement rarement, puisqu'il enverra l'en-tête avec succès avant d'immédiatement retourner en état envoi de données l'immense majorité du temps) et que ce résultat n'est toujours qu'une information sur l'état de nos échanges plus qu'une donnée réelle.

IV-D-5. Interface finale de SendingHandler

Interface finale de SendingHandler
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 SendingHandler
{
	enum class State {
		Idle,
		Header,
		Data,
	};
	public:
		SendingHandler() = default;
		void init(SOCKET sckt);
		bool send(const unsigned char* data, unsigned int datalen);
		void update();
		size_t queueSize() const;

	private:
		bool sendPendingBuffer();
		void prepareNextHeader();
		void prepareNextData();

	private:
		std::list<std::vector<unsigned char>> mQueueingBuffers;
		std::vector<unsigned char> mSendingBuffer;
		SOCKET mSocket{ INVALID_SOCKET };
		State mState{ State::Idle } ;
};

V. Implémentation de ClientImpl

Maintenant que nos différents modules sont implémentés, il est temps de les connecter et de les utiliser dans notre implémentation ClientImpl.

Notre interface devrait ressembler à :

 
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.
class ClientImpl
{
	enum class State {
		Connecting,
		Connected,
		Disconnected,
	};

	public:
		ClientImpl()= default;
		~ClientImpl();

		bool connect(const std::string& ipaddress, unsigned short port);
		void disconnect();
		bool send(const unsigned char* data, unsigned int len);
		std::unique_ptr<Messages::Base> poll();

	private:
		ConnectionHandler mConnectionHandler;
		SendingHandler mSendingHandler;
		ReceptionHandler mReceivingHandler; 
		SOCKET mSocket{ INVALID_SOCKET };
		State mState{ State::Disconnected };
};

Nous n'avons fait que reprendre l'interface de la classe Client et avons ajouté les différents membres pour chaque module, le socket et une machine à état, que nous initialisons aux valeurs par défaut de INVALID_SOCKET et Disconnected.

V-A. Destructeur

Le destructeur n'aura qu'à s'assurer que notre socket est correctement déconnecté et libéré :

 
Sélectionnez
1.
2.
3.
4.
ClientImpl::~ClientImpl()
{
	disconnect();
}

La logique pour vérifier que le socket est déjà déconnecté ou non sera à l'intérieur de la fonction disconnect.

V-B. bool connect(const std::string& ipaddress, unsigned short port);

connect devra initialiser le socket puis amorcer la connexion via le module de ConnectionHandler.

Pour l'initialisation d'un socket, souvenez-vous du premier chapitre à ce sujet.

 
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.
bool ClientImpl::connect(const std::string& ipaddress, unsigned short port)
{
	assert(mState == State::Disconnected);
	assert(mSocket == INVALID_SOCKET);
	if (mSocket != INVALID_SOCKET)
		disconnect();
	mSocket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
	if (mSocket == INVALID_SOCKET)
	{
		return false;
	}
	else if (!SetNonBlocking(mSocket))
	{
		disconnect();
		return false;
	}
	if (mConnectionHandler.connect(mSocket, ipaddress, port))
	{
		mState = State::Connecting;
		return true;
	}
	return false;
}

Cette fonction ne devrait être appelée que sur un Client déconnecté, d'où les assertions des premières lignes.

Cependant, pour simplifier l'utilisation et ne pas perturber le fonctionnement en cas de problème, on s'assurera de simplement déconnecter le socket s'il était déjà utilisé.

V-C. void disconnect();

Cette fonction aura deux objectifs : libérer le socket et réinitialiser l'état de la classe à Disconnected.

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
void ClientImpl::disconnect()
{
	if (mSocket != INVALID_SOCKET)
	{
		CloseSocket(mSocket);
	}
	mSocket = INVALID_SOCKET;
	mState = State::Disconnected;
}

CloseSocket pourrait être appelé dans tous les cas, il n'aurait aucun effet si le socket était déjà déconnecté. Mais il est toujours plus clair de réaliser le test et éviter d'avoir des erreurs inutiles si on veut récupérer une erreur.

V-D. bool send(const unsigned char* data, unsigned int len);

Ici il y a une seule chose à faire : rediriger l'appel vers le gestionnaire d'envoi :

 
Sélectionnez
1.
2.
3.
4.
bool ClientImpl::send(const unsigned char* data, unsigned int len)
{
	return mSendingHandler.send(data, len);
}

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

Enfin, le cœur du client, qui s'assure du bon fonctionnement des différents modules et changement d'état correspondant quand nécessaire.

 
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.
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)
				{
					mSendingHandler.init(mSocket);
					mReceivingHandler.init(mSocket);
					mState = State::Connected;
				}
				else
				{
					disconnect();
				}
			}
			return msg;
		} break;
		case State::Connected:
		{
			mSendingHandler.update();
			auto msg = mReceivingHandler.recv();
			if (msg && msg->is<Messages::Disconnection>())
			{
				disconnect();
			}
			return msg;
		} break;
		case State::Disconnected:
		{
		} break;
	}
	return nullptr;
}

Tant que la connexion est en cours, seul le gestionnaire de connexion est utilisé. Nous nous contentons d'appeler sa méthode poll pour récupérer le résultat de la connexion dès qu'il est disponible.

Si la connexion est réussie, nous initialisons le gestionnaire d'envoi et de réception et changeons l'état à Connected. Sinon on déconnecte et réinitialise le client.

Une fois le client connecté, nous mettons à jour le gestionnaire d'envoi pour traiter la file d'envoi, puis nous récupérons un éventuel message reçu. S'il s'agit d'un message de déconnexion, nous réinitialisons le client. Dans tous les cas, ce message est retourné au programme principal.

VI. Utilisation de Client dans le programme

Nous avons désormais accès à une écriture du code bien plus agréable et simple.

Voici un programme minimal utilisant cette nouvelle implémentation créée lors de ce chapitre :

Programme utilisant le nouveau 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.
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.
51.
52.
53.
54.
55.
56.
57.
58.
59.
60.
61.
62.
63.
64.
65.
66.
67.
68.
69.
70.
71.
72.
73.
74.
75.
76.
77.
#include "Sockets.hpp"
#include "TCP/Client.hpp"
#include "Messages.hpp"
#include "Errors.hpp"

#include <iostream>

int main()
{
	if (!Network::Start())
	{
		std::cout << "Error starting sockets : " << Network::Errors::Get() << std::endl;
		return -1;
	}

	Network::TCP::Client client;
	int port;
	std::cout << "Port du serveur ? ";
	std::cin >> port;
	if (!client.connect("127.0.0.1", port))
	{
		std::cout << "Impossible de se connecter au serveur [127.0.0.1:" << port << "] : " << Network::Errors::Get() << std::endl;
	}
	else
	{
		while (1)
		{
			while (auto msg = client.poll())
			{
				if (msg->is<Network::Messages::Connection>())
				{
					auto connection = msg->as<Network::Messages::Connection>();
					if (connection->result == Network::Messages::Connection::Result::Success)
					{
						std::cin.ignore();
						std::cout << "Connecte!" << std::endl;
						std::cout << "Entrez une phrase >";
						std::string phrase;
						std::getline(std::cin, phrase);
						if (!client.send(reinterpret_cast<const unsigned char*>(phrase.c_str()), static_cast<unsigned int>(phrase.length())))
						{
							std::cout << "Erreur envoi : " << Network::Errors::Get() << std::endl;
							break;
						}
					}
					else
					{
						std::cout << "Connexion echoue : " << static_cast<int>(connection->result) << std::endl;
						break;
					}
				}
				else if (msg->is<Network::Messages::UserData>())
				{
					auto userdata = msg->as<Network::Messages::UserData>();
					std::string reply(reinterpret_cast<const char*>(userdata->data.data()), userdata->data.size());
					std::cout << "Reponse du serveur : " << reply << std::endl;
					std::cout << ">";
					std::string phrase;
					std::getline(std::cin, phrase);
					if (!client.send(reinterpret_cast<const unsigned char*>(phrase.c_str()), static_cast<unsigned int>(phrase.length())))
					{
						std::cout << "Erreur envoi : " << Network::Errors::Get() << std::endl;
						break;
					}
				}
				else if (msg->is<Network::Messages::Disconnection>())
				{
					auto disconnection = msg->as<Network::Messages::Disconnection>();
					std::cout << "Deconnecte : " << static_cast<int>(disconnection->reason) << std::endl;
					break;
				}
			}
		}
	}
	Network::Release();
	return 0;
}

Vous pouvez réutiliser le serveur du chapitre 3 qui utilise déjà ce protocole en interne (avec une implémentation très différente) afin de tester ce code.

Le while(1) de la ligne 26 représente la boucle principale du programme, ou votre boucle de gameplay.

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.