Cartouche

Champ Valeur
Auteur·e Élise
Édition 2023-12-05
Durée 2 séances
Taille des équipes projet individuel
Rendu via git, dépôt 2023_tp_sfml_net, droits en lecture à delivery_collector

Notation

Critère Points
Serveur qui se fait attendre 3
Serveur poli 3
Serveur moqueur 3
Mise en pot 3
Plusieurs clients 3
Serde Serveur 3
Serde Client 3
Points accessibles 21
Note maximale 20

Un rendu sale et du code qui ne respecte pas la norme peut vous faire perdre une partie ou la totalité des points acquis.

Introduction

L’objectif de ce TP est de vous montrer comment on peut faire communiquer deux processus ensemble par le réseau, ce qui permet la communication entre plusieurs machines.

On va aussi voir comment s’y prendre dans le cas où un serveur doit gérer plusieurs clients.

À partir d’un certain point, vous aurez besoin du code que vous avez fait dans le cadre du tp 1 sur la sérialisation.

Pré-requis

Il faut que la SFML soit installée sur votre système.

Les objets dont on va faire usage sont tous décrits SFML/Network.hpp et leur code compilé est dans libsfml-network.so et libsfml-system.so.

On compilera donc toujours avec -lsfml-network et -lsfml-system.

Objets SFML

Nous allons manipuler les classes suivants :

  • sf::TcpSocket, peut accueillir une connexion établie en utilisant .connect(IP, PORT) ou depuis un sf::TcpListener en utilisant .accept() ;
  • sf::TcpListener, écoute sur un port en l’attente de nouvelles demande de connexion, lorsqu’une connection est disponible on pour l’accepter avec une TcpSocket ;
  • sf::SocketSelector, on lui donne des TcpSocket et des TcpListenner, et il est capable de nous dire sur lesquels il y a quelque chose à lire (sans que ça nous bloque.)

Serveur qui se fait attendre

Fichier à créer : main.cpp

Commençons par faire un serveur simple, qui prend un client, et qui est capable de répéter les messages qu’il reçoit.

Pour recevoir une connexion, il faut commencer par utiliser sf::TcpListener, choisir un port, puis utiliser accept.

accept prend en paramètre une sf::TcpSocket que l’on pourra utiliser pour lire et écrire.

#include <SFML/Network.hpp>
#include <thread>
#include <iostream>

int main(void)
{
	sf::TcpListener lstnr;
	sf::TcpSocket sock;

	lstnr.listen(1234);
	lstnr.accept(sock);
	std::cout << "a client is connected, closing in 5 seconds...\n";
	std::this_thread::sleep_for(std::chrono::seconds(5));
	return (0);
}

On peut tester que la connection fonctionne avec la commande nc ou netcat. On peut se connecter avec la commande :

nc 127.0.0.1 1234 -N

Si tout va bien, la connexion devrait rester active 5 secondes sans que rien ne se passe.

Serveur poli

Faisons dire à notre serveur un petit message de bienvenue à la connexion du client.

Il est possible d’envoyer un message sur une TcpSocket en utilisant la fonction membre .send(POINTEUR, TAILLE)

Vous pouvez :

  • mettre votre message dans une std::string ;
  • remplacer POINTEUR par str.c_str() ; et
  • TAILLE par str.size().

(dans le cas où votre chaîne s’appelle str.)

Je vous conseille de faire une fonction qui prend en paramètre une référence sur une sf::Socket et une std::string et qui envoie le message contenu dans la chaîne sur la socket. Cela vous fera gagner du temps plus tard.

Encore une fois, tentez de vous connecter avec nc :

nc 127.0.0.1 1234 -N

Serveur moqueur

Pour cette étape répétons au client ce qu’il envoie au serveur, pour ce faire, on doit commencer par lire ce qu’il nous dit.

La fonction membre receive nous permet de recevoir du contenu depuis la socket. Elle prend 3 paramètres :

  • un pointeur vers une zone mémoire ;
  • la taille maximum à écrire dans la zone mémoire ; et
  • une référence vers un std::size_t dans laquelle le nombre d’octets lus va être écrit.

Exemple de lecture dans un char * sur une sf::Socket qui s’appelle sock.

char *c_str;
std::size_t bytes_read;

c_str = new char[100];
sock.receive(c_str, 99, bytes_read);
c_str[bytes_read] = '\0';

Une fois que vous avez réussi à lire : renvoyez au client ce qu’il vous a écrit.

Si jamais vous recevez un message de 0 octets, arrêtez et déconnectez vous du client avec sock.disconnect().

Essayez de vous connecter et d’envoyer des messages avec nc. Quand vous avez fini appuyez sur ctrl+d. C’est sensé provoquer une déconnexion.

nc 127.0.0.1 1234 -N

Mise en pot

Fichiers à créer : {Server.{cpp,hpp},Server.main.cpp}

On va mettre ce que l’on a fait pour le moment dans une classe. Appelons-la Server.

Votre constructeur doit :

  • prendre en paramètre un numéro de port ;
  • démarrer le sf::TcpListener avec ce numéro de port.

