https://lafor.ge/feed.xml

Partie 2 : Sérialisation des données

2024-12-03
Les articles de la série

Bonjour à toutes et tous! 😄

On se retrouve pour la seconde partie de notre implémentation de sqlite en Rust.

Vous pouvez trouver la partie 1 ici où l'on a créé le REPL. Nous allons réutiliser notre travail aujourd'hui.

Dans cette partie nous allons attaquer le stockage des données.

Cette section sera légèrement plus dense que la précédente mais on devrait s'en sortir. 😅

Modéliser une ligne d'enregistrement

Avant de stocker de la données, il faut savoir comment la modéliser.

La solution naïve est d'en faire une structure.

Pour rappelle notre commande d'insert ressemble à insert 1 name email.

Il n'est pas déconnant de modéliser cela ainsi:

struct User {
 id: i64,
 username: String,
 email: String
}

Bon, on a notre modèle, maintenant il faut la stocker. Mais où?

Pour commencer simplement, nous allons stocker dans un tableau de bytes en mémoire.

Son type sera [u8; 1024], 1ko de mémoire, parce que ici on est riche!! 💰

Ne vous inquiétez pas pour cette simplicité apparente, les prochains articles vont être démentiellement plus complexes. 😈

La question maintenant est

Comment transformer notre structure en des &[u8]?

Serde (ma version)

Ce principe de transformer une structure de données en des données transférables sur un autre support, se nomme la sérialisation.

L'opération inverse de re-transformer les données sérialisées en une structure de données se nomme la désérialisation.

J'ai déjà réalisé un article sur le mécanisme utilisé par Rust pour normalisé ces opérations, vous pouvez y jeter un coup d'oeil, si vous êtes curieuse/curieux.

Mais aujourd'hui, au vu de la contrainte qui nous autorise uniquement la lib standard, nous ne pourront pas utiliser serde.

Au lieu de ça, nous allons en bâtir notre propre version qui sera plus simple, mais plus adaptée à nos usages.

La première chose que nous allons définir est l'interface publique de notre "serde".

Nous allons définir deux traits:

  • Serializable : qui permet de passer de la structure de données au &[u8].
  • Deserializable: qui permet l'inverse
trait Serializable {
    fn serialize(&self, buffer: &mut [u8]) -> Result<(), SerializationError>;
}

trait Deserializable: Sized {
    fn deserialize(buffer: &[u8]) -> Result<Self, DeserializationError>;
}

Nous avons quelques petites choses à rectifier, il nous manque les définitions de:

  • SerializationError
  • DeserializationError

Commençons par les erreurs, qui sont le plus simple à définir. On reviendra sur les variantes plus tard.

//------------------------
// DeserializationError
//------------------------ 
#[derive(Debug, PartialEq)]
pub enum DeserializationError {}

impl Display for DeserializationError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{:?}", self)
    }
}

impl Error for DeserializationError {}

//----------------------
// SerializationError
//----------------------
#[derive(Debug, PartialEq)]
pub enum SerializationError {}

impl Display for SerializationError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{:?}", self)
    }
}

impl Error for SerializationError {}

Implémentation du serde sur User

Pour cela, il faut un peu réfléchir à comment un ordinateur stocke les données.

Encodage des i64

Commençons par essayer de stocker un i64. Comme son nom l'indique, il s'agit d'un entier stocké sur 64 bits ou 8 octets.

Pour s'en convaincre, nous pouvons appeler la méthode size_of.

dbg!(size_of::<i64>()); // 8

Un ordinateur travaille avec une granularité minimale de l'octets, cela signifie que pour stocker notre entier, il nous faut 8 cases, 8 u8 de notre buffer.

Mais du coup comment est-il stocké?

Si on en fait la représentation binaire du nombre 42 sur 64 bits, cela nous donnes:

00000000 00000000 00000000 00000000 00000000 00000000 00000000 00101010
^                                                                     ^
MSB                                                                   LSB

Most Significant Bit signifie "Bit de poids fort" et Least Significant Bit signifie "Bit de poids faible".

Le LSB permet de repérer le bit qui encode la valeur 1 si tous les autres bits sont à 0.

Il y a deux manières de stocker la données:

  • Soit l'on lit de gauche à droite et on stocke du MSB vers le LSB.
  • Soit l'on lit de droite à gauche et on stocke du LSB vers le MSB.

