Tutoriel Boost.Asio
Date de publication : 19 mars 2009
Par
Gwenaël Dunand (Page personnelle)
Cet article introduit la programmation réseau en C++ à l'aide de Boost.Asio. Après un rapide tour d'horizon de l'architecture
globale de Boost.Asio et des possibilités offertes par cette bibliothèque (opérations synchrones et asynchrones),
cet article présentera les Timers, la communication TCP et UDP. Des exemples concrets de clients et serveurs seront étudiés.
Enfin, un projet réseau réaliste avec un code robuste sera présenté en dernière partie.
I. Introduction
I-A. Réseau et langage de programmation
I-B. Pré-requis
I-C. Installation de Boost.Asio
I-D. Code et commentaires dans ce tutoriel
II. Les bases de Boost.Asio
II-A. Opérations synchrones
II-B. Opérations asynchrones
II-C. Synchrone / Asynchrone ?
II-D. Architecture de Boost.Asio
III. Les Timers
III-A. Timers synchrones
III-B. Timers asynchrones
IV. Le protocole TCP
IV-A. Introduction
IV-B. Lecture / écriture courte et transfert complet
IV-C. Exemple d'un client synchrone
IV-D. Exemple d'un serveur synchrone
IV-E. Exemple d'un serveur asynchrone
IV-E-1. Premier essai
IV-E-2. Avec des pointeurs intelligents
IV-E-3. Intégration d'un timer
IV-F. Exemple d'un client asynchrone
V. Le protocole UDP
V-A. Exemple d'un client synchrone
V-B. Exemple d'un serveur synchrone
VI. Les sockets iostreams
VI-A. Exemple de client synchrone
VI-B. Exemple de serveur synchrone
VII. Projet démo : réalisation d'un 'chat' avec Boost.Asio
VII-A. Cahier des charges
VII-B. Conception
VII-B-1. Abstractions
VII-B-2. Architecture
VII-C. Implémentation du serveur
VII-C-1. Classe chat_message
VII-C-2. Classe tcp_connection
VII-C-3. Classe chat_server
VII-C-4. Classe chat_session
VII-C-5. Classe chat_room
VII-C-6. Bilan
VII-D. Bilan
VIII. Conclusion
IX. Remerciements
I. Introduction
I-A. Réseau et langage de programmation
En langage C++ (et C), la programmation réseau est dépendante du type des machines et systèmes d'exploitation utilisés.
Ce qui ne rend pas facile la tâche du programmeur... Par exemple, il est souvent fastidieux de réaliser une version Windows et Linux,
car s'il existe de nombreux points communs avec POSIX (socket, bind, connect, listen, accept),
il existe aussi de nombreuses divergences dans les en-têtes et les fonctions d'initialisations. Si bien que développer une application
réseau portable peut rapidement devenir un véritable challenge pour le programmeur.
Boost.Asio répond à ces problèmes en proposant une bibliothèque de haut niveau portable, facile à utiliser
et dans un style C++ très élégant.
I-B. Pré-requis
Les concepts C++ présentés dans une première partie sont relativement simples. La dernière partie fait toutefois appel à
des notions intermédiaires nécessaires au développement d'applications robustes, comme les
pointeurs intelligents [
Loïc Joly]
ou encore la
sérialisation [
Pierre Schwartz].
Le lecteur est vivement encouragé à lire les articles correspondants en cas de difficultés.
La compréhension globale du fonctionnement d'un réseau n'est pas obligatoire, mais conseillée. Le lecteur trouvera parmi
les liens suivant de bonnes références :
I-C. Installation de Boost.Asio
Boost.Asio fait parti de la grande bibliothèque Boost. Les exemples de cet article ont été compilés avec VC++ Express 2008 et Boost 1.37.
Pour pouvoir utiliser Boost.Asio, il est conseillé d'installer boost.regex, boost.thread, boost.date_time et
boost.serialization.
Voici quelques liens expliquant comment installer boost, soit à l'aide d'un exécutable (Windows et Visual uniquement),
ou bien en compilant à l'aide de bjam:
Pour ne prendre que l'utile pour ce tutoriel, compilez boost avec la ligne suivante:
bjam --with-system --with-thread --with-date_time --with-regex --with-serialization stage
|
I-D. Code et commentaires dans ce tutoriel
Dans la mesure du possible, le code ne sera pas coupé par des commentaires, afin de ne pas nuire à la lisibilité
du programme dans son ensemble. Ainsi, j'ai choisi dans la plupart des cas de décrire le programme d'abord,
avec des références sur
l'endroit du code entre parenthèses. Cela n'empêche évidemment pas de laisser quelques commentaires dans le code,
comme tout bon programme.
Pour plus de lisibilité, les noms de fonctions/classes seront en italiques dans tout l'article
(excepté dans le code) ainsi que les termes anglais.
Exemple:
La fonction ma_fonction() prend deux chaines de caractères en paramètres (std::string).
On commence par afficher les deux chaines (1).
On stocke la somme des deux chaines dans une variable temporaire (2).
On lance la fonction cherche_char sur chaque caractère (3).
void ma_fonction(const std::string& str1, const std::string& str2)
{
std::cout << str1 << std::endl;
std::cout << str2 << std::endl;
std::string chaine = str1 + str2;
std::for_each(chaine.begin(), chaine.end(), cherche_char);
}
|
II. Les bases de Boost.Asio
Dans cette partie, nous allons découvrir ensemble les bases de Boost.Asio, à savoir les classes principales, les opérations
synchrones et les opérations asynchrones.
 | Quelque soit la partie de Boost.Asio que l'on utilise, le programme devra toujours posséder
sa pièce centrale : un io_service. C'est en quelque sorte le "cœur" de la bibliothèque...
|
#include <boost/asio.hpp>
int main()
{
boost::asio::io_service io_service;
}
|
Nous pouvons également spécifié explicitement à Boost Asio la plateforme cible. Il se peut qu'elle soit différente de la
plateforme de développement.
| Boost.Asio et Windows XP |
#define _WIN32_WINNT 0x0501
|
| Boost.Asio et Windows 2000 |
#define _WIN32_WINNT 0x0500
|
Pour rappel, il existe deux grands types d'opérations réseaux : les opérations synchrones et les opérations asynchrones.
Nous allons détailler ces deux types d'opérations dans cette partie, avec leurs avantages et inconvénients.
II-A. Opérations synchrones
Une opération synchrone bloque la fonction appelante jusqu'à ce qu'elle aie terminé. Considérons le morceau de
code suivant, qui envoie un msg par l'intermédiaire d'une socket:
| Envoi synchrone |
void Mafonction()
{
boost::asio::send(socket, boost::asio::buffer(msg));
std::cout << "Terminé" << std::endl;
}
|
Le message "Terminé" ne sera affiché à l'écran que lorsque la fonction d'envoi de donnée sera terminée.
On dit que la fonction est bloquante.
Dans cet exemple, la fonction send() pourrait ne pas bloquer longtemps car il n'y a pas besoin d'attendre
pour envoyer des données. Cependant, on pourrait imaginer des cas où on fait des demandes de send
plus rapidement qu'elles ne sont transmises...
Autre exemple, que va t-il se passer si on effectue non pas un envoi, mais une réception de données?
| Réception synchrone |
void Mafonction()
{
boost::asio::read(socket, boost::asio::buffer(msg));
std::cout << "Terminé" << std::endl;
}
|
Dans ce code, la fonction read() va attendre la réception de donnée sur un port précis et on peut
parfois attendre longtemps... Tant que la fonction read() n'a pas reçu une certaine quantité de donnée,
la fonction appelante Mafonction() va rester bloquée.
Pour pallier à ce mode de fonctionnement, on pourrait lancer la réception de données dans un thread par exemple. On s'affranchirait
ainsi du problème de la réception synchrone. Par contre, le développeur devra dans ce cas gérer les accès concurrents lui-
même. Il existe une solution beaucoup sûre et plus élégante pour résoudre ce problème : les opérations asynchrones.
II-B. Opérations asynchrones
Des opérations asynchrones sont des opérations qui rendent la main immédiatement à l'appelant même si elles ne
sont pas encore terminées.
On sait quand elles commencent, mais on ne sait pas quand elles finissent, dans l'absolu.
Par contre, elles appellent une fonction callback lorsqu'elles ont terminé.
En effet, une opération asynchrone prend en général en paramètre un completion_handler (cf. exemple suivant)
qu'elle va appeler lorsqu'elle a terminé son travail. Elle n'empêche pas la fonction appelante de continuer
d'exécuter son code, on dit donc que l'opération est non bloquante.
Les callback sont gérés dans une boucle de traitement des événements. Cette boucle de traitement est
instanciée par l'utilisateur à l'aide de la fonction io_service::run() . Cette fonction est bloquante
tant qu'il reste des opérations asynchrones en cours et rend la main lorsqu'il n'y a plus de callback
à exécuter. Il faut donc impérativement donner du travail asynchrone à réaliser avant d'appeler
io_service::run(). Pour maintenir une IHM active, io_service::run peut être exécuté dans un
thread dédié.
 |
