Pattern de désérialisation flexible en Python
Conception d'un système de chargement d'objets Python depuis JSON/YAML avec constructeurs alternatifs et désérialisation récursive.
Note : Le code présenté dans cet article est du pseudo-code simplifié à des fins pédagogiques. Il n'est pas directement fonctionnel et nécessiterait des adaptations pour une utilisation en production (gestion d'erreurs, validation, etc.).
Le contexte du projet
Une équipe de R&D pour laquelle j'ai travaillé développait une toolbox de calcul en radiofréquences et télécommunications. L'une des problématiques rencontrées était de pouvoir configurer leurs simulations complexes à partir de fichiers de configuration JSON ou YAML.
L'objectif était de permettre le chargement d'objets Python métier (représentant des antennes, des réseaux, des configurations de simulation) à partir de définitions textuelles, tout en conservant la flexibilité d'une utilisation programmatique.
L'approche problématique initiale
Initialement, la définition d'un réseau d'antennes était implémentée de cette façon :
class AntennaArray:
def __init__(self, filename: str):
# Chargement forcé depuis un fichier
with open(filename, "r") as fp:
content = json.load(fp)
# Initialisation directe depuis le contenu
self.antennas = [Antenna(antenna) for antenna in content["antennas"]]
self.coordinates = np.array(content["coordinates"]) Les limitations de cette approche
Cette implémentation pose plusieurs problèmes architecturaux majeurs :
- Couplage fort avec le système de fichiers : Il devient impossible d'utiliser la classe comme une véritable librairie Python. Pour instancier un objet programmatiquement, on serait obligé de créer un fichier temporaire.
- Gestion d'erreurs peu explicite : Si le
contenu d'un fichier est invalide (par exemple, la clé "coordinates"
manquante), l'erreur résultante est une
KeyErrorpeu lisible pour l'utilisateur final. - Documentation et lisibilité limitées : Les
paramètres requis pour créer un objet ne sont pas visibles
dans la signature de la méthode
__init__et ne peuvent pas être documentés via des docstrings. - Rigidité du format : La classe est verrouillée sur le format JSON. Ajouter le support YAML nécessiterait de modifier toutes les classes implémentant ce mécanisme, créant une forte duplication de code.
La solution : constructeurs alternatifs flexibles
La solution que j'ai mise en œuvre repose sur l'utilisation de constructeurs alternatifs via des class methods. Cette approche permet de charger un objet depuis plusieurs sources différentes tout en conservant un constructeur principal clair et documenté.
Classe de base Deserializable
La première étape consiste à créer une classe de base qui encapsule toute la logique de désérialisation :
import json
import yaml
import os
from typing import Self, Any
class Deserializable:
"""Classe de base pour la désérialisation flexible d'objets."""
@classmethod
def from_dict(cls, data: dict) -> Self:
"""Crée une instance depuis un dictionnaire."""
return cls(**data)
@classmethod
def from_json(cls, filename: str) -> Self:
"""Crée une instance depuis un fichier JSON."""
with open(filename, "r") as fp:
return cls.from_dict(json.load(fp))
@classmethod
def from_yaml(cls, filename: str) -> Self:
"""Crée une instance depuis un fichier YAML."""
with open(filename, "r") as fp:
return cls.from_dict(yaml.safe_load(fp))
@classmethod
def from_file(cls, filename: str) -> Self:
"""Détecte automatiquement le format et charge le fichier."""
_, ext = os.path.splitext(filename)
if ext.lower() == '.json':
return cls.from_json(filename)
elif ext.lower() in ['.yaml', '.yml']:
return cls.from_yaml(filename)
else:
raise ValueError(f"Format de fichier non supporté : {ext}")
@classmethod
def from_str(cls, obj: str) -> Self:
"""Charge depuis une chaîne (fichier ou autre logique)."""
if os.path.isfile(obj):
return cls.from_file(obj)
else:
# Logique personnalisée de chargement depuis string
raise NotImplementedError("Chargement depuis string non supporté")
@classmethod
def from_obj(cls, obj: Any) -> Self:
"""Méthode générique pour charger depuis n'importe quel type."""
type_name = str(type(obj))
method_name = f"from_{type_name}"
if (method := getattr(cls, method_name, None)) is not None:
return method(obj)
raise NotImplementedError(f"Pas de méthode pour le type {type_name}") Utilisation simplifiée
Cette approche permet de définir des classes métier beaucoup plus propres et flexibles simplement en héritant d'une classe:
from typing import List
import numpy as np
class AntennaArray(Deserializable):
"""Réseau d'antennes avec support de désérialisation flexible."""
def __init__(self, antennas: List[Antenna], coordinates: List[float]):
"""
Initialise un réseau d'antennes.
Args:
antennas: Liste des antennes du réseau
coordinates: Coordonnées spatiales du réseau
"""
self.antennas = list(antennas)
self.coordinates = np.array(coordinates)
# Utilisation de la classe
ant1 = AntennaArray.from_file("config.json") # Depuis JSON
ant2 = AntennaArray.from_file("config.yaml") # Depuis YAML
ant3 = AntennaArray(ant2.antennas, ant1.coordinates) # Programmatique Cette approche résout tous les problèmes de l'implémentation initiale : la classe peut être utilisée programmatiquement, les paramètres sont documentés, et le support de nouveaux formats est centralisé.
Désérialisation récursive avancée
Il est possible d'aller plus loin en implémentant une désérialisation récursive automatique. Si AntennaArray prend une liste d'antennes en
paramètre, et que ces antennes sont elles aussi des objets complexes,
on peut les charger automatiquement depuis leurs représentations
dictionnaire.
Introspection et chargement automatique
En utilisant les capacités d'introspection de Python, on peut analyser la signature du constructeur et automatiquement désérialiser les paramètres complexes :
import inspect
from typing import get_origin, get_args
class Deserializable:
# ... toutes les méthodes précédentes identiques
@classmethod
def from_dict(cls, data: dict) -> Self:
"""Désérialisation récursive depuis un dictionnaire."""
# Analyse de la signature du constructeur
signature = inspect.signature(cls.__init__)
# Traitement de chaque paramètre attendu
for name, param in signature.parameters.items():
if name == 'self':
continue
expected_type = param.annotation
# Cas 1: Objet Deserializable simple
if (
isinstance(expected_type, type)
and issubclass(expected_type, Deserializable)
and name in data
and isinstance(data[name], dict)
):
# Désérialisation récursive de l'objet
data[name] = expected_type.from_dict(data[name])
# Cas 2: Liste d'objets Deserializable
elif (
get_origin(expected_type) == list
and name in data
and isinstance(data[name], list)
):
# Récupération du type des éléments de la liste
item_type = get_args(expected_type)[0]
if (isinstance(item_type, type)
and issubclass(item_type, Deserializable)):
# Désérialisation de chaque élément de la liste
data[name] = [
item_type.from_dict(item) if isinstance(item, dict) else item
for item in data[name]
]
# Construction de l'objet avec les données traitées
return cls(**data) Exemple d'utilisation complète
Avec cette implémentation avancée, il devient possible de définir des structures de données complexes et de les charger automatiquement :
class Antenna(Deserializable):
"""Antenne individuelle."""
def __init__(self, frequency: float, gain: float, position: List[float]):
self.frequency = frequency
self.gain = gain
self.position = position
class AntennaArray(Deserializable):
"""Réseau d'antennes avec chargement automatique."""
def __init__(self, antennas: List[Antenna], coordinates: List[float]):
self.antennas = antennas
self.coordinates = coordinates
# Fichier JSON correspondant :
# {
# "antennas": [
# {"frequency": 2.4, "gain": 15.0, "position": [0, 0, 0]},
# {"frequency": 2.4, "gain": 15.0, "position": [1, 0, 0]}
# ],
# "coordinates": [0, 0, 0]
# }
# Chargement automatique de toute la hiérarchie
array = AntennaArray.from_file("complex_config.json") Encapsulation de la complexité
Cette approche peut paraître complexe à première vue, mais elle présente l'avantage majeur d'encapsuler toute la complexité dans une seule classe de base. Une fois cette infrastructure en place, l'utilisation devient extrêmement simple et le code métier reste lisible et maintenable.
Cette solution a permis à l'équipe de R&D de drastiquement simplifier la configuration de leurs simulations tout en conservant la flexibilité nécessaire pour l'utilisation en tant que librairie Python.
Besoin d'aide en architecture Python ?
Discutons de vos défis en design patterns et architecture logicielle.