Cours programmation réseau en C++

UDP – Combiner tous les protocoles : les canaux de communication

Bousk

Jusqu’à présent nous avons créé deux protocoles : un ordonné non fiable et un ordonné fiable. Mais seul celui ordonné non fiable est actuellement utilisé par le moteur.

Ce chapitre va présenter comment utiliser ces deux protocoles, et plus, simultanément.

Commentez

- Titre : Cours programmation réseau en C++

- Auteur : Bousk

- Parution : 23 juillet 2019

- Mise à jour : 18 juillet 2019

- Public visé :

- Licence : 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 ® 2019 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.

Sommaire

- I. État des lieux

- II. Canaux de communications

- II-A. Gestionnaire de canaux

- III. Uniformiser les protocoles

- III-A. Créer une interface

- III-B. Utiliser cette interface

- IV. Introduire les canaux de communication

- IV-A. Interface du gestionnaire de canaux

- IV-B. Implémentations

- IV-B-1. Constructeur

- IV-B-2. void onDatagramAcked(Datagram::ID datagramId);

- IV-B-3. void onDatagramLost(Datagram::ID datagramId);

- IV-B-4. std::vector<std::vector<uint8_t>> process();

- IV-B-5. void queue(std::vector<uint8_t>&& msgData);

- IV-B-6. size_t serialize(uint8_t* buffer, const size_t buffersize, Datagram::ID datagramId);

- IV-B-7. void onDataReceived(const uint8_t* data, const size_t datasize);

- IV-B-7-a. En-tête de canal

- IV-B-7-b. Taille des paquets

- IV-B-7-c. Mise à jour de la sérialisation

- IV-B-7-d. Implémentation de la réception

- V. Mise à jour du reste du code

- V-A. Le client distant

- V-B. Le client UDP

- VI. De multiplexeur et démultiplexeur à protocole

- VI-A. Interface de protocole

- VI-B. Mise à jour du reste du code

- VI-B-1. Convertir un binôme multiplexeur / démultiplexeur en protocole

- VI-B-2. Gestionnaire de canaux

- VII. Configuration et protocoles utilisateurs

- VIII. Conclusion

I - État des lieux

Nous avons, dans les chapitres précédents, créé un protocole ordonné non fiable et un protocole ordonné fiable. Mais seul le protocole ordonné non fiable est actuellement utilisé par le moteur. Le protocole ordonné fiable étant uniquement utilisé par des tests unitaires…

Or, dans un projet, il est assez commun d’avoir besoin de ces deux protocoles de communication en parallèle : si la position des personnages est généralement envoyée par un protocole ordonné non fiable, de sorte à récupérer uniquement la plus récente, l’envoi de message texte par exemple sera de préférence envoyé avec un protocole ordonné fiable.

Il est tentant dans ce genre de cas de se dire que la solution serait de créer une seconde connexion, utilisant TCP, pour que les données ordonnées fiables transitent via celle-ci. Puis vient rapidement l’idée qu’une seule connexion TCP ne suffirait pas parce que l’on veut avoir plusieurs groupes de données à envoyer de manière ordonnée fiable sans qu’elles s’entremêlent (pour pouvoir communiquer textuellement avec des joueurs pendant que l’on télécharge la carte de la partie par exemple).

Ce n’est généralement pas une bonne idée : ça complexifie l’établissement des connexions, ajoute un nouveau point d’échec possible, si un joueur fait office d’hôte et de serveur il devra certainement ouvrir ou rediriger des ports de son pare-feu (pratique que l’on tend à éviter de nos jours)… Je vous invite à lire cet article pour plus de détails.

Donc toutes les communications devront passer par la connexion unique établie, et nous devons trouver un moyen d’utiliser tous les protocoles créés à travers celle-ci.

II - Canaux de communications

L’idée est d’introduire la notion de canaux : l’un servira aux échanges ordonnés non fiables, un autre aux échanges ordonnés fiables.

