Sérialisation
tCHu – étape 7
1. Introduction
Le but de cette étape est d'écrire le code permettant de « sérialiser » les différentes valeurs que les deux parties du programme tCHu — le client et le serveur — doivent pouvoir s'échanger à travers le réseau.
2. Concepts
2.1. Communication client-serveur
Comme cela a déjà été dit, une partie de tCHu se joue entre deux personnes utilisant chacune leur propre ordinateur. Sur chacun de ces ordinateurs s'exécute un programme que l'on nomme le client (client), qui gère principalement l'interface graphique. Un troisième programme, nommé le serveur (server), gère quant à lui le déroulement de la partie. En général, ce serveur s'exécute sur le même ordinateur que l'un des deux clients — comme illustré ci-dessous — mais il pourrait également s'exécuter sur un troisième ordinateur distinct des deux autres.
Les flèches de l'image ci-dessus représentent les échanges d'information entre ces trois programmes. Elles illustrent le fait que le serveur communique avec chacun des deux clients, mais les clients ne communiquent jamais entre eux.
La communication client/serveur se fait par échange de messages textuels. Un message est une simple chaîne de caractères ASCII imprimables, terminée par un caractère de retour à la ligne — le caractère dont le code ASCII est 10, et qui s'écrit \n
en Java.
La communication est toujours initiée par le serveur, qui envoie un message à un client. Certains des messages envoyés par le serveur demandent une réponse, tandis que d'autre n'en demandent aucune. Lorsqu'une réponse est demandée, le client la retourne au serveur sous la forme d'un message, c-à-d d'une chaîne de caractères ASCII terminée par un retour à la ligne.
Les messages échangés entre le serveur et les clients correspondent directement aux méthodes de l'interface Player
. C'est-à-dire qu'à chaque méthode de cette interface correspond un type de message, et inversement. En d'autres termes, chacun des deux clients peut être vu comme une instance d'un objet implémentant l'interface Player
. Le serveur, qui gère le déroulement de la partie au moyen d'une instance de Game
, appelle « à distance » les méthodes de ces objets, au moyen de messages textuels.
Pour illustrer le fonctionnement de cette communication, voici les cinq premiers messages qui pourraient être envoyés par le serveur à l'un des deux clients au début d'une partie — le second recevant des messages similaires :
INIT_PLAYERS 0 QWRh,Q2hhcmxlcw== RECEIVE_INFO QWRhIGpvdWVyYSBlbiBwcmVtaWVyLgoK SET_INITIAL_TICKETS 6,1,44,42,42 UPDATE_STATE 36:6,7,4,7,1;97;0:0:0;4;:0;4;: ;0,1,5,5; CHOOSE_INITIAL_TICKETS
Le premier de ces messages correspond à un appel à initPlayers
, le second à un appel à receiveInfo
, etc. Les quatre premiers de ces messages ne demandent aucune réponse du client — étant donné que les méthodes correspondantes ont void
comme type de retour — mais le dernier en demande une, puisqu'il correspond à un appel à chooseInitialTickets
. En admettant que le joueur utilisant ce client choisisse les trois premiers billets qui lui sont proposés, le client répondrait alors avec le message suivant :
6,1,44
Et ainsi de suite.
La signification des différentes parties des messages deviendra claire à la lecture des section ci-dessous.
2.2. (Dé)sérialisation
Sachant que les messages échangés entre le serveur et les clients correspondent aux méthodes de l'interface Player
, il faut que, d'une manière ou d'une autre, ces messages contiennent toute l'information contenue dans les arguments et les valeurs de retour de ces méthodes.
Par exemple, le message INIT_PLAYERS
, qui correspond à la méthode initPlayers
, doit contenir l'information donnant l'identité du joueur — de type PlayerId
— et la table donnant le nom des deux joueurs — de type Map<PlayerId, String>
.
Sachant que les messages sont textuels et ne peuvent contenir que des caractères ASCII imprimables, il doit donc être possible de transformer n'importe quelle valeur qu'une méthode de Player
peut prendre en argument ou retourner en résultat en une chaîne composée de tels caractères. Cette transformation doit bien entendu être inversible, dans le sens où il doit être possible d'obtenir la valeur Java correspondant à une chaîne donnée.
Nous appellerons sérialisation (serialization) l'opération consistant à transformer une valeur Java en une chaîne de caractères ASCII imprimables, et désérialisation (deserialization) l'opération inverse.
En examinant les méthodes de l'interface Player
, on constate que les valeurs Java suivantes doivent pouvoir être (dé)sérialisées :
- des entiers (
int
), - des chaînes de caractères (
String
), - des valeurs de types énumérés (
PlayerId
,TurnKind
etCard
), - des valeurs de types qui ne sont pas des types énumérés Java mais dont il n'existe néanmoins qu'un nombre fini de valeurs (
Ticket
etRoute
), - des listes (
List<…>
) et des multiensembles (SortedBag<…>
) de valeurs, - des valeurs de types composites, constitués de plusieurs attributs de types différents (
PublicGameState
,PublicCardState
,PublicPlayerState
,PlayerState
).
Il convient donc de choisir, pour chacun de ces types, une manière de sérialiser leurs valeurs, ce qui est fait dans les sections suivantes.
2.2.1. Entiers
Les entiers sont sérialisés par leur représentation en base 10. Par exemple, l'entier 2021 est sérialisé par la chaîne de longueur 4 suivante :
2021
N'importe quel entier peut donc être sérialisé en utilisant uniquement les caractères représentant les chiffres (0
à 9
) ainsi que le caractère moins (-
) lorsque l'entier est négatif. Ces caractères constituent l'alphabet de sérialisation des entiers.
2.2.2. Chaînes
Les chaînes sont sérialisées au moyen d'un encodage nommé base64, fréquemment utilisé en informatique. Cet encodage permet de représenter n'importe quelle chaîne de caractères en utilisant uniquement l'un des 64 caractères ASCII suivants :
- les 26 lettres minuscules (
a
àz
), - les 26 lettres majuscules (
A
àZ
), - les 10 chiffres (
0
à9
), - le signe plus (
+
) et la barre oblique (/
).
En plus de ces 64 caractères, l'encodage base64 utilise aussi parfois le signe « égal » (=
) comme caractère de remplissage. Ce caractère n'apparaît toutefois qu'à la fin d'une chaîne encodée.
La manière exacte dont l'encodage base64 fonctionne ne sera pas décrite ici, car la bibliothèque Java offre des méthodes permettant d'encoder et de décoder des valeurs en base64. Les personnes intéressées par les détails pourront se reporter à la page Wikipedia décrivant l'encodage.
Pour donner un exemple, l'encodage base64 de la chaîne Charles est :
Q2hhcmxlcw==
Comme cet exemple l'illustre, une chaîne encodée en base64 est plus longue que la chaîne originale. Cela s'explique facilement par le fait que chaque caractère de la version encodée d'une chaîne occupe 8 bits — comme tout caractère ASCII — mais ne contient que 6 bits d'information (car 26 = 64). Dès lors, dans le cas général, le fait d'encoder une chaîne en base64 multiplie sa taille par 8/6 (= 4/3), donc l'augmente d'un tiers.
L'alphabet de sérialisation des chaînes est celui de base64, à savoir les 52 lettres minuscules et majuscules (a
à z
et A
à Z
), les chiffres (0
à 9
) et les signes plus (+
), barre oblique (/
) et égal (=
).
2.2.3. Valeurs énumérées
Plusieurs des valeurs manipulées dans ce projet appartiennent à un petit ensemble de valeurs possibles. Il en va bien entendu ainsi de toutes les valeurs définies au moyen de types énumérés Java — les couleurs, les cartes, les identités de joueur —, mais aussi des gares, billets et routes, dont il n'existe qu'un nombre fini de valeurs. Nous appellerons ce type de valeurs des valeurs énumérées, même si toutes ne sont pas définies comme des types énumérés Java dans le projet.
Les valeurs énumérées le sont toujours dans un certain ordre : pour les types énumérés, cet ordre est celui de déclaration, tandis que pour les autres valeurs cet ordre est généralement défini par une liste contenant toutes les valeurs possibles — par exemple la liste des billets retournée par la méthode tickets
de ChMap
. Dès lors, à chaque valeur énumérée correspond un index dans cet ordre de déclaration, et il est possible de sérialiser les valeurs énumérées en sérialisant leur index.
Par exemple, une carte peut être sérialisée comme sa position dans la liste de toutes les cartes possibles, qui est ordonnée comme les valeurs du type énuméré Card
. En d'autres termes, la carte wagon noir est sérialisé comme l'entier 0, la carte wagon violet comme l'entier 1, et ainsi de suite jusqu'à la carte locomotive, qui est sérialisée comme l'entier 8.
La table ci-dessous donne les listes utilisées pour déterminer l'index correspondant aux différentes valeurs énumérées à sérialiser :
Type | Liste des valeurs |
---|---|
PlayerId |
PlayerId.ALL |
TurnKind |
TurnKind.ALL |
Card |
Card.ALL |
Ticket |
ChMap.tickets() |
Route |
ChMap.routes() |
L'alphabet de sérialisation des valeurs énumérées est donc celui correspondant aux entiers positifs, à savoir les caractères représentant les chiffres de 0 à 9.
2.2.4. Listes
Les listes sont sérialisées en séparant la sérialisation de leurs éléments au moyen d'un caractère de séparation. Bien entendu, ce caractère de séparation ne doit pas faire partie de l'alphabet de sérialisation des éléments eux-mêmes.
Par exemple, la liste des entiers de 1 à 5 (inclus) pourrait être sérialisée en utilisant la virgule (,
) comme séparateur, produisant la chaîne :
1,2,3,4,5
Une liste vide est sérialisée en une chaîne vide.
L'alphabet de sérialisation des listes est celui de ses éléments, augmenté du caractère de séparation utilisé.
2.2.5. Multiensembles
Dans un soucis de simplicité, les multiensembles sont transformés en listes avant d'être sérialisés exactement de la même manière.
Dès lors, le multiensemble d'entiers contenant deux occurrences de l'entier 20 et trois occurrences de l'entier 21 pourrait être sérialisé en utilisant la virgule comme séparateur, produisant la chaîne :
20,20,21,21,21
Toutes les listes et multiensembles du projet seront sérialisés en utilisant la virgule comme caractère de séparation, à une exception près : une liste de multiensembles de cartes — c-à-d une valeur de type List<SortedBag<Card>>
— sera sérialisée en utilisant deux séparateurs :
- la virgule pour séparer les cartes du multiensemble,
- le point-virgule pour séparer les multiensembles de la liste.
L'utilisation de deux séparateurs différents est bien entendu nécessaire ici, afin d'éviter les conflits.
2.2.6. Types composites
Les types composites — par exemple PublicCardState
— sont sérialisés en séparant la sérialisation de leurs composants au moyen d'un caractère de séparation. Une fois encore, il est important que ce caractère de séparation ne fasse pas partie de l'alphabet de sérialisation de l'un des composants.
Nous utiliserons systématiquement le point-virgule (;
) comme caractère de séparation pour les types composites, sauf pour PublicGameState
, pour lequel nous utiliserons le deux-points (:
). Il est nécessaire d'utiliser un autre caractère pour PublicGameState
, car il comporte lui-même des types composites (PublicCardState
, PublicPlayerState
).
Les sections suivantes résument les attributs des différents types composites à sérialiser, ainsi que leurs alphabets de sérialisation. Notez que les attributs sont donnés dans l'ordre dans lequel ils doivent être sérialisés !
PublicCardState
Les attributs de
PublicCardState
sont résumés dans la table ci-dessous, de même que leur alphabet de sérialisation. Aucun des alphabets des composants n'utilisant le point-virgule, celui-ci peut-être utilisé pour les séparer.Tableau 1 : PublicCardState
Attribut Type Alphabet faceUpCards
List<Card>
0123456789,
deckSize
int
(positif ou nul)0123456789
discardsSize
int
(positif ou nul)0123456789
PublicPlayerState
Les attributs de
PublicPlayerState
sont résumés dans la table ci-dessous. Là encore, aucun des alphabets des composants n'utilisant le point-virgule, celui-ci peut être utilisé pour les séparer.Tableau 2 : PublicPlayerState
Attribut Type Alphabet ticketCount
int
(positif ou nul)0123456789
cardCount
int
(positif ou nul)0123456789
routes
List<Route>
0123456789,
PlayerState
Les attributs de
PlayerState
sont résumés dans la table ci-dessous. Une fois encore, il est clair que le point-virgule convient comme séparateur.Tableau 3 : PlayerState
Attribut Type Alphabet tickets
SortedBag<Ticket>
0123456789,
cards
SortedBag<Card>
0123456789,
routes
List<Route>
0123456789,
PublicGameState
Les attributs de
PublicGameState
sont résumés dans la table ci-dessous. Cette fois, le point-virgule fait partie de l'alphabet des composants, et il convient donc d'utiliser un autre séparateur ici. Nous utiliserons donc le deux-points (:
).Tableau 4 : PublicGameState
Attribut Type Alphabet ticketsCount
int
(positif)0123456789
cardState
PublicCardState
0123456789,;
currentPlayerId
PlayerId
01
playerState(PLAYER_1)
PublicPlayerState
0123456789,;
playerState(PLAYER_2)
PublicPlayerState
0123456789,;
lastPlayer
PlayerId
(évt.null
)01
Comme cette table le suggère, les états publics des deux joueurs — contenus dans une table associative — sont sérialisés comme s'il s'agissait d'attributs individuels. Cette stratégie permet d'éviter de définir une technique de sérialisation spécifique aux tables associatives.
De plus, il faut noter que l'attribut
lastPlayer
peut être nul. Dans ce cas, il est sérialisé comme une chaîne vide.
2.2.7. Exemple
Le code ci-dessous construit la partie publique de l'état d'une partie et la stocke dans la variable gs
:
List<Card> fu = List.of(RED, WHITE, BLUE, BLACK, RED); PublicCardState cs = new PublicCardState(fu, 30, 31); List<Route> rs1 = ChMap.routes().subList(0, 2); Map<PlayerId, PublicPlayerState> ps = Map.of( PLAYER_1, new PublicPlayerState(10, 11, rs1), PLAYER_2, new PublicPlayerState(20, 21, List.of())); PublicGameState gs = new PublicGameState(40, cs, PLAYER_2, ps, null);
La sérialisation de l'état contenu dans gs
est la chaîne suivante :
40:6,7,2,0,6;30;31:1:10;11;0,1:20;21;:
3. Mise en œuvre Java
Cette étape est la première de la seconde partie du projet, durant laquelle vous serez plus libres et moins guidés que durant la première.
En particulier, vous avez maintenant le droit de modifier ou augmenter l'interface publique des classes et interfaces proposées, pour peu bien entendu que vous ayez une bonne raison de le faire. Pour faciliter la correction, nous vous demandons néanmoins de respecter les noms de paquetages et de classes, interfaces et types énumérés donnés.
D'autre part, nous nous attendons à ce que vous lisiez et compreniez la documentation des parties de la bibliothèque Java que vous devez utiliser.
Notez que toutes les entités définies dans cette étape appartiennent au nouveau paquetage ch.epfl.tchu.net
destiné à contenir tous les éléments liés à la communication réseau.
3.1. Type énuméré MessageId
Le type énuméré MessageId
du paquetage ch.epfl.tchu.net
énumère les types de messages que le serveur peut envoyer aux clients. Comme nous l'avons vu plus haut, ces messages correspondent directement aux méthodes de l'interface Player
. Les membres de ce type énuméré sont :
INIT_PLAYERS
,RECEIVE_INFO
,UPDATE_STATE
,SET_INITIAL_TICKETS
,CHOOSE_INITIAL_TICKETS
,NEXT_TURN
,CHOOSE_TICKETS
,DRAW_SLOT
,ROUTE
,CARDS
,CHOOSE_ADDITIONAL_CARDS
.
Notez que même si vous avez le droit d'utiliser d'autres noms, cela est fortement découragé car ces noms seront utilisés pour identifier les différents messages, comme l'exemple de la §2.1 l'illustre.
3.2. Interface Serde
L'interface Serde
du paquetage ch.epfl.tchu.net
représente ce que l'on nomme parfois un serde (de serializer-deserializer), à savoir un objet capable de sérialiser et désérialiser des valeurs d'un type donné.
Cette interface est générique, son paramètre de type représentant le type des éléments que le serde est capable de (dé)sérialiser. Elle contient deux méthodes abstraites :
- une méthode, nommée p.ex.
serialize
, prenant en argument l'objet à sérialiser et retournant la chaîne correspondante, - une méthode, nommée p.ex.
deserialize
, prenant en argument une chaîne et retournant l'objet correspondant.
Ces deux méthodes doivent bien entendu être inverses l'une de l'autre. L'extrait de test JUnit ci-dessous, qui devrait s'exécuter avec succès, illustre l'utilisation d'un serde sérialisant les entiers au moyen de leur représentation en base 10 :
Serde<Integer> intSerde = /* … */; assertEquals("2021", intSerde.serialize(2021)); assertEquals(2021, intSerde.deserialize("2021"));
En plus des méthodes abstraites de (dé)sérialisation, l'interface Serde
définit quatre méthodes statiques permettant de créer différents types de serdes :
- une méthode générique nommée p.ex.
of
prenant en arguments une fonction de sérialisation et une fonction de désérialisation, et retournant le serde correspondant ; le type de la fonction de sérialisation qu'on lui passe doit êtreFunction<T, String>
, tandis que celui de la fonction de désérialisation doit êtreFunction<String, T>
, oùT
est le paramètre de type de la méthode, - une méthode générique nommée p.ex.
oneOf
prenant en argument la liste de toutes les valeurs d'un ensemble de valeurs énuméré et retournant le serde correspondant, - une méthode générique nommée p.ex.
listOf
prenant en argument un serde et un caractère de séparation et retournant un serde capable de (dé)sérialiser des listes de valeurs (dé)sérialisées par le serde donné, - une méthode générique nommée p.ex.
bagOf
fonctionnant commelistOf
mais pour les multiensembles triés (SortedBag
).
La méthode of
est destinée à être utilisée avec des lambdas car, pour mémoire, l'interface Function
est une interface fonctionnelle définie dans le paquetage java.util.function
. Elle pourrait par exemple être utilisée ainsi pour définir un serde capable de (dé)sérialiser des entiers au moyen de leur représentation en base 10 :
Serde<Integer> intSerde = Serde.of( i -> Integer.toString(i), Integer::parseInt);
Les trois autres méthodes devraient pouvoir s'utiliser comme illustré ci-dessous pour obtenir des serdes capables de (dé)sérialiser des couleurs, des listes de couleurs (séparées par des +
), et des multiensembles de couleurs (séparées par des +
) :
Serde<Color> color = Serde.oneOf(Color.ALL); Serde<List<Color>> listOfColor = Serde.listOf(color, "+"); Serde<SortedBag<Color>> bagOfColor = Serde.bagOf(color, "+");
3.2.1. Conseils de programmation
Pour joindre les chaînes résultant de la sérialisation des éléments d'une liste ou d'un multiensemble, vous pouvez soit utiliser la méthode join
de String
— déjà utilisée à l'étape 1 — ou une instance de StringJoiner
.
Pour effectuer l'opération inverse, c-à-d découper une chaîne en fonction d'un séparateur, vous pouvez utiliser la méthode split
de String
. Il faut toutefois faire attention à deux points lorsqu'on utilise cette méthode :
- la chaîne de séparation ne doit pas être passée telle quelle à
split
; au lieu de cela, il faut passer la chaîne retournée par la méthodequote
dePattern
appliquée à cette chaîne de séparation1, - le second argument (la limite) doit toujours valoir
-1
.
L'exemple ci-dessous — un test JUnit s'exécutant avec succès — illustre l'usage correct de la méthode split
pour découper une chaîne construite au moyen de join
:
String[] a = new String[]{"", "a", "b", "", "c", ""}; List<String> l = Arrays.asList(a); String s = String.join("|", l); assertEquals("|a|b||c|", s); assertArrayEquals(a, s.split(Pattern.quote("|"), -1));
3.3. Classe Serdes
La classe Serdes
du paquetage ch.epfl.tchu.net
, non instanciable, contient la totalité des serdes utiles au projet. Chacun d'entre eux est défini comme un attribut publique, statique et final de la classe. La table ci-dessous liste les types pour lesquels un serde est nécessaire, en rappelant le séparateur à utiliser pour les serdes qui sérialisent des valeurs composites, ainsi que la liste de valeurs à utiliser pour ceux qui sérialisent des valeurs énumérées :
Type | Séparateur | Liste de valeurs |
---|---|---|
Integer |
||
String |
||
PlayerId |
PlayerId.ALL |
|
TurnKind |
TurnKind.ALL |
|
Card |
Card.ALL |
|
Route |
ChMap.routes() |
|
Ticket |
ChMap.tickets() |
|
List<String> |
virgule (, ) |
|
List<Card> |
virgule (, ) |
|
List<Route> |
virgule (, ) |
|
SortedBag<Card> |
virgule (, ) |
|
SortedBag<Ticket> |
virgule (, ) |
|
List<SortedBag<Card>> |
point-virgule (; ) |
|
PublicCardState |
point-virgule (; ) |
|
PublicPlayerState |
point-virgule (; ) |
|
PlayerState |
point-virgule (; ) |
|
PublicGameState |
deux-points (: ) |
3.3.1. Conseils de programmation
Les méthodes getEncoder
et getDecoder
de la classe Base64
du paquetage java.util
permettent respectivement d'obtenir des objets capables d'encoder — grâce à la méthode encodeToString
— et de décoder — grâce à la méthode decode
— des séquences d'octets quelconques au moyen de base64.
Pour transformer une chaîne en séquence d'octets, il faut utiliser la méthode getBytes
de String
en lui passant en argument l'encodage StandardCharsets.UTF_8
, ce qui permet d'obtenir les octets correspondant à l'encodage UTF-8 de la chaîne, que nous utiliserons dans ce projet. Pour effectuer l'opération inverse, il faut utiliser le constructeur de String
prenant en argument un tableau d'octets et une valeur de type Charset
.
3.4. Tests
À partir de cette étape, aucun fichier de vérification de signatures ne vous est fourni, étant donné que la signature des différentes méthodes n'est plus spécifiée en détail. Pour la même raison, aucun test unitaire ne sera plus fourni à l'avenir, à vous d'écrire les vôtres. Notez que cela est fortement recommandé en général.
4. Résumé
Pour cette étape, vous devez :
- écrire le type énuméré
MessageId
, l'interfaceSerde
et la classeSerdes
selon 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
L'appel à quote
permet d'éviter que la chaîne de séparation ne soit interprétée comme une expression régulière, notion que vous aurez l'occasion de découvrir plus tard dans vos études.