Cours programmation réseau en C++

UDP – Créer son protocole par-dessus UDP

| Bousk | Winjerome |

Cet article introduit l’interface de notre bibliothèque qui sera utilisée pour communiquer en UDP sur le réseau.

Les prémices du protocole sont également mises en place avant d’être étoffées dans les chapitres suivants.

Commentez

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

- Auteurs : Bousk, Winjerome

- Parution : 17 janvier 2019

- Mise à jour : 27 avril 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. Prémices de protocole

- II. Fonctionnement du moteur

- III. Client local

- III-A. Quelle peut être l’interface d’un tel objet ?

- III-A-1. Qu’est le destinataire ?

- III-A-2. Squelette d’un programme type cible

- IV. Client distant

- IV-A. Utilité du client distant

- IV-B. Interface du client distant

- IV-B-1. Fonctionnement du client distant

- IV-B-2. Comment y parvenir ?

- IV-B-2-a. Envoi de données

- IV-B-3. Traitement des données reçues

- V. Implémentation du Client

- V-A. Variables membres

- V-B. Initialisation

- V-C. Envoi de données

- V-D. Réception de données

- VI. En-tête du datagramme

- VI-A. Lors de la réception d’un datagramme

- VI-B. Lors de l’envoi d’un datagramme

- VII. Tests

I - Prémices de protocole

Les plus attentifs auront sans doute remarqué que notre datagramme s’est vu doter d’un en-tête comportant bien plus qu’un seul identifiant lors du deuxième chapitre.

En effet, notre datagramme possède un champ pour informer la machine distante, lors de la réception du datagramme en question, de l’identifiant le plus récent reçu, ainsi que de l’état, reçu ou non, des 64 précédents datagrammes.

Ces deux champs sont la pierre angulaire de la création de notre protocole : en ayant cette information partagée entre les parties, il devient possible de savoir quelles informations ont été reçues ou non et d’agir en conséquence !

Ce chapitre va vous montrer comment cet en-tête doit être utilisé.

II - Fonctionnement du moteur

Le moteur réseau que l’on créera dans le cadre de ce cours traitera des données pures, des tampons (buffers) bruts. C’est-à-dire que la phase de sérialisation devra être réalisée en amont par l’application, et c’est le tampon résultant de cette sérialisation qui sera passé au moteur pour être envoyé. Si vous suivez également les traductions de la série « Construire un protocole de jeu en réseau », vous verrez que dans cet article, Glenn prend une tout autre direction avec une structure de paquet de base dont doit hériter chaque paquet à transmettre, et c’est le moteur qui sérialise le paquet avant chaque envoi dans le tampon qui sera transmis.

Je favorise cette approche afin que la sérialisation, qui est un processus lent, ne soit exécutée qu’une seule fois. Ceci permet aussi un découpage net entre le moteur réseau et le reste de l’application, puisqu’aucune hiérarchie ni interface n’est imposée par le moteur réseau à l’application pour le format des structures de données. L’application est libre de définir et implémenter les structures de données à échanger comme bon lui semble.

Le tampon fourni par l’application sera appelé message sérialisé. La structure de données ayant servi à créer ce tampon sera le message. C’est ce tampon qui sera transféré à l’autre machine. À la réception, le moteur fournira les données sous forme de Message UserData tel qu’introduit dans ce chapitre TCP. Puis celles-ci devront être désérialisées par l’application afin d’obtenir les données d’origine.

Donc pour résumer :

- l’application traite les structures de données de son choix, puis fournit un tampon que l’on appellera message sérialisé au moteur réseau ;

- lors du passage au moteur réseau, le message sérialisé est transformé en paquet ;

- le paquet est ensuite emballé dans un datagramme et c’est ce dernier qui sera envoyé via sendto.

Emetteur

OS, routeurs, câbles…

récepteur

application

moteur réseau

sendto

…

recvfrom

moteur réseau

application

message

>

paquet

>

datagramme

>

…

>

datagramme