À terme, vous pourrez utiliser autant de canaux que voulu pour complexifier les échanges. Par exemple : envoyer des messages A, B, C via un premier canal ordonné fiable, les messages D, E, F via un second canal ordonné fiable et le reste via un autre canal ordonné non fiable. Vous pourrez même créer d’autres canaux implémentant d’autres protocoles. Multipliez les canaux, et les combinaisons, pour répondre à vos besoins !

Le protocole ordonné non fiable du chapitre 4 deviendra donc un canal de communication ordonné non fiable et le protocole ordonné fiable du chapitre précédent sera un canal ordonné fiable.

II-A - Gestionnaire de canaux

Afin de gérer plusieurs canaux de communication, nous utiliserons un gestionnaire de canaux.

Le gestionnaire est une simple couche qui s’insère entre ClientDistant et les canaux. Son seul rôle est de rediriger les données dans le bon canal afin d’assurer la sérialisation lors de l’envoi et la désérialisation à la réception.

III - Uniformiser les protocoles

Avant de parler du gestionnaire de canaux, intéressons-nous aux protocoles déjà existants.

Le gestionnaire devant travailler avec tous les canaux, ceux-ci doivent donc avoir une interface commune.

Nos deux protocoles actuels sont déjà très similaires : le protocole fiable a un paramètre supplémentaire à l’envoi et la réception (l’identifiant de datagramme) et quelques méthodes pour agir sur l’acquittement et la perte de datagramme.

L’uniformisation des protocoles est donc très simple : il suffit d’ajouter ces informations au protocole non fiable, qui ne les utilisera pas - donc sous forme de fonction vide.

III-A - Créer une interface

Puisqu’on parle d’interface commune, créons donc une interface au sens C++.

ProtocolInterface.hpp

1. class IMultiplexer

2. {

3. public:

4. IMultiplexer() = default;

5. virtual ~IMultiplexer() = default;

7. virtual void queue(std::vector<uint8_t>&& msgData) = 0;

8. virtual size_t serialize(uint8_t* buffer, const size_t buffersize, Datagram::ID datagramId) = 0;

10. virtual void onDatagramAcked(Datagram::ID /*datagramId*/) {}

11. virtual void onDatagramLost(Datagram::ID /*datagramId*/) {}

12. };

14. class IDemultiplexer

15. {

16. public:

17. IDemultiplexer() = default;

18. virtual ~IDemultiplexer() = default;

20. virtual void onDataReceived(const uint8_t* data, const size_t datasize) = 0;

21. virtual std::vector<std::vector<uint8_t>> process() = 0;

22. };

Notez que les fonctions onDatagramAcked et onDatagramLost ont une implémentation par défaut qui est vide. De cette manière, un protocole non fiable pourra ignorer ces fonctions tandis qu’un protocole fiable qui doit agir sur ces actions devra les surcharger – ce qui aurait été le cas de toute façon.

III-B - Utiliser cette interface

Maintenant, mettons à jour les protocoles existants pour qu’ils héritent de cette interface.

Il s’agit de faire hériter chacun de nos multiplexeurs et démultiplexeurs de la nouvelle interface.

D’abord le protocole ordonné fiable :

Multiplexer & Demultiplexer ReliableOrdered

1. #include "UDP/Protocoles/ProtocolInterface.hpp"

3. class Multiplexer : public IMultiplexer

4. {

5. public:

6. Multiplexer() = default;

7. ~Multiplexer() override = default;

9. void queue(std::vector<uint8_t>&& msgData) override;

10. size_t serialize(uint8_t* buffer, const size_t buffersize, Datagram::ID datagramId) override;

12. void onDatagramAcked(Datagram::ID datagramId) override;

13. void onDatagramLost(Datagram::ID datagramId) override;

14. };

16. class Demultiplexer : public IDemultiplexer

17. {

18. public:

19. Demultiplexer() = default;

20. ~Demultiplexer() override = default;

22. void onDataReceived(const uint8_t* data, const size_t datasize) override;

23. std::vector<std::vector<uint8_t>> process() override;

24. };

