1. Contexte
Il arrive souvent pour des applications à gros volumes (financières par exemple) d’intégrer de nombreuses données persistentes en entrée avant de les traiter avec leurs algorithmes. Par exemple, la gestion de fonds requière la récupération des ordres venant de différentes sources, dont le stockage persistant dépend du fournisseur du produit (Oracle, Sybase, etc…) ou du type de stockage : SGBDR, système de gestion de base de données orienté objet (OODBMS), documents XML, fichiers plats, service Web, LDAP, etc… Les dépendances de code dans les composants rendent difficile et fastidieuse la migration de l’application d’un type de source de données vers un autre. Lorsque la source de données change, les composants doivent être modifiés pour gérer ce nouveau type de source de données. Par ailleurs, les interfaces ne sont pas uniformes, donc une couche d’adaptation est nécessaire pour établir des connexions transparentes entre elles.
De ce fait, le patron de conception de l’adaptateur offre une solution populaire. En effet, il fait partie des patrons de conception de type structurel [1] et son but est de trouver un moyen simple de réaliser des relations entre les entités. L’adaptateur fait en sorte que deux interfaces non liées puissent fonctionner en parallèle. Par exemple, le modèle d’objet d’accès aux données (DAO) [2] joue essentiellement son rôle entre le composant et la source de données, car il vise à minimiser la dépendance directe entre le code de l’application et le code d’accès à ces données, tout en masquant complètement les détails de l’implémentation de la source pour adapter le format des données de ses clients.
En Python, il existe une librairie, SQLAlchemy [3], qui fait office de DAO pour les clients, et dont le mappeur objet-relationnel (ORM) reste intact lorsque l’implémentation de la source de données sous-jacente change. De la même manière qu’une adaptation des données pour un OODBMS, on pourrait aussi avoir besoin de transformer des données d’entrée dans un dictionnaire compatible avec les modules de manipulation prêts à être utilisés en Python, lorsque le format de ces données à leur réception (ou à l’envoi) sont par exemple du XML.
Dans cet article on va mettre en œuvre un adaptateur, qui se confronte au problème d’incompatibilité entre le format d’entrée et celui interne à l’application. La source est sous format XML, alors que l’instance du module disponible qui calcule le fond n’accepte que des données en dictionnaire en entrée. Cet adaptateur doit renvoyer le résultat en XML en retour au client. De plus, on va aborder deux principes de conception en Python 3 pour la réalisation : la composition et l’héritage [4], en respectant le principe SOLID [5].
2. Conception
Les diagrammes UML suivants illustrent deux possibilités du développement de la classe Adaptateur en Python 3. Quelle que soit la façon choisie de la concevoir, il y a trois composants principaux dans la structure : la cible (Target), l’adaptateur (Adaptor) et l’adapté (Adaptee). L’adaptateur fait office de conteneur (Wrapper) entre les deux autres composants. Il récupère les retours des appels à l’adaptee et les met dans un format et une interface reconnaissables par la cible.
La 1e manière de concevoir cet adapteur s’appuie sur la composition. De ce fait, l’adaptateur peut être identifié grâce à son implémentation de l’interface Target recherchée par le client. Lorsque l’une des méthodes de l’adaptateur est appelée, celui-ci traduit les paramètres dans un format approprié et redirige l’appel vers une ou plusieurs méthodes de son attribut contenu et instancié de la classe adaptee.
La 2e façon quant à elle s’appuie sur l’héritage, où l’adaptateur implémente l’interface Target comme auparavant, mais il hérite aussi de la classe adaptée. Du coup, tous les attributs et les méthodes chez l’adaptee sont aussi présents et utilisables chez l’adaptateur. Néanmoins, il faut faire attention à deux aspects avant de l’utiliser. Fonctionnellement, l’adaptateur doit partager exactement le même type que l’adaptee afin de ne pas violer le principe SOLID. En revanche, techniquement, on ne doit pas choisir cette solution de conception pour l’adaptor, si l’adaptee est une classe abstraite, qui laisse ses méthodes partiellement définies aux sous-classes.
Dans notre contexte, le client cherche à appeler l’interface Target pour manipuler les données au format XML, et pour les rendre en sortie en XML également. Toutefois, le module de calcul disponible ne travaille que sur les données en dictionnaire. Du coup, l’adaptateur intervient pour rendre les deux compatibles.
3. Développement
Dans notre exemple, on voudrait profiter de la classe FundSoftwarePackage qui s’occupe de la manipulation du fond. Afin de simplifier la démonstration, elle calcule directement le Mark to Market par la multiplication de la quantité, le prix unitaire et le taux d’échange. Pourtant, elle ne reconnaît que les données en JSON à fois à l’entrée et la sortie. On introduit l’adaptateur Fund qui implémente l’interface AbstractFund pour pouvoir réaliser les appels au/du client. Il profite des libraires dicttoxml et xmltodict en Python 3 pour les conversions entre JSON et XML.
D’abord, nous structurons la composition entre l’adaptateur et l’adaptee pour le développement.
from abc import ABC, abstractmethod from dicttoxml import dicttoxml import json import xmltodict class AbstractFund(ABC): @abstractmethod def operate_data(self): raise NotImplementedError @abstractmethod def get_data(self): raise NotImplementedError class FundSoftwarePackage(object): __slots__ = ('_data',) def __init__(self, json_data: str): self._data = self.input_data(json_data) def process_data(self): print(f"data processing: {self._data}") self._data['markToMarket'] = self.try_to_float( self._data.get('quantity')) * self.try_to_float( self._data.get('unitPrice')) * self.try_to_float( self._data.get('rate')) def output_data(self) -> str: return json.dumps(self._data) @staticmethod def try_to_float(value): try: return float(value) except TypeError: return .0 @staticmethod def input_data(data_string: str) -> dict: try: return json.loads(data_string) except ValueError: print(f'{data_string} is non a valid json string...') return {} @property def data(self): return self._data class Fund(AbstractFund): __slots__ = ('_data', '_fund_package',) def __init__(self, xml_data: str, key: str): json_data = json.dumps(xmltodict.parse(xml_data).get(key)) self._fund_package = FundSoftwarePackage(json_data) def operate_data(self): self._fund_package.process_data() def get_data(self) -> str: return dicttoxml(self._fund_package.data, attr_type=False).decode("utf-8") if __name__ == '__main__': data = '''<?xml version="1.0" encoding="UTF-8" ?> <note> <quantity>1000</quantity> <unitPrice>100.0</unitPrice> <rate>1.0</rate> <currency>EUR</currency> </note> ''' fund = Fund(data, 'note') fund.operate_data() result = fund.get_data() print(result) print(''.join(('''<?xml version="1.0" encoding="UTF-8" ?> <root> <quantity>1000</quantity> <unitPrice>100.0</unitPrice> <rate>1.0</rate> <currency>EUR</currency> <markToMarket>100000.0</markToMarket> </root>''').split())) assert (''.join(result.split()) == ''.join(('''<?xml version="1.0" encoding="UTF-8" ?> <root> <quantity>1000</quantity> <unitPrice>100.0</unitPrice> <rate>1.0</rate> <currency>EUR</currency> <markToMarket>100000.0</markToMarket> </root>''').split()))
Les tests unitaires sont bien passés. En effet, l’adaptateur Fund arrive à convertir les données d’entrée XML au format JSON pour l’adaptee FundSoftwarePackage qui prend la charge du calcul. Dès le retour du résultat, il le transforme en XML pour le client.
Par ailleurs, pour notre exemple, il est possible de mettre en lumière la 2e possibilité de ce patron de conception, car l’adaptateur Fund aura un type identique à celui de l’adaptee FundSoftwarePackage, avec les méthodes pleinement définies.
from abc import ABC, abstractmethod from dicttoxml import dicttoxml import json import xmltodict class AbstractFund(ABC): @abstractmethod def operate_data(self): raise NotImplementedError @abstractmethod def get_data(self): raise NotImplementedError class FundSoftwarePackage(object): __slots__ = ('_data',) def __init__(self, json_data: str): self._data = self.input_data(json_data) def process_data(self): print(f"data processing: {self._data}") self._data['markToMarket'] = self.try_to_float( self._data.get('quantity')) * self.try_to_float( self._data.get('unitPrice')) * self.try_to_float( self._data.get('rate')) def output_data(self) -> str: return json.dumps(self._data) @staticmethod def try_to_float(value): try: return float(value) except TypeError: return .0 @staticmethod def input_data(data_string: str) -> dict: try: return json.loads(data_string) except ValueError: print(f'{data_string} is non a valid json string...') return {} @property def data(self): return self._data class Fund(AbstractFund, FundSoftwarePackage): __slots__ = ('_data', '_fund_package',) def __init__(self, xml_data: str, key: str): json_data = json.dumps(xmltodict.parse(xml_data).get(key)) self._fund_package = super().__init__(json_data) def operate_data(self): self.process_data() def get_data(self) -> str: return dicttoxml(self.data, attr_type=False).decode("utf-8") if __name__ == '__main__': data = '''<?xml version="1.0" encoding="UTF-8" ?> <note> <quantity>1000</quantity> <unitPrice>100.0</unitPrice> <rate>1.0</rate> <currency>EUR</currency> </note> ''' fund = Fund(data, 'note') fund.operate_data() result = fund.get_data() print(result) print(''.join(('''<?xml version="1.0" encoding="UTF-8" ?> <root> <quantity>1000</quantity> <unitPrice>100.0</unitPrice> <rate>1.0</rate> <currency>EUR</currency> <markToMarket>100000.0</markToMarket> </root>''').split())) assert (''.join(result.split()) == ''.join(('''<?xml version="1.0" encoding="UTF-8" ?> <root> <quantity>1000</quantity> <unitPrice>100.0</unitPrice> <rate>1.0</rate> <currency>EUR</currency> <markToMarket>100000.0</markToMarket> </root>''').split()))
Les tests unitaires sont bien passés là aussi. En effet, l’adaptateur Fund prend tous les attributs et les opérations chez l’adaptee FundSoftwarePackage. Les conversions entre XML et JSON ainsi que le calcul ont lieu en interne.
4. Conclusion
Dans cet article, on commence par mettre en évidence la nécessité du patron de conception adaptateur pour notre contexte en question, en évoquant les mécanismes de sa famille structurelle des patrons de conception (design patterns). Selon son diagramme UML, on a présenté deux façons pour en réaliser la conception, que l’on peut choisir selon les contraintes d’utilisation dues à la façon dont se passe l’héritage dans les classes utiles. Sinon, les deux types de conception sont développables en Python 3 chez notre exemple, tandis que leurs implémentations ont bien passées les tests unitaires pour ce patron de conception structurelle.
Références
[1] https://www.invivoo.com/design-patterns-patrons-conception/
[2] https://fr.wikipedia.org/wiki/Objet_d%27accès_aux_données
[3] https://www.sqlalchemy.org
[4] https://leandeep.com/héritage-vs-composition-en-programmation-orientée-objet/
[5] https://www.invivoo.com/lart-clean-code-environnement-java/