I. Introduction▲
Bonjour, et bienvenue dans la traduction française du second article de la série d'articles « le réseau dans les jeux vidéo » écrits par Glenn Fiedler.
Dans l'article précédent, nous avons passé en revue les options pour partager des données entre des ordinateurs, et avons décidé d'utiliser UDP plutôt que TCP. Nous avons choisi UDP pour que nos données arrivent rapidement sans effet d'agglomération dans l'attente d'un renvoi de paquets.
Maintenant, je vais vous montrer comment on envoie et reçoit des paquets en UDP.
II. Sockets BSD▲
Pour la plupart des plateformes modernes vous avez une couche socket basique basée sur les sockets BSD.
Les sockets BSD sont manipulés avec de simples fonctions comme « socket », « bind », « sendto » et « recvfrom ». Bien sûr vous pouvez travailler directement avec ces fonctions si vous le souhaitez, mais il devient difficile de garder un code indépendant de la plateforme parce que chacune a ses spécificités.
Donc, je vais commencer par vous montrer les bases de l'utilisation de socket BSD avec des exemples de code, mais nous n'utiliserons pas bien longtemps les sockets BSD directement. À la place, dès que nous aurons traité les fonctionnalités de base des sockets, nous ajouterons un niveau d'abstraction grâce à des classes, pour rendre l'écriture de code indépendant de la plateforme.
III. Spécificités de plateforme▲
D'abord, initialisons un define pour qu'on puisse détecter la plateforme actuelle, afin de gérer les quelques différences dans la bibliothèque des sockets dépendant de la plateforme.
// détection de la plateforme
#define PLATFORM_WINDOWS 1
#define PLATFORM_MAC 2
#define PLATFORM_UNIX 3
#if defined(_WIN32)
#define PLATFORM PLATFORM_WINDOWS
#elif defined(__APPLE__)
#define PLATFORM PLATFORM_MAC
#else
#define PLATFORM PLATFORM_UNIX
#endif
Maintenant, nous pouvons inclure les fichiers d'en-tête appropriés pour chaque plateforme, grâce à l'utilisation de nos define .
#if PLATFORM == PLATFORM_WINDOWS
#include <winsock2.h>
#elif PLATFORM == PLATFORM_MAC || PLATFORM == PLATFORM_UNIX
#include <sys/socket.h>
#include <netinet/in.h>
#include <fcntl.h>
#endif
Les sockets sont intégrés à la bibliothèque standard sur les systèmes Unix, il n'y a donc rien à lier. Par contre, sur Windows, nous devons lier la bibliothèque winsock pour utiliser les méthodes de la bibliothèque des sockets.
Voici une astuce simple pour y parvenir sans avoir à changer votre projet ou makefile :
#if PLATFORM == PLATFORM_WINDOWS
#pragma comment( lib, "wsock32.lib" )
#endif
J'adore cette astuce parce que je suis très paresseux. Bien sûr, vous pouvez toujours lier la bibliothèque dans les propriétés du projet ou votre makefile si vous le souhaitez.
IV. Initialiser la couche socket▲
La plupart des plateformes Unix (Mac OS X inclus) ne nécessitent aucune initialisation particulière pour utiliser les sockets, contrairement à Windows. Vous devez appeler « WSAStartup » pour initialiser la couche socket avant d'appeler la moindre fonction de la bibliothèque des sockets, puis « WSACleanup » pour l'arrêter quand vous n'en avez plus besoin.
Ajoutons deux nouvelles fonctions :
bool InitializeSockets
(
)
{
#if PLATFORM == PLATFORM_WINDOWS
WSADATA WsaData;
return
WSAStartup
(
MAKEWORD
(
2
,2
), &
WsaData ) ==
NO_ERROR;
#else
return
true
;
#endif
}
void
ShutdownSockets
(
)
{
#if PLATFORM == PLATFORM_WINDOWS
WSACleanup
(
);
#endif
}
Maintenant, nous avons une méthode d'initialisation indépendante de la plateforme. Sur les plateformes qui ne nécessitent aucune initialisation, ces fonctions ne font rien.
V. Créer un socket▲
Il est temps de créer un socket UDP. Voici comment faire :
int
handle =
socket
(
AF_INET, SOCK_DGRAM, IPPROTO_UDP );
if
(
handle <=
0
)
{
printf
(
"
Erreur creation socket
\n
"
);
return
false
;
}
Ensuite, nous lions le socket UDP à un port (par exemple le 30000). Chaque socket doit être lié à un port unique, parce que, quand un paquet arrive, le numéro de port détermine à quel socket il doit être acheminé. N'utilisez pas les ports inférieurs à 1024, ils sont réservés pour le système.
Cas particulier : si vous vous moquez du port que votre socket va utiliser, vous pouvez demander à lier le port « 0 » et le système en sélectionnera un libre pour vous.
sockaddr_in address;
address.sin_family =
AF_INET;
address.sin_addr.s_addr =
INADDR_ANY;
address.sin_port =
htons
(
(
unsigned
short
) port );
if
(
bind
(
handle, (
const
sockaddr*
) &
address, sizeof
(
sockaddr_in) ) <
0
)
{
printf
(
"
Erreur liaison port
\n
"
);
return
false
;
}
Maintenant le socket est prêt à envoyer et recevoir des paquets.
Mais quelle est cette mystérieuse fonction « htons » dans le code ci-dessus ? Il s'agit d'une fonction d'aide pour convertir un entier 16 bits du boutisme (endianness) de l'hôte (petit ou gros-boutiste - little-endian ou big-endian) vers le boutisme du réseau (grand-boutiste). C'est nécessaire quand vous envoyez des entiers à travers le réseau.
Vous verrez « htons » (host to network short) et son cousin pour les entiers 32 bits « htonl » (host to network long) utilisés à plusieurs reprises dans cet article, donc restez attentif, et vous comprendrez ce qu'il se passe.
VI. Mettre le socket en mode non bloquant▲
Par défaut les sockets sont dans ce qu'on appelle un « mode bloquant ». Ça signifie que si vous essayez de lire un paquet avec la fonction « recvfrom », la fonction ne retournera pas tant qu'un paquet n'est pas disponible. Ce n'est pas du tout adapté à notre objectif. Les jeux vidéo sont des programmes temps réel qui simulent 30 à 60 images par seconde, ils ne peuvent pas se permettre d'attendre qu'un paquet arrive !
La solution est de passer le socket en « mode non bloquant » après l'avoir créé. Après ça, la fonction « recvfrom » retournera immédiatement quand aucun paquet n'est disponible, avec une valeur de retour qui indique que vous devriez essayer de lire un paquet plus tard.
Voici comment passer un socket en mode non bloquant :
#if PLATFORM == PLATFORM_MAC || PLATFORM == PLATFORM_UNIX
int
nonBlocking =
1
;
if
(
fcntl
(
handle, F_SETFL, O_NONBLOCK, nonBlocking ) ==
-
1
)
{
printf
(
"
Erreur parametrage non bloquant
\n
"
);
return
false
;
}
#elif PLATFORM == PLATFORM_WINDOWS
DWORD nonBlocking =
1
;
if
(
ioctlsocket
(
handle, FIONBIO, &
nonBlocking ) !=
0
)
{
printf
(
"
Erreur parametrage non bloquant
\n
"
);
return
false
;
}
#endif
Comme vous pouvez le voir ci-dessus, Windows ne fournit pas de fonction « fcntl », donc nous utilisons « ioctlsocket ».
VII. Envoyer des paquets▲
UDP est un protocole non connecté, donc chaque fois que vous envoyez un paquet, vous devez préciser l'adresse de destination. Vous pouvez utiliser un socket UDP pour envoyer des paquets à autant d'adresses IP différentes que souhaité, il n'y a pas d'ordinateur de l'autre côté auquel votre socket UDP est connecté.
Voici comment envoyer un paquet à une adresse spécifique :
int
sent_bytes =
sendto
(
handle, (
const
char
*
)packet_data, packet_size, 0
, (
sockaddr*
)&
address, sizeof
(
sockaddr_in) );
if
(
sent_bytes !=
packet_size )
{
printf
(
"
Erreur envoi paquet
\n
"
);
return
false
;
}
Attention ! La valeur de retour de « sendto » indique seulement si le paquet a été correctement envoyé depuis l'ordinateur local. Elle n'indique pas si le paquet a été reçu ou non par l'ordinateur distant ! UDP n'a aucun moyen de savoir si le paquet est arrivé à destination ou non.
Dans le code ci-dessus, nous passons une structure « sockaddr_in » comme adresse de destination. Comment devons initialiser cette structure ?
Disons que nous voulons envoyer à l'adresse 207.45.186.98:30000
Nous commençons avec cette adresse sous cette forme :
unsigned
int
a =
207
;
unsigned
int
b =
45
;
unsigned
int
c =
186
;
unsigned
int
d =
98
;
unsigned
short
port =
30000
;
Nous avons un peu de travail à faire pour le convertir dans une forme acceptée par « sendto » :
unsigned
int
address =
(
a <<
24
) |
(
b <<
16
) |
(
c <<
8
) |
d;
sockaddr_in addr;
addr.sin_family =
AF_INET;
addr.sin_addr.s_addr =
htonl
(
address );
addr.sin_port =
htons
(
port );
Comme vous pouvez le voir, nous commençons par associer les valeurs a,b,c,d dans la plage [0,255] en un seul entier, avec chaque octet de notre entier qui correspond à une valeur d'entrée. Ensuite nous initialisons une structure « sockaddr_in » avec notre adresse entière et le port, en s'assurant de les convertir dans le boutisme réseau avec les fonctions « htonl » et « htons ».
Cas particulier : si vous voulez envoyer un paquet à vous-même, pas besoin de demander l'adresse IP de votre machine, utilisez juste l'adresse de loopback 127.0.0.1 et le paquet sera envoyé à votre propre machine.
VIII. Recevoir des paquets▲
Une fois que vous avez un socket UDP lié à un port, tous les paquets UDP envoyés à votre socket, par votre IP et son port, sont mis dans une file. Pour recevoir un paquet, faites une simple boucle pour appeler « recvfrom » jusqu'à ce que la fonction indique une erreur indiquant que la file de paquets reçus est vide.
Étant donné qu'UDP est un protocole non connecté, les paquets peuvent arriver de nombreux ordinateurs. À chaque paquet reçu, « recvfrom » vous donne l'adresse IP et le port de l'expéditeur, donc vous savez d'où vient le paquet.
Voici comment récupérer tous les paquets reçus :
while
(
true
)
{
unsigned
char
packet_data[256
];
unsigned
int
max_packet_size =
sizeof
(
packet_data );
#if PLATFORM == PLATFORM_WINDOWS
typedef
int
socklen_t;
#endif
sockaddr_in from;
socklen_t fromLength =
sizeof
(
from );
int
bytes =
recvfrom
(
socket, (
char
*
)packet_data, max_packet_size, 0
, (
sockaddr*
)&
from, &
fromLength );
if
(
bytes <=
0
)
break
;
unsigned
int
from_address =
ntohl
(
from.sin_addr.s_addr );
unsigned
int
from_port =
ntohs
(
from.sin_port );
// traiter le paquet reçu
}
Les paquets dans la file dépassant le buffer de réception seront rejetés silencieusement. Donc si votre buffer fait 256 bits pour la réception, et que quelqu'un vous envoie un paquet de 300 bits, les 300 bits seront perdus. Vous ne recevrez pas juste les 256 premiers bits du paquet de 300.
Puisque vous écrivez votre propre protocole de jeu en réseau, ce n'est pas du tout un problème en pratique, assurez-vous juste que votre buffer de réception est assez grand pour récupérer le paquet le plus large que votre code puisse envoyer.
IX. Détruire un socket▲
Sur la plupart des plateformes Unix, les sockets sont des descripteurs de fichiers, vous pouvez donc utiliser la fonction « close » classique pour fermer un socket. Mais Windows aime se différencier, donc il faudra utiliser « closesocket » à la place :
#if PLATFORM == PLATFORM_MAC || PLATFORM == PLATFORM_UNIX
close
(
socket );
#elif PLATFORM == PLATFORM_WINDOWS
closesocket
(
socket );
#endif
Vive Windows !
X. Une classe socket▲
Nous avons couvert toutes les opérations de base : créer un socket, le lier à un port, le mettre non bloquant, envoyer et recevoir des paquets, puis fermer le socket.
Mais vous avez remarqué que la plupart de ces opérations sont quelque peu différentes selon la plateforme, et c'est plutôt gênant d'avoir à se souvenir de #ifdef et faire du code spécifique à la plateforme à chaque fois que vous voulez réaliser une opération sur votre socket.
Nous allons régler ce problème en enveloppant toutes les fonctionnalités de notre socket dans une classe « Socket ». Pendant qu'on y est, nous ajouterons une classe « Address » pour pouvoir manipuler aisément une adresse internet. Ça nous évitera d'avoir à manuellement encoder ou décoder une structure « sockaddr_in » à chaque envoi ou réception de paquets.
Voici à quoi notre classe Socket ressemble :
class
Socket
{
public
:
Socket();
~
Socket();
bool
Open( unsigned
short
port );
void
Close();
bool
IsOpen() const
;
bool
Send( const
Address &
destination, const
void
*
data, int
size );
int
Receive( Address &
sender, void
*
data, int
size );
private
:
int
handle;
}
;
Et voici à quoi notre classe Address ressemble :
class
Address
{
public
:
Address();
Address( unsigned
char
a, unsigned
char
b, unsigned
char
c, unsigned
char
d, unsigned
short
port );
Address( unsigned
int
address, unsigned
short
port );
unsigned
int
GetAddress() const
;
unsigned
char
GetA() const
;
unsigned
char
GetB() const
;
unsigned
char
GetC() const
;
unsigned
char
GetD() const
;
unsigned
short
GetPort() const
;
private
:
unsigned
int
address;
unsigned
short
port;
}
;
Et voilà comment utiliser ces classes pour envoyer et recevoir des paquets :
// créer le socket const int port = 30000;
Socket socket;
if
( !
socket.Open( port ) )
{
printf( "Erreur a la creation du socket!
\n
"
);
return
false
;
}
// envoyer un paquet const char data[] = "hello world!";
socket.Send( Address(127
,0
,0
,1
,port), data, sizeof
( data ) );
// recevoir des paquets
while
( true
)
{
Address sender;
unsigned
char
buffer[256
];
int
bytes_read =
socket.Receive( sender, buffer, sizeof
( buffer ) );
if
( !
bytes_read ) break
;
// traiter le paquet
}
Comme vous pouvez le voir, c'est beaucoup plus simple que d'utiliser les sockets BSD directement. En prime le code est identique sur toutes les plateformes, parce que tout ce qui est dépendant de la plateforme est géré dans vos classes Socket et Adress.
XI. Conclusion▲
Nous avons maintenant une manière indépendante de la plateforme d'envoyer et recevoir des paquets UDP.
UDP est un protocole non connecté, et j'ai voulu créer un programme d'exemple qui montre bien ça. Donc j'ai créé un simple programme qui lit des adresses IP d'un fichier texte et envoie un paquet à ces adresses toutes les secondes. Chaque fois que le programme reçoit un paquet, il affiche de quelle machine il provient, et la taille du paquet reçu.
Vous pouvez aisément le modifier pour avoir un nombre de nœuds qui s'envoient des paquets sur votre ordinateur, il suffit d'utiliser un port différent pour chaque instance du programme comme ceci :
> Node 30000
> Node 30001
> Node 30002
etc.
Ainsi chaque nœud essayera d'envoyer des paquets à chaque autre nœud, comme une mini-installation peer-to-peer.
Ce programme a été développé sous Mac OS X, mais vous devriez pouvoir le compiler sur n'importe quel système Unix assez facilement.
XII. Remerciements▲
Cet article est une traduction autorisée de l'article de Glenn Fiedler.
Merci aussi à Claude Leloup pour sa relecture orthographique.