Sur certaines plateformes, l'implémentation de Boost.Asio peut éventuellement utiliser des threads en interne pour
émuler l'asynchronicité. Ces threads restent invisibles pour l'utilisateur et la bibliothèque s'utilise comme si toutes
les opérations étaient lancées dans un thread unique. Tant que le programme ne possède qu'un seul io_service,
la boucle de traitement des événements est traité de manière strictement séquentielle.
Le développeur n'a donc pas à gérer les accès concurrents.
|
Le prototype de la fonction callback de Boost.Asio varie suivant les opérations asynchrones. La fonction
async_read prend deux paramètres, alors que la fonction async_connect n'en prend qu'un.
Afin de mieux contrôler soi même les arguments passés au callback, on utilisera boost::bind.
| Réception asynchrone |
void completion_handler(const boost::system::error_code& error)
{
std::cout << "Terminé" << std::endl;
}
void Mafonction()
{
boost::asio::async_read(socket, boost::asio::buffer(msg),
boost::bind(&completion_handler, boost::asio::placeholders::error)
);
std::cout << "Ca s'affiche ! " << std::endl;
}
|
 |
Attention, il est de la responsabilité du développeur de s'assurer que la durée de vie des structures/variables utilisées
lors d'opérations asynchrones sont suffisantes. Même si Boost.Asio effectue des copies de buffer, la
responsabilité de la mémoire sous jacente revient à l'appelant. La mémoire doit vivre jusqu'à l'appel du callback!
Le non respect de cette règle peut entrainer violation de mémoire et/ou
comportement indéterminé parfois difficiles à déboguer.
|
 |
En particulier, le buffer de réception ne doit pas être
un buffer temporaire alloué sur la pile s'il est libéré avant la fin de l'exécution et le buffer d'envoi ne doit pas
être libéré prématurément.
|
async_read() ne bloque pas la fonction appelante Mafonction().
Une fois que l'opération async_read() est terminée, notre fonction completion_handler() sera appelée pour
nous signaler qu'async_read() a bien effectué son travail. Pour nous informer du bon déroulement ou non de l'opération asynchrone,
il nous suffit d'inspecter le paramètre error_code de notre callback. Avec ce paramètre, on peut traiter de nombreux
cas comme :
- Connexion refusée
- Connexion perdue
- Argument invalide
- Message trop long
- etc..
| Réception asynchrone avec retour de code d'erreur |
void completion_handler(const boost::system::error_code& e)
{
if (!error)
{
std::cout << "Tout s'est bien passé" << std::endl;
}
else {
}
}
|
Pour pouvoir effectuer des opérations asynchrones, nous devons lancer la boucle de gestion des
événements par la fonction io_service::run():
|
boost::asio::io_service ios;
ios.run();
|
 |
Rappel : la fonction run() est bloquante continue de "travailler" tant qu'il reste des opérations asynchrones en cours.
|
II-C. Synchrone / Asynchrone ?
Inconvénients des opérations asynchrones:
- Complexité du programme
- Consommation mémoire (car les buffers de réception doivent tous être alloués et indépendants)
Avantages des opérations asynchrones:
- Performance
- Opérations non bloquantes
Le mode asynchrone sera beaucoup utilisé par la suite, en raison de ses performances et de ses appels non bloquants.
II-D. Architecture de Boost.Asio
Boost.Asio permet de gérer deux types de périphériques de communications : port Ethernet et port série. En ce qui
concerne les réseaux, Boost.Asio permet l'utilisation de plusieurs protocoles : TCP (mode connecté), UDP (mode
non connecté), ICMP. Le diagramme ci dessous permet d'apprécier la diversité des modes de communication qu'offre
Boost.Asio.

Diagrammes des classes de Boost.Asio
Les classes d'utilisation communes sont fournies par Boost à l'aide d'un typedef, dans le bon namespace:
namespace tcp {
typedef basic_stream_socket< tcp > socket;
typedef basic_socket_iostream< tcp > iostream;
}
namespace udp
{
typedef basic_datagram_socket< udp > socket;
}
|
III. Les Timers
Boost.Asio propose un seul timer (le deadline_timer) qui fait tout ce qu'on lui demande,
c'est à dire compter le temps, que ce soit de manière synchrone ou asynchrone.
Le deadline_timer prend en paramètre un io_service, toujours indispensable, ainsi qu'une durée de référence.
| Construction du timer Boost.Asio |
boost::asio::deadline_timer t(io, boost::posix_time::seconds(1));
|
 |
Le timer se déclenche au moment de sa construction et non pas à partir de l'attente !
|
III-A. Timers synchrones
Nous allons créer dans cette section un simple timer bloquant qui attend 5 secondes, puis rend la main au programme.
| Timer synchrone |
#include <iostream>
#include <boost/asio.hpp>
#include <boost/date_time/posix_time/posix_time.hpp>
int main()
{
boost::asio::io_service io;
boost::asio::deadline_timer t(io, boost::posix_time::seconds(5));
t.wait();
std::cout << "Terminé !" << std::endl;
return 0;
}
|
La programme affiche donc "Terminé !" après avoir attendu les 5 secondes du timer.
III-B. Timers asynchrones
On souhaite maintenant que notre timer de la section précédente soit non bloquant.
Pour cela, on va utiliser le mode asynchrone, déjà vu à la section précédente.
Le fonctionnement est très semblable à ce que nous avons déjà vu :
- Création d'un timer avec un durée de compte à rebours de 5 secondes
- Lancement en mode asynchrone du timer
- Lorsque le timer est expiré, il appelle son callback : la fonction print()
- La fonction print() affiche "Terminé !"
| Timer asynchrone |
#include <iostream>
#include <boost/asio.hpp>
#include <boost/date_time/posix_time/posix_time.hpp>
void print(const boost::system::error_code& )
{
std::cout << "Terminé !" << std::endl;
}
int main()
{
boost::asio::io_service io;
boost::asio::deadline_timer t(io, boost::posix_time::seconds(5));
t.async_wait(print);
io.run();
return 0;
}
|
 |
Rappel : Il est important de toujours donner du travail asynchrone à effectuer AVANT d'appeler io_service::run(),
sinon io_service::run() rendra la main immédiatement
|
Il peut arriver que l'on crée un timer pour s'en servir plus tard. Dans ce cas, il a de forte chance d'être dans
l'état "expiré".
La fonction membre expires_at() permet de changer la date d'expiration du timer, comme ceci:
|
boost::asio::deadline_timer t(io, boost::posix_time::seconds(5));
t.expires_at(t.expires_at() + boost::posix_time::seconds(5));
t.async_wait(print);
|
IV. Le protocole TCP
Dans cette section, nous allons étudier en détail comment communiquer en TCP/IP avec Boost.Asio, en mode synchrone
et asynchrone. Nous étudierons deux exemples d'architecture client/serveur. Un exemple beaucoup plus poussé sera
présenté dans la dernière partie de ce tutoriel.
IV-A. Introduction
Le protocole TCP s'utilise uniquement en mode connecté. Les fonctions et options classiques bas niveau POSIX sont disponibles,
(bind(), listen(), SO_REUSEADDR, etc.) pour permettre au développeur de contrôler plus finement son application.
Pour des applications "classiques", l'API haut niveau de Boost.Asio avec les valeurs par défaut suffisent amplement.
C'est ce que nous allons utiliser ici.
Listons dans le tableau suivant les classes TCP de Boost.Asio les plus utilisées.
| Les classes Boost.Asio |
Ce qu'elles représentent |
| boost::asio::ip::tcp::endpoint |
Représente un couple {adresse IP, port} |
| boost::asio::ip::tcp::resolver |
Le résolver permet de construire un TCP endpoint a partir du nom d'un serveur |
| boost::asio::ip::tcp::socket |
une socket, interface de communication avec le monde extérieur. Elle possède les fonctions
membres connect() pour se connecter à un serveur. send() et async_send() pour envoyer des données
par cette socket et receive() et async_receive() pour en recevoir. |
| boost::asio::ip::tcp::acceptor |
C'est elle qui possède la fonction membre accept() et async_accept()
pour accepter les connexions entrantes sur un serveur. |
Un endpoint prend une adresse et un port dans le constructeur. Il existe plusieurs moyens pour le remplir correctement:
- Si on connait l'adresse IP du serveur, on va lui passer par une chaine de caractère.
| Construction d'un endpoint à partir d'une adresse |
tcp::endpoint endpoint(boost::asio::ip::address::from_string("192.168.0.4"), 13);
|
- Si on ne connait que le nom DNS, il va falloir passer par un resolver. Dans l'exemple de code
ci-dessous, on va récupérer une liste d'adresses IP correspondant à l'acronyme recherché.
On commence par créer un
resolver (1) que l'on va utiliser pour récupérer
toutes les IP
correspondant au nom DNS fourni en entrée (ici
www.developpez.com).
Dans notre exemple, on va se connecter sur le port 80, qui est le port standard HTTP (2).
On lance la résolution (3). La fonction resolver::resolve() retourne un itérateur sur le premier
endpoint. Libre à nous d'en faire ce que l'on veut, comme l'afficher par exemple (4).
| Construction d'un ou plusieurs endpoint à partir d'un acronyme |
#define _WIN32_WINNT 0x0501 // Asio et Windows XP
#include <boost/asio.hpp>
#include <iostream>
int main()
{
boost::asio::io_service ios;
boost::asio::ip::tcp::resolver resolver(ios);
boost::asio::ip::tcp::resolver::query query("www.developpez.com", "80");
boost::asio::ip::tcp::resolver::iterator iter = resolver.resolve(query);
boost::asio::ip::tcp::resolver::iterator end;
while (iter != end)
{
boost::asio::ip::tcp::endpoint endpoint = *iter++;
std::cout << endpoint << std::endl;
}
return 0;
}
|
www.developpez.com possède une seul IP, contre 3 pour www.google.fr, ce qu'on peut observer dans le tableau
suivant :
| Acronyme |
Sortie écran |
| www.developpez.com |
87.98.130.52:80 |
| www.google.fr |
66.102.9.104:80
66.102.9.147:80
66.102.9.99:80
|
 |
