Cours programmation réseau en C++

UDP – Gérer des connexions entre machines

Dans ce chapitre nous allons mettre en place des mécanismes de gestion de connexion et déconnexion.

42 commentaires Donner une note  l'article (5) 

Article lu   fois.

L'auteur

Liens sociaux

Viadeo Twitter Facebook Share on Google+   

I. Une connexion UDP ?!

Oui vous avez bien lu, nous allons dans ce chapitre mettre en place un système de connexion pour notre protocole.

La faisabilité est pourtant évidente : TCP, qui est également une surcouche d’IP, y parvient. Pourquoi un système du même genre ne serait-il pas possible avec UDP, qui est un autre protocole par-dessus IP ?

Le système de connexion que nous créerons ici sera différent de ce qui se fait en TCP, mais la finalité est la même : pouvoir maintenir différentes machines dans un état connecté, pouvoir accepter, refuser ou terminer une connexion et bien sûr détecter une déconnexion.

I-A. Définir ce qu’est une connexion

Nous allons définir une connexion très simplement par la réception de données.

Tant qu’une machine B reçoit des données d’une machine A, alors, du point de vue de B, A est considérée connectée à B.

I-A-1. Une relation asymétrique

La définition précédente crée la connexion comme une relation asymétrique : tant que B reçoit des données de A, A et B ne sont pas connectées l’une à l’autre mais, B considère que A est connectée à elle.

Afin que la connexion soit établie dans les deux sens, il faut alors également que A reçoive des données de B.

I-A-2. Des échanges permanents…

Pour maintenir un état connecté entre deux machines il va donc falloir que celles-ci s’envoient régulièrement des données en maintenant un flux d’échange permanent entre elles.

Et puisque UDP est sujet à des pertes par nature, cet échange devra être d’autant plus régulier pour y remédier.

I-A-3. … de toute façon nécessaire

Mais en pratique ceci est généralement déjà le cas : vous avez probablement des données à envoyer à chaque frame ou presque, comme des positions qui changent par exemple. Donc ces échanges permanents n’ont pas à être forcés.

I-B. Définir une déconnexion

Puisque l’état connecté se manifeste par la réception de données, alors une déconnexion sera tout naturellement l’arrêt ou l’absence de réception de données pendant un certain temps.

Dans ce cas, chacune des machines peut initier une déconnexion : A peut décider de déconnecter B en arrêtant de lui envoyer des données et B peut décider de se déconnecter de A en arrêtant de lui envoyer des données.

II. Établir une connexion

II-A. Adresses de connexion

Souvenez-vous de la structure nécessaire à l’envoi ou la réception de données. Cette structure est peu évidente à utiliser et surtout héritée du C : elle se manipule à grand coup de reinterpret_cast et de tampon interne. Nous l’utilisions jusque-là faute de mieux, mais pour aller plus loin ce n’est pas acceptable pour l’utilisateur, et laborieux pour nous.

Une des premières étapes sera donc de proposer un objet permettant de simplifier l’utilisation d’un couple adresse IP/port.

Une adresse peut être créée à partir de plusieurs sources : une chaîne de caractères ou une structure C type sockaddr_storage. D’autres supports de sources pourront être ajoutés au fur et à mesure que les besoins évoluent.

Address.hpp
Cacher/Afficher le codeSé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.
namespace Bousk
{
	namespace Network
	{
		class Address
		{
		public:
			enum class Type {
				None,
				IPv4,
				IPv6,
			};
		public:
			Address() = default;
			Address(const Address&);
			Address(Address&&);
			Address& operator=(const Address&);
			Address& operator=(Address&&);
			~Address() = default;

			Address(const std::string& ip, uint16 port);
			Address(const sockaddr_storage& addr);

			Type type() const { return mType; }
			std::string address() const;
			uint16 port() const { return mPort; }
			std::string toString() const;


		private:
			sockaddr_storage mStorage{ 0 };
			uint16 mPort{ 0 };
			Type mType{ Type::None };
		};
	}
}

II-A-1. Implémentation

L’implémentation de la classe Address sera principalement une révision des fonctions de manipulation d’adresses IP et des structures sockaddr (_in, _out & _storage) utilisées jusqu’ici.

Ce devrait également être une des, si ce n’est la dernière, fois que vous manipulerez ces objets directement hérités du C, laissant place à l’utilisation de votre nouvelle classe Address pour la suite.

Un champ mPort est ajouté et contiendra le port de l’adresse dans l’endianness locale.