Puis le protocole ordonné non fiable :

Multiplexer & Demultiplexer UnreliableOrdered

1. class Multiplexer : public IMultiplexer

2. {

3. friend class Multiplexer_Test;

4. public:

5. Multiplexer() = default;

6. ~Multiplexer() override = default;

8. void queue(std::vector<uint8_t>&& msgData) override;

9. size_t serialize(uint8_t* buffer, const size_t buffersize, Datagram::ID) override;

10. };

12. class Demultiplexer : public IDemultiplexer

13. {

14. friend class Demultiplexer_Test;

15. public:

16. Demultiplexer() = default;

17. ~Demultiplexer() override = default;

19. void onDataReceived(const uint8_t* data, const size_t datasize) override;

20. std::vector<std::vector<uint8_t>> process() override;

21. };

IV - Introduire les canaux de communication

Il est maintenant temps d’introduire nos canaux de communication.

Comme bien souvent, la solution est d’ajouter une indirection et une couche intermédiaire : c’est exactement ce qu’est le gestionnaire de canaux.

Ce gestionnaire sera le gérant et propriétaire des protocoles utilisés. Notre ClientDistant n’utilisera plus de protocole directement mais le gestionnaire de canaux.

IV-A - Interface du gestionnaire de canaux

Puisqu’il s’agit d’une couche intermédiaire, nous devrions retrouver sans surprise les interfaces du multiplexeur et du démultiplexeur, mais aussi une liste de multiplexeurs et démultiplexeurs pour chaque canal que nous voulons utiliser. Partons donc sur ceci :

ChannelsHandler.hpp

1. #pragma once

3. #include <memory>

4. #include <vector>

6. namespace Bousk

7. {

8. namespace Network

9. {

10. namespace UDP

11. {

12. namespace Protocols

13. {

14. class IMultiplexer;

15. class IDemultiplexer;

16. }

17. class ChannelsHandler

18. {

19. public:

20. ChannelsHandler();

21. ~ChannelsHandler();

23. // Multiplexeur

24. void queue(std::vector<uint8_t>&& msgData);

25. size_t serialize(uint8_t* buffer, const size_t buffersize, Datagram::ID datagramId);

27. void onDatagramAcked(Datagram::ID datagramId);

28. void onDatagramLost(Datagram::ID datagramId);

30. // Demultiplexeur

31. void onDataReceived(const uint8_t* data, const size_t datasize);

32. std::vector<std::vector<uint8_t>> process();

34. private:

35. std::vector<std::unique_ptr<Protocols::IMultiplexer>> mMultiplexers;

36. std::vector<std::unique_ptr<Protocols::IDemultiplexer>> mDemultiplexers;

37. };

38. }

39. }

40. }

IV-B - Implémentations

IV-B-1 - Constructeur

Le constructeur aura pour le moment la responsabilité de créer les deux canaux : un pour le protocole ordonné non fiable et le second pour le protocole ordonné fiable.

ChannelsHandler.cpp

1. ChannelsHandler::ChannelsHandler()

2. {

3. mMultiplexers.push_back(std::make_unique<Protocols::UnreliableOrdered::Multiplexer>());

4. mMultiplexers.push_back(std::make_unique<Protocols::ReliableOrdered::Multiplexer>());

6. mDemultiplexers.push_back(std::make_unique<Protocols::UnreliableOrdered::Demultiplexer>());

7. mDemultiplexers.push_back(std::make_unique<Protocols::ReliableOrdered::Demultiplexer>());

8. }

Il est très important que les protocoles soient dans le même ordre.

Si mMultiplexers[0] est le multiplexeur du protocole ordonné non fiable, mDemultiplexers[0] doit être le démultiplexeur du protocole ordonné non fiable. Sinon il y aura des problèmes de désérialisation des paquets.