Les endpoint sont très utiles et importants puisqu'ils permettent de travailler indépendamment du
type d'adresses utilisées : IPv4 et IPv6.
|
IV-B. Lecture / écriture courte et transfert complet
Que ce soit en mode synchrone ou asynchrone, il existe deux types de communication via les sockets:
- Les transferts courts : Les messages transmis ne comportement pas de délimitations.
Ces appels réseaux peuvent transmettre moins d'informations que le contenu original. Ce phénomène peut être dû au contrôle
du flux du protocole de transport, ou bien à la bufferisation des données. Au final, on récupérera bien l'intégralité
des données, mais en plus ou moins de morceaux qu'au départ. A titre d'exemple, 2 trames envoyées peuvent se traduire
aussi bien par 1 ou 2 ou 3 réceptions.
- Les transferts complets: Dans ces appels réseaux, il faut préciser exactement la taille des données
que l'on souhaite envoyer et recevoir. Dans le cas de la réception particulièrement, les données ne sont
rendues disponibles (appel du
callback) que lorsque le buffer de réception est plein. Ce mode de fonctionnement est particulièrement
indiqué pour des messages de taille fixe ou lorsqu'on indique d'abord par un premier message de taille fixe la taille
des données que l'on doit recevoir. Beaucoup d'applications réseaux ont besoin de ces garanties pour fonctionner
correctement, lorsqu'on doit disposer d'un paquet intégral (pour décompression ou désérialisation).
 |
Le mode transfert complet est implémenté comme une succession de transferts courts, jusqu'au remplissage du
buffer de réception. Il évite par conséquent au développeur de rassembler les trames applicatives reçues.
|
| |
transfert court |
transfert complet |
| Synchrone |
socket::read_some() socket::write_some() socket::receive() socket::send() |
boost::asio::read() boost::asio::write() |
| Asynchrone |
socket::async_read_some() socket::async_write_some() socket::async_receive() socket::async_send() |
boost::asio::async_read() boost::asio::async_write() |
IV-C. Exemple d'un client synchrone
Etudions maintenant un exemple concret d'un client synchrone se connectant à un serveur. Une fois connecté,
le client va récupérer ce que le serveur lui envoie et se déconnecter lorsque le serveur n'aura plus rien à dire.
Nous avons déjà étudié dans les sections précédentes le début du code : création du service principal,
et création du endpoint. Reste à créer une socket TCP, pour pouvoir communiquer avec l'extérieur (1).
On connecte (2) ensuite la socket fraîchement créée au serveur à l'aide du endpoint.
Ensuite, il faut disposer d'un buffer pour la réception des données (3).
Nous avons le choix : std::vector, boost::array, ou bien encore tableau "C" classique. Ici j'ai choisi un
boost::array, de type char et de taille fixe 128. Le buffer est de taille confortable et permettra de
récupérer en une fois les données reçues, a priori. Toutefois, il se peut que le message envoyé par le serveur
soit très long. D'où la question : que se passe t-il si la taille du buffer est trop petite
par rapport aux données envoyées par le serveur? Pas de problèmes, dans ce cas
Boost.Asio lira le message en plusieurs fois,
la fonction read_some() ne lira pas plus que la taille du buffer.
 |
std::vector et boost::array font parti des classes utilisables directement dans le constructeur de
boost::asio::buffer, ils sont donc particulièrement indiqués, je vous encourage à les utiliser.
|
Les données sont récupérées via une communication courte synchrone (4). Dans le cas présent,
on ne tient pas particulièrement à connaître la taille des données reçues et on ne fait pas grand chose à part
les afficher. Cette voie de communication était donc tout indiquée... On notera qu'il faut utiliser
la valeur de retour de la fonction read_some() pour savoir combien d'octets ont été lus.
Il existe deux méthodes pour savoir si on est arrivé à la fin de la lecture:
- Soit on fourni une variable (5) qui sera remplie par la fonction read_some() à la valeur
end of file
- Soit n'utilise pas de codes d'erreur. Dans ce cas une exception sera lancée. A nous de l'attraper!
Je préfère nettement la première solution, il est en effet inutile de faire appel aux exceptions juste pour
être prévenus d'une fin de lecture. Gardons les pour des choses plus utiles...
Enfin, on affiche au cours de chaque boucle le contenu de notre buffer (6).
Voici le code avec les références au texte ci dessus entre parenthèses:
| Client synchrone |
#include <iostream>
#include <boost/asio.hpp>
#include <boost/array.hpp>
int main()
{
boost::asio::io_service ios;
tcp::endpoint endpoint(boost::asio::ip::address::from_string("127.0.0.1"), 7171);
tcp::socket socket(ios);
socket.connect(endpoint);
boost::array<char, 128> buf;
while (1)
{
boost::system::error_code error;
int len = socket.read_some(boost::asio::buffer(buf), error);
if (error == boost::asio::error::eof)
{
std::cout << "\nTerminé !" << std::endl;
break;
}
std::cout.write(buf.data(), len);
}
return 0;
}
|
Etudions maintenant le serveur correspondant.
IV-D. Exemple d'un serveur synchrone
On commence par la création d'un acceptor (1), qui prend en paramètre n'importe quelle adresse IP V4,
sur le port 7171. tcp::v4() est l'équivalent du INADDR_ANY du standard POSIX et cela correspond
à un bind sur toutes les interfaces réseau du serveur.
On crée le message de bienvenue (2).
Ensuite on réalise une boucle dans laquelle on effectue une attente bloquante d'un client. On commence par créer
une socket (3) puis l'acceptor va réaliser l'attente bloquante d'une connexion. Lorsqu'une connexion est
établie, on affiche "Client reçu !" sur notre serveur, bien que ce ne soit pas très utile.
On utilise ensuite la socket connectée au client pour lui envoyer un message (5). Notez qu'on utilise ici aussi une
écriture courte et donc le message envoyé pourrait très bien arriver en plusieurs morceaux ! Bien sûr, dans le
cas présent d'un si petit message, on peut présumer l'envoyer en un seul morceau. Mais on ne peut en avoir la garantie.
Dès la fin de la boucle, on perd la connexion avec le client ici pour commencer une attente bloquante sur un autre client.
| Serveur synchrone |
#define _WIN32_WINNT 0x0501 // Windows XP
#include <boost/asio.hpp>
#include <boost/array.hpp>
#include <iostream>
using boost::asio::ip::tcp;
int main()
{
boost::asio::io_service ios;
tcp::acceptor acceptor(ios, tcp::endpoint(tcp::v4(), 7171));
std::string msg ("Bienvenue sur le serveur !");
while (1)
{
tcp::socket socket(ios);
acceptor.accept(socket);
std::cout << "Client reçu ! " << std::endl;
socket.send(boost::asio::buffer(msg));
}
return 0;
}
|
Ce code illustre de manière simple comment réaliser un serveur minimal. Bien sûr avec cette technique on ne peut accueillir
qu'un seul client à la fois, ce n'est pas acceptable. La prochaine partie propose de résoudre ce problème en utilisant
une connexion asynchrone.
IV-E. Exemple d'un serveur asynchrone
Dans cette partie, nous allons traiter plusieurs clients simultanément, en modélisant un serveur et une connexion
TCP. La fonction main() sera réduite à sa plus simple expression :
int main()
{
try
{
boost::asio::io_service io_service;
tcp_server server(io_service, 7171);
io_service.run();
}
catch (std::exception& e)
{
std::cerr << e.what() << std::endl;
}
return 0;
}
|
IV-E-1. Premier essai
Créons une classe tcp_connection dont le rôle est de gérer l'interaction avec le client : envoi du
message de bienvenue. La gestion de la première connexion sera faite par un serveur.
Le constructeur de la classe (1) prend en paramètre un io_service avec lequel il construit sa socket.
Cette socket doit pouvoir être accessible de l'extérieur de la classe afin d'initialiser la
connexion entre le client et le serveur sur cette socket.
On pourrait faire autrement et passer une socket en paramètre du constructeur par exemple, un fois la connexion établie
par le serveur. Toutefois, les classes socket sont non copiables, donc pour ce faire il faudrait passer
une référence et tcp_connection ne serait donc pas propriétaire de la socket. Le mieux est donc de laisser
tcp_connection posséder cette socket et donner la possibilité au serveur de s'en servir directement pour
l'initialisation de la connexion. Pour cela, il nous faut donc retourner la socket par référence.
Comme dans les exemples précédents, nous avons choisis de faire le démarrage de la connexion avec un envoi
de bienvenue. Cet appel se fera depuis le serveur via la fonction membre start() (3).
Dans cette fonction, nous assignons le message à la variable membre, puisque le buffer doit être accessible
pendant toute la durée d'une opération asynchrone. La fonction handle_write sera appelée à la fin
de l'opération asynchrone async_write. (4)
 |
