Sérialisation

La sérialisation est un besoin fréquent depuis l’avènement d’internet. Une appplication web est souvent un assemblage de services indépendant qui s’échangent des informations complexes. Un service doit transmettre des données à un autre. L’opération est simple lorsqu’il s’agit de transmettre un nombre réel, un entier, une chaîne de caractère. C’est déjà un peu plus compliqué lorsque les données à transmettre sont constituées de listes et de dictionnaires. Elle est complexe lorsque ce sont des instances de classes définies par le programme lui-même. Or les services s’échangent uniquement des octets (ou byte en anglais). Il faut donc convertir des informations complexes en une séquence d’octets puis effectuer la conversion inverse. Ces opérations sont regroupées sous le terme de sérialisation.

JSON

Un des premiers utilisés dans le monde internet est le format XML. Le langage HTML suit la même logique que le format XML. Il peut représenter un assemblage de liste et de dictionnaires qui contiennent des nombres et des chaînes de caractères.

<xml>
    <personne>                          <!-- dictionnaire -->
        <nom>Dupré</nom>                <!-- str -->
        <prenom>Xavier</prenom>         <!-- str -->
        <langages>                      <!-- liste -->
            <lang>                      <!-- dictionnaire -->
                <nom>C++</nom>          <!-- str -->
                <vitesse>rapide</nom>   <!-- str -->
                <age>40</age>           <!-- int -->
            </lang>
            <lang>                      <!-- dictionnaire -->
                <nom>python</nom>       <!-- str -->
                <vitesse>lente</nom>    <!-- str -->
                <age>20</age>           <!-- int -->
            </lang>
        </langages>
    </personnes>
</xml>

La sérialisation passe par une étape intermédiaire qui revient à convertir tout type de variables en une séquence de listes et de dictionnaires puis à la convertir sous la forme d’une séquence d’octets.

Le format XML intervient lors de la seconde étape. Il est assez verbeux. La séquence d’octets ou plutôt de caractères qu’il produit est assez longue même une fois les espaces enlevés. On lui préfère le format JSON qui est très utilisé pour communiquer entre les services via des API REST car il est à la fois concis et lisible.

<<<

from json import dump
from io import StringIO

data = {'records': [{'nom': 'Xavier', 'prénom': 'Xavier',
                     'langages': [{'nom': 'C++', 'age': 40},
                                  {'nom': 'Python', 'age': 20}]}]}

buffer = StringIO()
res = dump(data, buffer)
seq = buffer.getvalue()
print(seq)

>>>

    {"records": [{"nom": "Xavier", "pr\u00e9nom": "Xavier", "langages": [{"nom": "C++", "age": 40}, {"nom": "Python", "age": 20}]}]}

Le format JSON est très proche de la syntaxe du langage Python. Le résultat apparaît sous la forme d’une chaîne de caractère et est facile à transmettre à n’importe quelle autre application. La conversion inverse s’effectue comme suit.

<<<

from json import load
from io import StringIO

seq = '{"records": [{"nom": "Xavier", "pr\\u00e9nom": "Xavier", ' + \
      '"langages": [{"nom": "C++", "age": 40}, {"nom": "Python", "age": 20}]}]}'

buffer = StringIO(seq)
read = load(buffer)
print(read)

>>>

    {'records': [{'nom': 'Xavier', 'prénom': 'Xavier', 'langages': [{'nom': 'C++', 'age': 40}, {'nom': 'Python', 'age': 20}]}]}

Tous les types ne sont pas sérialisables même si ceux-ci sont fréquemment utilisés :

<<<

from json import dump
from io import StringIO
import numpy

data = {'mat': numpy.array([0, 1])}

buffer = StringIO()
try:
    dump(data, buffer)
except Exception as e:
    print(e)

>>>

    Object of type ndarray is not JSON serializable

Les classes définies dans le programme ne sont pas non plus sérialisables par défaut.

<<<

from json import dump, JSONEncoder
from io import StringIO


class A:
    def __init__(self, att):
        self.att = att


class MyEncoder(JSONEncoder):
    def default(self, o):
        return {'classname': o.__class__.__name__, 'data': o.__dict__}


data = A('e')
buffer = StringIO()
try:
    dump(data, buffer, cls=MyEncoder)
except Exception as e:
    print(e)

>>>

    

On peut néanmoins contourner l’obstacle en indiquant à la fonction dump comment convertir l’instance en un assemblage de listes et de dictionnaires avec la classe JSONEncoder.

<<<

from json import dump, JSONEncoder
from io import StringIO


class A:
    def __init__(self, att):
        self.att = att


class MyEncoder(JSONEncoder):
    def default(self, o):
        return {'classname': o.__class__.__name__, 'data': o.__dict__}


data = A('e')
buffer = StringIO()
res = dump(data, buffer, cls=MyEncoder)
res = buffer.getvalue()
print(res)