IV-B-2 - void onDatagramAcked(Datagram::ID datagramId);

Commençons par le plus simple. Cette fonction aura pour seul but de propager cet appel aux multiplexeurs présents dans notre gestionnaire de canaux.

void ChannelsHandler::onDatagramAcked(Datagram::ID datagramId);

1. void ChannelsHandler::onDatagramAcked(Datagram::ID datagramId)

2. {

3. for (auto& protocol : mMultiplexers)

4. {

5. protocol->onDatagramAcked(datagramId);

6. }

7. }

IV-B-3 - void onDatagramLost(Datagram::ID datagramId);

Tout comme la précédente, il s’agit d’une simple propagation aux multiplexeurs de chaque protocole enregistré dans le gestionnaire.

void ChannelsHandler::onDatagramLost(Datagram::ID datagramId);

1. void ChannelsHandler::onDatagramLost(Datagram::ID datagramId)

2. {

3. for (auto& protocol : mMultiplexers)

4. {

5. protocol->onDatagramLost(datagramId);

6. }

7. }

IV-B-4 - std::vector<std::vector<uint8_t>> process();

Enfin la dernière fonction simple à implémenter puisqu’il s’agit de propager l’appel à process() de chaque démultiplexeur puis fusionner les résultats et retourner le tout.

std::vector<std::vector<uint8_t>> ChannelsHandler::process();

1. std::vector<std::vector<uint8_t>> ChannelsHandler::process()

2. {

3. std::vector<std::vector<uint8_t>> messages;

4. for (auto& protocol : mDemultiplexers)

5. {

6. std::vector<std::vector<uint8_t>> protocolMessages = protocol->process();

7. if (!protocolMessages.empty())

8. {

9. messages.reserve(messages.size() + protocolMessages.size());

10. messages.insert(messages.end(), std::make_move_iterator(protocolMessages.begin()), std::make_move_iterator(protocolMessages.end()));

11. }

12. }

13. return messages;

14. }

Notez l’utilisation des std::make_move_iterator. Ceux-ci permettent d’éviter une copie superflue alors que notre seul intérêt est de déplacer les tampons du démultiplexeur que l’on vient d’appeler vers le vector qui sera retourné.

IV-B-5 - void queue(std::vector<uint8_t>&& msgData);

Voici la première difficulté.

Lorsque nous mettons en file d’envoi des données, via quel canal veut-on les envoyer ? Et comment choisir ce canal ?

De toute évidence, la signature actuelle n’est pas suffisante. Il faut pouvoir choisir le canal d’envoi, et celui-ci doit être un paramètre.

Changeons donc la signature pour void queue(std::vector<uint8_t>&& msgData, uint32_t canalIndex);

Cette fois le choix du canal est clair et laissé à l’utilisateur (ici notre classe DistantClient). Avant de voir ce que cela implique, implémentons cette fonction :

void ChannelsHandler::queue(std::vector<uint8_t>&& msgData, uint32_t canalIndex)

1. void ChannelsHandler::queue(std::vector<uint8_t>&& msgData, uint32_t canalIndex)

2. {

3. assert(canalIndex < mMultiplexers.size());

4. mMultiplexers[canalIndex]->queue(std::move(msgData));

5. }

Dans notre cas actuel, avec uniquement deux canaux, l’un ordonné fiable et l’autre ordonné non fiable, il est tentant d’ajouter uniquement un paramètre bool indiquant si l’envoi doit être fait de manière fiable ou non.

Mais afin de garder la possibilité d’ajouter d’autres canaux par la suite, ou juste de pouvoir utiliser plusieurs canaux fiables ou non fiables, je favorise immédiatement l’index de canal à utiliser.

IV-B-6 - size_t serialize(uint8_t* buffer, const size_t buffersize, Datagram::ID datagramId);

Ici la difficulté est différente. La façon dont nous sérialisons les différents canaux aura une incidence directe sur la latence de chacun d’eux. Favoriser un canal en particulier aura pour effet de retarder l’envoi des autres canaux.

