Document ID: /fineweb-2-swissfilter-quality_10-filterrobots/filtered/07026.jsonl.gz/316

1 Introduction
Le but de cette étape est d'écrire les classes permettant à un ou plusieurs joueurs d'une partie de tCHu de jouer depuis un autre ordinateur que celui sur lequel la partie se déroule.
2 Concepts
2.1 Jeu distant
La classe
Game écrite à l'étape 6 fait l'hypothèse qu'elle peut « dialoguer » avec les joueurs — qui implémentent l'interface
Player — en appelant leurs différentes méthodes. Par exemple, pour savoir quelle action le joueur courant désire effectuer, la classe
Game appelle la méthode
nextTurn de ce joueur.
Or, comme cela a été dit à plusieurs reprises, au moins un des joueurs d'une partie se trouve sur un autre ordinateur que celui sur lequel la classe
Game s'exécute. Cela pose problème, car Java n'offre pas la possibilité à un objet se trouvant sur un ordinateur donné d'appeler une méthode d'un objet se trouvant sur un autre ordinateur1. La solution que nous utiliserons pour résoudre ce problème a été esquissée à l'étape précédente, et consiste à faire en sorte que l'instance de
Game « dialogue » avec les joueurs distants en échangeant avec eux des messages textuels à travers le réseau.
Pour cela, il serait bien entendu possible de modifier
Game afin qu'elle ne communique plus avec les joueurs par appel de méthodes, mais plutôt par échange de messages textuels via le réseau. Cette solution serait toutefois peu élégante, car elle forcerait
Game à toujours dialoguer avec les joueurs de la sorte, ce qui serait inutilement contraignant. Il existe en effet beaucoup de situations dans lesquelles au moins un des deux joueurs se trouve sur le même ordinateur — et dans le même programme — que l'instance de
Game gérant la partie, qui peut alors communiquer avec lui au moyen de simples appels de méthodes.
Il est donc nettement préférable d'offrir la possibilité à un joueur d'être soit un joueur local — c-à-d un objet de type
Player se trouvant dans le même programme que l'instance de
Game gérant la partie —, soit un joueur distant — c-à-d un objet de type
Player se trouvant sur un autre ordinateur. Ces deux joueurs doivent toutefois apparaître identiques à la classe
Game.
Cela peut se faire en introduisant les deux nouveaux objets suivants, dont le but est de permettre à une instance de
Player d'être utilisée à distance :
- ce que nous appellerons un mandataire (proxy), un objet de type
Playerqui se trouve dans le même programme que l'instance de
Gamegérant la partie, et qui a pour but de représenter le joueur distant se trouvant dans un autre programme — et généralement sur un autre ordinateur,
- ce que nous appellerons le client, un objet qui se trouve dans le même programme que le joueur distant, et qui agit sur lui en fonction des instructions qu'il reçoit du mandataire.
Le mandataire se trouve donc dans le programme que nous avons nommé le serveur à l'étape précédente, tandis que le client se trouve dans le programme que nous avons nommé le client. Notez bien que nous utilisons le terme « client » pour désigner à la fois :
- le programme complet qu'un joueur (humain) exécute sur son propre ordinateur pour jouer à une partie distante, et
- la partie de ce programme chargée de dialoguer avec le mandataire du joueur se trouvant dans le serveur.
Le contexte permet généralement de déterminer duquel des deux il est question.
Cette organisation est présentée à la figure 1 ci-dessous. On y voit le serveur et le client introduits à l'étape précédente, ainsi que des instances des classes qui nous intéressent ici : le mandataire du joueur distant,
RemotePlayerProxy, et son client,
RemotePlayerClient. Il est important de se souvenir que le serveur et le client s'exécutent généralement sur deux ordinateurs différents, reliés entre eux par le réseau.
Le serveur contient l'instance de
Game qui gère la partie. Elle communique avec les joueurs — des instances de classes qui implémentent l'interface
Player — en appelant leurs méthodes. Un de ces joueurs est un joueur distant, qui est représenté dans le serveur par une instance de
RemotePlayerProxy.
Le client contient l'interface graphique du joueur distant, ainsi qu'une instance de
RemotePlayerClient qui est chargée de dialoguer avec le mandataire du joueur — l'instance de
RemotePlayerProxy se trouvant sur le serveur — et d'agir sur le joueur distant en fonction des messages reçus.
La figure 1 illustre ce qu'il se passe lorsque l'instance de
Game désire demander au joueur distant quelle action il désire effectuer, car son tour vient de commencer :
- l'instance de
Gameappelle la méthode
nextTurndu mandataire — qui, du point de vue de l'instance de
Game, est le joueur distant,
- le mandataire envoie, via le réseau, un message textuel consistant en la chaîne
NEXT_TURNau client du joueur,
- à la réception de ce message, le client appelle la méthode
nextTurndu joueur distant, provoquant la mise à jour de l'interface graphique, qui indique au joueur humain que c'est à son tour de jouer, puis attend sa décision,
- en admettant que le joueur (humain) décide de tirer des cartes, la méthode
nextTurnretourne la valeur
TurnKind.DRAW_CARDSau client du joueur,
- le client du joueur sérialise cette valeur en la chaîne
1, selon la technique décrite à l'étape précédente, pour la transmettre en réponse au mandataire, via le réseau,
- à la réception de cette réponse, le mandataire la désérialise pour obtenir la valeur
TurnKind.DRAW_CARDS, qu'il retourne en résultat de
nextTurn.
Sachant que le joueur distant désire tirer une carte, l'instance de
Game appelle alors la méthode
drawSlot du mandataire, qui envoie un message textuel
DRAW_SLOT au client, et ainsi de suite.
2.2 Communication via le réseau
Pour communiquer via le réseau, le mandataire et le client s'échangent des messages textuels transmis au moyen du protocole TCP, un des protocoles d'Internet. La description complète de ce protocole sort du cadre de ce cours, mais une rapide introduction est donnée ci-dessous, et les personnes intéressées par plus de détails se rapporteront à Wikipedia en suivant les liens donnés dans le texte.
Les protocoles d'Internet sont organisés en différentes couches, correspondant à différents niveaux d'abstraction. Les protocoles d'une couche donnée sont toujours basés sur ceux de la couche juste en-dessous. Trois de ces couches et plusieurs protocoles importants de chacune d'entre elles sont illustrées sur la figure 2.
Comme dit plus haut, le protocole du jeu, désigné par
tCHu dans cette figure, est basé sur le protocole TCP de la couche inférieure, lui-même basé sur IP. Beaucoup de protocoles connus — p.ex. HTTP qui est le protocole du Web, ou SMTP et IMAP qui sont liés au courrier électronique — se basent également sur TCP.
2.2.1 IP
À la couche la plus basse qui nous intéresse ici se trouve le protocole de base d'Internet, nommé Internet Protocol et généralement abrégé IP. Ce protocole permet à deux hôtes (hosts) — souvent des ordinateurs — connectés à Internet de s'échanger des messages, appelés paquets (packets), d'une taille maximale fixe. Aucune garantie n'est offerte quant à cette transmission, et il est donc possible que les paquets soient corrompus, perdus, dupliqués ou arrivent dans un ordre différent de celui d'envoi.
Bien entendu, l'échange de tels messages suppose l'existence d'un mécanisme permettant de désigner individuellement chacun des hôtes connecté au réseau, au même titre que l'expédition de lettres et paquets physiques suppose l'existance d'un mécanisme permettant de désigner une boîte aux lettres, l'adresse postale. Dans le cas d'Internet, ce rôle est joué par ce que l'on nomme l'adresse IP (IP address). Il existe deux sortes d'adresses IP — nommées IPv4 et IPv6 — mais seules les premières sont décrites ci-dessous, car elles sont de loin les plus fréquemment utilisées.
Une adresse IPv4 est une valeur de 32 bits, généralement représentée par les quatre octets la composant, en base 10, séparés par un point. Par exemple,
<ip-pii> est la représentation d'une telle adresse, en l'occurrence celle de la machine hébergeant le serveur Web au cours. Certaines adresses ont une signification particulière et prédéterminée. Par exemple, l'adresse
<ip-pii> désigne toujours l'hôte local (local host), c-à-d l'hôte sur laquelle on l'utilise. Par exemple, si vous lisez ce texte sur un ordinateur, une tablette ou un téléphone, l'hôte local est cet appareil.
Comme il n'est pas facile pour un humain de se souvenir d'une adresse IP numérique, Internet est doté d'un service de noms nommé DNS (Domain Name System), qui joue le rôle d'annuaire et permet d'utiliser des noms alphanumériques au lieu d'adresses numériques. Par exemple, le nom
cs108.epfl.ch correspond à l'adresse
<ip-pii> mentionnée plus haut, et le nom
localhost correspond à l'adresse de l'hôte local,
<ip-pii>.
Le protocole IP n'est presque jamais utilisé directement par des applications, mais sert de base à d'autres protocoles de plus haut niveau. Les deux principaux protocoles construits directement sur IP sont UDP et TCP. La différence principale entre UDP et TCP est que UDP ne garantit pas que les données envoyées par l'expéditeur soient effectivement reçues par le destinataire, tandis que TCP offre cette garantie.
2.2.2 TCP
TCP (Transmission Control Protocol) se base sur IP et y ajoute la notion de connexion et la fiabilité.
Le fait que TCP requiert une connexion signifie que deux hôtes désirant communiquer au moyen de ce protocole doivent dans un premier temps établir une connexion entre eux, un peu comme deux personnes désirant se parler au téléphone. Une fois la connexion établie, TCP permet aux deux hôtes de s'échanger des données de manière fiable, c-à-d sans aucune perte ou corruption. Ces données sont échangées sous la forme de flots d'octets, et pas de paquets comme avec IP.
TCP augmente la notion d'adresse fournie par IP en lui ajoutant un numéro de port (port number), un entier compris entre 0 et 65 535. Ce numéro de port permet à plusieurs applications de se partager une même adresse IP pour peu qu'elles utilisent un numéro de port différent, un peu comme différentes personnes peuvent se partager une même adresse de bâtiment pour peu qu'une information additionnelle (p.ex. un numéro d'appartement) permette de les distinguer.
Les ports dont la valeur est inférieure à 1 024 sont réservés à des protocoles précis, et ne doivent pas être utilisées par d'autres applications. Par exemple, le port 25 du protocole TCP est réservé au protocole SMTP, qui sert à l'envoi de courriels.
2.3 Exemple client/serveur
Afin de rendre plus concrets les différents concepts présentés ci-dessus, et voir comment il est possible de les mettre en œuvre en Java, un petit exemple complet est présenté plus bas. Il est constitué de deux programmes :
- un serveur « successeur » qui écoute sur le port TCP 5108 et, dès qu'une connexion y est faite, lit un message textuel d'une ligne constitué de la représentation textuelle, en base 10, d'un entier, puis répond au moyen d'une ligne constituée du successeur de cet entier,
- un client qui se connecte au serveur, lui envoie le message
2021afin de connaître le successeur de ce nombre, et affiche le résultat (2022) à l'écran.
Le code du serveur est présenté en premier ci-après. La plupart de ce code est du code d'entrée/sortie standard, et le fait que ces entrées/sorties se fassent à travers le réseau n'est pas visible. Quelques points méritent toutefois d'être décrits plus en détails.
Pour commencer, le serveur crée une instance de
ServerSocket qui est une « prise » (socket) permettant au serveur d'attendre des connexions TCP sur un port donné, ici 5108. Une fois cette prise créée, le serveur appelle sa méthode
accept qui bloque l'exécution du programme jusqu'à ce qu'une connexion soit effectuée sur ce port. Une fois que cela se produit,
accept retourne une nouvelle prise, de type
Socket cette fois, qui peut être utilisée pour échanger des données avec l'entité connectée à l'autre bout, ici le client décrit plus bas. Cet échange de données se fait au moyen des flots d'entrée et de sortie de la prise, qui s'obtiennent au moyen des méthodes
getInputStream et
getOutputStream. Une fois obtenus, ces flots s'utilisent de manière habituelle.
Lorsque le serveur a lu le nombre envoyé par le client, calculé son successeur et écrit la représentation textuelle de ce dernier sur le flot de sortie, il fait encore deux choses :
- il écrit une fin de ligne (au moyen de
w.write('\n')), la séquence
\nreprésentant en Java un retour à la ligne — le caractère de contrôle LF pour être précis,
- il appelle la méthode
flushafin de vidanger le flot, ce qui permet de garantir que les données qui y ont été écrites jusqu'alors sont bien envoyées sur le réseau à ce moment-là.
Finalement, l'énoncé
catch à la fin de la méthode
main a pour but d'attraper les exceptions de type
IOException et de les lever à nouveau, en quelque sorte, sous forme d'exceptions équivalentes mais de type
UncheckedIOException. La différence entre les deux types d'exception est que le premier est un type d'exception checked, le second pas. Lorsqu'on ne désire pas traiter les exception de manière particulière, comme ici, il est plus simple d'avoir affaire au second type qu'au premier, car il n'est alors pas nécessaire d'ajouter les
throws dans les déclarations des méthodes.
import static java.nio.charset.StandardCharsets.US_ASCII; public final class SuccServer { public static void main(String[] args) { try (ServerSocket s0 = new ServerSocket(5108); Socket s = s0.accept(); BufferedReader r = new BufferedReader( new InputStreamReader(s.getInputStream(), US_ASCII)); BufferedWriter w = new BufferedWriter( new OutputStreamWriter(s.getOutputStream(), US_ASCII))) { int i = Integer.parseInt(r.readLine()); int i1 = i + 1; w.write(String.valueOf(i1)); w.write('\n'); w.flush(); } catch (IOException e) { throw new UncheckedIOException(e); } } }
Le code du client est quant à lui présenté ci-dessous. Le seul commentaire qui mérite d'être fait à son sujet est que pour se connecter au serveur le client crée directement une prise de type
Socket en passant en arguments au constructeur un nom d'hôte (ici
localhost qui désigne l'ordinateur sur lequel s'exécute le client) et un port. Le fait de passer ces arguments au constructeur a pour effet d'établir directement la connexion au serveur. Il est donc immédiatement possible d'échanger des données avec le serveur en utilisant les mêmes techniques que ci-dessus.
import static java.nio.charset.StandardCharsets.US_ASCII; public final class SuccClient { public static void main(String[] args) { try (Socket s = new Socket("localhost", 5108); BufferedReader r = new BufferedReader( new InputStreamReader(s.getInputStream(), US_ASCII)); BufferedWriter w = new BufferedWriter( new OutputStreamWriter(s.getOutputStream(), US_ASCII))) { int i = 2021; w.write(String.valueOf(i)); w.write('\n'); w.flush(); int succ = Integer.parseInt(r.readLine()); System.out.printf("succ(%d) = %d%n", i, succ); } catch (IOException e) { throw new UncheckedIOException(e); } } }
En exécutant d'abord le serveur, puis le client, ce dernier devrait afficher le message suivant à l'écran :
succ(2021) = 2022
Cela fait, les deux programmes devraient terminer leur exécution. Notez que lorsque le serveur est lancé, il bloque sur l'appel à
accept tant et aussi longtemps que le client ne se connecte pas à lui.
3 Mise en œuvre Java
Les deux classes à mettre en œuvre pour cette étape sont celles représentant le mandataire du joueur distant et son client, décrits plus haut. Pour les écrire, vous pouvez bien entendu vous inspirer de l'exemple du serveur successeur ci-dessus, le mandataire étant similaire au serveur successeur, le client étant similaire au client successeur.
Il vous faut toutefois tenir compte d'une différence très importante existant entre l'exemple ci-dessus et le projet tCHu : dans l'exemple, c'est le client qui envoie une demande (un message) au serveur, et le serveur lui répond ; dans le projet tCHu, c'est au contraire le serveur qui envoie — à travers le mandataire — des demandes au client, et le client lui répond.
3.1 Classe
RemotePlayerProxy
La classe instanciable
RemotePlayerProxy du paquetage
ch.epfl.tchu.net représente un mandataire (proxy en anglais) de joueur distant. Elle implémente l'interface
Player et peut ainsi jouer le rôle d'un joueur.
Son constructeur prend en argument la « prise » (socket), de type
Socket, que le mandataire utilise pour communiquer à travers le réseau avec le client par échange de messages textuels.
Les seules méthodes publiques offertes par cette classe sont les mises en œuvre concrètes des méthodes de l'interface
Player. Chacune d'entre elles fonctionne de la même manière :
- les éventuels arguments de la méthode sont sérialisés individuellement au moyen des serdes écrits à l'étape précédente,
- le texte du message est construit en séparant au moyen du caractère d'espacement les éléments suivants, dans l'ordre :
- le nom du message, qui est celui de l'élément du type énuméré
MessageIdqui correspond à la méthode,
- les arguments, dans le même ordre que la méthode les accepte,
- un retour à la ligne (code ASCII 10, écrit
\nen Java),
- le nom du message, qui est celui de l'élément du type énuméré
- le message est envoyé sur le réseau via la « prise »,
- si la méthode retourne une valeur — c-à-d que son type de retour n'est pas
void— alors une ligne est lue depuis le réseau, désérialisée au moyen du serde correspondant au type de retour de la méthode, et retournée.
Notez que les noms des joueurs passés à la méthode
initPlayers dans une table associative de type
Map<PlayerId, String> sont sérialisés comme une liste de chaînes de caractères. Le premier élément de cette liste est le nom du premier joueur, le second le nom du second. En d'autres termes, la table associative passée à
initiPlayers est sérialisée de manière similaire à celle de
PublicGameState contenant l'état (public) des joueurs.
Par exemple, le message correspondant à l'appel à
initPlayers suivant :
player.initPlayers( PLAYER_1, Map.of(PLAYER_1, "Ada", PLAYER_2, "Charles"));
est le message :
INIT_PLAYERS 0 QWRh,Q2hhcmxlcw==
QWRh étant la sérialisation de la chaîne
Ada,
Q2hhcmxlcw== celle de la chaîne
Charles.
3.1.1 Conseils de programmation
Si vous avez utilisé les noms suggérés à l'étape précédente pour les éléments du type énuméré
MessageId, alors vous pouvez obtenir la chaîne correspondant au nom d'un message au moyen de la méthode
name. Son utilisation est illustrée par le test JUnit ci-dessous, qui s'exécute avec succès :
assertEquals("INIT_PLAYERS", MessageId.INIT_PLAYERS.name());
De plus, pour simplifier votre code, vous pouvez définir des méthodes auxiliaires permettant de :
- envoyer un message étant donné son identité et les chaînes de caractères correspondants à la sérialisation de ses arguments,
- recevoir un message.
Chacune de ces deux méthodes contient un
try/catch similaire à celui donné en exemple plus haut et attrapant les exceptions de type
IOException pour les lever à nouveau sous la forme d'instances de
UncheckedIOException.
Notez finalement que comme les messages échangés sur le réseau, et leurs réponses, sont constitués chacun d'une ligne ne contenant que des caractères ASCII, l'utilisation d'instances de
BufferedReader et
BufferedWriter créées comme dans l'exemple plus haut simplifie énormément les choses. En particulier, la méthode
readLine de
BufferedReader peut alors être utilisée pour lire un message. Attention toutefois à ne surtout pas utiliser la méthode
newLine de
BufferedWriter pour terminer un message, car ceux-ci doivent impérativement se terminer par un unique retour à la ligne (
\n), or sur certaines plateformes (p.ex. Windows),
newLine produit d'autres caractères.
3.2 Classe
RemotePlayerClient
La classe instanciable
RemotePlayerClient du paquetage
ch.epfl.tchu.net représente un client de joueur distant.
Son constructeur prend en arguments le joueur (de type
Player) auquel elle doit fournir un accès distant, ainsi que le nom (de type
String) et le port (de type
int) à utiliser pour se connecter au mandataire.
En dehors du constructeur, cette classe n'offre qu'une seule méthode publique, nommée p.ex.
run, qui ne prend aucun argument et ne retourne aucun résultat.
Cette méthode effectue une boucle durant laquelle elle :
- attend un message en provenance du mandataire,
- le découpe en utilisant le caractère d'espacement comme séparateur,
- détermine le type du message en fonction de la première chaîne résultant du découpage,
- en fonction de ce type de message, désérialise les arguments, appelle la méthode correspondante du joueur ; si cette méthode retourne un résultat, le sérialise pour le renvoyer au mandataire en réponse.
3.2.1 Conseils de programmation
Si vous avez utilisé les noms suggérés à l'étape précédente pour les éléments du type énuméré
MessageId, alors vous pouvez obtenir l'élément de ce type énuméré correspondant à une chaîne au moyen de la méthode
valueOf, qui constitue en quelque sorte l'inverse de la méthode
name. Son utilisation est illustrée par le test JUnit ci-dessous, qui s'exécute avec succès :
assertEquals(MessageId.INIT_PLAYERS, MessageId.valueOf("INIT_PLAYERS"));
De plus, souvenez-vous que pour découper un message en ses composants, vous pouvez utiliser la méthode
split de
String. N'oubliez pas de passer la chaîne de séparation à la méthode
quote de
Pattern au préalable !
3.3 Tests
Pour tester vos deux classes, il est relativement facile d'écrire un petit serveur d'exemple comme celui-ci :
public final class TestServer { public static void main(String[] args) throws IOException { System.out.println("Starting server!"); try (ServerSocket serverSocket = new ServerSocket(5108); Socket socket = serverSocket.accept()) { Player playerProxy = new RemotePlayerProxy(socket); var playerNames = Map.of(PLAYER_1, "Ada", PLAYER_2, "Charles"); playerProxy.initPlayers(PLAYER_1, playerNames); } System.out.println("Server done!"); } }
ainsi qu'un petit client d'exemple comme celui-ci (à compléter) :
public final class TestClient { public static void main(String[] args) { System.out.println("Starting client!"); RemotePlayerClient playerClient = new RemotePlayerClient(new TestPlayer(), "localhost", 5108); playerClient.run(); System.out.println("Client done!"); } private final static class TestPlayer implements Player { @Override public void initPlayers(PlayerId ownId, Map<PlayerId, String> names) { System.out.printf("ownId: %s\n", ownId); System.out.printf("playerNames: %s\n", names); } // … autres méthodes de Player } }
En lançant d'abord le serveur, vous devriez constater qu'il démarre, affiche :
Starting server!
à l'écran, puis bloque sur l'appel à
accept, en attendant la connexion du client. En lançant alors le client — sans terminer l'exécution du serveur, bien entendu —, vous devriez voir ce dernier afficher :
Starting client! ownId: PLAYER_1 playerNames: {PLAYER_1=Ada, PLAYER_2=Charles} Client done!
tandis que le serveur devrait afficher :
Server done!
Vous pouvez bien entendu augmenter ce serveur et ce client de test pour essayer d'appeler toutes les méthodes de
Player, pas seulement
initPlayers.
4 Résumé
Pour cette étape, vous devez :
- écrire les classes
RemotePlayerProxyet
RemotePlayerClientselon les indications données ci-dessus,
- tester votre code,
- documenter la totalité des entités publiques que vous avez définies.
Aucun rendu n'est à faire pour cette étape avant le rendu final. N'oubliez pas de faire régulièrement des copies de sauvegarde de votre travail en suivant nos indications à ce sujet.
Notes de bas de page
En fait, un objet Java ne peut appeler que des méthodes d'objets se trouvant dans le même programme que lui. Il n'est donc jamais possible à un objet se trouvant dans un programme donné d'appeler des méthodes d'objets se trouvant dans un autre programme, même si les deux programmes sont en cours d'exécution sur le même ordinateur.