>

paquet

>

message

À la réception, c’est l’opération inverse qui est effectuée, comme l’indique le graphique ci-dessus :

- Le datagramme est reçu via recvfrom.

- Le paquet est extrait du datagramme.

- Le message sérialisé est mis à disposition de l’application.

Cette approche a l’inconvénient de gâcher quelques bits si le paquet n’a pas une taille alignée sur un octet, au bénéfice de consommer moins de ressources puisque la sérialisation n’a pas à être effectuée plusieurs fois en cas de renvoi.

Il s’agit donc du premier endroit où vous pourriez dévier de l’implémentation proposée dans le cadre de ce cours. Mais pas de panique, celui-ci reste valide et applicable quel que soit votre choix. Réfléchissez-donc bien à l’implémentation que vous souhaitez en pesant les avantages et inconvénients de chaque option.

Tout particulièrement, avec l’approche de Glenn, il est possible d’avoir une callback pour informer qu’un message n’a pas été remis, afin que l’application puisse réagir : modifier le message, le renvoyer tel quel, choisir de l’abandonner…

En pratique, bien que cette fonctionnalité soit presque toujours mentionnée lors d’un développement de moteur réseau, il s’avère qu’elle a un intérêt moindre :

- tout ceci étant généralement exécuté sur différents threads, avoir une telle callback obligerait quasi-obligatoirement à ajouter des points de synchronisation (mutex, lock) et aurait un impact sur les performances, mettant en pause la frame de jeu ou réseau, ce qui retarderait l’affichage ou l’envoi de données ;

- le traitement est rarement une opération pouvant être exécutée à tout moment mais est liée au gameplay ;

- il est finalement plus simple et robuste de prendre en compte qu’un message peut être perdu et de bâtir l’application en conséquence.

Il est toujours possible de passer d’une implémentation à l’autre, mais un tel changement se compliquera avec l’avancée du projet. En particulier parce que l’application doit s’adapter et être modifiée en conséquence.

III - Client local

Le client local sera l’objet qui servira de point d’entrée pour tous nos échanges utilisant UDP pour un port donné. Toutes les données à envoyer via ce port passeront par celui-ci et il réceptionnera tout ce qui arrive sur son port. Ceci sera donc l’interface principale entre l’application et un socket ouvert sur le port local souhaité.

III-A - Quelle peut être l’interface d’un tel objet ?

Nous ne nous intéresserons pour le moment qu’aux fonctions d’échanges de données :

- sendTo pour envoyer des données à une adresse ;

- receive pour réceptionner les données en attente.

Afin de garder une cohérence avec l’implémentation TCP et pour démarquer clairement la limite entre le code de la bibliothèque réseau et le code l’utilisant, le système de messages introduits dans cet article sera utilisé et une fonction poll servira à récupérer les messages reçus :

Client.hpp

1. class Client

2. {

3. public:

4. Client();

5. Client(const Client&) = delete;

6. Client(Client&&) = delete;

7. Client& operator=(const Client&) = delete;

8. Client& operator=(Client&&) = delete;

9. ~Client();

11. bool init(uint16_t port);

12. void release();

13. void sendTo(destinataire, std::vector<uint8_t>&& data);

14. void receive();

15. std::vector<std::unique_ptr<Messages::Base>> poll();

16. };

III-A-1 - Qu’est le destinataire ?

Le destinataire devra définir à qui les données sont envoyées. D’après la signature de sendto vue au premier chapitre, il devrait s’agir d’un const sockaddr*. Toutefois, on y préférera un sockaddr_storage afin d’avoir une structure utilisable en IPv4 et IPv6. Bien que le cours utilise uniquement IPv4 jusque-là, autant commencer à utiliser la structure la plus adaptée vu qu’il n’y a aucune différence ni difficulté réelle à avoir l’une ou l’autre ici.

III-A-2 - Squelette d’un programme type cible

L’objectif au terme de ce chapitre est de pouvoir manipuler un tel objet avec un code proche de

