I. État des lieux▲
Nous avons, dans les chapitres précédents, créé un protocole ordonné non fiable et un protocole ordonné fiable. Mais seul le protocole ordonné non fiable est actuellement utilisé par le moteur. Le protocole ordonné fiable étant uniquement utilisé par des tests unitaires…
Or, dans un projet, il est assez commun d’avoir besoin de ces deux protocoles de communication en parallèle : si la position des personnages est généralement envoyée par un protocole ordonné non fiable, de sorte à récupérer uniquement la plus récente, l’envoi de message texte par exemple sera de préférence envoyé avec un protocole ordonné fiable.
Il est tentant dans ce genre de cas de se dire que la solution serait de créer une seconde connexion, utilisant TCP, pour que les données ordonnées fiables transitent via celle-ci. Puis vient rapidement l’idée qu’une seule connexion TCP ne suffirait pas parce que l’on veut avoir plusieurs groupes de données à envoyer de manière ordonnée fiable sans qu’elles s’entremêlent (pour pouvoir communiquer textuellement avec des joueurs pendant que l’on télécharge la carte de la partie par exemple).
Ce n’est généralement pas une bonne idée : ça complexifie l’établissement des connexions, ajoute un nouveau point d’échec possible, si un joueur fait office d’hôte et de serveur il devra certainement ouvrir ou rediriger des ports de son pare-feu (pratique que l’on tend à éviter de nos jours)… Je vous invite à lire cet article pour plus de détails.
Donc toutes les communications devront passer par la connexion unique établie, et nous devons trouver un moyen d’utiliser tous les protocoles créés à travers celle-ci.
II. Canaux de communications▲
L’idée est d’introduire la notion de canaux : l’un servira aux échanges ordonnés non fiables, un autre aux échanges ordonnés fiables.
À terme, vous pourrez utiliser autant de canaux que voulu pour complexifier les échanges. Par exemple : envoyer des messages A, B, C via un premier canal ordonné fiable, les messages D, E, F via un second canal ordonné fiable et le reste via un autre canal ordonné non fiable. Vous pourrez même créer d’autres canaux implémentant d’autres protocoles. Multipliez les canaux, et les combinaisons, pour répondre à vos besoins !
Le protocole ordonné non fiable du chapitre 4 deviendra donc un canal de communication ordonné non fiable et le protocole ordonné fiable du chapitre précédent sera un canal ordonné fiable.
II-A. Gestionnaire de canaux▲
Afin de gérer plusieurs canaux de communication, nous utiliserons un gestionnaire de canaux.
Le gestionnaire est une simple couche qui s’insère entre ClientDistant et les canaux. Son seul rôle est de rediriger les données dans le bon canal afin d’assurer la sérialisation lors de l’envoi et la désérialisation à la réception.
III. Uniformiser les protocoles▲
Avant de parler du gestionnaire de canaux, intéressons-nous aux protocoles déjà existants.
Le gestionnaire devant travailler avec tous les canaux, ceux-ci doivent donc avoir une interface commune.
Nos deux protocoles actuels sont déjà très similaires : le protocole fiable a un paramètre supplémentaire à l’envoi et la réception (l’identifiant de datagramme) et quelques méthodes pour agir sur l’acquittement et la perte de datagramme.
L’uniformisation des protocoles est donc très simple : il suffit d’ajouter ces informations au protocole non fiable, qui ne les utilisera pas - donc sous forme de fonction vide.
III-A. Créer une interface▲
Puisqu’on parle d’interface commune, créons donc une interface au sens C++.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
class
IMultiplexer
{
public
:
IMultiplexer() =
default
;
virtual
~
IMultiplexer() =
default
;
virtual
void
queue(std::
vector<
uint8_t>&&
msgData) =
0
;
virtual
size_t serialize(uint8_t*
buffer, const
size_t buffersize, Datagram::
ID datagramId) =
0
;
virtual
void
onDatagramAcked(Datagram::
ID /*datagramId*/
) {}
virtual
void
onDatagramLost(Datagram::
ID /*datagramId*/
) {}
}
;
class
IDemultiplexer
{
public
:
IDemultiplexer() =
default
;
virtual
~
IDemultiplexer() =
default
;
virtual
void
onDataReceived(const
uint8_t*
data, const
size_t datasize) =
0
;
virtual
std::
vector<
std::
vector<
uint8_t>>
process() =
0
;
}
;
Notez que les fonctions onDatagramAcked et onDatagramLost ont une implémentation par défaut qui est vide. De cette manière, un protocole non fiable pourra ignorer ces fonctions tandis qu’un protocole fiable qui doit agir sur ces actions devra les surcharger – ce qui aurait été le cas de toute façon.
III-B. Utiliser cette interface▲
Maintenant, mettons à jour les protocoles existants pour qu’ils héritent de cette interface.
Il s’agit de faire hériter chacun de nos multiplexeurs et démultiplexeurs de la nouvelle interface.
D’abord le protocole ordonné fiable :
Puis le protocole ordonné non fiable :
IV. Introduire les canaux de communication▲
Il est maintenant temps d’introduire nos canaux de communication.
Comme bien souvent, la solution est d’ajouter une indirection et une couche intermédiaire : c’est exactement ce qu’est le gestionnaire de canaux.
Ce gestionnaire sera le gérant et propriétaire des protocoles utilisés. Notre ClientDistant n’utilisera plus de protocole directement mais le gestionnaire de canaux.
IV-A. Interface du gestionnaire de canaux▲
Puisqu’il s’agit d’une couche intermédiaire, nous devrions retrouver sans surprise les interfaces du multiplexeur et du démultiplexeur, mais aussi une liste de multiplexeurs et démultiplexeurs pour chaque canal que nous voulons utiliser. Partons donc sur ceci :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
37.
38.
39.
40.
#pragma once
#include
<memory>
#include
<vector>
namespace
Bousk
{
namespace
Network
{
namespace
UDP
{
namespace
Protocols
{
class
IMultiplexer;
class
IDemultiplexer;
}
class
ChannelsHandler
{
public
:
ChannelsHandler();
~
ChannelsHandler();
// Multiplexeur
void
queue(std::
vector<
uint8_t>&&
msgData);
size_t serialize(uint8_t*
buffer, const
size_t buffersize, Datagram::
ID datagramId);
void
onDatagramAcked(Datagram::
ID datagramId);
void
onDatagramLost(Datagram::
ID datagramId);
// Demultiplexeur
void
onDataReceived(const
uint8_t*
data, const
size_t datasize);
std::
vector<
std::
vector<
uint8_t>>
process();
private
:
std::
vector<
std::
unique_ptr<
Protocols::
IMultiplexer>>
mMultiplexers;
std::
vector<
std::
unique_ptr<
Protocols::
IDemultiplexer>>
mDemultiplexers;
}
;
}
}
}
IV-B. Implémentations▲
IV-B-1. Constructeur▲
Le constructeur aura pour le moment la responsabilité de créer les deux canaux : un pour le protocole ordonné non fiable et le second pour le protocole ordonné fiable.
2.
3.
4.
5.
6.
7.
8.
ChannelsHandler::
ChannelsHandler()
{
mMultiplexers.push_back(std::
make_unique<
Protocols::UnreliableOrdered::
Multiplexer>
());
mMultiplexers.push_back(std::
make_unique<
Protocols::ReliableOrdered::
Multiplexer>
());
mDemultiplexers.push_back(std::
make_unique<
Protocols::UnreliableOrdered::
Demultiplexer>
());
mDemultiplexers.push_back(std::
make_unique<
Protocols::ReliableOrdered::
Demultiplexer>
());
}
Il est très important que les protocoles soient dans le même ordre.
Si mMultiplexers[0
] est le multiplexeur du protocole ordonné non fiable, mDemultiplexers[0
] doit être le démultiplexeur du protocole ordonné non fiable. Sinon il y aura des problèmes de désérialisation des paquets.
IV-B-2. void onDatagramAcked(Datagram::ID datagramId);▲
Commençons par le plus simple. Cette fonction aura pour seul but de propager cet appel aux multiplexeurs présents dans notre gestionnaire de canaux.
IV-B-3. void onDatagramLost(Datagram::ID datagramId);▲
Tout comme la précédente, il s’agit d’une simple propagation aux multiplexeurs de chaque protocole enregistré dans le gestionnaire.
IV-B-4. std::vector<std::vector<uint8_t>> process();▲
Enfin la dernière fonction simple à implémenter puisqu’il s’agit de propager l’appel à process() de chaque démultiplexeur puis fusionner les résultats et retourner le tout.
Notez l’utilisation des std::
make_move_iterator. Ceux-ci permettent d’éviter une copie superflue alors que notre seul intérêt est de déplacer les tampons du démultiplexeur que l’on vient d’appeler vers le vector qui sera retourné.
IV-B-5. void queue(std::vector<uint8_t>&& msgData);▲
Voici la première difficulté.
Lorsque nous mettons en file d’envoi des données, via quel canal veut-on les envoyer ? Et comment choisir ce canal ?
De toute évidence, la signature actuelle n’est pas suffisante. Il faut pouvoir choisir le canal d’envoi, et celui-ci doit être un paramètre.
Changeons donc la signature pour void
queue(std::
vector<
uint8_t>&&
msgData, uint32_t canalIndex);
Cette fois le choix du canal est clair et laissé à l’utilisateur (ici notre classe DistantClient). Avant de voir ce que cela implique, implémentons cette fonction :
2.
3.
4.
5.
void
ChannelsHandler::
queue(std::
vector<
uint8_t>&&
msgData, uint32_t canalIndex)
{
assert(canalIndex <
mMultiplexers.size());
mMultiplexers[canalIndex]->
queue(std::
move(msgData));
}
Dans notre cas actuel, avec uniquement deux canaux, l’un ordonné fiable et l’autre ordonné non fiable, il est tentant d’ajouter uniquement un paramètre bool
indiquant si l’envoi doit être fait de manière fiable ou non.
Mais afin de garder la possibilité d’ajouter d’autres canaux par la suite, ou juste de pouvoir utiliser plusieurs canaux fiables ou non fiables, je favorise immédiatement l’index de canal à utiliser.
IV-B-6. size_t serialize(uint8_t* buffer, const size_t buffersize, Datagram::ID datagramId);▲
Ici la difficulté est différente. La façon dont nous sérialisons les différents canaux aura une incidence directe sur la latence de chacun d’eux. Favoriser un canal en particulier aura pour effet de retarder l’envoi des autres canaux.
Par exemple, considérons l’implémentation suivante :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
size_t ChannelsHandler::
serialize(uint8_t*
buffer, const
size_t buffersize, Datagram::
ID datagramId)
{
size_t remainingBuffersize =
buffersize;
for
(auto
&
protocol : mMultiplexers)
{
const
size_t serializedData =
protocol->
serialize(buffer, remainingBuffersize, datagramId);
assert(serializedData <=
remainingBuffersize);
buffer +=
serializedData;
remainingBuffersize -=
serializedData;
}
return
buffersize -
remainingBuffersize;
}
Cette implémentation est tout à fait valide mais a également une conséquence importante : les canaux sont traités dans leur ordre d’enregistrement. Autrement dit, le premier canal enregistré sera le premier traité et aura donc le plus de chances d’envoyer ses données. Imaginez mettre en file un paquet de taille maximale dans un des canaux suivants, et ces données ont très peu de chances d’être jamais envoyées.
Pour contrer ce phénomène on peut par exemple diminuer la taille maximale d’un paquet et limiter le nombre de paquets de chaque canal dans un datagramme donné.
Pour le moment, contentons-nous de cette implémentation.
IV-B-7. void onDataReceived(const uint8_t* data, const size_t datasize);▲
Dernière fonction et probablement la plus problématique actuellement.
Cette fonction reçoit les données brutes du datagramme et doit rediriger chaque paquet qu’il contient au canal correct. Mais puisqu’il s’agit de données brutes, il n’y a absolument aucune information indiquant ceci.
Notre couche intermédiaire manque d’information et est en l’état incapable de réaliser cette tâche.
IV-B-7-a. En-tête de canal▲
Il faut donc ajouter un peu d’information pour indiquer via quel canal le paquet doit être extrait, sous forme d’en-tête de canal.
Un tel en-tête doit contenir l’identifiant du canal, et la taille des données dédiées à ce canal. Ainsi nous savons quelle quantité de données doit être redirigée vers quel canal afin que ce dernier puisse les gérer via sa propre fonction onDataReceived.
Un en-tête de canal sera représenté par la structure suivante :
2.
3.
4.
5.
struct
ChannelHeader
{
uint32_t channelId;
uint32_t datasize;
}
;
IV-B-7-b. Taille des paquets▲
Ajouter un tel en-tête a une incidence directe sur nos paquets : la taille disponible pour ceux-ci dans nos datagrammes diminue.
Il faut donc mettre à jour la définition des paquets pour répercuter ceci :
static
constexpr
uint16_t PacketMaxSize =
Datagram::
DataMaxSize -
ChannelHeader::
Size;
Avec ChannelHeader ayant une définition complète telle que :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
#pragma once
#include
<cstdint>
namespace
Bousk
{
namespace
Network
{
namespace
UDP
{
struct
ChannelHeader
{
static
constexpr
size_t Size =
sizeof
(uint32_t) +
sizeof
(uint32_t);
uint32_t channelId;
uint32_t datasize;
}
;
}
}
}
IV-B-7-c. Mise à jour de la sérialisation▲
La donnée est nécessaire à la réception, mais c’est dès l’envoi que celle-ci doit être intégrée.
Afin d’optimiser notre bande passante, l’en-tête doit être ajouté uniquement si le canal en question envoie des paquets.
Voici l’implémentation que nous utiliserons :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
size_t ChannelsHandler::
serialize(uint8_t*
buffer, const
size_t buffersize, Datagram::
ID datagramId)
{
size_t remainingBuffersize =
buffersize;
for
(uint32_t channelid =
0
; channelid <
mMultiplexers.size(); ++
channelid)
{
Protocols::
IMultiplexer*
protocol =
mMultiplexers[protocolid].get();
uint8_t*
const
channelHeaderStart =
buffer;
uint8_t*
const
channelDataStart =
buffer +
ChannelHeader::
Size;
const
size_t channelAvailableSize =
remainingBuffersize -
ChannelHeader::
Size;
const
size_t serializedData =
protocol->
serialize(channelDataStart, channelAvailableSize, datagramId);
assert(serializedData <=
channelAvailableSize);
if
(serializedData)
{
// Le canal a sérialisé des données pour l’envoi, ajoutons l’en-tête de canal
ChannelHeader*
const
channelHeader =
reinterpret_cast
<
ChannelHeader*>
(channelHeaderStart);
channelHeader->
channelId =
protocolid;
channelHeader->
datasize =
static_cast
<
uint32_t>
(serializedData);
const
size_t channelTotalSize =
serializedData +
ChannelHeader::
Size;
buffer +=
channelTotalSize;
remainingBuffersize -=
channelTotalSize;
}
}
return
buffersize -
remainingBuffersize;
}
IV-B-7-d. Implémentation de la réception▲
Il est maintenant temps d’implémenter void
ChannelsHandler::
onDataReceived(const
uint8_t*
data, const
size_t datasize).
Son implémentation sera bien plus simple que le code de la sérialisation. Ici nous devons juste extraire l’en-tête de canal, puis transférer les données requises à celui correspondant.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
void
ChannelsHandler::
onDataReceived(const
uint8_t*
data, const
size_t datasize)
{
size_t processedData =
0
;
while
(processedData <
datasize)
{
const
ChannelHeader*
channelHeader =
reinterpret_cast
<
const
ChannelHeader*>
(data);
if
(processedData +
channelHeader->
datasize >
datasize ||
channelHeader->
datasize >
Datagram::
DataMaxSize)
{
// Tampon mal formé
return
;
}
if
(channelHeader->
channelId >=
mChannels.size())
{
// Canal demandé non existant
return
;
}
mChannels[channelHeader->
channelId]->
onDataReceived(data +
ChannelHeader::
Size, channelHeader->
datasize);
const
size_t channelTotalSize =
channelHeader->
datasize +
ChannelHeader::
Size;
data +=
channelTotalSize;
processedData +=
channelTotalSize;
}
}
V. Mise à jour du reste du code▲
V-A. Le client distant▲
Notre classe DistantClient utilise jusque-là un multiplexeur du protocole ordonné non fiable et son démultiplexeur. En plus d’avoir changé leurs interfaces, rendant en particulier le multiplexeur incompatible, nous voulons maintenant utiliser plusieurs protocoles via des canaux et donc utiliser le gestionnaire de canaux.
Apportons donc les corrections nécessaires, d’abord dans le fichier d’en-tête :
Puis dans l’implémentation :
void
DistantClient::
send(std::
vector<
uint8_t>&&
data, uint32_t canalIndex)
{
mChannelsHandler.queue(std::
move(data), canalIndex);
}
bool
DistantClient::
fillDatagram(Datagram&
dgram)
{
dgram.header.ack =
htons(mReceivedAcks.lastAck());
dgram.header.previousAcks =
mReceivedAcks.previousAcksMask();
dgram.datasize =
mChannelsHandler.serialize(dgram.data.data(), Datagram::
DataMaxSize, mNextDatagramIdToSend);
if
(dgram.datasize >
0
)
{
dgram.header.id =
htons(mNextDatagramIdToSend);
++
mNextDatagramIdToSend;
return
true
;
}
return
false
;
}
void
DistantClient::
onDataReceived(const
uint8_t*
data, const
size_t datasize)
{
mChannelsHandler.onDataReceived(data, datasize);
auto
receivedMessages =
mChannelsHandler.process();
for
(auto
&&
msg : receivedMessages)
{
onMessageReady(std::
make_unique<
Messages::
UserData>
(std::
move(msg)));
}
}
V-B. Le client UDP▲
Il reste une dernière modification à apporter, au client UDP cette fois. Puisque la signature de send a changé dans DistantClient, elle est désormais incompatible avec le client.
Nous allons apporter une modification similaire et ajouter le canal d’envoi dans les paramètres :
Il faut bien entendu également mettre à jour les tests unitaires existants. Ceci ne sera pas abordé dans le cours, mais les changements sont disponibles sur le dépôt Mercurial.
VI. De multiplexeur et démultiplexeur à protocole▲
Nous avons désormais à notre disposition un gestionnaire de canaux qui permet d’utiliser plusieurs protocoles. Et ces protocoles partagent une interface commune.
Seulement il se compose en ce moment d’un vector de multiplexeurs et d’un vector de démultiplexeurs.
Et nous avons appuyé lors de l’enregistrement de nos deux canaux combien il est important de respecter l’ordre afin que l’index du multiplexeur corresponde à son index de multiplexeur.
Pour plus de facilité, et afin d’éviter d’introduire une source d’erreurs, créons donc une interface de protocole. Un protocole combinera l’interface de notre actuelle interface de multiplexeur et du démultiplexeur.
Ainsi le gestionnaire de canaux gérera une liste de protocoles, les canaux d’échange de données, et non une liste de multiplexeurs et une autre de démultiplexeurs.
VI-A. Interface de protocole▲
L’interface de protocole consistera littéralement en la fusion de IMultiplexeur et IDemultiplexeur introduits auparavant.
VI-B. Mise à jour du reste du code▲
Il convient de mettre à jour le code existant afin de répandre le concept de protocoles là où multiplexeur et démultiplexeur sont utilisés.
Notez que l’idée d’avoir une classe de multiplexeur dédiée à l’envoi et une classe de démultiplexeur pour traiter la réception reste valide.
L’implémentation d’un protocole aura tout intérêt à découpler ces concepts afin d’avoir un code plus lisible.
Le concept de protocole est principalement introduit comme étant l’agrégation d’un multiplexeur et d’un démultiplexeur puisque ces deux éléments fonctionnent de pair.
VI-B-1. Convertir un binôme multiplexeur / démultiplexeur en protocole▲
Voyons comment un tel changement peut s’opérer sur le cas du protocole ordonné fiable.
Les opérations sont les suivantes :
- transformer le
namespace
ReliableOrdered enclass
ReliableOrdered ; - faire hériter cette classe de l’interface IProtocol ;
- garder le multiplexeur et le démultiplexeur comme classes internes à notre nouvelle classe ;
- définir une variable membre de notre nouvelle classe de protocole pour le multiplexeur, et une autre pour le démultiplexeur ;
- implémenter l’interface de IProtocole pour rediriger les appels au multiplexeur ou démultiplexeur membre.
En suivant ce schéma, la transformation est plutôt simple et le code à modifier minime. Vous pouvez voir les détails des changements sur le dépôt Mercurial.
VI-B-2. Gestionnaire de canaux▲
Le gestionnaire de canaux n’aura plus qu’à gérer une liste de canaux via un unique vector de protocoles std::vector<std::unique_ptr<Protocols::IProtocol>> mChannels;.
Modifier l’implémentation du gestionnaire de canaux pour répercuter ces changements relève du trivial et ne sera donc pas présenté ici, mais est visible sur le dépôt Mercurial.
VII. Configuration et protocoles utilisateurs▲
Voici la partie intéressante suite à ces modifications : donner la possibilité à l’utilisateur de choisir quels protocoles utiliser et par extension d’utiliser ses propres protocoles.
Pour ce faire, ajoutons des interfaces pour enregistrer le canal de son choix. Puisque ces canaux doivent être de nouvelles instances pour chaque gestionnaire de canaux, j’aime bien utiliser une fonction template :
template
<
class
T>
void
ChannelsHandler::
registerChannel()
{
mChannels.push_back(std::
make_unique<
T>
());
}
Et nous pouvons créer la même fonction dans la classe DistantClient :
template
<
class
T>
void
DistantClient::
registerChannel()
{
mChannelsHandler.registerChannel<
T>
();
}
Il reste un problème : ces canaux doivent être enregistrés pour chaque client distant. Et ces clients ne sont pas connus à la compilation. Il faut donc trouver un moyen d’enregistrer une liste de canaux, et d’utiliser cette liste pour initialiser les clients distants à leur création.
Voici une astuce simple pour y parvenir : enregistrer dans le client UDP une liste de foncteurs qui créeront les canaux.
Pour la liste de foncteurs, un std::
vector<
std::
function<
void
(DistantClient&
)>>
sera parfait. Pour enregistrer un canal, une fonction template répond à nouveau à la situation :
template
<
class
T>
void
Client::
RegisterChannel()
{
mRegisteredCanals.push_back([](DistantClient&
distantClient) {
distantClient.registerChannel<
T>
(); }
);
}
Maintenant, modifions la création de clients distants pour y ajouter l’étape d’enregistrements des canaux :
DistantClient&
Client::
getClient(const
sockaddr_storage&
clientAddr)
{
auto
itClient =
std::
find_if(mClients.begin(), mClients.end(), [&
](const
std::
unique_ptr<
DistantClient>&
client) {
return
memcmp(&
(client->
address()), &
clientAddr, sizeof
(sockaddr_storage)); }
);
if
(itClient !=
mClients.end())
return
*
(itClient->
get());
mClients.emplace_back(std::
make_unique<
DistantClient>
(*
this
, clientAddr));
setupChannels(*
(mClients.back()));
return
*
(mClients.back());
}
void
Client::
setupChannels(DistantClient&
client)
{
for
(auto
&
fct : mRegisteredChannels)
fct(client);
}
VIII. Conclusion▲
L’ensemble du chapitre est un remaniement de la notion de multiplexeur et démultiplexeur et une légère réarchitecture du code afin de fusionner ces concepts pour créer un objet canal utilisant un protocole.
Il n’y a pas de tests cette fois puisqu’aucune nouvelle fonctionnalité n’a vraiment été introduite. Le gestionnaire de canaux n’étant finalement qu’un conteneur à protocoles.
De plus les tests existants sur le client distant ont dû être mis à jour suite à ces changements et l’ajout du gestionnaire de canaux. Le gestionnaire de canaux est donc déjà utilisé via ces tests, ce qui permet de s’assurer que son en-tête est correctement sérialisé et désérialisé et valider son fonctionnement.
Récupérez le code source sous le tag V1.0.7 dans le dépôt Mercurial à l’adresse suivante.
Article précédent | |
---|---|
<< UDP – Envoi de paquets ordonné et fiable |