Ceci est une optimisation afin de ne pas avoir à vérifier le type d’adresse avant de convertir mStorage vers le bon type et convertir le port vers l’endianness locale – puisque dans mStorage le port est stocké en endianness réseau – chaque fois que l’utilisateur veut cette simple information.

De cette façon, l’utilisateur peut récupérer le port quand bon lui semble et autant de fois qu’il le souhaite pour un cout nul ou presque.

Vous trouverez les implémentations principales ci-après. Peu de détails seront fournis puisque celles-ci devraient être triviales à faire en lisant les chapitres précédents.

II-A-1-a. Constructeur depuis une chaîne et un port

Un constructeur permet de créer une adresse depuis une chaîne de caractères représentant une IP et un port.

Afin de vérifier que l’IP est valide, nous utiliserons la fonction inet_pton introduite dès le premier chapitre.

Pour rappel, inet_pton va interpréter la chaîne de caractères en paramètres en adresse IPv4 ou IPv6, si elle est valide.

Address.cpp
Cacher/Afficher le codeSélectionnez

II-A-1-b. Constructeur depuis un sockaddr_storage

Le deuxième constructeur intéressant permet de créer une adresse depuis un sockaddr_storage, qui est la structure de stockage d’une adresse indépendamment du protocole.

Il s’agit de la structure que nous manipulons principalement puisqu’elle permet de gérer une adresse IPv4 ou IPv6 dans le même objet.

Address.cpp
Cacher/Afficher le codeSélectionnez

II-A-1-c. Récupérer l’adresse IP sous format lisible

En utilisant inet_ntop nous pouvons proposer à l’utilisateur de récupérer l’adresse IP sous un format chaîne de caractères lisible. Ce qui est toujours pratique pour des fichiers de logs par exemple.

Address.cpp
Cacher/Afficher le codeSélectionnez

II-A-1-d. Opérateurs de comparaison

Pouvoir identifier deux adresses comme identiques peut s’avérer utile.

Address.cpp
Cacher/Afficher le codeSélectionnez

Après l’égalité, nous pouvons également fournir l’opérateur opposé : bool operator!=(const Address& other) const { return !(*this == other); }

II-A-1-e. Astuces et raccourcis : créer une adresse ANY

Pour utiliser un socket UDP, nous avions vu qu’il fallait utiliser bind en utilisant INADDR_ANY afin d’écouter sur toutes les interfaces réseau. La même opération avait été faite pour créer un socket serveur TCP.

Afin de ne plus avoir à utiliser sockaddr_in à la faveur de Address, il est intéressant de proposer une interface permettant de créer un tel objet, sans avoir à manipuler quelconque objet de l’API socket : static Address Any(Type type, uint16 port);

Address.cpp
Cacher/Afficher le codeSé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.
Address Address::Any(Type type, uint16 port)
{
	switch (type)
	{
	case Type::IPv4:
	{
		sockaddr_storage storage{ 0 };
		sockaddr_in& addr = reinterpret_cast<sockaddr_in&>(storage);
		addr.sin_addr.s_addr = INADDR_ANY;
		addr.sin_port = htons(port);
		addr.sin_family = AF_INET;
		return Address(storage);
	}
	case Type::IPv6:
	{
		sockaddr_storage storage{ 0 };
		sockaddr_in6& addr = reinterpret_cast<sockaddr_in6&>(storage);
		addr.sin6_addr = in6addr_any;
		addr.sin6_port = htons(port);
		addr.sin6_family = AF_INET6;
		return Address(reinterpret_cast<sockaddr_storage&>(addr));
	}
	default:
		assert(false);
		return Address();
	}
}

II-A-1-f. Aller plus loin

Vous aurez peut-être remarqué qu’il n’existe pas d’interface pour récupérer le sockaddr_storage interne à Address. En l’état notre nouvelle classe est donc inutilisable…

Seulement, il est dommage à mon avis de proposer une telle classe pour finalement laisser l’utilisateur avoir à manipuler une structure héritée du C pour réaliser ses appels bas niveau.

C’est pourquoi, je propose de faire de Address l’unique point d’entrée en termes de manipulation de structure d’adresse : toutes les manipulations de structure d’adresse C sont uniquement internes à Address, et c’est uniquement un objet Address qui sera manipulé par l’utilisateur.