Main.cpp

Client client;
client.init(8888) ;
client.sendTo(destinataire, …);
client.receive();
auto messages = client.poll();
for (auto&& msg : messages)
{
// traitement du message reçu
}

En proposant une interface similaire, il sera relativement aisé de passer d’une implémentation utilisant TCP à UDP. Ceci est particulièrement intéressant si vous souhaitez développer le moteur en parallèle de votre projet, afin d’avoir une version rapidement fonctionnelle via TCP, pour que le reste de l’équipe n’ait pas à vous attendre, avant d’implémenter la version optimisée utilisant UDP. Cela représente également un intérêt si vous voulez passer de TCP à UDP selon les projets au sein d’un même moteur.

IV - Client distant

La structure de client distant sera la représentation locale d’un client distant.

Pour faire une analogie avec le cours TCP : le client local est le socket serveur, et le client distant est le socket client sur ce serveur.

IV-A - Utilité du client distant

Puisque l’utilisation d’UDP se fait via un unique socket utilisé pour envoyer à tous les destinataires, absolument rien ne nous lie à ces destinataires. Il faudra donc avoir une liste de ces clients, afin d’avoir un suivi des identifiants de datagrammes reçus et à envoyer pour chacun d’eux, pour pouvoir défausser les duplicatas.

IV-B - Interface du client distant

Sans surprise, l’interface est assez similaire à celle du client local, puisqu’il s’agit plus ou moins d’une simple redirection de traitement :

DistantClient.hpp

1. class DistantClient

2. {

3. public:

4. DistantClient();

5. DistantClient(const DistantClient&) = delete;

6. DistantClient(DistantClient&&) = delete;

7. DistantClient& operator=(const DistantClient&) = delete;

8. DistantClient& operator=(DistantClient&&) = delete;

9. ~DistantClient();

11. void send(std::vector<uint8_t>&& data);

12. void onDatagramReceived(Datagram&& datagram);

14. private:

15. Datagram::ID mNextDatagramIdToSend{ 0 }; // Identifiant du prochain datagramme à envoyer

16. };

IV-B-1 - Fonctionnement du client distant

L’idée est de faire passer toutes les données reçues et à envoyer à ce client via cette classe afin qu’elle gère le suivi des échanges. En particulier, elle sera responsable du suivi de l’identifiant à envoyer, mais aussi du suivi des acquittements afin d’interrompre les traitements quand un datagramme est reçu en doublon.

Et si les données sont valides, elle les transmettra au client local afin que l’application puisse les récupérer.

IV-B-2 - Comment y parvenir ?

Pour accomplir ces tâches, nous réutiliserons le gestionnaire d’acquittements créé lors du deuxième chapitre. Celui-ci possède une interface adaptée à notre besoin présent, et d’après les tests mis en place lors du chapitre précédent, il est tout à fait fonctionnel. Nous pouvons donc bâtir sur sa base à moindre risque.

IV-B-2-a - Envoi de données

L’envoi ne devrait pas poser de problème particulier. Il s’agira d’envoyer un datagramme en remplissant les champs de la structure introduite dans le second chapitre.

1. void DistantClient::send(std::vector<uint8_t>&& data)

2. {

3. Datagram datagram;

4. datagram.header.id = htons(mNextDatagramIdToSend);

5. ++mNextDatagramIdToSend;

6. memcpy(datagram.data.data(), data.data(), data.size());

8. sendto(mClient.mSocket, reinterpret_cast<const char*>(&datagram), static_cast<int>(Datagram::HeaderSize + data.size()), 0, reinterpret_cast<const sockaddr*>(&mAddress), sizeof(mAddress));

9. }

IV-B-3 - Traitement des données reçues

C’est ici que ça devient plus intéressant. Le traitement des données reçues couvre plusieurs points :

- vérifier que le datagramme n’est pas un doublon ;

- vérifier que le datagramme n’est pas trop vieux et désordonné le rendant obsolète ;