Les fonctions membres non statiques ont un paramètre implicite this qui correspond en fait à l'instance
de la classe concernée par la fonction membre. C'est pourquoi dans le boost::bind nous devons préciser
à quelle instance de classe tcp_connection nous appliquons la fonction membre handle_write().
|
| classe tcp_connection, premier essai |
class tcp_connection
{
public:
tcp_connection(boost::asio::io_service& io_service)
: m_socket(io_service)
{
}
tcp::socket& socket()
{
return m_socket;
}
void start()
{
m_message = "Bienvenue sur le serveur!";
boost::asio::async_write(m_socket, boost::asio::buffer(m_message),
boost::bind(&tcp_connection::handle_write, this,
boost::asio::placeholders::error)
);
}
private:
void handle_write(const boost::system::error_code& error)
{
if (!error)
{
}
}
tcp::socket m_socket;
std::string m_message;
};
|
Voyons maintenant le code du serveur. Il doit écouter et accepter les connections des clients. Lorsqu'un client se connecte,
il charge tcp_connection de gérer le reste de la connexion et relance immédiatement une écoute pour les
nouveaux clients potentiels. Il n'attend pas que tcp_connection aie terminé, c'est l'avantage du mode
asynchrone.
Le constructeur de tcp_server (1) doit prendre en paramètre un io_service et un numéro de port.
On peut donc initialiser notre acceptor avec et lancer une écoute avec la fonction start_accept().
Dans la fonction start_accept(), on crée une nouvelle connexion TCP (2) et on attend une connexion
entrante sur la socket de la connexion TCP fraîchement créée (3).
Une fois le client connecté, l'opération asynchrone appelle la fonction callback que l'on avait précisée,
c'est-à-dire handle_accept() (4). S'il n'y a pas d'erreurs, on affiche que l'on a reçu un client,
on fait appel à tcp_connection::start() vu précédemment pour qu'elle gère la suite de la connexion,
c'est-à-dire envoyer un message de bienvenue.
Très important ensuite, on relance l'écoute de nouvelles connexions client avec start_accept() (5).
| classe tcp_server, premier essai, code non fonctionnel |
class tcp_server
{
public:
tcp_server(boost::asio::io_service& io_service, int port)
: m_acceptor(io_service, tcp::endpoint(tcp::v4(), port))
{
start_accept();
}
private:
void start_accept()
{
tcp_connection new_connection(m_acceptor.io_service());
m_acceptor.async_accept(new_connection.socket(),
boost::bind(&tcp_server::handle_accept, this, new_connection,
boost::asio::placeholders::error));
}
void handle_accept(tcp_connection& new_connection, const boost::system::error_code& error)
{
if (!error)
{
std::cout << "Reçu un client!" << std::endl;
new_connection.start();
start_accept();
}
}
tcp::acceptor m_acceptor;
};
|
Ce code donne bien l'idée générale, mais ne fonctionne pas. Pourquoi ? Eh bien, dans la fonction
start_accept() du serveur, la connexion est créée sur la pile et va donc disparaître dès que l'on
va sortir de la fonction. En résumé, elle va être détruite à peine construite et le programme va planter
lamentablement. La solution, c'est d'allouer tcp_connection sur le tas, via un new. Un nouveau problème
se pose à nous : dès lors, qui va libérer la mémoire ? Certainement pas le serveur puisqu'il ne "possède"
pas la connexion. Et la connexion pouvant lancer tout un tas d'opérations asynchrones, elle ne saurait
être responsable d'elle même...
C'est là qu'interviennent les pointeurs intelligents. boost::shared_ptr pour être plus précis. Il va
nous permettre de garder en vie la connexion tant qu'il reste des opérations asynchrones à réaliser. Et
lorsque le compteur de référence s'apprête à devenir nul, la ressource est libérée. Découvrons cette implémentation
dans la section suivante.
IV-E-2. Avec des pointeurs intelligents
On va retrouver quasiment le même code, avec quelques modifications.
On ne peut pas faire les choses à moitié, il faut qu'on force l'utilisateur de la classe tcp_connection
à la manipuler avec des pointeurs intelligents. Pour cela, on fait hériter de boost::enable_shared_from_this
afin de pouvoir réaliser l'appel asynchrone d'écriture en augmentant le comptage de référence (2). Le
constructeur de la classe est déclaré privé (3) et une fonction statique tcp_connection::create()
va retourner une instance tout neuve enveloppée dans un shared_ptr.
| tcp_connection, Version finale |
using boost::asio::ip::tcp;
class tcp_connection : public boost::enable_shared_from_this<tcp_connection>
{
public:
typedef boost::shared_ptr<tcp_connection> pointer;
static pointer create(boost::asio::io_service& ios)
{
return pointer(new tcp_connection(ios));
}
tcp::socket& socket()
{
return m_socket;
}
void start()
{
m_message = "Bienvenue sur le serveur!";
boost::asio::async_write(m_socket, boost::asio::buffer(m_message),
boost::bind(&tcp_connection::handle_write, shared_from_this(),
boost::asio::placeholders::error)
);
}
private:
tcp_connection(boost::asio::io_service& io_service)
: m_socket(io_service)
{
}
void handle_write(const boost::system::error_code& error)
{
if (!error)
{
}
}
tcp::socket m_socket;
std::string m_message;
};
|
Le code du serveur n'aura presque pas changé. Les seules fonctions qui changent, c'est le start_accept(),
et le handle_accept().
La nouvelle connexion est faite via la fonction create() (1). Et maintenant, on est garanti que
notre instance ne sera pas détruite à la fin de cette fonction. Qui plus est, cette instance ne sera détruite
qu' à la fin de vie de la connexion!
void start_accept()
{
tcp_connection::pointer new_connection = tcp_connection::create(m_acceptor.io_service());
m_acceptor.async_accept(new_connection->socket(),
boost::bind(&tcp_server::handle_accept, this, new_connection,
boost::asio::placeholders::error));
}
void handle_accept(tcp_connection::pointer new_connection, const boost::system::error_code& error)
{
if (!error)
{
std::cout << "Reçu un client!" << std::endl;
new_connection->start();
start_accept();
}
}
|
IV-E-3. Intégration d'un timer
Si on lance une connexion qui écoute après avoir écrit le message de bienvenue, il peut être intéressant
de fixer un temps d'attente maximal au delà duquel on ferme la connexion... Pour cela nous allons intégrer
un timer.
Il faut créer une nouvelle fonction do_read (1) qui va écouter les données entrantes sur la socket.
On lance cette fonction juste après la fin de l'envoi du message de bienvenue (2). La fonction va lancer
une écoute asynchrone (3). Juste derrière on reparamètre le timer (4) et on le lance (5).
Si la lecture réussit, on relance une autre lecture (6), sinon on ferme (7). Si le temps d'attente dépasse
5 secondes pour la lecture, alors on ferme la socket (7).
Le timer permet de fermer proprement la connexion en cas d'attente trop longue. Ce système peut s'appliquer
dans beaucoup d'autres cas comme l'attente de connexion, etc...
class tcp_connection : public boost::enable_shared_from_this<tcp_connection>
{
public:
typedef boost::shared_ptr<tcp_connection> pointer;
static pointer create(boost::asio::io_service& ios)
{
return pointer(new tcp_connection(ios));
}
tcp::socket& socket()
{
return m_socket;
}
void do_read()
{
boost::asio::async_read(m_socket, boost::asio::buffer(m_buffer),
boost::bind(&tcp_connection::handle_read, shared_from_this(),
boost::asio::placeholders::error)
);
timer.expires_from_now(boost::posix_time::seconds(5));
timer.async_wait(boost::bind(&tcp_connection::close, shared_from_this() ));
}
void start()
{
m_message = "Bienvenue sur le serveur!";
boost::asio::async_write(m_socket, boost::asio::buffer(m_message),
boost::bind(&tcp_connection::handle_write, shared_from_this(),
boost::asio::placeholders::error)
);
}
private:
tcp_connection(boost::asio::io_service& io_service)
: m_socket(io_service),
timer(io_service, boost::posix_time::seconds(5))
{
}
void handle_write(const boost::system::error_code& error)
{
if (!error)
{
do_read();
}
else {
std::cout << error.message() << std::endl;
}
}
void handle_read(const boost::system::error_code& error)
{
if (!error)
{
do_read();
}
else
{
close();
}
}
void close()
{
m_socket.close();
}
boost::asio::deadline_timer timer;
tcp::socket m_socket;
std::string m_message;
boost::array<char, 128> m_buffer;
};
|
IV-F. Exemple d'un client asynchrone
On va dans cette partie réaliser un client asynchrone travaillant avec le serveur temporisé vu précédemment.
Le code va ressembler beaucoup à ce que nous avons déjà vu auparavant.
Le main ci-dessous ne devrait pas poser problème, nous lançons un client pour se connecter sur le serveur.
| main_client.cpp |
#define _WIN32_WINNT 0x0501
#include "tcp_client.h"
int main()
{
try
{
boost::asio::io_service io_service;
tcp::endpoint endpoint(boost::asio::ip::address::from_string("127.0.0.1"), 7171);
tcp_client client(io_service, endpoint);
io_service.run();
}
catch (std::exception& e)
{
std::cerr << e.what() << std::endl;
}
return 0;
}
|
Ensuite vient tcp_connection, qui va être légèrement modifiée par rapport à celui du serveur.
En effet, nous avons maintenant une fonction read (1) qui va réaliser une lecture asynchrone sur la
socket. Notez qu'on peut récupérer le nombre d'octets reçus avec boost::asio::placeholders::bytes_transferred.
Le principal problème ici, c'est qu'en faisant appel à un transfert complet via async_read, l'appel n'aboutit
que lorsque le buffer de réception est remplit. Autrement dit ici, il ne se passera rien. Nous avons deux solutions
pour résoudre ce problème : passer en transfert court, ou bien spécifier à l'appel synchrone que nous ne voulons
pas forcément remplir tout notre buffer. Cette dernière solution a été retenue dans notre cas en spécifiant
boost::asio::transfer_at_least pour imposer de recevoir au moins 20 octets (2). Passé ces vingt octets, l'
appel asynchrone peut appeler le handler correspondant (3), quand le transfert est terminé pour lui.
 |