Voilà les fonctions dont on parle, qui sont celles que nous avons rencontrées dans le cours jusque-là, tant TCP qu’UDP, qui nécessitent une manipulation de sockaddr_in ou sockaddr_storage.

Address.hpp
Cacher/Afficher le codeSélectionnez
namespace Bousk
{
	namespace Network
	{
		class Address
		{public:
			// Connecte le socket en paramètre à l’adresse interne
			// Retourne true si la connexion réussit ou débute (socket non bloquant), false sinon
			bool connect(SOCKET sckt) const;
			// Accepte une connexion entrante sur le socket en paramètre, puis met à jour l’adresse interne avec celle de l’émetteur
			// Retourne true si un nouveau socket a été accepté et met à jour newClient avec le nouveau socket, false sinon
			bool accept(SOCKET sckt, SOCKET& newClient);
			// Assigne l’adresse interne au socket en paramètre
			bool bind(SOCKET sckt) const;
			// Envoie des données depuis le socket en paramètre vers l’adresse interne
			int sendTo(SOCKET sckt, const char* data, size_t datalen) const;
			// Reçoit des données depuis le socket en paramètre puis met à jour l’adresse interne avec celle de l’émetteur
			int recvFrom(SOCKET sckt, uint8* buffer, size_t bufferSize); 
		};
	}
}

Quant aux implémentations, là aussi elles devraient être automatiques puisque déjà vues :

Address.cpp
Cacher/Afficher le codeSélectionnez

Avec ces implémentations supplémentaires, l’initialisation d’un socket UDP passe de

 
Sélectionnez
sockaddr_in addr;
addr.sin_addr.s_addr = INADDR_ANY; // permet d'écouter sur toutes les interfaces locales
addr.sin_port = htons(port); // toujours penser à traduire le port en endianess réseau
addr.sin_family = AF_INET; // notre adresse est IPv4
int res = bind(sckt, reinterpret_cast<sockaddr*>(&addr), sizeof(addr));
if (res != 0)
	// erreur

à

 
Sélectionnez
const Address addr = Address::Any(Address::Type::IPv4, port);
if (!addr.bind(mSocket))
	// erreur

Ce qui est bien plus lisible et plutôt élégant !

II-B. Interface de connexion

Maintenant que nous avons une adresse aisément manipulable (tant par le moteur que par les utilisateurs de celui-ci), nous pouvons ajouter une interface à notre client UDP pour initialiser une connexion.

UDPClient.hpp
Sélectionnez
namespace Bousk
{
	namespace Network
	{
		namespace Messages
		{
			class Base;
		}
		namespace UDP
		{
			class DistantClient;
			class Client
			{
				friend class DistantClient;
			public:bool connect(const Address& addr);
			};
		}
	}
}

II-C. Demande de connexion

La connexion démarre par une demande de connexion, que la machine distante peut accepter ou refuser.

Les premiers échanges de données symbolisent le début de la connexion. Nous n’utilisons pas de paquet ou données spécifiques : dès lors qu’une machine reçoit des données d’une autre, celle-ci est considérée comme souhaitant se connecter.

La machine émettrice de la demande de connexion est donc en demande de connexion à la machine distante de facto. La machine distante doit ensuite accepter cette connexion pour que la relation soit bilatérale ou la refuser.

II-C-1. Refuser une connexion

Pour refuser une connexion, il suffit d’ignorer la demande de connexion. Celle-ci finira par expirer via un timeout qui sera ajouté et défini dans les paragraphes suivants.

II-C-2. Accepter une connexion

Pour accepter une connexion, il faut se connecter au demandeur via la même fonction connect.

II-D. Message de connexion entrante

À l’instar de la connexion, déconnexion ou réception de données, il faut ajouter un message de demande de connexion pour notifier l’application qu’un client distant souhaite se connecter à notre socket :

 
Sélectionnez
class Base
{enum class Type {
		IncomingConnection,
		Connection,
		Disconnection,
		UserData,
	};
	…
};
class IncomingConnection : public Base
{
	DECLARE_MESSAGE(IncomingConnection);
public:
	IncomingConnection(const Address& emitter, uint64 emitterid)
		: Base(Type::IncomingConnection, emitter, emitterid)
	{}
};

II-E. Mise en attente des données

Une machine faisant une demande de connexion peut commencer à envoyer des données.

Dans le cas de données non fiables, il est correct de les ignorer, mais pas s’il s’agit de données fiables. Dans le cas de la machine qui reçoit la connexion, elle ne doit pas avoir à traiter des données alors qu’elle n’a pas encore accepté la connexion – et va potentiellement la refuser.