- vérifier la mise à jour des acquittements reçus ;

- vérifier la mise à disposition des données à l’application.

Pour couvrir les deux premiers points, nous utiliserons à nouveau un gestionnaire d’acquittements. Celui-ci nous fournira un suivi glissant des datagrammes reçus, permettant de détecter les doublons et les datagrammes trop anciens afin de les rejeter.

Ce gestionnaire servira également au troisième point.

Avec un découpage net permettant de traiter les données puis le message, le code devrait être plus facile à (re)lire :

DistantClient.hpp

1. class DistantClient

2. {

3. …

4. private:

5. void onDataReceived(std::vector<uint8_t>&& data);

6. void onMessageReady(std::unique_ptr<Messages::Base>&& msg);

8. private:

9. AckHandler mReceivedAcks;    //!< Pour détecter les duplications

10. };

Nous pouvons maintenant implémenter la réception en soi :

1. void DistantClient::onDatagramReceived(Datagram&& datagram)

2. {

3. const auto datagramid = ntohs(datagram.header.id);

4. //!< Mise à jour des datagrammes reçus

5. mReceivedAcks.update(datagramid, datagram.header.previousAcks, true);

6. //!< Ignorer les duplications

7. if (!mReceivedAcks.isNewlyAcked(datagramid))

8. return;

9. //!< Gérer les données reçues

10. onDataReceived(std::vector<uint8_t>(datagram.data.data(), datagram.data.data() + datagram.datasize));

11. }

Ce qui mène à l’implémentation de onDataReceived :

1. void DistantClient::onDataReceived(std::vector<uint8_t>&& data)

2. {

3. onMessageReady(std::make_unique<Messages::UserData>(std::move(data)));

4. }

Puis onMessageReady implémentée comme ceci :

1. void DistantClient::onMessageReady(std::unique_ptr<Messages::Base>&& msg)

2. {

3. memcpy(&(msg->from), &mAddress, sizeof(mAddress));

4. mClient.onMessageReceived(std::move(msg));

5. }

Ces fonctions permettent de factoriser les traitements des données et des messages, comme la mise à jour des données communes de tout type de message, à commencer par l’adresse de l’émetteur.

Il s’agit aussi de l’emplacement idéal pour générer quelques statistiques à diverses étapes sur notre connexion avec cette machine. Un chapitre ultérieur traitera ceci plus précisément.

Finalement, il est temps d’implémenter cette nouvelle fonction Client::onMessageReady :

1. void Client::onMessageReady(std::unique_ptr<Messages::Base>&& msg)

2. {

3. mMessages.push_back(std::move(msg));

4. }

L’idée est là aussi de factoriser les traitements des messages reçus, au niveau du client local cette fois. Par exemple, toujours pour parler de statistiques, du nombre de messages reçus sur ce port en particulier, de l’ensemble des clients connus.

V - Implémentation du Client

Avant toute chose, il convient d’étoffer cette classe de variables membres comme :

- le socket à utiliser ;

- la liste des destinataires connus pour faire le suivi des données reçues et envoyées (voir plus haut : le Client distant) ;

- les messages reçus prêts à être extraits par l’application.

V-A - Variables membres

Ajoutons donc toutes les variables citées plus haut et déclarons DistantClient amie afin qu’elle ait une relation privilégiée sans avoir à afficher le nécessaire totalement public.

Client.hpp

1. class Client

2. {

3. friend class DistantClient;

4. …

5. private:

6. SOCKET mSocket{ INVALID_SOCKET };

7. std::vector< std::unique_ptr<DistantClient>> mClients;

8. std::vector<std::unique_ptr<Messages::Base>> mMessages;

9. };

L’amitié est une relation extrêmement forte qu’il convient de maîtriser et d’utiliser avec parcimonie.

Ici DistantClient est déclarée amie afin d’avoir un accès privilégié à Client, sans avoir à offrir ces accès dans la portée publique qui les rendraient alors utilisables depuis n’importe qui et l’application.