Par exemple, considérons l’implémentation suivante :

size_t ChannelsHandler::serialize(uint8_t* buffer, const size_t buffersize, Datagram::ID datagramId);

1. size_t ChannelsHandler::serialize(uint8_t* buffer, const size_t buffersize, Datagram::ID datagramId)

2. {

3. size_t remainingBuffersize = buffersize;

4. for (auto& protocol : mMultiplexers)

5. {

6. const size_t serializedData = protocol->serialize(buffer, remainingBuffersize, datagramId);

7. assert(serializedData <= remainingBuffersize);

8. buffer += serializedData;

9. remainingBuffersize -= serializedData;

10. }

11. return buffersize - remainingBuffersize;

12. }

Cette implémentation est tout à fait valide mais a également une conséquence importante : les canaux sont traités dans leur ordre d’enregistrement. Autrement dit, le premier canal enregistré sera le premier traité et aura donc le plus de chances d’envoyer ses données. Imaginez mettre en file un paquet de taille maximale dans un des canaux suivants, et ces données ont très peu de chances d’être jamais envoyées.

Pour contrer ce phénomène on peut par exemple diminuer la taille maximale d’un paquet et limiter le nombre de paquets de chaque canal dans un datagramme donné.

Pour le moment, contentons-nous de cette implémentation.

IV-B-7 - void onDataReceived(const uint8_t* data, const size_t datasize);

Dernière fonction et probablement la plus problématique actuellement.

Cette fonction reçoit les données brutes du datagramme et doit rediriger chaque paquet qu’il contient au canal correct. Mais puisqu’il s’agit de données brutes, il n’y a absolument aucune information indiquant ceci.

Notre couche intermédiaire manque d’information et est en l’état incapable de réaliser cette tâche.

IV-B-7-a - En-tête de canal

Il faut donc ajouter un peu d’information pour indiquer via quel canal le paquet doit être extrait, sous forme d’en-tête de canal.

Un tel en-tête doit contenir l’identifiant du canal, et la taille des données dédiées à ce canal. Ainsi nous savons quelle quantité de données doit être redirigée vers quel canal afin que ce dernier puisse les gérer via sa propre fonction onDataReceived.

Un en-tête de canal sera représenté par la structure suivante :

ChannelHeader

1. struct ChannelHeader

2. {

3. uint32_t channelId;

4. uint32_t datasize;

5. };

IV-B-7-b - Taille des paquets

Ajouter un tel en-tête a une incidence directe sur nos paquets : la taille disponible pour ceux-ci dans nos datagrammes diminue.

Il faut donc mettre à jour la définition des paquets pour répercuter ceci :

Packet.hpp

static constexpr uint16_t PacketMaxSize = Datagram::DataMaxSize - ChannelHeader::Size;

Avec ChannelHeader ayant une définition complète telle que :

ChannelHeader.hpp

1. #pragma once

3. #include <cstdint>

5. namespace Bousk

6. {

7. namespace Network

8. {

9. namespace UDP

10. {

11. struct ChannelHeader

12. {

13. static constexpr size_t Size = sizeof(uint32_t) + sizeof(uint32_t);

15. uint32_t channelId;

16. uint32_t datasize;

17. };

18. }

19. }

20. }

IV-B-7-c - Mise à jour de la sérialisation

La donnée est nécessaire à la réception, mais c’est dès l’envoi que celle-ci doit être intégrée.

Afin d’optimiser notre bande passante, l’en-tête doit être ajouté uniquement si le canal en question envoie des paquets.

Voici l’implémentation que nous utiliserons :

size_t ChannelsHandler::serialize(uint8_t* buffer, const size_t buffersize, Datagram::ID datagramId);

1. size_t ChannelsHandler::serialize(uint8_t* buffer, const size_t buffersize, Datagram::ID datagramId)

