Ce qui suit se veut un petit exemple de solution à un problème proposé informellement en classe, soit celui d'écrire un programme (inefficace, mais c'est pour se faire la main) qui consommera des données d'un fichier source à l'aide d'un fil d'exécution, et rendra progressivement ces données disponibles à d'autres à travers un socket de type datagramme à l'autre extrémité duquel un autre fil d'exécution les en consommera pour les écrire dans un autre fichier.
Conceptuellement, le code proposé se sépare en deux processus :
J'ai mis le tout dans un même processus pour simplifier la présentation, mais je vous invite bien sûr à finir le travail; cela impliquera un peu de copier/ coller, pas beaucoup plus de travail que ça.
J'ai proposé que vous acomplissiez cette tâche avec un fichier binaire quelconque, et le code ci-dessous fonctionnerait, avec quelques modifications, avec un .jpeg ou un .mp3, mais j'ai écrit l'exemple de manière à vous le rendre disponible rapidement alors ma version lit... le code source du programme (fichier Principal.cpp) pour l'écrire dans un autre fichier (sortie.txt), pour enfin en afficher le contenu à la console. Ceci permet de s'assurer qu'il ne manquera aucun caractère en fin de parcours (un réel piège ici).
Puisque les sockets ne sont pas représentés par des types standards de C++ mais bien par des bibliothèques locales aux plateformes, j'ai isolé le code des sockets derrière une interface simple inspirée de l'idiome pImpl. Conséquemment, le code ci-dessous se subdivise en quatre fichiers :
Si vous séparez le tout en deux processus, seul Principal.cpp sera affecté.
Le fichier messages.h, comme indiqué plus haut, décrit le format d'un message dans ce programme. Par souci de similitude avec la version TCP d'un programme analogue, nous transférerons le contenu d'un fichier texte à travers un canal déterminé par une paire de sockets. Toutefois, la métaphore UDP demande de passer par des enregistrements, pas par un flux de données, alors nous utiliserons des petits blocs représentant des lignes d'un fichier texte. Puisque le texte devra être encodé à même les messages, nous utiliserons un bloc de capacité fixe (un tableau), ce qui limitera la taille des lignes à cette capacité. |
|
Une ligne, donc, sera constituée d'un bloc de char, le tableau data, et d'une taille exprimée en nombre de caractères, soit l'attribut size. La capacité d'une ligne est déterminée par la constante CAPACITE_MAX, mais puisqu'il s'agit d'une constante de classe, cette information n'occupe pas d'espace dans une ligne donnée. J'y ai implémenté un constructeur par défaut, représentant une ligne vide, et un constructeur paramétrique sur la base d'une std::string, qui valide l'absence de débordement de capacité et initialise une ligne avec le contenu de la std::string. Le constructeur par défaut permettra au code client de recevoir une ligne (les fonctions de réception destinées à un socket UDP doivent écrire dans une variable existante). J'ai traité un débordement de capacité comme une erreur grave (volation d'une assertion dynamique), mais si vous estimez qu'il s'agit d'une erreur dont il est raisonnable de récupérer, vous pouvez bien sûr utiliser une levée d'exception ici. |
|
J'ai déterminé trois sortes de messages, identifiés par un code de type sorte_message. C'est une technique classique, qu'on applique souvent avec des union étiquetés. |
|
Un message en soi sera ici un enregistrement comprenant :
Pour faciliter les tests, un opérateur de projection sur un flux pour un message a été écrit. Son implémentation, dans messages.cpp, va comme suit :
|
|
J'ai implémenté une couche de type pImpl sur les sockets de type datagramme pour que leur interface (le fichier socket_udp.h) soit pleinement portable, et pour éviter de trop faire fuir les détails de la plateforme sous-jacente dans le code client. Notez que le code proposé ici est du code écrit rapidement. Le code des sockets de type datagramme proposé ici est logé dans l'espace nommé socket_udp. |
|
J'ai défini une classe incopiable nommée chargeur dont le constructeur chargera les sockets en mémoire et dont le destructeur assurera la finalisation correspondante. En pratique, le programme principal définira localement une instance de chargeur avant de commencer à manipuler des sockets. Le chargement explicite des sockets est un artéfact de Microsoft Windows; sous Linux, le constructeur et le destruceur de cette classe seraient vides. |
|
L'implémentation à proprement dit d'un socket est abstraite sous une déclarations a priori. La classe pImpl que j'ai utilisé joue deux rôles :
Notez que j'ai implémenté la sémantique de mouvement, ce qui me permettra de manipuler mes objets un peu comme on manipulerait des classes concrètes ou des types primitifs, mais que mes classes sont incopiables en vertu de leur attribut qui est lui-même incopiable. Notez aussi que les attributs de cette classe sont publics, mais que cela ne m'importe pas beaucoup ici puisque le type du pointé est inaccessible au code client, qui ne pourra à peu près rien faire avec lui. |
|
Pour faciliter la gestion des erreurs, j'ai défini une classe par type d'exception possible. Ne confondez pas ceci avec « une exception par code d'erreur possible », ce qui serait faux ici (il y a des tas de codes d'erreurs possibles pour chaque opération sur un socket). Je me suis intéressé à deux choses, pratiquement :
Normalement, je n'aurais pas utilisé std::exception ou des messages d'erreur pour chaque type, mais dans le code de test que j'ai écrit, j'ai limité le traitement des exceptions à un affichage suivi de la conclusion du fil d'exécution ayant détecté le problème. Cette stratégie plutôt naïve ne se veut pas de qualité industrielle, et se limite à rapporter les cas problèmes plutôt que de les gérer. |
|
Enfin, j'ai exprimé les opérations exposées au code client dans des fonctions opérant sur mes classes pImpl, et faisant essentiellement abstraction des détails d'implémentation. À noter tout de même :
C'est une interface naïve mais opaque et utilisable. |
|
Sans surprises, l'implémentation des types et des fonctions déclarées dans socket_udp.h repose en partie sur du code propre à la plateforme, donc éminemment non-portable. Le code proposé ici vaut pour Microsoft Windows, et peut bien entendu être adapté au besoin pour les autres plateformes. Considérez cela comme un exercice. |
|
J'ai implémenté une classe parent nommée erreur pour mes cas d'exceptions, ceci dans l'optique d'alléger mon propre travail à peu de frais. Chaque erreur dérive de std::exception et lui relaie un bref message expliquant la nature de l'opération ayant échoué. Un code d'erreur logé à même erreur, implémenté sous forme de constante d'instance (initialisée à la construction) est exposé de manière publique et permet de comprendre plus en détail la nature du problème s'étant manifesté. |
|
La classe chargeur réalise un chargement et un déchargement RAII du module de sockets de Microsoft Windows. Sous Linux, on laissera ces deux fonctions vides, tout simplement. |
|
La classe impl isole le détail d'implémentation qu'est un socket pour la plateforme. J'y ai essentiellement implémenté les éléments constitutifs de la règle de cinq que j'estimais pertinents, la rendant entre autres incopiable, de même qu'une méthode pour transférer explicitrement un socket vers un tiers. |
|
La classe pImpl servant à encapsuler un impl se limite à implémenter le mouvement, ce qui lui suffit. |
|
La fonction creer_socket() est pour usage interne seulement, n'étant pas exposée dans socket_udp.h. |
|
La fonction creer() retourne un socket capable d'envoyer et de recevoir des trames UDP. Elle encapsule les détails techniques reliés à une connexion bloquante par socket de type datagramme lié à un port spéficiés en paramètre. |
|
La fonction envoyer_vers() émet sur un socket UDP une trame d'une certaine taille vers un destinataire identifié en paramètre, et retourne le nombre de bytes réellement envoyés. |
|
La fonction recevoir_de() reçoit sur un socket UDP une trame provenant d'un destinataire identifié par les paramètre, et retourne de nombre de bytes réellement reçus |
|
De manière amusante, la fonction fermer(), sans l'annoncer ouvertement, ne fait rien : c'est un exemple de ce qu'on nomme une fonction Sink, donc une fonction à qui l'on confie un paramètre qui ne ressortira plus. Ici, le type accepté ne peut être passé que par mouvement, et est un type RAII, ce qui fait qu'on n'a même pas à nommer les paramètres : le simple fait de les recevoir localement mènera à leur éventuelle destruction, et à leur finalisation en propre. |
|
Enfin, les fonctions de normalisation et de dénormalisation permettent principalement d'isoler le code client des détails d'implémentation de ces macros, et des en-têtes qui les définissent. Ce sont des fonctions pour le moins triviales. |
|
Remarquez tout d'abord que le code proposé ici est totalement portable à toute plateforme, dans la mesure où un compilateur C++ 11 y est disponible (et dans la mesure où socket_udp.h est portable, bien sûr). |
|
Le programme principal se séparera en deux fils d'exécution :
Aucun signal de fin ne sera requis; l'erreur résultant de la fermeture d'un socket, joint à l'émission d'un message indiquant explicitement la fin de la transmission, suffiront à constater la fin des opérations. |
|
Le fil d'exécution emission consommera des données du disque et les enverra, un message à la fois, vers le récepteur. Un message de fin conclura l'envoi. |
|
Le fil d'exécution reception consommera une trame à la fois et en inscrira le contenu dans un fichier de destination. La réception d'un message représentant la fin des envois conclura les opérations.. |
|
Avant de fermer le programme, nous attendons la complétion de l'exécution des deux fils d'exécution, puis (pour fins de débogage) nous affichons le contenu du fichier de destination à la console. Si vous avez copié un fichier contenant des données autres que du texte, présumant que l'extension du fichier de destination corresponde à son contenu, il suffit (du moins avec Microsoft Windows) de remplacer l'appel à copy() par un appel à system() en lui passant le nom du fichier en question, ce qui démarrera le programme associé à ce type de fichier. |
|
Évidemment, ce code ne fonctionne que si les paquets arrivent au destinataire sans pertes et dans l'ordre selon lequel ils ont été émis. Ce scénario « idéal » n'est pas réaliste en général.
Examinons maintenant une version plus complète (et plus réaliste, bien que très simple) d'un programme réalisant la même tâche que celui décrit ci-dessus.
Nous conserverons la même couche primitive de sockets UDP (voir socket_udp.h), mais raffinerons à la fois la modélisation des messages et le programme de test.
Tout d'abord, notons que du fait que la représentation d'un message sera plus riche et plus complexe que dans la version précédente, j'ai fait le choix de loger les outils qui y participent dans un espace nommé msg. |
|
Dans cette nouvelle déclinaison, les sortes de messages sont un peu plus nombreuses et incluent l'idée d'un message inconnu (par exemple, un message qui ne serait pas encore convenablement initialisé), un début de transmission, une fin de transmission, du texte (car je transférerai du texte), un accusé de réception, et un signal qu'une trame aurait été manquée. Pour formaliser la numérotation du séquencement des messages, j'ai défini un type spécifique (sequence_type) ce qui permet au code client d'écrire facilement du code en concordance avec la nature de cette API. |
|
Pour clarifier la nature des messages en tant que tels, j'ai défini un type distinct pour chaque sorte de message. Les deux plus simples, nommés ici message_debut et message_fin, sont des classes vides qui modélisent des concepts purs. Il serait possible de leur attribuer des membres (les autres types de messages ci-dessous en contiennent d'ailleurs), dans le respect de certaines règles. |
|
La principale règle en ce sens est que les constructeurs de nos types de messages devront être triviaux, ce qui implique que les constructeurs de leurs attributs le soient aussi. La raison pour cette règle est que les messages seront modélisés par des union étiquetés, comme cela s'avère souvent avec des trames transmises par UDP, et que les membres d'un union doivent être trivialement constructibles. Le type de message le plus complexe que nous utiliserons sera le message_texte, qui offre entre autres une interface permettant un parcours du texte qu'il inclut à l'aide d'itérateurs. Remarquez la fonction de fabrication creer_texte(); n'ayant pas l'option d'utiliser des constructeurs non-triviaux, les fabriques sont une alternative intéressante pour définir des opérations d'initialisation qui ne sont pas banales. |
|
De manière plus humble, mais suivant le même modèle, les types de messages représentant un accusé de réception (message_ack) ou un signal de trame manquée (message_miss) contiennent le numéro de séquence de la trame reçue ou manquante, et les fonctions de fabrication make_ack() et make_miss() facilitent leur construction. |
|
Le type message en tant que tel est un union étiqueté. Son attribut seq_id indique son numéro de séquence, chose importante du fait que le protocole UDP ne garantit pas une livraison des trames dans l'ordre selon lequel elles ont été envoyées. La paire faite de sorte_ et de l'union (anonyme) des divers types de messages constitue l'union étiqueté en tant que tel. Les divers constructeurs permettent de bien initialiser un message en fonction de paramètres de types choisis. |
|
Enfin, pour faciliter le débogage et les tests, une fonction pour afficher le contenu d'un message_texte est offerte. |
|
Le fichier messages.cpp est relativement simple, se limitant à quelques fonctions de fabrication et un outil pour afficher du texte sur un flux. |
|
Cette dernière est des plus simples, considérant la présence d'iune interface simple d'itérateurs dans le type message_texte. |
|
Les fonctions de fabrication pallient l'absence de constructeurs dans les types fabriqués; souvenons-nous que le recours à des constructeurs triviaux dans ces types tient à leur utilisation dans un union, où les règles sont contraignantes. |
|
Le code de test est « plus complet », sans être « vraiment complet ». Pour être vraiment complet, il devrait tenir compte de beaucoup plus de formes d'attaque et de situations anormales qu'il ne le fait; en retour, en examinant quelques cas d'erreurs communs, il peut servir de guide. |
|
Pour la communication, le modèle mis en application suppose que les messages doivent utiliser une numérotation croissante monotone () et que le destinataire s'attend à une telle numérotation. Le destinataire doit émettre un accusé de réception pour chaque message reçu (mais ne pas recevoir un accusé de réception n'est pas considéré comme une erreur; après tout, ces trames peuvent se perdre en chemin). Si une trame émise ne se rend pas à destination, ou si une trame semble escamotée (parce qu'une trame ultérieure semble arriver avant une trame antérieure), le destinataire fait signe à l'émetteur qui reprendra l'envoi des trames à partir de celle qui semble avoir fait défaut. J'ai suivi le modèle suivant. Il est imparfait mais illustratif :
Le fil d'exécution emetteur enverra les messages. Le fil d'exécution nettoyeur_emisson consommera les accusés de réception et les signalements de trames manquantes. La communication entre eux implique de la synchronisation dû au partage des files envoyes et a_envoyer, d'où le type canal_sortant à droite. Notez la signature de la plupart des services offerts ici, qui permet de valider le succès ou l'échec de l'entreprise modélisée par le service en tant que tel. Il existe plusieurs moyens de modéliser cette paire faite d'un (possible) résultat de calcul et d'un code de succès ou d'échec, mais l'important est de combiner les deux, pour éviter les TOCTTOU. |
|
Le programme de test se décline en quelques fils d'exécution, mais en pratique il serait plus réaliste d'avoir plusieurs programmes qui communiquent entre eux. Dans cette version, j'ai ajouté un booléen atomique du fait que deux fils d'exécution se partagent la responsabilité du volet émetteur, et que je souhaite que les deux se terminent gracieusement. |
|
L'un des deux fils d'exécution du volet « émission » est nettoyeur_emission. Son rôle est de recevoir les trames ack (accusé de réception) et miss (trame manquante) du destinataire, et de réorganiser le canal_sortant nommé canal_emetteur en fonction de ces messages reçus. Évidemment, une synchronisation est requise, et est implémentée dans canal_sortant. Je n'ai implémenté ici que quelques cas d'erreurs possibles; la communication par UDP est remplie de ... divertissements. Ceci se veut illustratif et éducatif, pas complet. |
|
L'autre fil d'exécution du volet « émission » est emission. Celui-ci a pour rôle d'envoyer les messages destinés à son homologue, en tenant compte des réponses obtenues de ce dernier. C'est par le canal_sortant nommé canal_emetteur que cette information circulera, étant en partie consommée par le fil d'exécution nettoyeur_emission. La logique de base est simple : s'il reste au moins un vieux message à envoyer, envoyons-le, sinon consommons le prochain message et ajoutons-le à ce qui doit être envoyé.
|
|
Enfin, le fil d'exécution consommateur se nomme reception. Son rôle est simple :
|
|
Ne reste plus enfin qu'à fermer les livres |
|
Voilà.