Cette amitié est maîtrisée et aura notamment pour restriction de ne jamais accéder à des variables membres. Il s'agira d'utiliser uniquement les fonctions privées dédiées à cette utilisation.

Malheureusement, rien ne permet cela dans le langage, il s’agira donc d’une restriction implicite et d’une règle autoimposée à suivre.

V-B - Initialisation

Cette fonction initialisera un socket IPv4 sur le port choisi et le passera en mode non-bloquant, puisque c’est ce dernier qui sera utilisé pour l’échange de données. Il suffira de copier la création de socket du premier chapitre et l’initialisation du mode non bloquant du cinquième chapitre sur TCP.

1. bool Client::init(uint16_t port)

2. {

3. release();

4. mSocket = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);

5. if (mSocket == INVALID_SOCKET)

6. return false;

8. sockaddr_in addr;

9. addr.sin_addr.s_addr = INADDR_ANY;

10. addr.sin_port = htons(port);

11. addr.sin_family = AF_INET;

12. int res = bind(mSocket, reinterpret_cast<sockaddr*>(&addr), sizeof(addr));

13. if (res != 0)

14. return false;

16. if (!SetNonBlocking(mSocket))

17. return false;

19. return true;

20. }

V-C - Envoi de données

sendTo devra transmettre au client distant les données afin d’y appliquer le traitement adéquat et les transformer en un datagramme valide pour notre protocole (ajout de l’en-tête).

Il faudra donc récupérer le client distant correspondant, ou en créer un s’il n’existe pas, afin de lui transmettre les données pour qu’il effectue l’envoi sur le réseau.

1. DistantClient& Client::getClient(const sockaddr_storage& clientAddr)

2. {

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

4. if (itClient != mClients.end())

5. return *(itClient->get());

7. mClients.emplace_back(std::make_unique<DistantClient>(*this, clientAddr));

8. return *(mClients.back());

9. }

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

11. {

12. auto& client = getClient(target);

13. client.send(std::move(data));

14. }

memcmp retourne un entier qui vaut 0 si les deux tampons sont égaux. Pour vérifier que les adresses sont identiques il faut donc vérifier memcmp(…) == 0.

V-D - Réception de données

receive se chargera de recevoir tous les datagrammes en attente et de les transférer aux clients correspondants afin que ceux-ci fassent le suivi des identifiants reçus avant de mettre le nouveau message correspondant en file de réception pour être récupéré par l’application via poll, s’il est accepté.

1. void Client::receive()

2. {

3. for (;;)

4. {

5. Datagram datagram;

6. sockaddr_in from{ 0 };

7. socklen_t fromlen = sizeof(from);

8. int ret = recvfrom(mSocket, reinterpret_cast<char*>(&datagram), Datagram::BufferMaxSize, 0, reinterpret_cast<sockaddr*>(&from), &fromlen);

9. if (ret > 0)

10. {

11. if (ret > Datagram::HeaderSize)

12. {

13. datagram.datasize = ret - Datagram::HeaderSize;

14. auto& client = getClient(reinterpret_cast<sockaddr_storage&>(from));

15. client.onDatagramReceived(std::move(datagram));

16. }

17. else

18. {

19. //!< Datagramme innatendu

20. }

21. }

22. else

23. {

24. if (ret < 0)

25. {

26. //!< Gestion des erreurs

27. const auto err = Errors::Get();

28. if (err != Errors::WOULDBLOCK)

29. {

30. //!< Une erreur s’est produite

31. }

32. }

33. return;

34. }

35. }

36. }

Notez au passage l’ajout du membre size_t datasize{ 0 }; dans Datagram afin de contenir la taille effective des données du datagramme. Cette variable sera utilisée localement uniquement et jamais transférée.

VI - En-tête du datagramme

Voyons maintenant comment utiliser cet en-tête afin d’établir notre protocole.

VI-A - Lors de la réception d’un datagramme