2. {

3. size_t remainingBuffersize = buffersize;

4. for (uint32_t channelid = 0; channelid < mMultiplexers.size(); ++channelid)

5. {

6. Protocols::IMultiplexer* protocol = mMultiplexers[protocolid].get();

8. uint8_t* const channelHeaderStart = buffer;

9. uint8_t* const channelDataStart = buffer + ChannelHeader::Size;

10. const size_t channelAvailableSize = remainingBuffersize - ChannelHeader::Size;

12. const size_t serializedData = protocol->serialize(channelDataStart, channelAvailableSize, datagramId);

13. assert(serializedData <= channelAvailableSize);

14. if (serializedData)

15. {

16. // Le canal a sérialisé des données pour l’envoi, ajoutons l’en-tête de canal

17. ChannelHeader* const channelHeader = reinterpret_cast<ChannelHeader*>(channelHeaderStart);

18. channelHeader->channelId = protocolid;

19. channelHeader->datasize = static_cast<uint32_t>(serializedData);

21. const size_t channelTotalSize = serializedData + ChannelHeader::Size;

22. buffer += channelTotalSize;

23. remainingBuffersize -= channelTotalSize;

24. }

25. }

26. return buffersize - remainingBuffersize;

27. }

IV-B-7-d - Implémentation de la réception

Il est maintenant temps d’implémenter void ChannelsHandler::onDataReceived(const uint8_t* data, const size_t datasize).

Son implémentation sera bien plus simple que le code de la sérialisation. Ici nous devons juste extraire l’en-tête de canal, puis transférer les données requises à celui correspondant.

void ChannelsHandler::onDataReceived(const uint8_t* data, const size_t datasize);

1. void ChannelsHandler::onDataReceived(const uint8_t* data, const size_t datasize)

2. {

3. size_t processedData = 0;

4. while (processedData < datasize)

5. {

6. const ChannelHeader* channelHeader = reinterpret_cast<const ChannelHeader*>(data);

7. if (processedData + channelHeader->datasize > datasize || channelHeader->datasize > Datagram::DataMaxSize)

8. {

9. // Tampon mal formé

10. return;

11. }

12. if (channelHeader->channelId >= mChannels.size())

13. {

14. // Canal demandé non existant

15. return;

16. }

17. mChannels[channelHeader->channelId]->onDataReceived(data + ChannelHeader::Size, channelHeader->datasize);

18. const size_t channelTotalSize = channelHeader->datasize + ChannelHeader::Size;

19. data += channelTotalSize;

20. processedData += channelTotalSize;

21. }

22. }

V - Mise à jour du reste du code

V-A - Le client distant

Notre classe DistantClient utilise jusque-là un multiplexeur du protocole ordonné non fiable et son démultiplexeur. En plus d’avoir changé leurs interfaces, rendant en particulier le multiplexeur incompatible, nous voulons maintenant utiliser plusieurs protocoles via des canaux et donc utiliser le gestionnaire de canaux.

Apportons donc les corrections nécessaires, d’abord dans le fichier d’en-tête :

DistantClient.hpp

class DistantClient
{
…
void send(std::vector<uint8_t>&& data, uint32_t canalIndex);
…
ChannelsHandler mChannelsHandler;
};

Puis dans l’implémentation :

DistantClient.hpp

void DistantClient::send(std::vector<uint8_t>&& data, uint32_t canalIndex)
{
mChannelsHandler.queue(std::move(data), canalIndex);
}
bool DistantClient::fillDatagram(Datagram& dgram)
{
dgram.header.ack = htons(mReceivedAcks.lastAck());
dgram.header.previousAcks = mReceivedAcks.previousAcksMask();

dgram.datasize = mChannelsHandler.serialize(dgram.data.data(), Datagram::DataMaxSize, mNextDatagramIdToSend);
if (dgram.datasize > 0)
{
dgram.header.id = htons(mNextDatagramIdToSend);
++mNextDatagramIdToSend;
return true;
}
return false;
}
void DistantClient::onDataReceived(const uint8_t* data, const size_t datasize)
{
mChannelsHandler.onDataReceived(data, datasize);
auto receivedMessages = mChannelsHandler.process();
for (auto&& msg : receivedMessages)
{
onMessageReady(std::make_unique<Messages::UserData>(std::move(msg)));
}
}