Il faut donc avoir une file d’attente des messages fiables reçus avant d’avoir accepté la connexion entrante.

II-E-1. Interface des protocoles

Ajoutons un accesseur afin de savoir si notre protocole est fiable ou non :

ProtocolInterface.hpp
Sélectionnez
class IProtocol
{virtual bool isReliable() const = 0;
};

II-E-2. Gestionnaire de protocoles

Ensuite, nous agissons lors du processus d’extraction des messages afin d’ignorer ceux issus d’un protocole non fiable si la connexion n’a pas été acceptée :

ChannelsHandler.hpp
Sélectionnez
class ChannelsHandler
{std::vector<std::vector<uint8_t>> process(bool isConnected);
};
ChannelsHandler.cpp
Sélectionnez
std::vector<std::vector<uint8_t>> ChannelsHandler::process(bool isConnected)
{
	std::vector<std::vector<uint8_t>> messages;
	for (auto& channel : mChannels)
	{
		std::vector<std::vector<uint8_t>> protocolMessages = channel->process();
		if (!protocolMessages.empty() && (channel->isReliable() || isConnected))
		{}
	}
	return messages;
}

II-E-3. Client distant

Enfin une dernière modification dans le client distant, utilisateur du gestionnaire de protocoles, afin de passer le nouveau paramètre :

DistantClient.cpp
Sélectionnez
void DistantClient::onDataReceived(const uint8_t* data, const size_t datasize)
{auto receivedMessages = mChannelsHandler.process(isConnected());
	…
}

Voilà qui gère le fait de ne pas traiter de messages non fiables sans être connectés, mais on ne couvre pas la deuxième partie du problème : la réception de messages (fiables) avant d’avoir accepté la connexion.

Pour cela, il faut enregistrer nos messages fiables reçus avant que la connexion ne soit établie dans une liste secondaire :

DistantClient.hpp
Sélectionnez
class DistantClient
{std::vector<std::unique_ptr<Messages::Base>> mPendingMessages; // Stocke les messages avant que la connexion ne soit acceptée
};

Le moyen le plus simple d’y parvenir est de modifier le point d’entrée de tous les messages : onMessageReady :

DistantClient.cpp
Sélectionnez
void DistantClient::onMessageReady(std::unique_ptr<Messages::Base>&& msg)
{
	if (isConnected())
	{
		mClient.onMessageReady(std::move(msg));
	}
	else if (isConnecting())
	{
		mPendingMessages.push_back(std::move(msg));
	}
}

Puis, quand la connexion est réalisée, transférer ces messages dans la liste d’attente de messages reçus et disponibles pour l’application :

DistantClient.cpp
Sélectionnez
void DistantClient::onConnected()
{
	mState = State::Connected;
	maintainConnection();
	onMessageReady(std::make_unique<Messages::Connection>(mAddress, mClientId, Messages::Connection::Result::Success));
	// Transfert des messages en attente !
	for (auto&& pendingMessage : mPendingMessages)
	{
		onMessageReady(std::move(pendingMessage));
	}
}

Enfin, il faut s’assurer que le message de demande de connexion ne soit pas filtré et arrive au client pour pouvoir l’accepter :

DistantClient.cpp
Sélectionnez
void DistantClient::onConnectionReceived()
{
	if (mState == State::None)
	{
		mState = State::ConnectionReceived;
		maintainConnection();
		// Transférer le message de demande de connexion à l’application
		mClient.onMessageReady(std::make_unique<Messages::IncomingConnection>(mAddress, mClientId));
	}}

III. Déconnexion

Tout comme pour la connexion, nous ajoutons une interface pour déconnecter une adresse donnée :

UDPClient.hpp
Sélectionnez
namespace Bousk
{
	namespace Network
	{
		namespace Messages
		{
			class Base;
		}
		namespace UDP
		{
			class DistantClient;
			class Client
			{
				friend class DistantClient;
			public:void disconnect(const Address& addr);
			};
		}
	}
}

III-A. Comment se déconnecter ?

La déconnexion est l’inverse de la connexion : l’arrêt d’échange de données.

Pour déconnecter une machine, il suffit donc de cesser l’envoi de datagrammes vers celle-ci.

Dans les faits, ce n’est pas si simple…

III-B. Processus de déconnexion

Puisque les machines s’échangent des données en continu, la déconnexion doit être entendue de chacune d’elles afin de s’effectuer.