Lors de la réception du datagramme, nous pouvons extraire les datagrammes acquittés par l’autre machine depuis l’en-tête.

Une fois le gestionnaire d’acquittements mis à jour avec cette donnée, il devient possible de savoir quels datagrammes ont été récemment reçus ou si certains sont définitivement perdus. Cette information est la fondation du protocole.

1. void DistantClient::onDatagramReceived(Datagram&& datagram)

2. {

3. …

4. //!< Mise à jour du suivi des datagrammes envoyés et acquittés par l’autre partie

5. mSentAcks.update(ntohs(datagram.header.ack), datagram.header.previousAcks, true);

6. …

7. //!< Traiter les pertes à la réception

8. const auto lostDatagrams = mReceivedAcks.loss();

9. for (const auto receivedLostDatagram : lostDatagrams)

10. {

11. onDatagramReceivedLost(receivedLostDatagram);

12. }

13. //!< Gérer les données envoyées et non reçues

14. const auto datagramsSentLost = mSentAcks.loss();

15. for (const auto sendLoss : datagramsSentLost)

16. {

17. onDatagramSentLost(sendLoss);

18. }

19. //!< Traiter les données envoyées et acquittées

20. const auto datagramsSentAcked = mSentAcks.getNewAcks();

21. for (const auto sendAcked : datagramsSentAcked)

22. {

23. onDatagramSentAcked(sendAcked);

24. }

25. …

26. }

Les implémentations de onDatagramReceivedLost, onDatagramSentLost et onDatagramSentAcked seront laissées vides pour le moment.

VI-B - Lors de l’envoi d’un datagramme

L’envoi sera bien plus simple : il suffit de remplir l’en-tête du datagramme avec les données locales pour informer la machine distante de ce que nous avons reçu.

1. void DistantClient::send(std::vector<uint8_t>&& data)

2. {

3. …

4. datagram.header.ack = htons(mReceivedAcks.lastAck());

5. datagram.header.previousAcks = mReceivedAcks.previousAcksMask();

6. …

7. }

N’oubliez pas de convertir l’acquittement le plus récent en endianness réseau. Le masque ne doit quant à lui pas être converti.

VII - Tests

Comme pour le gestionnaire d’acquittements et chaque élément que nous rajouterons par la suite, le client distant n’a de valeur et ne peut être décrété fonctionnel qu’après avoir passé avec succès quelques tests.

Écrivons donc ces tests.

Le test du client distant est similaire à celui du gestionnaire d’acquittements : un datagramme identique ou trop ancien doit être rejeté. Il s’agira donc d’écrire quelques scénarios afin de vérifier tout ça.

DistatnClient_Test.hpp

1. #pragma once

3. #include "Tester.hpp"

4. #include "UDP/DistantClient.hpp"

5. #include "UDP/UDPClient.hpp"

6. #include "Messages.hpp"

8. class DistantClient_Test

9. {

10. public:

11. static void Test();

12. };

14. void DistantClient_Test::Test()