V-B - Le client UDP

Il reste une dernière modification à apporter, au client UDP cette fois. Puisque la signature de send a changé dans DistantClient, elle est désormais incompatible avec le client.

Nous allons apporter une modification similaire et ajouter le canal d’envoi dans les paramètres :

UDPClient.cpp

1. void Client::sendTo(const sockaddr_storage& target, std::vector<uint8_t>&& data, uint32_t canalIndex)

2. {

3. auto& client = getClient(target);

4. client.send(std::move(data), canalIndex);

5. }

Il faut bien entendu également mettre à jour les tests unitaires existants. Ceci ne sera pas abordé dans le cours, mais les changements sont disponibles sur le dépôt Mercurial.

VI - De multiplexeur et démultiplexeur à protocole

Nous avons désormais à notre disposition un gestionnaire de canaux qui permet d’utiliser plusieurs protocoles. Et ces protocoles partagent une interface commune.

Seulement il se compose en ce moment d’un vector de multiplexeurs et d’un vector de démultiplexeurs.

Et nous avons appuyé lors de l’enregistrement de nos deux canaux combien il est important de respecter l’ordre afin que l’index du multiplexeur corresponde à son index de multiplexeur.

Pour plus de facilité, et afin d’éviter d’introduire une source d’erreurs, créons donc une interface de protocole. Un protocole combinera l’interface de notre actuelle interface de multiplexeur et du démultiplexeur.

Ainsi le gestionnaire de canaux gérera une liste de protocoles, les canaux d’échange de données, et non une liste de multiplexeurs et une autre de démultiplexeurs.

VI-A - Interface de protocole

L’interface de protocole consistera littéralement en la fusion de IMultiplexeur et IDemultiplexeur introduits auparavant.

ProtocolInterface.hpp

1. class IProtocol

2. {

3. public:

4. IProtocol() = default;

5. virtual ~IProtocol() = default;

7. virtual void queue(std::vector<uint8_t>&& msgData) = 0;

8. virtual size_t serialize(uint8_t* buffer, const size_t buffersize, Datagram::ID datagramId) = 0;

10. virtual void onDatagramAcked(Datagram::ID /*datagramId*/) {}

11. virtual void onDatagramLost(Datagram::ID /*datagramId*/) {}

13. virtual void onDataReceived(const uint8_t* data, const size_t datasize) = 0;

14. virtual std::vector<std::vector<uint8_t>> process() = 0;

15. };

VI-B - Mise à jour du reste du code

Il convient de mettre à jour le code existant afin de répandre le concept de protocoles là où multiplexeur et démultiplexeur sont utilisés.

Notez que l’idée d’avoir une classe de multiplexeur dédiée à l’envoi et une classe de démultiplexeur pour traiter la réception reste valide.

L’implémentation d’un protocole aura tout intérêt à découpler ces concepts afin d’avoir un code plus lisible.

Le concept de protocole est principalement introduit comme étant l’agrégation d’un multiplexeur et d’un démultiplexeur puisque ces deux éléments fonctionnent de pair.

VI-B-1 - Convertir un binôme multiplexeur / démultiplexeur en protocole

Voyons comment un tel changement peut s’opérer sur le cas du protocole ordonné fiable.

Les opérations sont les suivantes :

- transformer le namespace ReliableOrdered en class ReliableOrdered ;

- faire hériter cette classe de l’interface IProtocol ;

- garder le multiplexeur et le démultiplexeur comme classes internes à notre nouvelle classe ;

- définir une variable membre de notre nouvelle classe de protocole pour le multiplexeur, et une autre pour le démultiplexeur ;