Faites une fonction membre echo_loop_single_client qui reprend le comportement de l’étape précédente (accepte un client, attend un message, le répète, attend un message, le répète, etc. jusqu’à une lecture de 0 octets).

Testez votre serveur dans Server.main.cpp.

Plusieurs clients

On a vu les fonctions membres TcpListener::accept et TcpSocket::receive qui vont toutes deux bloquer notre programme tant qu’il n’y a aucune connexion à accepter ou de message à recevoir.

Si on veut gérer plusieurs clients, il va falloir trouver un moyen d’accepter les connexions et recevoir des messages sans bloquer.

En programmation un tel outil s’appelle un sélecteur. D’après l’appel système select. Il existe une déclinaison de cet appel système qui s’appelle poll et qui est un peu plus moderne. Mais nous n’allons pas utiliser d’appel système aujourd’hui.

La SFML prévoit une classe qui s’appelle sf::SocketSelector. Elle peut vérifier pour vous s’il y a quelque chose à lire sur une liste de socket.

Pour utiliser sf::SocketSelector, il suffit de le construire, puis d’utiliser :

  • .add(SOCKET) en remplaçant SOCKET par un de vos objets TcpSocket ou TcpListener pour le surveille ;
  • .wait(DURÉE) en replaçant DURRÉE par un sf::seconds, pour attendre qu’il se passe quelque chose sur les socket données pedant la durée choisie : s’il se passe quelque chose, elle renvoie immédiatement true, sinon elle renvoie false ;
  • .isReady(SOCKET) pour demander après un .wait s’il y a quelque chose à lire sur laSOCKET passée en paramètre.

Voici un exemple qui fait usage de la classe sf::SocketSelector.

On commence par créer notre Listener et on lui fait écouter le port 1234. Seulement APRÈS on peut l’ajouter au Selector. Autrement ça nous aurait posé problème plus loin.

On accept un premier client et on lui dit bonjour.

Dans notre boucle :

  • On attend sur le Selector.
  • S’il s’est passé quelque chose :
    • sur la TcpSocket sock on répète le message au client (fonction echo_once que vous pouvez créer à l’aide de ce qu’on a fait des les exos précédents) ;
    • sur le TcpListener lstnr on dit au revoir au client, on le retire de stor, on le déconnecte, puis on en accept un nouveau (qu’on ajoute au stor et auquel on dit bonjour)
int main(void)
{
	sf::TcpListener lstnr;
	sf::TcpSocket sock;
	sf::SocketSelector stor;

	if (lstnr.listen(1234) != sf::Socket::Done) {
		return 89;
	}
	stor.add(lstnr);
	if (lstnr.accept(sock) != sf::Socket::Done) {
		return 89;
	}
	sock.send("hey!\n", 5);
	stor.add(sock);
	while (true) {
		if (stor.wait(sf::Seconds(10.f))) {
			if (stor.isReady(sock)) {
				echo_once(sock);
			} else if (stor.isReady(lstnr)) {
				sock.send("bye!\n", 5);
				stor.remove(sock);
				sock.disconnect();
				if (lstnr.accept(sock) != sf::Socket::Done) {
					return 89;
				}
				stor.add(sock);
				sock.send("hey!\n", 5);
			}
		}
	}
}

Maintenant à votre tour : dans votre classe Server, écrivez la fonction membre run_once. Elle doit :

  • .wait sur le selector.
  • Si le Listener est prêt :
    • .accept un nouveau client,
    • l’ajouter au Selector,
    • l’ajouter à la liste des clients ;
  • Si un des clients de la liste est prêt :
    • lire son message et lui renvoyer,
    • si son message est vide : retirer le client du Selector, le déconnecter.

Vous devez ajouter aux champs de votre classe une liste de clients en utilisant un conteneur de la STL (comme std::list, std::vector, etc.).

Pour tester, dans votre Server.main.cpp, vous pouvez appeler run_once en boucle.

Sérialisation et dé-sérialisation de Student

Serveur

Servons-nous du travail de sérialisation que l’on a fait dans TP 1 • Sérialisation.

Maintenant, lorsqu’un client vous envoie un message : essayez de construire un objet de type Student avec le contenu du message.

Si la construction réussit, stockez cette instance de Student dans un conteneur (liste, vector, peu importe).

Si la construction échoue, renvoyez au client son message avec pour préfixe : "Not a student: ".

Client

Fichier à créer : client.cpp

Écrivez un programme client qui prend 4 arguments :

  • l’identifiant d’un étudiant (un nombre) ;
  • le prénom de l’étudiant ;
  • le nom de famille de l’étudiant ; et
  • l’année d’étude de l’étudiant (un nombre).

Créez une instance de Student avec les données des arguments du programme et sérialisez-là dans un std::stringstream.

Utilisez sf::TcpSocket pour :

  • vous connecter au serveur avec .connect(IP, PORT) ; et
  • envoyer l’instance de Student sérialisée.

Testez votre client et votre serveur ensemble.