Code Python - Architecture logicielle

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.

Python Design Patterns Métaprogrammation JSON/YAML Architecture logicielle

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 KeyError peu 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.