15. {

16. static constexpr uint64_t MASK_COMPLETE = std::numeric_limits<uint64_t>::max();

17. static constexpr uint64_t MASK_FIRST_ACKED = Bousk::Utils::Bit<uint64_t>::Right;

18. static constexpr uint64_t MASK_FIRST_AND_SECOND_ACKED = (MASK_FIRST_ACKED << 1) | Bousk::Utils::Bit<uint64_t>::Right;

19. static constexpr uint64_t MASK_FIRST_MISSING = ~MASK_FIRST_ACKED;

20. static constexpr uint64_t MASK_LAST_ACKED = (MASK_FIRST_ACKED << 63);

22. Bousk::Network::UDP::Client client; //!< Un client est nécessaire pour créer un DistantClient

23. sockaddr_in localAddress;

24. localAddress.sin_addr.s_addr = htonl(INADDR_LOOPBACK); //!< Disons que notre client distant a une adresse de loopback (n’importe quelle adresse ferait l’affaire ici)

25. localAddress.sin_family = AF_INET;

26. localAddress.sin_port = htons(8888);

27. Bousk::Network::UDP::DistantClient distantClient(client, reinterpret_cast<const sockaddr_storage&>(localAddress));

29. CHECK(distantClient.mNextDatagramIdToSend == 0);

30. CHECK(distantClient.mReceivedAcks.lastAck() == std::numeric_limits<uint16_t>::max());

32. constexpr char* TestString = "Test data";

33. constexpr size_t TestStringLength = sizeof(TestString);

34. distantClient.send(std::vector<uint8_t>(TestString, TestString + TestStringLength));

35. CHECK(distantClient.mNextDatagramIdToSend == 1);

37. //!< Créons un datagramme pour vérifier sa réception

38. Bousk::Network::UDP::Datagram datagram;

39. datagram.header.id = 0;

40. memcpy(datagram.data.data(), TestString, TestStringLength);

41. datagram.datasize = TestStringLength;

42. //!< Si un datagramme est accepté, il créera un Message UserData chez le Client

43. {

44. distantClient.onDatagramReceived(std::move(datagram));

45. CHECK(distantClient.mReceivedAcks.lastAck() == 0);

46. CHECK(distantClient.mReceivedAcks.previousAcksMask() == MASK_COMPLETE);

48. auto polledMessages = client.poll();

49. CHECK(polledMessages.size() == 1);

50. const auto& msg = polledMessages[0];

51. CHECK(msg->is<Bousk::Network::Messages::UserData>());

52. const auto dataMsg = msg->as<Bousk::Network::Messages::UserData>();

53. CHECK(dataMsg->data.size() == TestStringLength);

54. CHECK(memcmp(TestString, dataMsg->data.data(), TestStringLength) == 0);

55. }

57. //!< Le datagramme #0 est reçu en double : il doit être ignoré

58. datagram.header.id = 0;

59. {

60. distantClient.onDatagramReceived(std::move(datagram));

61. CHECK(distantClient.mReceivedAcks.lastAck() == 0);

62. CHECK(distantClient.mReceivedAcks.previousAcksMask() == MASK_COMPLETE);

64. auto polledMessages = client.poll();

65. CHECK(polledMessages.size() == 0);

66. }

68. //!< Envoi du datagramme #2, le #1 est maintenant manquant

69. datagram.header.id = htons(2);

70. {

71. distantClient.onDatagramReceived(std::move(datagram));

72. CHECK(distantClient.mReceivedAcks.lastAck() == 2);

73. CHECK(distantClient.mReceivedAcks.previousAcksMask() == MASK_FIRST_MISSING);

75. auto polledMessages = client.poll();

76. CHECK(polledMessages.size() == 1);

77. const auto& msg = polledMessages[0];

78. CHECK(msg->is<Bousk::Network::Messages::UserData>());

79. const auto dataMsg = msg->as<Bousk::Network::Messages::UserData>();

80. CHECK(dataMsg->data.size() == TestStringLength);

81. CHECK(memcmp(TestString, dataMsg->data.data(), TestStringLength) == 0);

82. }

84. //!< Réception du datagramme #1 désordonné

85. datagram.header.id = htons(1);

86. {

87. distantClient.onDatagramReceived(std::move(datagram));

88. CHECK(distantClient.mReceivedAcks.lastAck() == 2);

89. CHECK(distantClient.mReceivedAcks.isNewlyAcked(1));

90. CHECK(!distantClient.mReceivedAcks.isNewlyAcked(2));

91. CHECK(distantClient.mReceivedAcks.previousAcksMask() == MASK_COMPLETE);

93. auto polledMessages = client.poll();

94. CHECK(polledMessages.size() == 1);

95. const auto& msg = polledMessages[0];

96. CHECK(msg->is<Bousk::Network::Messages::UserData>());

97. const auto dataMsg = msg->as<Bousk::Network::Messages::UserData>();

98. CHECK(dataMsg->data.size() == TestStringLength);

99. CHECK(memcmp(TestString, dataMsg->data.data(), TestStringLength) == 0);

100. }

102. //!< Saut de 64 datagrammes, tous les intermédiaires sont manquants

103. datagram.header.id = htons(66);

104. {

105. distantClient.onDatagramReceived(std::move(datagram));

106. CHECK(distantClient.mReceivedAcks.lastAck() == 66);

107. CHECK(distantClient.mReceivedAcks.isNewlyAcked(66));

108. CHECK(distantClient.mReceivedAcks.previousAcksMask() == MASK_LAST_ACKED);

109. CHECK(distantClient.mReceivedAcks.loss().empty());

111. auto polledMessages = client.poll();

112. CHECK(polledMessages.size() == 1);

113. const auto& msg = polledMessages[0];

114. CHECK(msg->is<Bousk::Network::Messages::UserData>());

115. const auto dataMsg = msg->as<Bousk::Network::Messages::UserData>();

116. CHECK(dataMsg->data.size() == TestStringLength);

117. CHECK(memcmp(TestString, dataMsg->data.data(), TestStringLength) == 0);

118. }

120. //!< Réception du datagramme suivant

121. datagram.header.id = htons(67);

122. {

123. distantClient.onDatagramReceived(std::move(datagram));

124. CHECK(distantClient.mReceivedAcks.lastAck() == 67);

125. CHECK(distantClient.mReceivedAcks.isNewlyAcked(67));

126. CHECK(distantClient.mReceivedAcks.previousAcksMask() == MASK_FIRST_ACKED);

127. CHECK(distantClient.mReceivedAcks.loss().empty());

129. auto polledMessages = client.poll();

130. CHECK(polledMessages.size() == 1);

131. const auto& msg = polledMessages[0];

132. CHECK(msg->is<Bousk::Network::Messages::UserData>());

133. const auto dataMsg = msg->as<Bousk::Network::Messages::UserData>();

134. CHECK(dataMsg->data.size() == TestStringLength);

135. CHECK(memcmp(TestString, dataMsg->data.data(), TestStringLength) == 0);

136. }

138. //!< Réception du suivant, le datagramme #3 est maintenant perdu

139. datagram.header.id = htons(68);

140. {

141. distantClient.onDatagramReceived(std::move(datagram));

142. CHECK(distantClient.mReceivedAcks.lastAck() == 68);

143. CHECK(distantClient.mReceivedAcks.isNewlyAcked(68));

144. CHECK(distantClient.mReceivedAcks.previousAcksMask() == MASK_FIRST_AND_SECOND_ACKED);

146. auto polledMessages = client.poll();

147. CHECK(polledMessages.size() == 1);

148. const auto& msg = polledMessages[0];

149. CHECK(msg->is<Bousk::Network::Messages::UserData>());

150. const auto dataMsg = msg->as<Bousk::Network::Messages::UserData>();

151. CHECK(dataMsg->data.size() == TestStringLength);

152. CHECK(memcmp(TestString, dataMsg->data.data(), TestStringLength) == 0);

153. }

155. //!< Réception du datagramme #3 : trop ancien, ignoré

156. datagram.header.id = htons(3);

157. {

158. distantClient.onDatagramReceived(std::move(datagram));

159. CHECK(distantClient.mReceivedAcks.lastAck() == 68);

160. CHECK(!distantClient.mReceivedAcks.isNewlyAcked(68));

161. CHECK(distantClient.mReceivedAcks.previousAcksMask() == MASK_FIRST_AND_SECOND_ACKED);

163. auto polledMessages = client.poll();

164. CHECK(polledMessages.size() == 0);

165. }

166. }

Encore une fois, ajoutez autant de tests que vous le souhaitez jusqu’à être convaincu que le client distant est fonctionnel.

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

Article précédent

<< UDP - S'assurer du bon fonctionnement de son code

UDP – Découpage et unification de paquets & création d’un protocole ordonné non fiable >>