Ce principe de nomme l'endianess et est une convention que l'on doit suivre à la fois en lecture et en écriture pour être capable de récupérer les données stockées.

On va passer notre nombre binaire en hexa pour que ça soit plus simple.

00 00 00 00 00 00 00 2A

Si l'on stocke du MSB vers le LSB, en Big Endian, cela nous donne le tableau suivant.

let data = [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x2A]

Si par contre on stocke du LSB vers le MSB, alors on sera en Little Endian:

let data = [0x2A, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]

Rust fourni des outils pour désérialiser (car c'est de ça qu'il s'agit) les bytes en un i64.

Il existe deux méthodes:

Petite expérience:

// Little endian
let data = [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x2A];
dbg!(i64::from_le_bytes(data)); // 3026418949592973312
dbg!(i64::from_be_bytes(data)); // 42

// Big endian
let data = [0x2A, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00];
dbg!(i64::from_le_bytes(data)); // 42
dbg!(i64::from_be_bytes(data)); // 3026418949592973312

On voit bien ici l'importance capitale de l'endianess dans la manipulation des données.

Ok cool, mais comment on transforme notre i64 en bytes ?

Pareil, Rust a tout prévu, il y a les méthodes pour:

let data = 42_i64.to_le_bytes();
dbg!(i64::from_le_bytes(data)); // 42
dbg!(i64::from_be_bytes(data)); // 3026418949592973312

let data = 42_i64.to_be_bytes();
dbg!(i64::from_be_bytes(data)); // 42
dbg!(i64::from_le_bytes(data)); // 3026418949592973312

De manière totalement arbitraire, j'ai choisi de faire du Little Endian. C'est ce qui est utilisé sur les archi x86, alors pourquoi pas.

Du coup, on passe comment de nombre à de la données sérialisé dans notre buffer?

Et bien on va tout simplement copier les bytes dans notre tableau.

Pour cela, nous alons utiliser la méthode copy_from_slice, mais attention, elle est extrêmement capricieuse.

Elle panique si la slice de destination et de source n'ont pas exactement la même taille.

Heureursement, nous on sait la taille que ça prendra : size_of::<i64>().

On peut alors encoder dans notre buffer notre entier en Little Endian.

let mut buffer = [0_u8; 1024];
buffer[..size_of::<i64>()].copy_from_slice(42_i64.to_le_bytes().as_ref());
assert_eq!(
    42,
    i64::from_le_bytes(buffer[..size_of::<i64>()].try_into().unwrap())
);

Le .try_into().unwrap() est nécessaire car la signature est

pub const fn from_le_bytes(bytes: [u8; 8]) -> i64

Ce qui n'est pas un &[u8], le try_into réalise la conversion. Le unwrap assure que exactement 8 octets sont transférés.

Encodage des String

Le stockage des chaînes de caractères est un vaste sujet qui a fait coulé beaucoup d'encre.

Dans les années 60, seuls les américains semblent faire de l'informatique et imposent leur alphabet et leur manière de stocker le texte. j'ai nommé le American Standard Code for Information Interchange ou ASCII.

L'idée est simple, chaque chiffre codé sur 8 bits ou un octet, correspond à une lettre dans le "charset" occidental américain .

 !"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~

C'est bien, mais les français, aiment beaucoup les accents et les cédilles et autres œ. Bref, on était pas content et on a fait notre sauce. On a alors créé le latin-1.

Mais les griefs que l'on avait, les autres peuples, les avaient également:

Tout le monde est partie de son côté bâtir son monde idéal.

Et à côté de tout ce beau monde, l'Asie a également développé son propre charset, mais à la différence des précédents, celui-ci mélange des alphabets entre eux pour pouvoir encoder des choses comme:

日本語版Wikipedia

Ce besoin est dû à la très forte influence occidentale qui a été imposé à la fois par le colonialisme et les occupations successives qui ont amené à rendre courant l'usage du syllabaire occidental en plus du régional.

Mais il y a infiniement plus de caractères dans du japonais ou du chinois que dans du français ou de l'anglais.

Tellement que l'on ne peut plus coder sur 8 bits les caractères, il en faut plus, au moins 2 octets pour le japonais.

On se retrouve alors au début des années 2000 avec une foultitudes de standards qui ne sont standards que de nom car quasi incompatible les uns par-rapport aux autres.

Bref, c'est le zbeul !

Une volonté d'unification de tout ce bazar, va conduire à un effort de standardisation mondiale qui n'a rien de moins comme objectif d'encoder tous les dialectes de la planète.

J'ai nommé l'UTF-8.

Contrairement à l'ASCII, le codage des caractères n'est pas réalisé de manière fixe, on peut avoir 1 octet, 2 octets, 3 octets ou 4 octets pour encoder un caractères.

Un fort lobbyisme a dû avoir lieu à l'époque, car le premier octet est celui de l'ASCII. Autrement dit, tant que vous ne fricottez pas avec les accents ou autres caractères reniés par l'Oncle Sam, une chaîne ASCII est compatible avec de UTF-8 et vice versa.

La conséquence est que la longeur sera la même également.

Pourquoi je vous est parlé d'encodage de string et d'UTF-8?

C'est parce que Rust gère nativement l'UTF-8. Et le gère bien, pas de soucis à se faire sur la longueur de la chaîne par exemple.

dbg!("tête".len()); // 5
dbg!("tete".len()); // 4

De même que récupérer les bytes est aisé.

println!("{:X?}", "tête".as_bytes()); // [74, C3, AA, 74, 65]
println!("{:X?}", "tete".as_bytes()); // [74, 65, 74, 65]

On remarque également que l'encodage se fait de gauche à droite, d'abord le 't' puis le 'e', puis le 't', ...

Et aussi que 'ê' n'est pas un 'e'+'^', c'est autre chose, mais cette complexité est cachée, et ça nous arrange bien. 😄

Et l'opération est réversible. On peut retransformer les bytes en string via la méthode String::from_utf8.

println!("{}", String::from_utf8("tête".as_bytes().to_vec()).unwrap()); // tête
println!("{}", String::from_utf8("tete".as_bytes().to_vec()).unwrap()); // tete

L'opération peut échouer si la séquence de bytes est inconnue de UTF-8

Du coup il est aisé de venir encoder la chaîne dans notre buffer.

let mut buffer = [0_u8; 1024];
let data = "tête".to_string();
buffer[..data.len()].copy_from_slice(data.as_bytes());
assert_eq!(
    data,
    String::from_utf8(buffer[..data.len()].to_vec()).unwrap()
);

Alors oui, vu qu'on connais la données à rechercher, c'est facile de récupérer la bonne slice à décoder dans le buffer.

Mais si vous ne savez pas ce que vous cherchez, vous pouvez vous arrêter trop tard ou trop tôt.

Pour y arriver, nous allons ruser et encoder la taille de la String dans l'octet avant les données.

let mut buffer = [0_u8; 1024];
let data = "tête".to_string();
// on stocke la taille dans le premier octet
buffer[0] = data.len() as u8;
// on avance d'un pour stocker
buffer[1..1 + data.len()].copy_from_slice(data.as_bytes());
// lors de la lecture on récupère la taille à lire du premier octet
let size = buffer[0] as usize;
assert_eq!(
    data,
    // on lit à partir du second octets autant de bytes que nécessaire
    String::from_utf8(buffer[1..1 + size].to_vec()).unwrap()
);

Encodage de la structure User

Encoder une structure, consiste à encoder successivement chacun de ses champs.

Mais il y a une subtilité: il faut se décaler d'autant que ce que l'on a écrit pour le premier champ, pour pouvoir écrire le second et ainsi de suite.

On va donc introduire une variable cursor qui va mémoriser notre état d'avancement.

Nous pouvons implémenter notre sérialisation en utilisant toutes les connaissances que l'on possède désormais.

impl Serializable for User {
    fn serialize(&self, buffer: &mut [u8]) -> Result<(), SerializationError> {
        let mut cursor = 0_usize;
        // encode id
        buffer[cursor..cursor + size_of_val(&self.id)]
            .copy_from_slice(self.id.to_le_bytes().as_ref());
        // on se décale de size_of<i64>
        cursor += size_of_val(&self.id);

        // encode username
        // on encode la taille de la String
        buffer[cursor] = self.username.len() as u8;
        // on se décale de 1
        cursor += 1;
        // on écite dans le buffer
        buffer[cursor..cursor + self.username.len()].copy_from_slice(self.username.as_bytes());
        // on se décale de la longueur de la chaine
        cursor += self.username.len();

        // encode email
        buffer[cursor] = self.email.len() as u8;
        cursor += 1;
        buffer[cursor..cursor + self.email.len()].copy_from_slice(self.email.as_bytes());
        Ok(())
    }
}

Et de même la désérialisation

impl Deserializable for User {
    fn deserialize(buffer: &[u8]) -> Result<Self, DeserializationError> {
        let mut cursor = 0_usize;
        // decode id
        let data = &buffer[cursor..cursor + size_of::<i64>()];
        let id = i64::from_le_bytes(
            data.try_into()
                .map_err(|_| DeserializationError::UnableToDeserializeInteger)?,
        );
        cursor += size_of::<i64>();
        // decode username
        let size = buffer[cursor];
        cursor += 1;
        let data = &buffer[cursor..cursor + size as usize];
        let username = String::from_utf8(data.to_vec())
            .map_err(DeserializationError::UnableToDeserializeString)?;
        cursor += size as usize;
        // decode email
        let size = buffer[cursor];
        cursor += 1;
        let data = &buffer[cursor..cursor + size as usize];
        let email = String::from_utf8(data.to_vec())
            .map_err(DeserializationError::UnableToDeserializeString)?;
        // recreate User
        Ok(User {
            id,
            username,
            email,
        })
    }
}

On en profite pour rajouter les variates d'erreur

#[derive(Debug, PartialEq)]
pub enum DeserializationError {
    UnableToDeserializeString(FromUtf8Error),
    UnableToDeserializeInteger,
}

On peut alors vérifier notre travail

#[test]
fn test_serde_user() {
    let mut buffer = [0_u8; 1024];
    let user = User {
        id: 42,
        username: "user".to_string(),
        email: "email".to_string(),
    };
    user.serialize(&mut buffer)
        .expect("Unable to serialize user");
    let result = User::deserialize(&buffer).expect("Unable to deserialize user");
    assert_eq!(user, result);
}

Et voilà! On sérialise notre structure! 🤩

Curseur

C'est bien, mais quelque chose m'ennuie: je n'aime pas gérer à la main les offsets dans le buffer. 😑

Heureusement, ce que l'on réalise est du basico-basique, des problèmes vu et revu, et donc il existe un outils dans la lib standard qui se nomme Cursor qui a pour exact but de fournir cette aspect de déplacement dans la donnée.

Mais aussi des traits Read et Write permettant de lire et d'écrire dans le buffer au fur et à mesure que l'on avance.

Notre sérialisation devient:

impl Serializable for User {
    fn serialize(&self, buffer: &mut [u8]) -> Result<(), SerializationError> {
        let mut cursor = std::io::Cursor::new(buffer);
        // -- encode id
        cursor
            .write(self.id.to_le_bytes().as_ref())
            .map_err(|e| SerializationError::Buffer(BufferError::BufferFull(e.to_string())))?;

        // -- encode username
        // encode longueur de la string
        cursor
            .write(&[self.username.len() as u8])
            .map_err(|e| SerializationError::Buffer(BufferError::BufferFull(e.to_string())))?;
        // encode la string    
        cursor
            .write(self.username.as_bytes())
            .map_err(|e| SerializationError::Buffer(BufferError::BufferFull(e.to_string())))?;
        
        // -- encode email
        // encode longueur de la string
        cursor
            .write(&[self.email.len() as u8])
            .map_err(|e| SerializationError::Buffer(BufferError::BufferFull(e.to_string())))?;
        // encode la string    
        cursor
            .write(self.email.as_bytes())
            .map_err(|e| SerializationError::Buffer(BufferError::BufferFull(e.to_string())))?;
        Ok(())
    }
}

On se créé une BufferError pour l'occasion que l'on rajoute dans notre SerializationError

#[derive(Debug, PartialEq)]
enum BufferError {
    /// Impossible d'écrire plus dans le buffer
    BufferFull(String),
    /// Impossible de lire plus depuis le buffer
    ReadTooMuch(String),
}

enum SerializationError {
    Buffer(BufferError),
}

Puis on implémente la désérialisation

impl Deserializable for User {
    fn deserialize(buffer: &[u8]) -> Result<Self, DeserializationError> {
        let mut cursor = std::io::Cursor::new(buffer);
        // -- decode id
        // récupération des 8 octets
        let mut data = [0_u8; size_of::<i64>()];
        cursor
            .read_exact(&mut data)
            .map_err(|e| DeserializationError::Buffer(BufferError::ReadTooMuch(e.to_string())))?;
        // décodage
        let id = i64::from_le_bytes(data);

        // -- decode username
        // récupération du premier octet contenant la taille de la string
        let mut data = [0_u8; 1];
        cursor
            .read_exact(&mut data)
            .map_err(|e| DeserializationError::Buffer(BufferError::ReadTooMuch(e.to_string())))?;
        let size = data[0] as usize;
        // définition d'un buffer pouvant accueillir les données
        let mut data = vec![0_u8; size];
        cursor
            .read_exact(&mut data)
            .map_err(|e| DeserializationError::Buffer(BufferError::ReadTooMuch(e.to_string())))?;
        // décodage 
        let username =
            String::from_utf8(data).map_err(DeserializationError::UnableToDeserializeString)?;

        // -- decode email
        let mut data = [0_u8; 1];
        cursor
            .read_exact(&mut data)
            .map_err(|e| DeserializationError::Buffer(BufferError::ReadTooMuch(e.to_string())))?;
        let size = data[0] as usize;
        let mut data = vec![0_u8; size];
        cursor
            .read_exact(&mut data)
            .map_err(|e| DeserializationError::Buffer(BufferError::ReadTooMuch(e.to_string())))?;
        let email =
            String::from_utf8(data).map_err(DeserializationError::UnableToDeserializeString)?;

        // recreate User
        Ok(User {
            id,
            username,
            email,
        })
    }
}

On se rajoute aussi notre BufferError dans DeserializationError

#[derive(Debug, PartialEq)]
pub enum DeserializationError {
    Buffer(BufferError),
    UnableToDeserializeString(FromUtf8Error),
}

Et voilà !! Plus besoin de s'occuper du curseur ! 😄

Implémentation de serde sur les types primitifs

Alors oui, c'est mieux, mais on sent que les opération de sérialisation et désérialisation des champs "username" et "email" sont identiques. Cela manque de factorisation!

Et c'est là qu'on est heureux d'être en Rust car on peut accrocher des comportements sur des types primitifs.

Cela veut dire: pouvoir implémenter Serializable et Deserializable sur String.

impl Serializable for String {
    fn serialize(&self, buffer: &mut [u8]) -> Result<(), SerializationError> {
        let mut cursor = std::io::Cursor::new(buffer);
        cursor
            .write(&[self.len() as u8])
            .map_err(|e| SerializationError::Buffer(BufferError::BufferFull(e.to_string())))?;
        // encode la string
        cursor
            .write(self.as_bytes())
            .map_err(|e| SerializationError::Buffer(BufferError::BufferFull(e.to_string())))?;
        Ok(())
    }
}

impl Deserializable for String {
    fn deserialize(buffer: &[u8]) -> Result<Self, DeserializationError> {
        let mut cursor = std::io::Cursor::new(buffer);
        let mut data = [0_u8; 1];
        cursor
            .read_exact(&mut data)
            .map_err(|e| DeserializationError::Buffer(BufferError::ReadTooMuch(e.to_string())))?;
        let size = data[0] as usize;
        let mut data = vec![0_u8; size];
        cursor
            .read_exact(&mut data)
            .map_err(|e| DeserializationError::Buffer(BufferError::ReadTooMuch(e.to_string())))?;

        String::from_utf8(data).map_err(DeserializationError::UnableToDeserializeString)
    }
}

Et on peut faire de même sur du i64

impl Serializable for i64 {
    fn serialize(&self, buffer: &mut [u8]) -> Result<(), SerializationError> {
        let mut cursor = std::io::Cursor::new(buffer);
        cursor
            .write(self.to_le_bytes().as_ref())
            .map_err(|e| SerializationError::Buffer(BufferError::BufferFull(e.to_string())))?;
        Ok(())
    }
}

impl Deserializable for i64 {
    fn deserialize(buffer: &[u8]) -> Result<Self, DeserializationError> {
        let mut cursor = std::io::Cursor::new(buffer);
        let mut data = [0_u8; size_of::<i64>()];
        cursor.read_exact(&mut data).map_err(|e| DeserializationError::Buffer(BufferError::ReadTooMuch(e.to_string())))?;
        Ok(i64::from_le_bytes(data))
    }
}

Cela nous donnera quelque chose comme ça:

impl Serializable for User {
    fn serialize(&self, buffer: &mut [u8]) -> Result<(), SerializationError> {
        self.id.serialize(buffer)?;
        self.username.serialize(buffer)?;
        self.email.serialize(buffer)?;
        
        Ok(())
    }
}

impl Deserializable for User {
    fn deserialize(buffer: &[u8]) -> Result<Self, DeserializationError> {
        Ok(User {
            id: i64::deserialize(buffer)?,
            username: String::deserialize(buffer)?,
            email: String::deserialize(buffer)?,
        })
    }
}

C'est élégant non ? 🤩

par contre ça ne marche pas du tout! Comme on reset le Cursor à chaque serialize et deserialize, notre cursor est toujours à l'offset 0 lorsqu'il débute.

La solution? Balader le curseur qui wrap le buffer.

Pour ça on doit un peut modifier notre interface serde

trait Serializable {
    fn serialize(&self, cursor: &mut std::io::Cursor<&mut [u8]>)
        -> Result<(), SerializationError>;
}

trait Deserializable: Sized {
    fn deserialize(cursor: std::io::Cursor<&[u8]>) -> Result<Self, DeserializationError>;
}

Modifier les implementations pour String et i64

impl Serializable for String {
    fn serialize(&self,cursor: &mut std::io::Cursor<&mut [u8]>) -> Result<(), SerializationError> {
        // ...
    }
}

impl Deserializable for String {
    fn deserialize(cursor: &mut std::io::Cursor<&[u8]>) -> Result<Self, DeserializationError> {
        // ...
    }
}

impl Serializable for i64 {
    fn serialize(&self,cursor: &mut std::io::Cursor<&mut [u8]>) -> Result<(), SerializationError> {
        // ...
    }
}

impl Deserializable for i64 {
    fn deserialize(cursor: &mut std::io::Cursor<&[u8]>) -> Result<Self, DeserializationError> {
        // ...
    }
}

Et modifier l'implémentation pour User

impl Serializable for User {
    fn serialize(&self, cursor: &mut std::io::Cursor<&mut [u8]>) -> Result<(), SerializationError> {
        self.id.serialize(cursor)?;
        self.username.serialize(cursor)?;
        self.email.serialize(cursor)?;

        Ok(())
    }
}

impl Deserializable for User {
    fn deserialize(cursor: &mut std::io::Cursor<&[u8]>) -> Result<Self, DeserializationError> {
        // recreate User
        Ok(User {
            id: i64::deserialize(cursor)?,
            username: String::deserialize(cursor)?,
            email: String::deserialize(cursor)?,
        })
    }
}

Il faut aussi modifier notre test en conséquence

#[test]
fn test_serde_user() {
    let mut buffer = [0_u8; 1024];
    let user = User {
        id: 42,
        username: "user".to_string(),
        email: "email".to_string(),
    };

    // création du curseur d'écriture
    let mut writer = Cursor::new(&mut buffer[..]);
    user.serialize(&mut writer).expect("Unable to serialize user");

    // création du curseur de lecture
    let mut reader = Cursor::new(&buffer[..]);
    let result = User::deserialize(&mut reader).expect("Unable to deserialize user");

    assert_eq!(user, result);
}

Et bingo ! 🎯

Nous avons une interface stable pour manipuler nos données! 😍

Conclusion

Dans cette partie on est rentré au coeur des données, on a analysé comment elle était agencée et comment on pouvait la transformer en un format de transport au travers de la sérialisation.

Dans la partie suivante nous allons voir comment utiliser notre API bas niveau de sérialisation pour construire une API haut niveau qui vous semblera bien plus familière.

Puis nous fusionnerons cette API avec le REPL fabriqué en Partie 1 pour pouvoir interagir avec notre API de haut niveau.

Vous pouvez trouver le code de la partie 2 ici. Ainsi que le diff.

Merci de votre lecture et à la prochaine. ❤️

avatar

Auteur: Akanoa

Je découvre, j'apprends, je comprends et j'explique ce que j'ai compris dans ce blog.

Ce travail est sous licence CC BY-NC-SA 4.0.