Imaginez le scénario suivant :

  • A et B sont connectés ;
  • B veut se déconnecter de A et cesse tout envoi vers A
    • B supprime A de sa liste de clients ;
  • A continue ses envois vers B ;
  • B reçoit des données de A qu’il ne connait plus
    • B traite ces données comme une nouvelle demande de connexion.

Dans ce cas, il est impossible pour A et B de se déconnecter – et les datagrammes reçus en double ou en retard n’ont même pas été pris en compte !

Il faut donc que la déconnexion soit un processus dans le temps pour que chaque machine le détecte.

Une déconnexion se déroulera en deux étapes :

  • l’arrêt de l’envoi de données pour passer dans un état « Déconnexion en cours » ;
  • puis le passage à un état « Déconnecté » après un certain délai dans l’état « Déconnexion en cours » ;
  • enfin la suppression du client.

Une fois le processus de déconnexion amorcé, il n’est pas possible de revenir en arrière.

La déconnexion doit se réaliser puis une nouvelle connexion doit être émise et établie pour reprendre l’échange de données.

III-C. Timeout

Comme indiqué pour refuser une connexion, une notion de timeout doit apparaître.

Le timeout aura pour rôle principal de détecter l’arrêt de réception de données et déclencher le processus de déconnexion puisque la machine distante ne souhaite plus communiquer – ou ne répond plus.

Le timeout aura donc deux conséquences :

  • si aucune donnée n’a été reçue depuis un timeout, alors la connexion est perdue et l’état passe à « Déconnexion en cours » ;
  • après deux timeout, on passe dans l’état final « Déconnecté ».

Le timeout est généralement de une seconde. Si vous souhaitez pouvoir supporter des clients avec une latence plus élevée, vous devrez le montrer.

Le processus de timeout prend place dans le processus d’envoi, puisque c’est le code qui sera exécuté de façon régulière.

DistantClient.hpp
Sélectionnez
class DistantClient
{std::chrono::milliseconds mLastKeepAlive; // Dernière fois que la connexion a été marquée valide, pour gérer le timeout
};

Cette variable doit être initialisée avec un appel à Utils::Now(); dans le constructeur.

DistantClient.cpp
Sélectionnez
void DistantClient::maintainConnection()
{
	mLastKeepAlive = Utils::Now();
}
void DistantClient::processSend(const size_t maxDatagrams /*= 0*/)
{
	const auto now = Utils::Now();
	…
	if (isDisconnecting() && now > mLastKeepAlive + 2 * GetTimeout())
	{
		// Après 2 timeouts, la connexion est marquée comme perdue
		// Ceci laisse assez de temps pour que chaque partie remarque la déconnexion et agisse en conséquence
		mState = State::Disconnected;
	}
	else if (isConnected() && now > mLastKeepAlive + GetTimeout())
	{
		onConnectionLost();
	}
}

III-D. Datagramme de déconnexion

Nous pouvons utiliser ce délai pour envoyer des informations sur la déconnexion, en particulier pour la faire apparaître comme normale et non comme perte de connexion par la machine distante.

Pour cela, ajoutons un type de datagramme différent, en plus de l’unique type que nous possédons actuellement pour envoyer des données utilisateur.

Rajoutons donc un champ de type dans l’en-tête du datagramme :

Datagram.hpp
Cacher/Afficher le codeSélectionnez
struct Datagram
{enum class Type : uint8 {
		ConnectedData,
		Disconnection,
	};
	struct Header
	{
		…
		Type type;
	};
};

En limitant le type à un uint8, nous simplifions sa sérialisation tout en permettant d’en ajouter d’autres par la suite si besoin.

Ce datagramme sera envoyé pendant le processus de déconnexion :

DistantClient.cpp
Sélectionnez
void DistantClient::processSend(const size_t maxDatagrams /*= 0*/)
{
	const auto now = Utils::Now();
	…
	if (isDisconnecting()))
	{
		if (now > mLastKeepAlive + 2 * GetTimeout())
		{
			// Après 2 timeouts, la connexion est marquée comme perdue
			// Ceci laisse assez de temps pour que chaque partie remarque la déconnexion et agisse en conséquence
			mState = State::Disconnected;
		}
		else
		{
			// Envoyer un datagramme de déconnexion pendant le processus de déconnexion pour informer la machine distante
			Datagram datagram;
			fillDatagramHeader(datagram, Datagram::Type::Disconnection);
			send(datagram);
		}
	}
	else if (isConnected() && now > mLastKeepAlive + GetTimeout())
	{
		onConnectionLost();
	}
}