La solution la plus "propre" serait de d'abord transférer la taille du message puis d'envoyer le message lui-même.
Cette technique sera illustrée plus tard dans le projet chat.
|
S'il n'y a pas d'erreurs (4), on affiche le message. Sinon on affiche le message d'erreur (5).
| tcp_connection.h, client |
using boost::asio::ip::tcp;
class tcp_connection : public boost::enable_shared_from_this<tcp_connection>
{
public:
typedef boost::shared_ptr<tcp_connection> pointer;
static pointer create(boost::asio::io_service& ios)
{
return pointer(new tcp_connection(ios));
}
tcp::socket& socket()
{
return m_socket;
}
void read()
{
boost::asio::async_read(m_socket, boost::asio::buffer(m_network_buffer),
boost::asio::transfer_at_least(20),
boost::bind(&tcp_connection::handle_read, shared_from_this(),
boost::asio::placeholders::error,
boost::asio::placeholders::bytes_transferred)
);
}
private:
tcp_connection(boost::asio::io_service& io_service)
: m_socket(io_service)
{
}
void handle_read(const boost::system::error_code& error, size_t number_bytes_read)
{
if (!error)
{
std::cout.write(&m_network_buffer[0], number_bytes_read);
read();
}
else {
std::cout << error.message() ;
}
}
tcp::socket m_socket;
boost::array<char, 128> m_network_buffer;
};
|
Voyons maintenant le code du client TCP.
Ici encore, pas grand chose de complètement nouveau! Le constructeur de tcp_client lance la fonction
membre connect pour tenter de se connecter au serveur (1).
La fonction connect commence par créer une nouvelle connexion (2), puis récupère la socket sous-jacente
pour lancer une connexion asynchrone sur le serveur (3).
Lorsque la connexion est établie, le handle_connect (4) lance l'opération de lecture de données en
provenance du serveur.
| tcp_client.h |
class tcp_client
{
public:
tcp_client(boost::asio::io_service& io_service, tcp::endpoint& endpoint)
:m_io_service (io_service)
{
connect(endpoint);
}
private:
void connect(tcp::endpoint& endpoint)
{
tcp_connection::pointer new_connection = tcp_connection::create(m_io_service);
tcp::socket& socket = new_connection->socket();
socket.async_connect(endpoint,
boost::bind(&tcp_client::handle_connect, this,
new_connection,
boost::asio::placeholders::error)
);
}
void handle_connect(tcp_connection::pointer new_connection, const boost::system::error_code& error)
{
if (!error)
{
new_connection->read();
}
}
boost::asio::io_service& m_io_service;
};
|
Ce code fonctionne correctement avec le serveur de la section précédente. C'est-à-dire que le client va recevoir
un message provenant du serveur et va continuer à écouter. Le serveur écoute aussi et va fermer la connexion au
bout de 5 secondes s'il n'a rien reçu... C'est ce qui va se passer : le client va être déconnecté !
V. Le protocole UDP
Le protocole UDP s'utilise en mode non connecté. Il est plus rapide, mais moins fiable que le protocole
TCP. on retrouve les opérations réseau send_to et receive_from du standard POSIX.
On peut toutefois utiliser un mode "pseudo-connecté", avec les fonctions de transferts courts des sockets.
Par contre, il n'existe pas de transfert complet en UDP dans Boost.Asio. C'est impossible à cause de l'ordre d'arrivée
des paquets qui n'est pas garanti, pas plus que la bonne réception des données.
V-A. Exemple d'un client synchrone
Comme vous pouvez le constater, le début du code est similaire à celui rencontré pour le protocole TCP :
On définit un point d'arrivée (endpoint), soit en lui passant directement d'adresse, soit en utilisant
un résolveur. Ici j'ai choisi l'adresse locale 127.0.0.1.
On crée ensuite une socket UDP pour l'envoi et la réception de données sous forme de datagram (1). La socket peut
envoyer n'importe où, elle n'est pas connectée, donc on lui attribue juste le standard d'adresses avec
lequel elle va travailler (IPv4 ici).
On crée un mini buffer vide d'un octet qui va nous servir à donner notre adresse au serveur pour qu'il puisse nous
répondre (2). On envoie ensuite ce petit octet au serveur (3).
Le serveur va bientôt nous répondre, on alloue un buffer de réception plus conséquent (128 octets) (4).
Puis on s'apprête à recevoir des données en provenance de n'importe qui (5). Notez qu'on récupère le nombre
d'octets lus et le endpoint de l'envoyeur...
Enfin, on écrit les données reçues sur le flux de sortie standard (6).
#include <iostream>
#include <boost/array.hpp>
#include <boost/asio.hpp>
using boost::asio::ip::udp;
int main()
{
try
{
boost::asio::io_service io_service;
udp::endpoint receiver_endpoint (boost::asio::ip::address::from_string("127.0.0.1"), 7171);
udp::socket socket(io_service);
socket.open(udp::v4());
boost::array<char, 1> send_buf = { 0 };
socket.send_to(boost::asio::buffer(send_buf), receiver_endpoint);
boost::array<char, 128> recv_buf;
udp::endpoint sender_endpoint;
size_t len = socket.receive_from(boost::asio::buffer(recv_buf), sender_endpoint);
std::cout.write(recv_buf.data(), len);
}
catch (std::exception& e)
{
std::cerr << e.what() << std::endl;
}
return 0;
}
|
Cet exemple tout simple montre bien l'utilisation différente du mode non connecté avec UDP par rapport au mode
connecté avec TCP. On envoie à n'importe qui et on reçoit de n'importe où.
V-B. Exemple d'un serveur synchrone
Comme le client, le serveur n'a rien d'exceptionnel, il attend un message d'un octet en provenance de n'importe
qui, puis renvoie un message à cette adresse. Toujours sans notion de connexion, bien entendu!
On commence par créer une socket UDP qui écoute sur le port 7171 pour une adresse IPv4 (1).
On reçoit à partir de ce type d'adresse un octet (2), qu'on ignore. Par contre, on stocke bien soigneusement
l'adresse IP (endpoint) de l'envoyeur pour pouvoir lui répondre (3).
Si l'erreur est due à un message trop long, ce n'est pas grave. On ignore le reste et on continue le programme.
Par contre, toute autre erreur lève une exception (4).
On crée ensuite un message de bienvenue qu'on envoie à l'adresse IP précédemment reçue (5). Les erreurs sont
ignorées dans ce cas.
int main()
{
try
{
boost::asio::io_service io_service;
udp::socket socket(io_service, udp::endpoint(udp::v4(), 7171));
while (1)
{
boost::array<char, 1> recv_buf;
udp::endpoint remote_endpoint;
boost::system::error_code error;
socket.receive_from(boost::asio::buffer(recv_buf), remote_endpoint, 0, error);
if (error && error != boost::asio::error::message_size)
throw boost::system::system_error(error);
std::string message = "Bienvenue sur le serveur ! Mode non connecté.";
boost::system::error_code ignored_error;
socket.send_to(boost::asio::buffer(message), remote_endpoint, 0, ignored_error);
}
}
catch (std::exception& e)
{
std::cerr << e.what() << std::endl;
}
return 0;
}
|
Si le mode non connecté est fondamentalement différent du mode connecté, la manière de gérer l'UDP et le TCP
se ressemble fortement avec Boost.Asio. Ainsi, il ne sera pas difficile pour le lecteur de réaliser un serveur
asynchrone à partir des exemples TCP qu'on peut trouver dans la section précédente.
VI. Les sockets iostreams
tcp::iostream est une classe de Boost.Asio qui implémente les iostreams comme surcouche des sockets. Plus
de résolution de noms DNS, plus de problèmes de protocole. Bref, une classe très simple à utiliser.
VI-A. Exemple de client synchrone
On crée une classe iostream que l'on affecte à l'IP locale, sur le port 7171.
On utilise std::getline (comme pour les flux standard) pour récupérer dans une chaine de caractère
ce qui provient de tcp::iostream...
Et c'est tout!
#include <iostream>
#include <string>
#include <boost/asio.hpp>
using boost::asio::ip::tcp;
int main()
{
tcp::iostream s("127.0.0.1", "7171");
std::string line;
std::getline(s, line);
std::cout << line << std::endl;
return 0;
}
|
Ce code extrêmement simple fonctionne très bien avec les serveurs synchrones et asynchrones TCP que l'on a
pu construire jusqu'ici. On récupère bien "Bienvenue sur le serveur !"
Mais évidemment on n'a pas autant de contrôle qu'en choisissant nous-mêmes le protocole,
l'architecture, etc... Tout de même, avec quelques lignes de code, on peut réaliser d'étonnantes opérations
relativement complexes.
VI-B. Exemple de serveur synchrone
tcp::iostream permet également de créer des serveurs très simples. Voici un serveur synchrone équivalent
à tout ce qu'on a vu précédemment.
Comme d'habitude, le serveur prend tout type d'adresse IPv4.
L'
acceptor accepte la connexion d'un client (2).
rdbuf() renvoie un pointeur sur le
streambuf sous-jacent, c'est-à-dire
basic_socket_streambuf ici (voir
diagramme des classes ).
L'acceptor reçoit donc un paramètre valide !
On peut donc enfin envoyer un message via l'opérateur de flux standard.
int main()
{
boost::asio::io_service io_service;
tcp::endpoint endpoint(tcp::v4(), 7171);
tcp::acceptor acceptor(io_service, endpoint);
while (1)
{
tcp::iostream stream;
acceptor.accept(*stream.rdbuf());
stream << "Bienvenue sur le serveur ! "<< std::endl;
}
return 0;
}
|
VII. Projet démo : réalisation d'un 'chat' avec Boost.Asio
Le but de cette section est de réaliser un programme de chat (genre MSN) assez générique en utilisant des principes de
bonne programmation. Nous allons mettre en œuvre une bonne partie de ce que nous avons vu jusqu'à présent côté
réseau, en insistant encore un peu sur le côté généricité et abstraction des données. Nous allons pour cela
utiliser d'autres excellentes bibliothèques de boost : sérialisation, pointeurs intelligents...
VII-A. Cahier des charges
Le cahier des charges ressemble beaucoup à ce que fût une des premières versions de MSN Messenger.
Le programme devra comporter un serveur vers lequel les clients peuvent se connecter. Par défaut, tous les nouveaux
clients arrivent dans une salle de chat unique et peuvent ainsi se parler directement. Les participants
de la salle de chat devront être averti de l'arrivée au départ d'un tiers.
VII-B. Conception
Décomposons le problème proprement : analyse descendante, puis conception ascendante!
VII-B-1. Abstractions
Faisons ressortir les abstractions de notre cahier des charges. On va bien sûr retrouver des éléments de notre exemple
TCP de la partie IV.
- chat_client : client se connectant au serveur
- chat_server : Serveur pour gérer les connexions entrantes
- chat_room : salle de chat pour les participants
- chat_session : représente une connexion côté serveur
- tcp_connection : gère la connexion, écriture lecture sur une socket.
- chat_message : structure pour échanger des messages et des informations
Illustrons un peu ces abstractions à l'aide d'un petit schéma.
VII-B-2. Architecture
Voici donc un schéma UML mélangeant un peu diagramme de classe et Use Case pour essayer d'être le plus clair
possible sans faire 15 schémas. On a en haut le côté serveur, programme à part qui accepte les nouvelles
connexions. En bas le côté client avec un programme à part également. La communication entre le client et
le serveur se fait exclusivement par le réseau.