- implémenter l’interface de IProtocole pour rediriger les appels au multiplexeur ou démultiplexeur membre.

En suivant ce schéma, la transformation est plutôt simple et le code à modifier minime. Vous pouvez voir les détails des changements sur le dépôt Mercurial.

VI-B-2 - Gestionnaire de canaux

Le gestionnaire de canaux n’aura plus qu’à gérer une liste de canaux via un unique vector de protocoles std::vector<std::unique_ptr<Protocols::IProtocol>> mChannels;.

Modifier l’implémentation du gestionnaire de canaux pour répercuter ces changements relève du trivial et ne sera donc pas présenté ici, mais est visible sur le dépôt Mercurial.

VII - Configuration et protocoles utilisateurs

Voici la partie intéressante suite à ces modifications : donner la possibilité à l’utilisateur de choisir quels protocoles utiliser et par extension d’utiliser ses propres protocoles.

Pour ce faire, ajoutons des interfaces pour enregistrer le canal de son choix. Puisque ces canaux doivent être de nouvelles instances pour chaque gestionnaire de canaux, j’aime bien utiliser une fonction template :

ChannelsHandler.hpp

template<class T>
void ChannelsHandler::registerChannel()
{
mChannels.push_back(std::make_unique<T>());
}

Et nous pouvons créer la même fonction dans la classe DistantClient :

DistantClient.hpp

template<class T>
void DistantClient::registerChannel()
{
mChannelsHandler.registerChannel<T>();
}

Il reste un problème : ces canaux doivent être enregistrés pour chaque client distant. Et ces clients ne sont pas connus à la compilation. Il faut donc trouver un moyen d’enregistrer une liste de canaux, et d’utiliser cette liste pour initialiser les clients distants à leur création.

Voici une astuce simple pour y parvenir : enregistrer dans le client UDP une liste de foncteurs qui créeront les canaux.

Pour la liste de foncteurs, un std::vector<std::function<void(DistantClient&)>> sera parfait. Pour enregistrer un canal, une fonction template répond à nouveau à la situation :

UDPClient.hpp

template<class T>
void Client::RegisterChannel()
{
mRegisteredCanals.push_back([](DistantClient& distantClient) { distantClient.registerChannel<T>(); });
}

Maintenant, modifions la création de clients distants pour y ajouter l’étape d’enregistrements des canaux :

UDPClient.cpp

DistantClient& Client::getClient(const sockaddr_storage& clientAddr)
{
auto itClient = std::find_if(mClients.begin(), mClients.end(), [&](const std::unique_ptr<DistantClient>& client) { return memcmp(&(client->address()), &clientAddr, sizeof(sockaddr_storage)); });
if (itClient != mClients.end())
return *(itClient->get());

mClients.emplace_back(std::make_unique<DistantClient>(*this, clientAddr));
setupChannels(*(mClients.back()));
return *(mClients.back());
}
void Client::setupChannels(DistantClient& client)
{
for (auto& fct : mRegisteredChannels)
fct(client);
}

VIII - Conclusion

L’ensemble du chapitre est un remaniement de la notion de multiplexeur et démultiplexeur et une légère réarchitecture du code afin de fusionner ces concepts pour créer un objet canal utilisant un protocole.

Il n’y a pas de tests cette fois puisqu’aucune nouvelle fonctionnalité n’a vraiment été introduite. Le gestionnaire de canaux n’étant finalement qu’un conteneur à protocoles.

De plus les tests existants sur le client distant ont dû être mis à jour suite à ces changements et l’ajout du gestionnaire de canaux. Le gestionnaire de canaux est donc déjà utilisé via ces tests, ce qui permet de s’assurer que son en-tête est correctement sérialisé et désérialisé et valider son fonctionnement.

Récupérez le code source sous le tag V1.0.7 dans le dépôt Mercurial à l’adresse suivante.

Article précédent

<< UDP – Envoi de paquets ordonné et fiable

Bases de la sérialisation >>