III-D-1. Gestion du datagramme de déconnexion

Puisque nous avons un nouveau type de datagramme, nous devons le gérer à sa réception :

DistantClient.cpp
Sélectionnez
void DistantClient::onDatagramReceived(Datagram&& datagram)
{switch (datagram.header.type)
	{
	case Datagram::Type::ConnectedData:
	{
		onDataReceived(datagram.data.data(), datagram.datasize);
	} break;
	case Datagram::Type::Disconnection:
	{
		onDisconnectionFromOtherEnd();
	}
	}
}

Avec

DistantClient.cpp
Sélectionnez
void DistantClient::onDisconnectionFromOtherEnd()
{
	if (isConnecting())
	{
		onConnectionRefused();
	}
	else if (isConnected())
	{
		mState = State::Disconnecting;
	}
}

III-E. Reconnexion

La reconnexion pourra se produire après que l’état « Déconnecté » a été mis en place et que le client a été oublié.

Du point de vue du moteur, il s’agit d’une nouvelle connexion qui n’a aucun lien avec une connexion précédente.

III-F. Quand notifier la déconnexion

Puisque la déconnexion est un processus non instantané, il s’agit de rendre ça au plus simple d’utilisation pour l’utilisateur.

L’exigence sera donc que l’utilisateur puisse effectuer une nouvelle connexion, dès qu’il reçoit un message de déconnexion.

Nous devons donc notifier via le message correspondant l’application quand le processus de déconnexion est terminé et non quand il débute.

III-F-1. Raison de la déconnexion

Le message sera donc délivré à l’application après que la déconnexion a été perçue, et plus exactement un timeout plus tard d’après ce que nous avons établi plus haut.

Nous devons alors enregistrer la raison de la déconnexion, s’il y en a une, afin de pouvoir la récupérer le moment venu.

DistantClient.hpp
Sélectionnez
class DistantClient
{
	enum class DisconnectionReason {
		None,
		Refused,
		ConnectionTimedOut,
		Disconnected,
		Lost,
	};
	…
	DisconnectionReason mDisconnectionReason{ DisconnectionReason::None };
};

Davantage de raisons pourront être ajoutées quand le besoin se présentera.

Cette raison doit bien sûr être initialisée à None puis mise à jour quand la déconnexion est démarrée. Sa valeur dépendra du contexte quand cela se produit.

Nous avons pour cela des fonctions pour chaque cas, certaines déjà introduites et d’autres qui le seront plus loin.

DistantClient.cpp
Cacher/Afficher le codeSélectionnez
void DistantClient::onDisconnectionFromOtherEnd()
{
	if (isConnecting())
	{
		onConnectionRefused();
	}
	else if (isConnected())
	{
		mState = State::Disconnecting;
		mDisconnectionReason = DisconnectionReason::DisconnectedFromOtherEnd;
	}
}
void DistantClient::onConnectionLost()
{
	if (isConnected())
	{
		// Démarrer la déconnexion et sauvegarder sa raison pour pouvoir la notifier plus tard
		mState = State::Disconnecting;
		mDisconnectionReason = DisconnectionReason::Lost;
	}
}
void DistantClient::onConnectionRefused()
{
	if (mState == State::ConnectionSent)
	{
		mDisconnectionReason = DisconnectionReason::Refused;
	}
	mState = State::Disconnecting;
}
void DistantClient::onConnectionTimedOut()
{
	if (mState == State::ConnectionSent)
	{
		mDisconnectionReason = DisconnectionReason::ConnectionTimedOut;
	}
	mState = State::Disconnecting;
}
void DistantClient::disconnect()
{
	mDisconnectionReason = DisconnectionReason::Disconnected;
	mState = State::Disconnecting;
}

Il est important de ne pas écraser cette raison une fois déterminée afin de ne pas fausser la déconnexion et les informations envoyées au programme.

III-F-2. Déconnexion ne devant pas être notifiée

Il existe un cas où la déconnexion ne doit pas être notifiée : s’il s’agit d’une connexion non acceptée que l’on a laissé périmer.

Dans ce cas, inutile d’importuner l’utilisateur d’une déconnexion alors qu’il n’a jamais accepté cette connexion au préalable.

La machine distante doit par contre recevoir un message de connexion échouée.

