I. Sérialisation et Désérialisation▲
Ce chapitre est une introduction à la sérialisation afin de comprendre comment envoyer un tampon de données d’une machine à une autre de telle sorte que notre destinataire puisse interpréter ce qu’il reçoit.
Nous allons dans ce chapitre créer une classe dédiée à la sérialisation de données et sa réciproque dédiée à la désérialisation.
Quand on parle de sérialisation, il s’agit de transformer les données locales avant l’envoi, en un format que le receveur peut interpréter afin de récupérer la donnée initiale.
Les principaux problèmes viennent de l’endianness (parfois traduit en français par boutisme) de chaque machine.
I-A. Endianness▲
Le problème d’endianness affectera uniquement les données multi-octets. Un seul octet n’est pas sujet à ce souci. Et les soucis se matérialisent lorsque des machines ayant des endianness différents s’envoient des données.
Il existe deux principaux types d’endianness : little-endian et big-endian.
I-A-1. Little-endian▲
Le little-endian se définit par l’octet de poids faible en premier ou comme une lecture de droite à gauche.
Ainsi, la valeur 32 bits 0x01020304 sera enregistrée ainsi dans la mémoire
adresse | 0 | 1 | 2 | 3 |
Valeur de l’octet | 0x04 | 0x03 | 0x02 | 0x01 |
I-A-2. Big-endian▲
Le big-endian sera décrit par l’octet de poids fort en premier ou comme une lecture de gauche à droite.
Dans ce cas, la valeur 32 bits 0x01020304 sera enregistrée ainsi dans la mémoire
adresse | 0 | 1 | 2 | 3 |
Valeur de l’octet | 0x01 | 0x02 | 0x03 | 0x04 |
I-A-3. Conséquences▲
Pour comprendre les conséquences, prenons un entier sur deux octets : uint16_t i =
1
;
Sur une machine little-endian i sera stocké sur deux octets consécutifs : 0x01 & 0x00.
Sur une machine big-endian i sera stocké sur deux octets consécutifs : 0x00 & 0x01.
Ainsi, si une machine A little-endian envoie i tel quel sur deux octets, une machine B big-endian recevra 0x0100 et l’interprétera comme la valeur 256. Alors que la valeur transmise était 1.
Pour éviter ces problèmes, il faut que nos applications s’entendent sur l’encodage des valeurs multi-octets. Le choix est généralement porté vers le big-endian puisqu’il s’agit de la norme choisie pour le réseau, que l’on appelle alors network-endianness. Ce sera également notre choix.
I-A-4. Conversions d’endianness▲
Les fonctions de conversion vers l’endianness local et réseau ont été vues dans le premier chapitre.
Toutes les données doivent être converties en network-endianness avant d’être sérialisées et envoyées, puis vers l’endianness local à la réception avant utilisation.
I-B. Sérialisation▲
Le sérialiseur possédera un tampon qui devra contenir les données à transférer à la machine distante, au format big-endian/network-endian.
Il se chargera également de transformer les données vers cet endianness si nécessaire. Toutes les données que nous lui fournirons seront en endianness local afin de simplifier leur utilisation : l’utilisateur ne se soucie pas de l’endianness, c’est la responsabilité de la sérialisation d’appliquer les transformations nécessaires.
I-C. Désérialisation▲
Le désérialiseur sera l’inverse de la classe précédente : il sera chargé d’extraire les données de l’utilisateur à partir d’un tampon reçu et les transformer dans l’endianness local.
I-D. Taille des données▲
Vous avez sûrement remarqué l’utilisation des types int8_t, int16_t, int32_t, int64_t et leurs versions non signées uint8_t, uint16_t, uint32_t, uint64_t.
Ces types sont particulièrement intéressants et utiles dans le cadre de la sérialisation de données : ils permettent de connaître sur combien de bits et donc d’octets (puisqu’on suppose qu’un octet est composé de 8 bits - ce qui est vrai pour l’immense majorité des plateformes) sont encodées leurs valeurs. Nous manipulerons ici des tampons et octets, connaître la représentation mémoire d’une variable sera nécessaire.
I-D-1. Redéfinition des types de base▲
Il est habituel de redéfinir ces types dans notre code. Ça permet de simplifier leur écriture et s’affranchir d’une dépendance : en cas de nouvelle plateforme, ou de nommage différent sur l’une d’elles, il suffit de modifier un fichier de redéfinition de ces types pour la rendre compatible.
Une telle redéfinition est totalement gratuite et transparente : elle n’ajoute aucun coût mémoire ou d’exécution puisque ce ne sont que des allias sur le type réel qui sont créés.
Maintenant que les types deviennent plus importants, il est temps de réaliser cette redéfinition :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
#pragma once
#include
<cstdint>
namespace
Bousk
{
using
int8 =
int8_t;
using
int16 =
int16_t;
using
int32 =
int32_t;
using
int64 =
int64_t;
using
uint8 =
uint8_t;
using
uint16 =
uint16_t;
using
uint32 =
uint32_t;
using
uint64 =
uint64_t;
using
float32 =
float
;
using
float64 =
double
;
}
Les prochains codes utiliseront donc ces nouveaux types.
Certaines bases de codes redéfinissent également le type bool
.
Vous verrez plus loin que la sérialisation d’un booléen est un cas particulier qui ne nécessite pas de redéfinir son type pour s’assurer d’une taille fixe.
II. Implémentations▲
II-A. Conversions▲
Commençons par créer des fonctions de conversion d’endianness. Il est intéressant de surcharger ces fonctions pour plusieurs raisons :
- afin d’unifier leurs syntaxes ;
- proposer des conversions supplémentaires, pour les entiers sur 64 ou 128 bits par exemple ;
- pouvoir optimiser ces fonctions dans certains cas :
- si la machine est déjà big-endian, elle n’a rien à faire,
- certains compilateurs ou plateformes fournissent des fonctions optimisées pour ce genre de traitements.
Nous créerons donc des fonctions pour convertir les données depuis l’endianness local vers l’endianness réseau et inversement :
Leurs implémentations seront très simples dans un premier temps :
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
<Serialization/Convert.hpp>
#ifdef _WIN32
#define NOMINMAX
#include
<WinSock2.h>
#else
#include
<arpa/inet.h>
#endif
namespace
Bousk
{
namespace
Serialization
{
namespace
Conversion
{
void
ToNetwork(uint16 from, uint16&
to)
{
to =
htons(from);
}
void
ToNetwork(uint32 from, uint32&
to)
{
to =
htonl(from);
}
void
ToLocal(uint16 from, uint16&
to)
{
to =
ntohs(from);
}
void
ToLocal(uint32 from, uint32&
to)
{
to =
ntohl(from);
}
}
}
}
II-B. Sérialisation▲
Passons maintenant à la sérialisation des données dans un tampon pour l’envoi sur le réseau.
L’objectif est de pouvoir sérialiser tous nos types de base, ainsi que std::
vector et std::
string afin de couvrir toute utilisation classique.
II-B-1. Interface▲
Notre sérialiseur devrait avoir une interface de ce type :
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.
36.
37.
#pragma once
#include
<Types.hpp>
#include
<string>
#include
<vector>
namespace
Bousk
{
namespace
Serialization
{
class
Serializer
{
public
:
Serializer() =
default
;
bool
write(uint8 data);
bool
write(uint16 data);
bool
write(uint32 data);
bool
write(bool
data);
bool
write(int8 data);
bool
write(int16 data);
bool
write(int32 data);
bool
write(float32 data);
template
<
class
T>
bool
write(const
std::
vector<
T>&
data);
bool
write(const
std::
string&
data);
inline
const
uint8*
buffer() const
{
return
mBuffer.data(); }
inline
size_t bufferSize() const
{
return
mBuffer.size(); }
private
:
std::
vector<
uint8>
mBuffer;
}
;
}
}
Chaque fonction retourne un booléen afin d’indiquer si la donnée a pu être sérialisée ou non.
Pour l’instant inutile, ceci sera très utile par la suite lorsque nous voudrons limiter la taille du tampon interne ou pouvoir écrire dans un tampon existant dont la taille est limitée.
Ainsi notre classe a une interface prête pour cette amélioration, ce qui permet de commencer à l’utiliser en prévoyant ce cas.
II-B-2. Implémentation pour les types de base▲
Lors de la sérialisation, nous traitons des octets et non des types. Ce qui importe est donc le nombre d’octets du type plus que le type lui-même. Notez également que les fonctions de conversion traitent uniquement les types non signés pour cette raison.
Nous pouvons donc commencer par factoriser les implémentations ainsi :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
#include
<Serialization/Serializer.hpp>
#include
<Serialization/Convert.hpp>
namespace
Bousk
{
namespace
Serialization
{
bool
Serializer::
write(int8 data)
{
return
write(*
reinterpret_cast
<
uint8*>
(&
data));
}
bool
Serializer::
write(int16 data)
{
return
write(*
reinterpret_cast
<
uint16*>
(&
data));
}
bool
Serializer::
write(int32 data)
{
return
write(*
reinterpret_cast
<
uint32*>
(&
data));
}
}
}
Si vous êtes sûr que toutes vos cibles de compilation utilisent le complément à 2 pour les entiers signés, un static_cast
est utilisable ici.
Malheureusement ceci est à la discrétion du compilateur et du CPU (au moins jusqu’à la norme C++20) et comme ce sont les bits qui nous intéressent, le mieux reste d’utiliser un simple reinterpret_cast
.
Et seules les versions non signées ont besoin d’implémentation spécifique : réordonner les octets dans le bon endianness avant d’écrire dans le tampon, via nos fonctions de conversion :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
#include
<Serialization/Convert.hpp>
namespace
Bousk
{
namespace
Serialization
{
bool
Serializer::
write(uint8 data)
{
return
writeBytes(&
data, 1
);
}
bool
Serializer::
write(uint16 data)
{
uint16 conv;
Conversion::
ToNetwork(data, conv);
return
writeBytes(reinterpret_cast
<
const
uint8*>
(&
conv), 2
);
}
bool
Serializer::
write(uint32 data)
{
uint32 conv;
Conversion::
ToNetwork(data, conv);
return
writeBytes(reinterpret_cast
<
const
uint8*>
(&
conv), 4
);
}
}
}
Avec writeBytes qui se définit de cette façon :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
namespace
Bousk
{
namespace
Serialization
{
bool
Serializer::
writeBytes(const
uint8*
buffer, size_t nbBytes)
{
mBuffer.insert(mBuffer.cend(), buffer, buffer +
nbBytes);
return
true
;
}
}
}
II-B-2-a. Cas du bool▲
Afin de pouvoir transférer un booléen, il faut le transformer proprement en uint8.
Créons donc deux valeurs bien définies pour la valeur vraie et fausse :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
#pragma once
#include
<Types.hpp>
namespace
Bousk
{
namespace
Serialization
{
static
constexpr
uint8 BoolTrue =
0x01
;
static
constexpr
uint8 BoolFalse =
0
;
}
}
Maintenant que nous avons fixé les valeurs vraie et fausse pour transférer un booléen, son implémentation est plutôt simple :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
#include
<Serialization/Serialization.hpp>
namespace
Bousk
{
namespace
Serialization
{
bool
Serializer::
write(bool
data)
{
return
write(data ? BoolTrue : BoolFalse);
}
}
}
II-B-2-b. Cas du float32▲
Le flottant est un autre cas particulier. Sa spécificité vient des multiples représentations qu’il peut avoir.
Dans notre cas, le problème est simplifié parce que nous communiquerons entre applications contrôlées et utilisant le même moteur : nous ne créons pas ici un cas super général pouvant communiquer avec absolument toutes les machines, mais voulons faire communiquer entre elles les machines les plus communes qui sont susceptibles d’exécuter notre application (typiquement PC Windows, Unix, MacOs, smartphones, tablettes et consoles de jeu).
Et dans ce cadre, nous pouvons choisir la représentation de nos float
, et ce sera généralement la norme IEEE 754 qui sera choisie, qui est la plus commune. Ce sera donc la seule norme que nous aurons à supporter. Cela ne fait pas disparaître tous les problèmes, mais est un bon début.
Avec ces hypothèses, sérialiser un float
sera aussi simple que de le traiter comme une simple valeur sur 4 octets : il faut réordonner ses octets dans l’endianness réseau, puis les envoyer, et les réordonner vers l’endianness local à la réception.
Il existe une astuce très répandue pour y parvenir, utilisant une union :
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.
namespace
Bousk
{
namespace
Serialization
{
namespace
Conversion
{
union
FloatConversionHelper
{
static_assert
(sizeof
(float32) ==
sizeof
(uint32), ""
);
float32 f;
uint32 u;
}
;
void
ToNetwork(float32 in, uint32&
out)
{
FloatConversionHelper helper;
helper.f =
in;
ToNetwork(helper.u, out);
}
void
ToLocal(uint32 in, float32&
out)
{
FloatConversionHelper helper;
ToLocal(in, helper.u);
out =
helper.f;
}
}
}
}
Qui nous permet d’implémenter la sérialisation d’un float32 ainsi :
2.
3.
4.
5.
6.
bool
Serializer::
write(float32 data)
{
uint32 conv;
Conversion::
ToNetwork(data, conv);
return
writeBytes(reinterpret_cast
<
const
uint8*>
(&
conv), 4
);
}
Cette astuce est plus ou moins critiquée, et pourtant présente et utilisée dans des bibliothèques comme protobuf ou SDL2.
Je vous invite à en lire plus à ce sujet sur les liens suivants :
https://stackoverflow.com/questions/25664848/unions-and-type-punning/31080901#31080901
Je l’ai également personnellement utilisée depuis des années sans soucis sur différentes plateformes, et vais donc continuer ainsi…
II-B-3. Implémentation pour vector▲
Le vector pourra contenir des types de base uniquement : il s’agit de pouvoir envoyer des std::
vector<
uint8>
, std::
vector<
int16>
, etc, et std::
vector<
std::
string>
.
Il faut que le receveur connaisse le nombre d’éléments qui le composent. Ça peut se faire de deux manières :
- via l’envoi d’une sentinelle. Une valeur spécifique qui définit la fin du tableau (comme le \0 pour une chaîne de caractères) ;
- en envoyant la taille du tableau, qui précédera les données afin de savoir quand s’arrêter.
La sentinelle est efficace pour une chaîne de caractères, mais pas dans un cas général. À vrai dire, même dans le cas de la chaîne de caractères, il vaut mieux utiliser l’astuce du préfixe de la taille : ça évite de faire des cas particuliers et permet une gestion similaire, quel que soit l’encodage des caractères (alors qu’une sentinelle n’est optimale que pour les chaînes en char
*
).
Il faut également limiter le nombre de données possibles : il ne s’agit pas de pouvoir envoyer un tableau contenant 12 000 entrées (c’est stupide et vous ne rencontrerez probablement pas ce cas – si jamais ça se produit, vous pouvez très certainement remettre en cause la raison en premier lieu).
Nous limiterons arbitrairement le nombre d’entrées à 255, soit un uint8, afin que le préfixe soit un octet :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
#include
<cassert>
#include
<numeric>
namespace
Bousk
{
namespace
Serialization
{
template
<
class
T>
bool
Serializer::
write(const
std::
vector<
T>&
data)
{
assert(data.size() <=
std::
numeric_limits<
uint8>
::
max());
mBuffer.reserve(mBuffer.size() +
data.size() +
1
);
if
(!
write(static_cast
<
uint8>
(data.size())))
return
false
;
for
(T entry : data)
{
if
(!
write(entry))
return
false
;
}
return
true
;
}
}
}
II-B-4. Implémentation pour string ▲
std::string est un autre container standard. Il partage donc certaines propriétés avec std::
vector et il est donc possible et intéressant de factoriser ces deux implémentations :
2.
3.
4.
5.
6.
7.
8.
9.
10.
namespace
Bousk
{
namespace
Serialization
{
class
Serializer
{
bool
write(const
std::
string&
data) {
return
writeContainer(data); }
}
;
}
}
Avec writeContainer défini de la sorte :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
#include
<cassert>
#include
<numeric>
namespace
Bousk
{
namespace
Serialization
{
template
<
class
CONTAINER>
bool
Serializer::
writeContainer(const
CONTAINER&
container)
{
assert(container.size() <=
std::
numeric_limits<
uint8>
::
max());
mBuffer.reserve(mBuffer.size() +
container.size() +
1
);
if
(!
write(static_cast
<
uint8>
(container.size())))
return
false
;
for
(auto
&&
data : container)
{
if
(!
write(data))
return
false
;
}
return
true
;
}
}
}
Et on peut maintenant réécrire l’implémentation pour vector ainsi :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
namespace
Bousk
{
namespace
Serialization
{
class
Serializer
{
template
<
class
T>
bool
write(const
std::
vector<
T>&
data) {
return
writeContainer(data); }
}
;
}
}
Toutefois, il y a un problème avec cette implémentation pour std::
string.
Une std::
string est composée de char
, et nous n’avons pas de fonction write pour char
. Le compilateur fera alors une conversion implicite du type char
vers un type ayant une fonction write, généralement un int
et donc int32 dans la plupart des cas.
Mais nous souhaitons sérialiser nos caractères comme uint8.
Il faut donc ajouter une fonction pour sérialiser un char
afin d’être utilisable avec std::
string sans que le caractère ne soit promu vers un autre type :
bool
write(char
data) {
return
write(*
reinterpret_cast
<
uint8*>
(&
data)); }
Avec cette idée de containers, il est trivial d’ajouter la sérialisation d’autres types comme std::
list ou std::
dequeue et de façon générale tout type qui possède une fonction size et que l’on peut parcourir à l’aide d’une ranged-for-loop.
II-C. Désérialisation▲
Maintenant que nous sommes capables de sérialiser nos données, voyons comment les désérialiser.
II-C-1. Interface▲
Notre désérialiseur aura une interface similaire au sérialiseur : il doit traiter les mêmes données, mais dans l’autre sens : à partir d’un tampon donné, il permet d’extraire les données. Chaque donnée ne peut être extraite qu’une seule fois : l’extraction d’une donnée fait avancer la lecture du tampon :
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.
36.
37.
38.
39.
40.
41.
42.
43.
44.
45.
46.
47.
#pragma once
#include
<Types.hpp>
#include
<string>
#include
<vector>
namespace
Bousk
{
namespace
Serialization
{
class
Deserializer
{
public
:
Deserializer(const
uint8*
buffer, const
size_t bufferSize)
:
mBuffer(buffer)
, mBufferSize(bufferSize)
{}
bool
read(uint8&
data);
bool
read(uint16&
data);
bool
read(uint32&
data);
bool
read(bool
&
data);
bool
read(int8&
data);
bool
read(int16&
data);
bool
read(int32&
data);
bool
read(float32&
data);
template
<
class
T>
bool
read(std::
vector<
T>&
data) {
return
readContainer(data); }
bool
read(std::
string&
data) {
return
readContainer(data); }
inline
size_t remainingBytes() const
{
return
mBufferSize -
mBytesRead; }
private
:
bool
readBytes(size_t nbBytes, uint8*
buffer);
template
<
class
CONTAINER>
bool
readContainer(CONTAINER&
container);
private
:
const
uint8*
mBuffer;
const
size_t mBufferSize;
size_t mBytesRead{
0
}
;
}
;
}
}
II-C-2. Implémentations pour les types de base▲
La désérialisation des types de base est tout aussi simple que leur sérialisation et suit les mêmes principes. N’oubliez pas d’avancer le compteur de lecture :
II-C-3. Implémentations des containers▲
Tout comme pour la sérialisation, la désérialisation de std::
vector et std::
string peuvent être factorisées :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
#include
<cassert>
#include
<numeric>
namespace
Bousk
{
namespace
Serialization
{
template
<
class
CONTAINER>
bool
Deserializer::
readContainer(CONTAINER&
container)
{
uint8 nbElements;
if
(!
read(nbElements))
return
false
;
container.reserve(nbElements);
for
(uint8 i =
0
; i <
nbElements; ++
i)
{
CONTAINER::
value_type element;
if
(!
read(element))
return
false
;
container.push_back(element);
}
return
true
;
}
}
}
Il y a un problème avec cette implémentation : std::string::
value_type est char
et nous n’avons pas de fonction read prenant un char en paramètre. Ce code ne compilera donc pas.
La manière la plus simple est alors de proposer un read(char
&
), en interne, afin de pouvoir lire une std::
string :
bool
read(char
&
data) {
return
read(reinterpret_cast
<
uint8&>
(data)); }
III. Tests▲
Créons quelques tests pour vérifier que tout ceci fonctionne comme attendu :
Comme d’habitude, ajoutez autant de tests que nécessaire jusqu’à être convaincu que le fonctionnement est correct.
IV. Conclusion▲
Nous voilà maintenant en possession de classes pour sérialiser des données à envoyer sur le réseau et désérialiser celles reçues.
La sérialisation présentée dans ce chapitre est très basique, ne couvre que certains types et est loin d’être optimale. C’est une introduction au principe de sérialisation et gestion des données qui transitent via sockets.
Le plus important à retenir est l’existence des architectures big-endian et little-endian et leurs conséquences sur la représentation des données en mémoire et d’utiliser une représentation connue, network-endian, afin que chaque machine sache interpréter ce qu’elle reçoit et puisse faire comprendre ce qu’elle envoie.
Retrouvez le code source sous le tag SerializationBasics dans le dépôt Mercurial à l’adresse suivante.