I. Introduction▲
Un socket, qu’il soit TCP ou UDP, sera défini par un simple entier qui devra être passé aux fonctions qui l’utilisent. Sur plateformes UNIX il s’agira d’un descripteur de fichier, mais sur Windows c’est un descripteur de socket. La différence majeure est sur le type utilisé : un descripteur de socket (sous Windows donc) est un entier non signé (
unsigned
int
), tandis que sous UNIX il s’agira d’un entier signé (
int
). Cela peut entraîner des avertissements supplémentaires à la compilation lors du portage d’un code d’une plateforme à l’autre.
I-A. Le socket TCP▲
Un socket TCP représente un lien direct avec une machine distante et est une route d’échange de données bilatérale avec celle-ci.
Un socket TCP sera utilisé pour envoyer des données à la machine distante qu’il représente, mais également pour en recevoir de cette dernière.
Dans cette première partie, nous allons voir comment créer un socket TCP et se connecter à un serveur dont on connait l’adresse IP et le port.
La plupart des fonctions présentées ici seront également utilisées pour un socket UDP.
II. Spécificité Windows : initialisation▲
Quelques spécificités existent sur plateforme Windows pour utiliser les sockets.
Il s’agit de deux méthodes particulières à appeler pour démarrer et arrêter la bibliothèque de sockets.
II-A. WSAStartup - int WSAStartup(WORD version, WSADATA* data);▲
Permet d’initialiser la DLL pour utiliser les sockets. La version actuelle est la 2.2. Derrière ce prototype, il s’agit d’effectuer un simple appel de fonction comme suit :
WSADATA data;
WSAStartup(MAKEWORD(2
, 2
), &
data);
Retourne un code d’erreur en cas d’échec, 0 sinon.
II-B. WSACleanup - int WSACleanup();▲
Appeler cette fonction à la fin du programme pour libérer la DLL.
WSACleanup();
Retourne SOCKET_ERROR en cas d’erreur, 0 sinon.
III. Gestion d’erreurs▲
Quand une fonction génère une erreur, dans la majorité des cas, elle retourne -1 sous UNIX ou SOCKET_ERROR sous Windows, et met à jour l'indicateur d’erreur de son thread (souvenez-vous qu’il s’agit d’une bibliothèque en C). Pour récupérer la valeur d’erreur correcte, il faut récupérer la dernière erreur ainsi mise à jour.
III-A. Windows - int WSAGetLastError();▲
Comme son nom l’indique, retourne la dernière erreur survenue dans la bibliothèque de sockets pour le thread appelant la fonction.
Attention, sur Windows, la plupart des codes d'erreurs sont également « spécifiques ». EWOULDBLOCK sera par exemple remplacé par WSAEWOULDBLOCK , etc. La liste complète est disponible sur le site de la MSDN .
int
error =
WSAGetLastError();
III-B. Unix - errno▲
Sur Unix, il suffira de lire la valeur de la variable globale errno , disponible dans errno.h
#include
<errno.h>
int
error =
errno;
IV. Présentation des fonctions utiles▲
IV-A. hton*▲
Les fonctions de cette forme sont les fonctions Host/Home to Network . Elles servent à convertir les données numériques de la machine en données « réseau ».
Par convention, les communications réseau sont en big-endian , c’est-à-dire l’octet de poids fort en premier. On parle aussi de network byte order .
Il existe une méthode pour chaque type numérique existant :
short
htons(short
value);
long
htonl(long
value);
IV-B. ntoh*▲
Il s’agit des fonctions inverses des hton*. Elles convertissent les données réseau en données Host/Home.
short
ntohs(short
value);
long
ntohl(long
value);
V. Manipuler un socket▲
Avant toute chose, il faut créer le socket à manipuler.
V-A. socket - int socket(int family, int type, int protocol);▲
Crée un socket avec les paramètres passés.
- family définit la famille du socket. Les valeurs principales sont AF_INET pour un socket IPv4, AF_INET6 pour un support IPv6.
- type spécifie le type de socket. Les valeurs principales utilisées sont SOCK_STREAM pour TCP, SOCK_DGRAM pour UDP.
- protocol définit le protocole à utiliser. Il sera dépendant du type de socket et de sa famille. Les valeurs principales sont IPPROTO_TCP pour un socket TCP, IPPROTO_UDP pour un socket UDP.
Retourne INVALID_SOCKET sous Windows, -1 sous UNIX, en cas d’erreur, le socket sinon.
SOCKET socket =
socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if
(socket ==
INVALID_SOCKET)
// erreur
Une fois qu’on en a fini avec notre socket, il faut le fermer pour indiquer au système qu’il peut disposer de celui-ci. Que le port qu’il utilisait est à nouveau disponible, que les ressources nécessaires à son utilisation peuvent être libérées.
V-B. Windows – int closesocket(SOCKET socket);▲
Ferme le socket précédemment ouvert.
- socket est le socket à fermer.
Retourne SOCKET_ERROR en cas d’erreur, 0 sinon.
V-C. UNIX – int close(int socket);▲
Ferme le socket.
- socket est le socket à fermer.
Retourne -1 en cas d’erreur, 0 sinon.
Effectivement, il s’agit de la simple fonction close() utilisée habituellement pour fermer un fichier. Mais comme indiqué en début de partie, sous UNIX les sockets sont de simples descripteurs de fichiers, de simples fichiers. Ce n’est donc pas si surprenant que ça.
VI. Se connecter à une machine distante▲
VI-A. Windows - int connect(SOCKET _socket, const sockaddr* server, int serverlen);▲
Connecte un socket précédemment créé au serveur passé en paramètre.
- _socket est le socket à connecter.
- server la structure représentant le serveur auquel se connecter.
-
serverlen
est la taille de la structure server
. Généralement un
sizeof
(server) suffit.
Retourne 0 si la connexion réussit, SOCKET_ERROR sinon.
VI-B. UNIX – int connect(int _socket, const struct sockaddr* server, socklen_t serverlen); ▲
- _socket est le socket à connecter.
- server la structure représentant le serveur auquel se connecter.
-
serverlen
est la taille de la structure server.
socklen_t
est un type spécifique aux plateformes UNIX et peut être un
int
ouunsigned
int
. Généralement unsizeof
(server) suffit, nous ne nous attarderons donc pas sur lui pour l’instant.
L’appel à cette fonction, quelle que soit la plateforme, est bloquant tant que la connexion n’a pas été effectuée. Autrement dit : si cette fonction retourne, c’est que votre connexion a été effectuée et acceptée par l’ordinateur distant. Sauf si elle retourne une erreur bien sûr.
Une fois notre socket connecté, il agira comme un identifiant vers la machine distante. Quand nous passerons ce socket en paramètre des fonctions, ce sera pour indiquer que l’on appelle cette fonction à destination de la machine à laquelle il est connecté, pour envoyer des données à cette machine spécifiquement ou recevoir des données qu’elle nous aurait envoyées par exemple. Par abus de langage on parlera de la machine ou du socket qui sert de passerelle vers cette machine indistinctement.
Pour créer le paramètre server , on utilise une structure sockaddr_in à initialiser ainsi :
sockaddr_in server;
server.sin_addr.s_addr =
inet_addr(const
char
*
ipaddress);
server.sin_family =
AF_INET;
server.sin_port =
htons(int
port);
Que l’on peut ensuite utiliser comme paramètre à connect :
If (connect(socket, &
server, sizeof
(server) !=
0
)
// Erreur
Attention, l’adresse à utiliser avec inet_addr est une adresse IP (v4 ou v6) et non un nom de domaine tel que google.com. Pour se connecter à un nom de domaine, d’autres manipulations sont à réaliser, que nous verrons plus tard.
Notez également l’utilisation de htons pour indiquer le port de destination auquel se connecter.
Training Time ! Avant d’aller plus loin, pourquoi ne pas déjà s’entraîner ? Lancez ou compilez le TD 01. Un serveur se lancera sur le port de votre choix et créez un client capable de se connecter à celui-ci.
Vous devriez avoir une fenêtre comme ça en lançant le serveur :
Puis quand un client se connecte, une ligne d’information relative à celui-ci s’affichera :
Puisque le serveur est sur la même machine que le client, votre pc, l’IP du serveur sera 127.0.0.1 aussi appelée adresse locale ou de loopback.
VII. Proposition de corrigé▲
Si vous êtes parvenus à vous connecter au serveur (et voir apparaître la ligne correspondante sur sa console), alors c’est que votre code est bon. Toutefois je vous propose comment personnellement j’aurais réalisé ceci.
Tout d’abord, le plus simple (pour moi), ce que j’aurais écrit sous Windows, en travaillant sous Visual Studio :
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.
#include
<iostream>
#include
<WinSock2.h>
#pragma comment(lib,
"Ws2_32.lib"
)
int
main()
{
WSADATA data;
WSAStartup(MAKEWORD(2
, 2
), &
data);
SOCKET socket =
socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if
(socket ==
INVALID_SOCKET)
{
std::
cout <<
"Erreur creation socket : "
<<
WSAGetLastError() <<
std::
endl;
return
0
;
}
sockaddr_in server;
server.sin_addr.s_addr =
inet_addr("127.0.0.1"
);
server.sin_family =
AF_INET;
server.sin_port =
htons(6666
);
if
(connect(socket, &
server, sizeof
(server)) ==
SOCKET_ERROR)
{
std::
cout <<
"Erreur connection : "
<<
WSAGetLastError() <<
std::
endl;
return
0
;
}
std::
cout <<
"Socket connecte !"
<<
std::
endl;
closesocket(socket);
WSACleanup();
}
Quelques avertissements peuvent éventuellement survenir, notamment sur l’utilisation de inet_addr qui est dépréciée sur les versions récentes de Visual Studio.
Globalement, le code serait identique à peu de choses près pour les autres plateformes. Les seules spécificités à ce niveau sont l’appel à WSAStartup pour initialiser la DLL, WSACleanup pour la désinitialiser, WSAGetLastError pour récupérer l’erreur survenue et closesocket pour fermer le socket. Remarquez également que Windows déclare le type SOCKET, un define sur unsigned
int
, alors que sous UNIX on manipulerait un simple int
. Servons-nous de la proposition de Windows pour utiliser SOCKET dans notre code, qui s’adaptera à la plateforme à la compilation. Faisons de même pour INVALID_SOCKET qui, en lisant la documentation de socket(), devra valoir -1 et sera un int
:
#ifndef _WIN32
#define SOCKET int
#define INVALID_SOCKET ((int)-1)
#endif
Ainsi SOCKET sera un unsigned
int
sous Windows grâce à sa déclaration dans Winsock2.h que l’on inclut, et sera un int
sous les autres plateformes via notre define. Et on utilisera désormais cette déclaration pour nos sockets.
Puisque ce cours se veut un minimum accessible sur différentes plateformes, faisons en sorte que ce soit le cas maintenant. Comme bien souvent, « l’astuce » consiste simplement à ajouter une indirection.
Ajoutons un fichier Sockets.h contenant ceci :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
#ifndef _WIN32
#define SOCKET int
#define INVALID_SOCKET ((int)-1)
#endif
namespace
Sockets
{
bool
Start();
void
Release();
int
GetError();
bool
CloseSocket(SOCKET socket);
}
L’implémentation de chacune des fonctions dépendra de la plateforme, quitte à être vides, mais elles seront utilisables sur toutes. Ainsi le code initial deviendra :
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.
#include
"Socket.h"
#include
<iostream>
#include
<WinSock2.h>
int
main()
{
if
(!
Sockets::
Start())
{
std::
cout <<
"Erreur initialisation : "
<<
Sockets::
GetError() <<
std::
endl;
return
0
;
}
SOCKET socket =
socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if
(socket ==
INVALID_SOCKET)
{
std::
cout <<
"Erreur creation socket : "
<<
Socket::
GetError() <<
std::
endl;
return
0
;
}
sockaddr_in server;
server.sin_addr.s_addr =
inet_addr("127.0.0.1"
);
server.sin_family =
AF_INET;
server.sin_port =
htons(6666
);
if
(connect(socket, &
server, sizeof
(server)) ==
SOCKET_ERROR)
{
std::
cout <<
"Erreur connection : "
<<
Socket::
GetError() <<
std::
endl;
return
0
;
}
std::
cout <<
"Socket connecte !"
<<
std::
endl;
Sockets::
CloseSocket(socket);
Sockets::
Release();
}
Concernant l’implémentation de nos fonctions, ajoutons un fichier Sockets.cpp pour celles-ci :
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.
#include
"Sockets.h"
namespace
Sockets
{
bool
Start()
{
#ifdef _WIN32
WSAData wsaData;
return
WSAStartup(MAKEWORD(2
, 2
), &
wsaData) ==
0
;
#else
return
true
;
#endif
}
void
Release()
{
#ifdef _WIN32
WSACleanup();
#endif
}
int
GetError()
{
#ifdef _WIN32
return
WSAGetLastError();
#else
return
errno;
#endif
}
void
CloseSocket(SOCKET s)
{
#ifdef _WIN32
closesocket(s);
#else
close(s);
#endif
}
}
Si la vue d’instructions ifdef au milieu du code vous dérange, vous pouvez opter pour avoir un fichier d’implémentation différent selon la plateforme. Ici par simplicité, et préférence personnelle, j’ai choisi de n’utiliser qu’un seul fichier et ces instructions préprocesseurs.
Il ne reste plus que l’include de Winsock2.h qui traîne, puisque nous avons regroupé nos fonctions dans Sockets.h, et qu’il nous servira de porte d’entrée pour utiliser nos sockets, utilisons ce fichier pour inclure le header correct selon la plateforme cible :
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.
#ifdef _WIN32
#if _MSC_VER >= 1800
#include
<WS2tcpip.h>
#else
#define inet_pton(FAMILY, IP, PTR_STRUCT_SOCKADDR) (*(PTR_STRUCT_SOCKADDR)) = inet_addr((IP))
typedef
int
socklen_t;
#endif
#include
<WinSock2.h>
#ifdef _MSC_VER
#if _WIN32_WINNT >= _WIN32_WINNT_WINBLUE
//!
< Win8.1 & higher
#pragma comment(lib,
"Ws2_32.lib"
)
#else
#pragma comment(lib,
"wsock32.lib"
)
#endif
#endif
#else
#include
<sys/socket.h>
#include
<netinet/in.h>
// sockaddr_in, IPPROTO_TCP
#include
<arpa/inet.h>
// hton*, ntoh*, inet_addr
#include
<unistd.h>
// close
#include
<cerrno>
// errno
#define SOCKET int
#define INVALID_SOCKET ((int)-1)
#endif
namespace
Sockets
{
bool
Start();
void
Release();
int
GetError();
bool
CloseSocket(SOCKET socket);
}
N’oubliez pas que vous devrez également lier Ws32_2.lib sous Windows.
Et puisque nous sommes en C++, et qu’un socket s’y prête bien, pourquoi ne pas avoir une classe Socket pour le manipuler plus aisément ?
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
#ifndef TCPSOCKET_HPP
#define TCPSOCKET_HPP
#pragma once
#include
"Sockets.h"
#include
<string>
class
TCPSocket
{
public
:
TCPSocket();
~
TCPSocket();
bool
Connect(const
std::
string&
ipaddress, unsigned
short
port);
private
:
SOCKET mSocket;
}
;
#endif
// TCPSOCKET_HPP
Avec une telle interface, notre code source initial deviendra alors :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
#include
"TCPSocket.h"
#include
<iostream>
int
main()
{
if
(!
Sockets::
Start())
{
std::
cout <<
"Erreur initialisation : "
<<
Sockets::
GetError() <<
std::
endl;
return
0
;
}
{
TCPSocket socket;
if
(!
socket.Connect("127.0.0.1"
, 6666
))
{
std::
cout <<
"Erreur connection : "
<<
Sockets::
GetError() <<
std::
endl;
return
0
;
}
std::
cout <<
"Socket connecte !"
<<
std::
endl;
}
Sockets::
Release();
}
Beaucoup plus clair n’est-ce pas ?
L’implémentation de notre TCPSocket sera très simple à réaliser :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
#include
"TCPSocket.hpp"
TCPSocket::
TCPSocket()
{
mSocket =
socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if
(mSocket ==
INVALID_SOCKET)
{
std::
ostringstream error;
error <<
"Erreur initialisation socket ["
<<
Sockets::
GetError() <<
"]"
;
throw
std::
runtime_error(error.str());
}
}
TCPSocket::
~
TCPSocket()
{
Sockets::
CloseSocket(mSocket);
}
bool
TCPSocket::
Connect(const
std::
string&
ipaddress, unsigned
short
port)
{
sockaddr_in server;
server.sin_addr.s_addr =
inet_addr(ipaddress.c_str());
server.sin_family =
AF_INET;
server.sin_port =
htons(port);
return
connect(mSocket, &
server, sizeof
(server)) ==
0
;
}
Enfin, pour peaufiner le tout, remplaçons cet inet_addr par inet_pton comme préconisé par l’avertissement de compilation. inet_pton a été introduit avec l’arrivée d’IPv6 et est donc préféré puisqu’il peut traduire une adresse IPv4 ou IPv6, alors que inet_addr ne pouvait gérer qu’une adresse IPv4.
Un appel à inet_addr sera remplacé par inet_pton de la sorte, dans le cas de l’utilisation pour connect :
sockaddr_in server;
server.sin_addr.s_addr =
inet_addr("127.0.0.1"
);
// équivalent à
inet_pton(AF_INET, "127.0.0.1"
, &
server.sin_addr.s_addr);
Remarquez la symétrie d’écriture, nous permettant d’écrire la macro suivante pour n’utiliser que inet_pton dans notre code et que celui-ci effectue un appel à inet_addr s’il n’est pas disponible :
#define inet_pton(FAMILY, IP, PTR_STRUCT_SOCKADDR) (*(PTR_STRUCT_SOCKADDR)) = inet_addr((IP))
On peut éventuellement ajouter un assert pour que FAMILY soit toujours égale à AF_INET puisque la valeur AF_INET6 et les adresses IPv6 ne sont pas permises avec inet_addr. Ce n’est pas trop dérangeant puisque si l’adresse est incorrecte, une valeur d’erreur sera de toute façon retournée.
Ainsi notre TCPSocket::
Connect finale sera :
bool
TCPSocket::
Connect(const
std::
string&
ipaddress, unsigned
short
port)
{
sockaddr_in server;
inet_pton(AF_INET, ipaddress.c_str(), &
server.sin_addr.s_addr);
server.sin_family =
AF_INET;
server.sin_port =
htons(port);
return
connect(mSocket, (const
sockaddr*
)&
server, sizeof
(server)) ==
0
;
}
Nous possédons désormais les fondations pour écrire un client TCP pouvant se connecter à une IP. Ce code évoluera au fil du cours pour ajouter des possibilités à notre application.
Télécharger les codes sources du cours
Article précédent | Article suivant |
---|---|
<< Introduction | TCP – Envoi et réception >> |