DistantClient.cpp
Cacher/Afficher le codeSélectionnez
void DistantClient::processSend(const size_t maxDatagrams /*= 0*/)
{
	const auto now = Utils::Now();
	…
	if (isDisconnecting())
	{
		if (now > mLastKeepAlive + 2 * GetTimeout())
		{
			mState = State::Disconnected;
			// Informer de la déconnexion, si besoin
			switch (mDisconnectionReason)
			{
				case DisconnectionReason::Disconnected: 
				case DisconnectionReason::DisconnectedFromOtherEnd:
					mClient.onMessageReady(std::make_unique<Messages::Disconnection>(mAddress, mClientId, Messages::Disconnection::Reason::Disconnected));
					break;
				case DisconnectionReason::Lost:
					mClient.onMessageReady(std::make_unique<Messages::Disconnection>(mAddress, mClientId, Messages::Disconnection::Reason::Lost));
					break;
				case DisconnectionReason::Refused:
					mClient.onMessageReady(std::make_unique<Messages::Connection>(mAddress, mClientId, Messages::Connection::Result::Refused));
					break;
				case DisconnectionReason::ConnectionTimedOut:
					mClient.onMessageReady(std::make_unique<Messages::Connection>(mAddress, mClientId, Messages::Connection::Result::TimedOut));
					break;
			}
		}
		else if (mDisconnectionReason != DisconnectionReason::None && mDisconnectionReason != DisconnectionReason::Lost)
		{
			// Envoi du datagramme de déconnexion s’il s’agit d’une fin de connexion normale
			Datagram datagram;
			fillDatagramHeader(datagram, Datagram::Type::Disconnection);
			send(datagram);
		}
	}
	else if (isConnected() && now > mLastKeepAlive + GetTimeout())
	{
		onConnectionLost();
	}}

III-G. Échec de connexion

En plus de détecter les pertes de connexion, le timeout précédemment introduit pourra aussi détecter une connexion qui échoue par un dépassement du temps imparti.

Pour cela, nous devons utiliser un second compteur dédié :

DistantClient.hpp
Sélectionnez
Class DistantClient
{std::chrono::milliseconds mConnectionStartTime; // Démarrage de la connexion, pour timeout de connexion
};

Qui sera également initialisé avec Utils::Now(); et qui permettra de détecter un timeout pendant le processus de connexion.

DistantClient.cpp
Sélectionnez
void DistantClient::processSend(const size_t maxDatagrams /*= 0*/)
{
	const auto now = Utils::Now();
	…
	if (isDisconnecting())
	{}
	else if (isConnected() && now > mLastKeepAlive + GetTimeout())
	{
		onConnectionLost();
	}
	else if (isConnecting() && now > mConnectionStartTime + GetTimeout())
	{
		// La connexion n’a pas été acceptée dans les temps
		onConnectionTimedOut();
	}
}

Avec

DistantClient.cpp
Cacher/Afficher le codeSélectionnez

IV. Maintenir une connexion

IV-A. État des lieux

Nous avons pour le moment en notre possession un protocole qui fait un suivi des datagrammes reçus et différents canaux de communication. Ces canaux remplissent parfaitement leur rôle de transmission de données mises en file d’envoi par l’utilisateur.

Mais un utilisateur peut vouloir garder sa connexion sans pour autant avoir des données probantes à envoyer. Exiger de l’utilisateur de mettre en continu des données pour l’envoi, dans le seul but de maintenir sa connexion, serait très contraignant.

IV-A-1. Keepalive

Afin de remédier à ce problème, nous allons mettre en place un signal keepalive au sein du protocole.

Ce signal sera un datagramme particulier, contenant un minimum de données, qui sera transmis lorsque l’envoi de données est effectué alors que les canaux n’ont rien à envoyer.

IV-B. Types de datagrammes

Nous devons donc ajouter un type de datagramme qui contiendra uniquement des données propres au signal keepalive.

Datagram.hpp
Cacher/Afficher le codeSélectionnez
struct Datagram
{enum class Type : uint8 {
		ConnectedData,
		Disconnection,
		KeepAlive,
	};
	struct Header
	{
		…
		Type type;
	};
};

IV-C. Mise à jour de l’envoi

Il faut ensuite mettre à jour l’envoi des données afin d’envoyer un keepalive si aucune donnée n’est disponible à l’envoi :