>>>

    {"classname": "A", "data": {"att": "e"}}

Et la relecture s’effectue comme suit :

<<<

from json import load, JSONDecoder
from io import StringIO


class A:
    def __init__(self, att):
        self.att = att


class MyDecoder(JSONDecoder):
    def decode(self, o):
        dec = JSONDecoder.decode(self, o)
        if isinstance(dec, dict) and dec.get('classname') == 'A':
            return A(dec['data']['att'])
        else:
            return dec


res = '{"classname": "A", "data": {"att": "e"}}'
buffer = StringIO(res)
obj = load(buffer, cls=MyDecoder)
print(obj)

>>>

    <pyquickhelper.sphinxext.sphinx_runpython_extension.run_python_script_140659864953408.<locals>.A object at 0x7feded4ade80>

Il existe des alternatives plus rapides au module json come le module ultrajson.

Binaire

Le format binaire n’est pas un format à proprement parler. Le problème principale lié à l’utilisation d’un format comme JSON est le temps passé à convertir les informations numériques en texte et réciproquement. Il est plus efficace de conserver cette information dans un format plus proche de celui de la mémoire. Cette remarque est aussi vrai pour tous les autre types. Lorsque la sérialisation est nécessaire au sein d’une même application, il est nettement préférable de choisir ce type de format même s’il est illisible pour un autre langage voire même pour un autre programme. Le code ressemble beaucoup à ce qui a été écrit pour le format JSON.

<<<

from pickle import dump
from io import BytesIO

data = {'records': [{'nom': 'Xavier', 'prénom': 'Xavier',
                     'langages': [{'nom': 'C++', 'age': 40},
                                  {'nom': 'Python', 'age': 20}]}]}

buffer = BytesIO()
res = dump(data, buffer)
seq = buffer.getvalue()
print(seq)

>>>

    b'\x80\x04\x95f\x00\x00\x00\x00\x00\x00\x00}\x94\x8c\x07records\x94]\x94}\x94(\x8c\x03nom\x94\x8c\x06Xavier\x94\x8c\x07pr\xc3\xa9nom\x94h\x05\x8c\x08langages\x94]\x94(}\x94(h\x04\x8c\x03C++\x94\x8c\x03age\x94K(u}\x94(h\x04\x8c\x06Python\x94h\x0bK\x14ueuas.'

La relecture s’effectue tout aussi simplement.

<<<

from pickle import load, dump
from io import BytesIO

data = {'records': [{'nom': 'Xavier', 'prénom': 'Xavier',
                     'langages': [{'nom': 'C++', 'age': 40},
                                  {'nom': 'Python', 'age': 20}]}]}

# écriture
buffer = BytesIO()
res = dump(data, buffer)
seq = buffer.getvalue()

# lecture
buffer = BytesIO(seq)
read = load(buffer)
print(read)

>>>

    {'records': [{'nom': 'Xavier', 'prénom': 'Xavier', 'langages': [{'nom': 'C++', 'age': 40}, {'nom': 'Python', 'age': 20}]}]}

Certaines configurations impossible, un peu comme pour le format JSON, de modifier les éléments sérialisés et restaurés. Cela se fait grâce aux fonctions __getstate__ et __setstate__. Dans l’exemple suivant, la classe copie un attribut deux fois. Pour réduire la taille de l’objet une fois sérialisé, on en stocke qu’un seul.

<<<

from io import BytesIO
from pickle import dump, load


class B:
    def __init__(self, att):
        self.att1 = att
        self.att2 = att

    def __getstate__(self):
        # On indique les attributs à converser.
        return dict(att=self.att1)

    def __setstate__(self, state):
        # On restaure l'objet à partir de ce qui a été sérialisé.
        setattr(self, 'att1', state["att"])
        setattr(self, 'att2', state["att"])


data = B('r')
buffer = BytesIO()
res = dump(data, buffer)
seq = buffer.getvalue()

buffer = BytesIO(seq)
read = load(buffer)
print(read.att1, read.att2)

>>>

    r r

Optimisation

La communication entre services devient parfois cruciale pour assurer un service temps réel. Le premier enjeu est d’écrire ou de lire rapidement. Le second est de pouvoir accéder à une information sans avoir à déchiffrer l’ensemble des données sérialisées. Pour ces deux objectifs, la solution la plus aboutie est la librarie protobuf. L’efficacité est gagnée en imposant un format strict et figé aux données. La première étape consiste à définir un schéma de donnée, qui est convertit ensuite en un programme qui permette de sérialiser, désérialiser et accéder à une information précise dans le language de votre choix.

La suite est dans le notebook :

Sérialiser autre chose que des données

D’une manière générale, il vaut mieux éviter de sérialiser les fonctions, itérateurs ou générateurs. Il est souvent impossible des les sérialiser proprement sans ajouter du code spécifiquement développés pour cela. Le notebook suivant étudie certains cas où cela est néanmoins possible sans trop de développement.