Projet chat en image
Détaillons un peu les étapes clefs du chat
- chat_client se connecte à chat_server : c'est une demande de connexion.
- chat_server accepte et crée une chat_session côté serveur. C'est désormais avec chat_session que chat_client communiquera.
- chat_server ajoute le chat_session dans chat_room, il en perd le contrôle. Seul la room est responsable de ses participants.
- chat_server reprend l'écoute de nouveaux clients.
- Un chat_client envoie un message par le réseau, réceptionné par chat_session.
- chat_session le transmet à chat_room
- chat_room le diffuse à tous ses participants (y compris lui même)
- Tous les chat_client reçoivent le message (5).
Le principe est assez simple, voyons comment le mettre en œuvre avec Boost.Asio et Boost.Serialization.
VII-C. Implémentation du serveur
VII-C-1. Classe chat_message
On va commencer simple, cette classe, ou structure plutôt, est le message que l'on va faire transiter par
le réseau. Elle va contenir des champs de données divers suivant nos envies.
Dans notre cas, on va y voir figurer :
- le type de message : nouveau message, nouveau client connecté, etc.
- une chaine de caractère pour le message
- une chaine de caractère pour le login de l'envoyeur
- une liste de chaine de caractère pour récupérer toutes les personnes connectées
(non utilisée ici, mais pourrait l'être si on pousse le projet plus loin...)
| chat_message.h |
class chat_message
{
public:
void reset()
{
m_list_string.clear();
m_message.clear();
m_login.clear();
}
int m_type;
std::list<std::string> m_list_string;
std::string m_message;
std::string m_login;
template<class Archive>
void serialize(Archive& ar, const unsigned int version){
ar & m_type & m_list_string & m_message & m_login;
}
enum {
NEW_MSG = 0,
PERSON_LEFT = 1,
PERSON_CONNECTED = 2,
};
};
|
VII-C-2. Classe tcp_connection
La classe tcp_connection va encapsuler l'envoi et la réception de données via une socket. Nous avons pu
remarquer que nous écrivions souvent dans le cadre d'application réseau :
boost::async_read(socket, boost::asio::buffer(network_buffer),
boost::bind(&ma_classe::handler, this,
boost::asio::placeholders::error)
);
|
Il existe plusieurs inconvénients à utiliser toujours cette même méthode pour lire / écrire des données.
D'abord on est toujours obligé d'écrire cette longue ligne de code juste parce que le callback a
changé. Ensuite, comment allouer un buffer de réception lorsqu'on ne connait pas la taille des données
qui vont arriver ?
Le but serait d'encapsuler tous ces appels dans une abstraction suffisamment haute pour qu'elle couvre
toutes nos "variations". Nos principales variations, ce sont les messages et leurs tailles (il ne faut pas
qu'on dépende de la structure) et le callback.
Pour le callback , je vois deux solutions : une dynamique (boost::function) et une statique
(les template). On aura un net gain de performance à utiliser les template,
c'est la solution que j'ai choisie ici.
Ensuite, la taille des données. Comme nous effectuons une sérialisation, nous avons besoin de
connaitre la taille des données à récupérer. La solution est d'envoyer la taille des données suivant un
format bien précis, avant d'envoyer les données elle-même pour l'envoi.
En ce qui concerne la réception, il faut d'abord lire la taille des données à recevoir avant
d'allouer le buffer de réception et de demander à recevoir les données.
Bien entendu, nous devons utiliser les transferts complets
(à l'aide boost::asio::async_read et boost::asio::async_write).
 |
Le code est relativement long, surtout à cause du fait que boost::bind ne supporte pas l'appel
récursif lorsqu'il est templaté. C'est-à-dire que boost::bind ne peut pas (pour l'instant sans doute)
binder une fonction passée via un template où boost::bind est utilisé.
Du coup, on est obligé de passer par des tuple, qui n'ont normalement rien à voir là dedans. Je les ai gardés
pour avoir un code qui compile. Faites en abstraction et gardez à l'esprit que le tuple en question est juste
le callback.
|
Le constructeur de tcp_connection prend un io_service comme seul et unique paramètre, qui sert à
initialiser une socket.
Du côté des variables privées, on retrouve la socket (1) et la taille du header (2). Le reste étant des
variables membres (donc à durée de vie aussi longue que la durée de vie de l'instance de la classe)
pour pouvoir effectuer des opérations asynchrones dessus.
Côté fonction membre, les deux fonctions principales sont async_write et sync_read.
la fonction membre aync_read (3) prend deux paramètres : une structure à remplir et la fonction de
callback à appeler à la fin du remplissage. Elles sont toutes les deux templatées, si
bien que la seule condition pour la structure, c'est d'être sérialisable au sens de boost, c'est-à-dire
implémenter la fonction serialize. La classe tcp_connection fait donc le minimum d'hypothèse sur les
structures de données utilisées.
Dans la fonction en elle-même, on va retrouver une sérialisation des données (4). On écrit ensuite la
taille des données dans un header au format fixe (5). Puis on envoie le header suivi des données dans
le réseau, en appelant le callback passé en paramètre lorsque le dernier async_write (6)
sera terminé.
La fonction async_read suit à peu près le même schéma. On commence par récupérer le header (10). S'il
n' y a pas d'erreurs, on lit la taille du message à récupérer (11). On change la taille du buffer de réception
puis on lance une lecture du message derrière (12).
Dans le handler après lecture (13), on désérialise les données (14), on affecte le résultat de la
désérialisation à la structure passé en paramètre d'entrée. On termine en appelant le callback
passé en paramètre de la fonction async_read (15).
| tcp_connection.h |
class tcp_connection
{
public:
tcp_connection(boost::asio::io_service& io_service)
: m_socket(io_service)
{
}
boost::asio::ip::tcp::socket& socket()
{
return m_socket;
}
template <typename T, typename Handler>
void async_write(const T& t, Handler handler)
{
std::ostringstream archive_stream;
boost::archive::text_oarchive archive(archive_stream);
archive << t;
m_outbound_data = archive_stream.str();
std::ostringstream header_stream;
header_stream << std::setw(header_length)
<< std::hex << m_outbound_data.size();
if (!header_stream || header_stream.str().size() != header_length)
{
boost::system::error_code error(boost::asio::error::invalid_argument);
m_socket.io_service().post(boost::bind(handler, error));
return;
}
m_outbound_header = header_stream.str();
std::vector<boost::asio::const_buffer> buffers;
buffers.push_back(boost::asio::buffer(m_outbound_header));
buffers.push_back(boost::asio::buffer(m_outbound_data));
boost::asio::async_write(m_socket, buffers, handler);
}
template <typename T, typename Handler>
void async_read(T& t, Handler handler)
{
void (tcp_connection::*f)( const boost::system::error_code&, T&, boost::tuple<Handler>)
= &tcp_connection::handle_read_header<T, Handler>;
boost::asio::async_read(m_socket, boost::asio::buffer(m_inbound_header),
boost::bind(f,
this, boost::asio::placeholders::error, boost::ref(t),
boost::make_tuple(handler)));
}
template <typename T, typename Handler>
void handle_read_header(const boost::system::error_code& e,
T& t, boost::tuple<Handler> handler)
{
if (e)
{
boost::get<0>(handler)(e);
}
else
{
std::istringstream is(std::string(m_inbound_header, header_length));
std::size_t m_inbound_datasize = 0;
if (!(is >> std::hex >> m_inbound_datasize))
{
boost::system::error_code error(boost::asio::error::invalid_argument);
boost::get<0>(handler)(error);
return;
}
m_inbound_data.resize(m_inbound_datasize);
void (tcp_connection::*f)(const boost::system::error_code&, T&, boost::tuple<Handler>)
= &tcp_connection::handle_read_data<T, Handler>;
boost::asio::async_read(m_socket, boost::asio::buffer(m_inbound_data),
boost::bind(f, this,
boost::asio::placeholders::error, boost::ref(t), handler));
}
}
template <typename T, typename Handler>
void handle_read_data(const boost::system::error_code& e,
T& t, boost::tuple<Handler> handler)
{
if (e)
{
boost::get<0>(handler)(e);
}
else
{
try
{
std::string archive_data(&m_inbound_data[0], m_inbound_data.size());
std::istringstream archive_stream(archive_data);
boost::archive::text_iarchive archive(archive_stream);
archive >> t;
}
catch (std::exception& e)
{
boost::system::error_code error(boost::asio::error::invalid_argument);
boost::get<0>(handler)(error);
return;
}
boost::get<0>(handler)(e);
}
}
private:
boost::asio::ip::tcp::socket m_socket;
enum { header_length = 8 };
std::string m_outbound_header;
std::string m_outbound_data;
char m_inbound_header[header_length];
std::vector<char> m_inbound_data;
};
typedef boost::shared_ptr<tcp_connection> connection_ptr;
#endif
|
On dispose maintenant d'un code indépendant de la structure des messages. tcp_connection peut envoyer
et recevoir simplement des données. La fonction appelant est informé via un callback
fournit en paramètre par le développeur lui même !
Exemple simple de réception d'un message réseau:
|
chat_message msg;
m_tcp_connection->async_read(msg, my_handler);
|
Dans lequel my_handler est un callback défini par nos soins. Quoi de plus naturel de s'affranchir dans nos
programmes des sérialisations / désérialisations et récupérer directement le message sous la structure attendue!
VII-C-3. Classe chat_server
Le serveur a pour rôle d'accepter les connexions entrantes. Découvrons son interface.
Le constructeur du serveur prend pour paramètres un io_service et un endpoint (1).
La fonction principale est d'attendre les connexions entrantes (2), puis de les accepter grâce à
l'acceptor (5) dans le handler (3).
On notera que chat_server possède une chambre de chat, sous forme de shared_ptr (6).
Il est responsable de sa durée de vie.
| chat_server.h |
using boost::asio::ip::tcp;
class chat_server
{
public:
chat_server(boost::asio::io_service& io_service, const tcp::endpoint& endpoint);
void wait_for_connection ();
private:
void handle_accept (const boost::system::error_code& error, connection_ptr);
boost::asio::io_service& m_io_service;
tcp::acceptor m_acceptor;
chat_room_ptr m_room;
};
|
L'interface est relativement simple et l'implémentation aussi!
On lance une écoute dans le constructeur (1). On commence par créer une connexion TCP (2) vue
précédemment, puis on attend une nouvelle connexion en asynchrone via la socket de la connexion
fraichement créée (3).
Lorsqu'un client se connecte, le callback (4) est appelé. On crée une session qui sera responsable
de l'échange des données via le réseau avec le client (5).
On ajoute cette session dans la salle de chat (6). Puis on réécoute à nouveau les connexions entrantes (7).
| char_server.cpp |
#include "chat_server.h"
#include "chat_session.h"
chat_server::chat_server(boost::asio::io_service& io_service, const tcp::endpoint& endpoint)
:m_io_service(io_service),
m_acceptor(io_service, endpoint),
m_room(new chat_room(*this))
{
std::cout << "Creation d'un serveur " << std::endl;
wait_for_connection();
}
void chat_server::wait_for_connection()
{
connection_ptr new_connection(new tcp_connection(m_io_service));
m_acceptor.async_accept(new_connection->socket(),
boost::bind(&chat_server::handle_accept, this,
boost::asio::placeholders::error,
new_connection)
);
}
void chat_server::handle_accept(const boost::system::error_code& error, connection_ptr new_connection)
{
if (!error)
{
std::cout << "Connection acceptée" << std::endl;
chat_session_ptr session = chat_session::create(new_connection, m_room);
m_room->join(session);
wait_for_connection();
}
else {
std::cerr << "Connection refusee" << std::endl;
}
}
|
Le code de chat_server est relativement simple, la gestion de la connexion avec le client va
s'effectuer à travers chat_session, que nous découvrir sans attendre dans la prochaine section.
VII-C-4. Classe chat_session
Elle représente la connexion côté serveur, avec le client. Découvrons tout d'abord son interface.
Parmi les fonctions membres, nous retrouvons du connu : une fonction create() pour renvoyer un
shared_ptr d'une nouvelle instance. On oblige ainsi chat_session à être manipulée par des pointeurs
intelligents. On en profite également pour lancer l'écoute (la fonction wait_for_data() ). En effet,
la fonction wait_for_data() nécessite un shared_from_this() dans l'appel asynchrone. Nous ne
pouvons donc l'inclure directement dans le constructeur, mais bien passer par une sorte de factory.
On retrouve également une fonction deliver (2) pour envoyer un message au client.
La session possède un lien vers sa salle de chat (3), en weak_ptr parce qu'elle n'en est pas
responsable.
| chat_session.h |
using boost::asio::ip::tcp;
class chat_server;
class chat_session
: public boost::enable_shared_from_this<chat_session>
{
public:
~chat_session();
static chat_session_ptr create(connection_ptr tcp_connection, chat_room_ptr room)
{
chat_session_ptr session (new chat_session(tcp_connection, room));
session->wait_for_data();
return session;
}
void deliver (const chat_message& msg);
private:
chat_session(connection_ptr tcp_connection, chat_room_ptr room);
void wait_for_data ();
void handle_write (const boost::system::error_code& error);
void handle_read (const boost::system::error_code& error);
connection_ptr m_tcp_connection;
chat_room_wptr m_room;
chat_message m_message;
bool is_leaving;
};
typedef boost::shared_ptr<chat_session> chat_session_ptr;
|
Nous disposons ici d'un code avec une interface très simple, voyons maintenant l'implémentation.
 |
Grâce à l'effort fourni pour encapsuler la connexion dans tcp_connection, vous noterez la simplicité
des appels réseaux désormais, puisqu'on peut jouer directement avec nos structures de message
(chat_message ici).
|
Comme nous l'avons vu quelques lignes au dessus, la fonction create() appelle wait_for_data()
pour commencer l'écoute de données en provenance du client. On utilise pour cela tcp_connection, en
précisant juste le chat_message de réception et le callback (handle_read ici).
Dans la fonction handle_read (2), on récupère un shared_ptr sur la salle de chat à partir du
weak_ptr. Puis on demande à la room de faire passer le message (3). Enfin, on demande pour recevoir
à nouveau (4).
Si jamais une erreur survient (dans 99% des cas il s'agit d'une déconnexion du client), alors
on quitte la room (5).
La fonction pour acheminer les messages vers le client est relativement simple (6), toujours grâce à notre
tcp_connection. On demande une écriture via async_write. Nul besoin de stocker une variable
de "longue durée de vie", puisque c'est tcp_connection qui s'en charge.
En cas d'erreur, la fonction callback (7) nous retire de la room.
On noter l'existence d'une variable booléenne is_leaving. En effet, nous avons souvent deux opérations
asynchrones menées en parallèles : la lecture et l'écriture. En cas de déconnexion du client, il est fort
probable que les deux se passent mal. Le mieux est donc de placer un garde fou pour ne pas nous retirer deux
fois de la room.
| chat_session.cpp |
#include "chat_session.h"
#include "chat_server.h"
chat_session::chat_session(connection_ptr tcp_connection, chat_room_ptr room)
: m_tcp_connection(tcp_connection),
m_room(room)
{
is_leaving = false;
std::cout << "New chat_session ! " << std::endl;
}
chat_session::~chat_session()
{
std::cout << "Session détruite" << std::endl;
}
void chat_session::wait_for_data()
{
m_tcp_connection->async_read(m_message,
boost::bind(&chat_session::handle_read, shared_from_this(),
boost::asio::placeholders::error)
);
}
void chat_session::handle_read(const boost::system::error_code &error)
{
chat_room_ptr room = m_room.lock();
if (room)
{
if (!error)
{
room->deliver(m_message);
wait_for_data();
}
else
{
if (!is_leaving)
{
is_leaving = true;
room->leave(shared_from_this() );
}
}
}
}
void chat_session::deliver(const chat_message& msg)
{
m_tcp_connection->async_write(msg,
boost::bind(&chat_session::handle_write, shared_from_this(),
boost::asio::placeholders::error)
);
}
void chat_session::handle_write(const boost::system::error_code &error)
{
chat_room_ptr room = m_room.lock();
if (room && error && (!is_leaving) )
{
is_leaving = true;
room->leave(shared_from_this() );
}
}
|
chat_session communique donc directement avec le client, mais aussi avec la room pour qu'elle passe
le message aux autres clients connectés.
VII-C-5. Classe chat_room
chat_room est une salle de chat contenant des participants. Elle reçoit des messages en provenance
d'un participant (un chat_session) et le transmet à tout le monde (tous les chat_session).
L'interface aurait pu être simplifiée. J'ai choisis de laisser à la room un lien vers le serveur qu'il l'a
créée. Ce n'était pas nécessaire dans notre cas, mais en cas d'ajouts de fonctionnalités importantes, ce sera
indispensable.
Quoiqu'il en soit, nous avons donc une fonction join pour ajouter un participant, une fonction
leave pour retirer un participant et deliver pour passer un message à tous les
participants.
Les participants sont stockés dans un set sous forme de shared_ptr.
| chat_room.h |
class chat_session;
class chat_server;
typedef boost::shared_ptr<chat_session> chat_session_ptr;
class chat_room
{
public:
chat_room(chat_server& server);
void join (chat_session_ptr participant);
void leave (chat_session_ptr participant);
void deliver (const chat_message& msg);
private:
std::set<chat_session_ptr> m_participants;
chat_server& m_server;
};
typedef boost::shared_ptr<chat_room> chat_room_ptr;
typedef boost::weak_ptr<chat_room> chat_room_wptr;
|
L'implémentation est vraiment triviale et ne concerne pas vraiment le réseau.
On notera toutefois la création d'un message d'information lorsqu'un client se connecte (1) ou s'en va (2).
| chat_room.cpp |
#include "chat_room.h"
#include "chat_session.h"
#include "chat_server.h"
chat_room::chat_room(chat_server& server)
:m_server(server)
{
std::cout << "New room" << std::endl;
}
void chat_room::join(chat_session_ptr participant)
{
m_participants.insert(participant);
chat_message e;
e.m_type = chat_message::PERSON_CONNECTED;
deliver(e);
}
void chat_room::leave(chat_session_ptr participant)
{
chat_message e;
e.m_type = chat_message::PERSON_LEFT;
deliver(e);
m_participants.erase(participant);
}
void chat_room::deliver(const chat_message& msg)
{
std::for_each(m_participants.begin(), m_participants.end(),
boost::bind(&chat_session::deliver, _1, boost::ref(msg)));
}
|
VII-C-6. Bilan
Le serveur est prêt. Nous avons créé des classes et affecté des responsabilités à chacune d'entre elles.
Le niveau d'abstraction proposé permet de découpler structure de données à envoyer, réseau et gestion de
l'application. Boost.Serialization et Boost.Asio fonctionne particulièrement bien ensemble en envoyant sur
le réseau n'importe quelle structure sérialisable.
VII-D. Bilan
Le client reste beaucoup plus simple que le serveur. C'est un choix de ne pas le détailler. L'esprit du code est
exactement le même, donc la compréhension devrait être assez facile.
A noter tout de même, j'utilise un pattern Observer pour découpler événements reçus et traitement
des événements. En effet, le client transmet à une liste d'observateurs les événements qu'il a reçu. C'est aux
observateurs d'implémenter une queue d'événements ou autre structure pour les traiter.
A travers cet exemple de chat, nous avons pu mettre en œuvre des appels réseaux asynchrones avec Boost.Asio, que
ce soit en lecture ou en écriture. L'asynchronicité ne nous a pas gêné dans notre développement puisque Boost.Asio
nous décharge de la gestion de la concurrence.
Boost.Serialization nous a permis de ne pas se soucier de la structure des données, elle peut être modifiée comme
bon nous semble et à n'importe quel moment du développement du projet. Cette souplesse est très appréciable dans
du développement réseau.
Enfin, j'ai essayé autant que possible d'utiliser des bons principes de programmation, comme les
pointeurs intelligents, le découplage du code avec le pattern observateur. Ces bons principes doivent
être présents dans tout programme ayant la prétention de devenir robuste et de résister à l'épreuve du temps,
et des changements de cahier des charges!
VIII. Conclusion
Au cours de ce tutoriel, nous avons pu apprécier les nombreuses fonctionnalités proposées par Boost.Asio, à savoir
la gestion des opérations synchrones et surtout asynchrones, les timers, le support de plusieurs protocoles réseaux
(TCP, UDP, ...).
Nous avons pu voir la puissance et la simplicité des opérations asynchrones mises en place avec Boost.Asio, ne
nécessitant pas d'attention particulière pour les accès concurrents. Couplé
avec Boost.Serialization, Boost.Asio nous permet d'envoyer et recevoir presque n'importe quoi par le réseau et ce,
sans se soucier ni dépendre des structures de données.
Certains aspects comme la communication via un
port série
n'ont pas été traités ici. Cependant, le fonctionnement reste
le même puisque les écritures et lectures se font non plus via une socket, mais un port série. Le lecteur ayant compris
le fonctionnement des sockets de Boost.Asio ici n'aura aucune difficulté à le transférer sur un port série.
Au final, l'élégance, la puissance et la portabilité de Boost.Asio en font une bibliothèque incontournable pour
programmer des applications réseaux en C++.
IX. Remerciements


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 ©
2009 Gwenaël Dunand. 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.
Cette page est déposée.