DistantClient.cpp
Sélectionnez
void DistantClient::processSend(const size_t maxDatagrams /*= 0*/)
{
	const auto now = Utils::Now();
	// Pendant que la connexion est en attente d’approbation, nous devons continuer l’envoi de données afin de la conserver disponible
	if (isConnecting() || isConnected())
	{
		for (size_t loop = 0; maxDatagrams == 0 || loop < maxDatagrams; ++loop)
		{
			Datagram datagram;
			datagram.datasize = mChannelsHandler.serialize(datagram.data.data(), Datagram::DataMaxSize, mNextDatagramIdToSend);
			if (datagram.datasize > 0)
			{
				fillDatagramHeader(datagram, Datagram::Type::ConnectedData);
				send(datagram);
			}
			else
			{
				if (loop == 0)
				{
					// Rien à envoyer, envoyons un keepalive pour maintenir la connexion
					fillKeepAlive(datagram);
					send(datagram);
				}
				break;
			}
		}
	}}

IV-D. Keepalive connecté

La seule information contenue dans le keepalive sera si la connexion est souhaitée (émise ou acceptée) ou non.

Pour cela, nous utilisons notre système de sérialisation pour créer le contenu du datagramme keepalive qui sera constitué d’un unique booléen :

DistantClient.cpp
Sélectionnez
void DistantClient::fillKeepAlive(Datagram& dgram)
{
	fillDatagramHeader(dgram, Datagram::Type::KeepAlive);
	// Notifier la machine distante si nous devons être connecté ou demandons la connexion
	Serialization::Serializer serializer;
	serializer.write(mState == State::ConnectionSent || isConnected());
	memcpy(dgram.data.data(), serializer.buffer(), serializer.bufferSize());
	dgram.datasize = serializer.bufferSize();
}

Bien sûr il y aura un code similaire pour traiter un datagramme keepalive et extraire cette donnée :

DistantClient.cpp
Sélectionnez
void DistantClient::onDatagramReceived(Datagram&& datagram)
{switch (datagram.header.type)
	{case Datagram::Type::KeepAlive:
	{
		handleKeepAlive(datagram.data.data(), datagram.datasize);
	} break;
	}
}

void DistantClient::handleKeepAlive(const uint8* data, const size_t datasize)
{
	maintainConnection();
	if (mState == State::None || isConnecting())
	{
		Serialization::Deserializer deserializer(data, datasize);
		bool isConnectedKeepAlive = false;
		if (deserializer.read(isConnectedKeepAlive) && isConnectedKeepAlive)
		{
			onConnectionReceived();
		}
	}
}

V. Diagramme des échanges de données

Voici un diagramme récapitulatif des échanges de données tout au long d’une connexion.

Image non disponible

Vous pouvez y voir les différents états du DistantClient et les messages reçus par l’application.

Chaque datagramme, excepté ceux de type Disconnection, peut contenir des données utilisateur s’il y en a disponibles et éligibles à l’envoi (type ConnectedData), ou un keepalive (type KeepAlive) connecté ou non.

Souvenez-vous que chaque datagramme peut être perdu, ce qui est symbolisé par les flèches hachurées. Pour indiquer un datagramme reçu, qui déclenche une action, il s’agit d’une flèche entière.

VI. Tests

Cette fois les tests ne seront pas assurés par un test unitaire, mais par des programmes de test afin de vérifier que la connexion s’effectue et qu’un timeout finit par arriver.

Chaque programme de test créera deux clients UDP dans deux threads, ouvrant chacun un port et devant se connecter l’un à l’autre.

VI-A. Test de connexion

Le premier test sert à illustrer la mise en place de la connexion et l’échange de données fiables et ordonnées entre deux machines. Il s’agit d’envoyer une liste de chaînes de caractères qui devra être identique (chaque chaîne et leur ordre) chez chaque client :

Samples/UDP/Connection/Main.cpp
Cacher/Afficher le codeSélectionnez

VI-B. Test du timeout

Le second test reprend le squelette du test précédent, mais une fois la connexion établie éteint un client afin que l’autre détecte une perte de connexion :

Samples/UDP/Timeout/Main.cpp
Cacher/Afficher le codeSélectionnez

VI-C. Timeout de connexion

Enfin, un dernier test démontre le fonctionnement du timeout qui survient pendant la connexion :

Samples/UDP/ConnectionTimeout/Main.cpp
Cacher/Afficher le codeSélectionnez

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 © 2020 Cyrille (Bousk) Bousquet. Aucune reproduction, même partielle, ne peut être faite de ce site ni 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.