IdentifiantMot de passe
Loading...
Mot de passe oublié ?Je m'inscris ! (gratuit)

Cours programmation réseau en C++

Bases de la sérialisation

Dans ce chapitre nous allons introduire les bases de la sérialisation de données afin de pouvoir transférer correctement des tampons d’octets entre machines.

53 commentaires Donner une note à l´article (5) 

Article lu   fois.

L'auteur

Profil ProSite personnel

Liens sociaux

Viadeo Twitter Facebook Share on Google+   

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 :

Types.hpp
Cacher/Afficher le codeSélectionnez
1.
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 :

Conversion.hpp
Cacher/Afficher le codeSélectionnez

Leurs implémentations seront très simples dans un premier temps :

Conversion.cpp
Cacher/Afficher le codeSélectionnez
1.
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 :

Serializer.hpp
Cacher/Afficher le codeSélectionnez
1.
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 :

Serializer.cpp
Cacher/Afficher le codeSélectionnez
1.
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 :

Serializer.cpp
Cacher/Afficher le codeSélectionnez
1.
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 :

Serializer.cpp
Cacher/Afficher le codeSélectionnez
1.
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 :

Serialization.hpp
Cacher/Afficher le codeSélectionnez
1.
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 :

Serializer.cpp
Cacher/Afficher le codeSélectionnez
1.
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 :

Conversion.cpp
Cacher/Afficher le codeSélectionnez
1.
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 :

Serializer.cpp
Cacher/Afficher le codeSélectionnez
1.
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

https://bousk.developpez.com/traductions/gafferongames/construire-son-protocole-jeu-reseau/techniques-serialisation/#LIV

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 :

Serializer.inl
Cacher/Afficher le codeSélectionnez
1.
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 :

Serializer.hpp
Cacher/Afficher le codeSélectionnez
1.
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 :

Serializer.inl
Cacher/Afficher le codeSélectionnez
1.
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 :

Serializer.hpp
Cacher/Afficher le codeSélectionnez
1.
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 :

Serializer.hpp
Sélectionnez
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 :

Deserializer.hpp
Cacher/Afficher le codeSélectionnez
1.
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 :

Deserializer.cpp
Cacher/Afficher le codeSélectionnez

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 :

Deserializer.inl
Cacher/Afficher le codeSélectionnez
1.
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 :

Deserializer.hpp
Sélectionnez
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 :

Serialization_Test.hpp
Cacher/Afficher le codeSélectionnez

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.

Vous avez aimé ce tutoriel ? Alors partagez-le en cliquant sur les boutons suivants : Viadeo Twitter Facebook Share on Google+   

Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright © 2019 Cyrille (Bousk) Bousquet. Aucune reproduction, même partielle, ne peut être faite de ce site ni de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.