Gestion des dépendances Python: une comparaison des approches

image

J'écris en python depuis cinq ans, dont les trois dernières années ont développé mon propre projet. La plupart de cette façon, mon équipe m'aide. Et avec chaque version, avec chaque nouvelle fonctionnalité, nous essayons de plus en plus de faire en sorte que le projet ne se transforme pas en désordre avec du code non pris en charge; nous luttons avec les importations cycliques, les dépendances mutuelles, allouons des modules réutilisables, reconstruisons la structure.

Malheureusement, dans la communauté Python, il n'y a pas de concept universel de «bonne architecture», il n'y a que le concept de «pythonicité», nous devons donc trouver l'architecture nous-mêmes. Under the cut - Longrid avec des réflexions sur l'architecture, et tout d'abord - sur la gestion des dépendances est applicable à Python.

django.setup ()


Je vais commencer par une question aux junglers. Écrivez-vous souvent ces deux lignes?

import django django.setup() 

Vous devez démarrer le fichier à partir de cela si vous souhaitez travailler avec des objets django sans démarrer le serveur web django lui-même. Cela s'applique aux modèles et aux outils pour travailler avec le temps ( django.utils.timezone ), les django.urls.reverse ( django.urls.reverse ), et bien plus encore. Si cela n'est pas fait, vous obtiendrez une erreur:

 django.core.exceptions.AppRegistryNotReady: Apps aren't loaded yet. 

J'écris constamment ces deux lignes. Je suis un grand fan du code d'éjection; J'aime créer un fichier .py séparé, y tordre les choses, le comprendre - puis l'intégrer dans le projet.

Et cette constante django.setup() me gêne beaucoup. Premièrement, vous en avez assez de le répéter partout; et, deuxièmement, l'initialisation de django prend plusieurs secondes (nous avons un gros monolithe), et lorsque vous redémarrez le même fichier 10, 20, 100 fois - cela ralentit simplement le développement.

Comment se débarrasser de django.setup() ? Vous devez écrire du code qui dépend au minimum de django.

Par exemple, si nous écrivons un client d'une API externe, nous pouvons le rendre dépendant de django:

 from django.conf import settings class APIClient: def __init__(self): self.api_key = settings.SOME_API_KEY # : client = APIClient() 

ou il peut être indépendant de django:

 class APIClient: def __init__(self, api_key): self.api_key = api_key # : client = APIClient(api_key='abc') 

Dans le deuxième cas, le constructeur est plus encombrant, mais toutes les manipulations avec cette classe peuvent être effectuées sans charger l'ensemble des machines dzhangovskoy.

Les tests deviennent également plus faciles. Comment tester un composant qui dépend des paramètres de django.conf.settings ? Verrouillez-les simplement avec le décorateur @override_settings . Et si le composant ne dépend de rien, il n'y aura rien à mouiller: il a transmis les paramètres au constructeur - et l'a conduit.

Gestion des dépendances


L'histoire des dépendances django est l'exemple le plus frappant d'un problème que je rencontre tous les jours: les problèmes de gestion des dépendances en python - et l'architecture globale des applications python.

La relation avec la gestion des dépendances dans la communauté Python est mitigée. On distingue trois camps principaux:

  • Python est un langage flexible. Nous écrivons comme nous voulons, selon ce que nous voulons. Nous ne sommes pas gênés par les dépendances cycliques, la substitution d'attributs pour les classes lors de l'exécution, etc.

  • Python est un langage spécial. Il existe des façons idiomatiques de créer une architecture et des dépendances. Le transfert de données vers le haut et vers le bas de la pile d'appels est effectué par des itérateurs, des coroutines et des gestionnaires de contexte.

    Rapport de classe sur ce sujet et exemple
    Brandon Rhodes, Dropbox: Hissez votre IO .

    Exemple du rapport:

     def main(): """          """ with open("/etc/hosts") as file: for line in parse_hosts(file): print(line) def parse_hosts(lines): """    -   """ for line in lines: if line.startswith("#"): continue yield line 


  • La flexibilité de Python est un moyen supplémentaire de se tirer une balle dans le pied. Vous avez besoin d'un ensemble rigide de règles pour gérer les dépendances. Un bon exemple est les gars russes de python sec . Il y a toujours une approche moins hardcore - la structure Django pour l'échelle et la longévité , mais l'idée est la même.

Il existe plusieurs articles sur la gestion des dépendances en python ( exemple 1 , exemple 2 ), mais ils se résument tous à la publicité des cadres d'injection de dépendance de quelqu'un. Cet article est une nouvelle entrée sur le même sujet, mais cette fois c'est une pure expérience de pensée sans publicité. Il s'agit d'une tentative de trouver un équilibre entre les trois approches ci-dessus, de se passer d'un cadre supplémentaire et de le rendre «pythonique».

J'ai récemment lu Clean Architecture - et je semble comprendre quelle est la valeur de l'injection de dépendances en python et comment elle peut être implémentée. Je l'ai vu sur l'exemple de mon propre projet. En bref, cela protège le code contre la rupture lorsqu'un autre code change .

Données source


Il existe un client API qui exécute les requêtes HTTP pour le raccourcisseur de service:

 # shortener_client.py import requests class ShortenerClient: def __init__(self, api_key): self.api_key = api_key def shorten_link(self, url): response = requests.post( url='https://fstrk.cc/short', headers={'Authorization': self.api_key}, json={'url': url} ) return response.json()['url'] 

Et il y a un module qui raccourcit tous les liens dans le texte. Pour ce faire, il utilise le client API raccourcisseur:

 # text_processor.py import re from shortener_client import ShortenerClient class TextProcessor: def __init__(self, text): self.text = text def process(self): changed_text = self.text links = re.findall( r'https?://[^\r\n\t") ]*', self.text, flags=re.MULTILINE ) api_client = ShortenerClient('abc') for link in links: shortened = api_client.shorten_link(link) changed_text = changed_text.replace(link, shortened) return changed_text 

La logique d'exécution du code réside dans un fichier de contrôle séparé (appelons-le un contrôleur):

 # controller.py from text_processor import TextProcessor processor = TextProcessor("""  1: https://ya.ru  2: https://google.com """) print(processor.process()) 

Tout fonctionne. Le processeur analyse le texte, raccourcit les liens à l'aide d'un raccourcisseur, renvoie le résultat. Les dépendances ressemblent à ceci:

image

Le problème


Voici le problème: la classe TextProcessor dépend de la classe ShortenerClient - et s'arrête lorsque l'interface ShortenerClient change .

Comment cela peut-il arriver?

Supposons que dans notre projet, nous avons décidé de suivre les shorten_link et d'ajouter l'argument callback_url à la méthode shorten_link . Cet argument signifie l'adresse à laquelle les notifications doivent parvenir lorsque vous cliquez sur un lien.

La méthode ShortenerClient.shorten_link commencé à ressembler à ceci:

 def shorten_link(self, url, callback_url): response = requests.post( url='https://fstrk.cc/short', headers={'Authorization': self.api_key}, json={'url': url, 'callback_on_click': callback_url} ) return response.json()['url'] 

Et que se passe-t-il? Et il s'avère que lorsque nous essayons de commencer, nous obtenons une erreur:

 TypeError: shorten_link() missing 1 required positional argument: 'callback_url' 

Autrement dit, nous avons changé le raccourcisseur, mais ce n'est pas lui qui s'est cassé, mais son client:

image

Et alors? Eh bien, le fichier d'appel s'est cassé, nous sommes allés le réparer. Quel est le problème?

Si cela est résolu en une minute - ils sont allés et corrigés - alors ce n'est bien sûr pas un problème du tout. S'il y a peu de code dans les classes et si vous les supportez vous-même (c'est votre projet parallèle, ce sont deux petites classes du même sous-système, etc.), alors vous pouvez vous arrêter là.

Les problèmes commencent lorsque:

  • les modules appelants et appelés ont beaucoup de code;
  • différents modules sont pris en charge par différentes personnes / équipes.

Si vous écrivez la classe ShortenerClient et que votre collègue écrit TextProcessor , vous obtenez une situation offensive: vous avez modifié le code, mais il s'est cassé. Et il s'est cassé dans un endroit que vous n'avez pas vu dans la vie, et maintenant vous devez vous asseoir et comprendre le code de quelqu'un d'autre.

Ce qui est encore plus intéressant, c'est lorsque votre module est utilisé à plusieurs endroits, et non à un seul; et votre modification cassera le code sur le tas de fichiers.

Par conséquent, le problème peut être formulé comme suit: comment organiser le code de sorte que lorsque l'interface ShortenerClient est modifiée, ShortenerClient lui-même ShortenerClient , et non ses consommateurs (il peut y en avoir beaucoup)

La solution ici est:

  • Les consommateurs de classe et la classe elle-même doivent se mettre d'accord sur une interface commune. Cette interface devrait devenir loi.
  • Si la classe cesse de correspondre à son interface, ce seront ses problèmes, et non les problèmes des consommateurs.

image

Geler l'interface


À quoi ressemble la fixation d'une interface en python? Ceci est une classe abstraite:

 from abc import ABC, abstractmethod class AbstractClient(ABC): @abstractmethod def __init__(self, api_key): pass @abstractmethod def shorten_link(self, link): pass 

Si maintenant nous héritons de cette classe et oublions d'implémenter une méthode, nous obtiendrons une erreur:

 class ShortenerClient(AbstractClient): def __ini__(self, api_key): self.api_key = api_key client = ShortenerClient('123') >>> TypeError: Can't instantiate abstract class ShortenerClient with abstract methods __init__, shorten_link 

Mais cela ne suffit pas. Une classe abstraite capture uniquement les noms des méthodes, mais pas leur signature.

Besoin d'un deuxième outil de vérification de signature Ce deuxième outil est mypy . Cela aidera à vérifier les signatures des méthodes héritées. Pour ce faire, nous devons ajouter des annotations à l'interface:

 # shortener_client.py from abc import ABC, abstractmethod class AbstractClient(ABC): @abstractmethod def __init__(self, api_key: str) -> None: pass @abstractmethod def shorten_link(self, link: str) -> str: pass class ShortenerClient(AbstractClient): def __init__(self, api_key: str) -> None: self.api_key = api_key def shorten_link(self, link: str, callback_url: str) -> str: return 'xxx' 

Si nous vérifions maintenant ce code avec mypy , nous obtenons une erreur en raison de l'argument supplémentaire callback_url :

 mypy shortener_client.py >>> error: Signature of "shorten_link" incompatible with supertype "AbstractClient" 

Nous avons maintenant un moyen fiable de valider l'interface de classe.

Inversion de dépendance


Après avoir débogué l'interface, nous devons la déplacer vers un autre endroit afin d'éliminer complètement la dépendance du consommateur envers le fichier shortener_client.py . Par exemple, vous pouvez faire glisser l'interface directement vers le consommateur - vers un fichier avec le processeur TextProcessor :

 # text_processor.py import re from abc import ABC, abstractmethod class AbstractClient(ABC): @abstractmethod def __init__(self, api_key: str) -> None: pass @abstractmethod def shorten_link(self, link: str) -> str: pass class TextProcessor: def __init__(self, text, shortener_client: AbstractClient) -> None: self.text = text self.shortener_client = shortener_client def process(self) -> str: changed_text = self.text links = re.findall( r'https?://[^\r\n\t") ]*', self.text, flags=re.MULTILINE ) for link in links: shortened = self.shortener_client.shorten_link(link) changed_text = changed_text.replace(link, shortened) return changed_text 

Et cela va changer le sens de la dépendance! Désormais, le TextProcessor possède l'interface d'interaction et, par conséquent, ShortenerClient dépend, et non l'inverse.

image

En termes simples, nous pouvons décrire l'essence de notre transformation comme suit:

  • TextProcessor dit: Je suis un processeur et je suis impliqué dans la conversion de texte. Je ne veux rien savoir du mécanisme de raccourcissement: ce n'est pas mon affaire. Je veux tirer la méthode shorten_link pour qu'elle shorten_link tout pour moi. Alors s'il vous plaît, donnez-moi un objet qui joue selon mes règles. Les décisions sur la façon dont j'interagis sont prises par moi, pas par lui.
  • ShortenerClient dit: il semble que je ne puisse pas exister dans le vide, et ils nécessitent un certain comportement de ma part. Je vais demander à TextProcessor ce dont j'ai besoin pour ne pas casser.

Plusieurs consommateurs


Si plusieurs modules utilisent des liens raccourcis, l'interface doit être placée non pas dans l'un d'eux, mais dans un fichier séparé, situé au-dessus des autres fichiers, plus haut dans la hiérarchie:

image

Composant de contrôle


Si les consommateurs n'importent pas ShortenerClient , alors qui l'importera et créera un objet de classe? Ce devrait être un composant de contrôle - dans notre cas, c'est controller.py .

L'approche la plus simple est une injection de dépendance directe, l'injection de dépendance «dans le front». Nous créons des objets dans le code appelant, transférons un objet à un autre. Bénéfice

 # controller.py import TextProcessor import ShortenerClient processor = TextProcessor( text=' 1: https://ya.ru  2: https://google.com', shortener_client=ShortenerClient(api_key='123') ) print(processor.process()) 

Approche Python


Une approche plus «pythonique» serait l'injection de dépendance par héritage.

Raymond Hettinger en parle en détail dans son rapport Super réfléchi.

Pour adapter le code à ce style, vous devez modifier légèrement le TextProcessor , le rendant héritable:

 # text_processor.py class TextProcessor: def __init__(self, text: str) -> None: self.text = text self.shortener_client: AbstractClient = self.get_shortener_client() def get_shortener_client(self) -> AbstractClient: """      """ raise NotImplementedError 

Et puis, dans le code appelant, héritez-le:

 # controller.py import TextProcessor import ShortenerClient class ProcessorWithClient(TextProcessor): """   ,    """ def get_shortener_client(self) -> ShortenerClient: return ShortenerClient(api_key='abc') processor = ProcessorWithClient( text=' 1: https://ya.ru  2: https://google.com' ) print(processor.process()) 

Le deuxième exemple est omniprésent dans les cadres populaires:

  • Chez Django, nous sommes constamment hérités. Nous redéfinissons les méthodes de vue basée sur les classes, les modèles, les formulaires; en d'autres termes, injectez nos dépendances dans le travail déjà débogué du framework.
  • En DRF, la même chose. Nous élargissons les vues, les sérialiseurs et les autorisations.
  • Et ainsi de suite. Il y a beaucoup d'exemples.

Le deuxième exemple semble plus joli et plus familier, n'est-ce pas? Développons-la et voyons si cette beauté est préservée.

Développement Python


Dans la logique métier, il y a généralement plus de deux composants. Supposons que notre TextProcessor n'est pas une classe indépendante, mais seulement l'un des éléments du TextPipeline qui traite le texte et l'envoie à la messagerie:

 class TextPipeline: def __init__(self, text, email): self.text_processor = TextProcessor(text) self.mailer = Mailer(email) def process_and_mail(self) -> None: processed_text = self.text_processor.process() self.mailer.send_text(text=processed_text) 

Si nous voulons isoler le TextPipeline des classes utilisées, nous devons suivre la même procédure que précédemment:

  • la classe TextPipeline déclarera des interfaces pour les composants utilisés;
  • les composants utilisés seront obligés de se conformer à ces interfaces;
  • du code externe rassemblera tout et fonctionnera.

Le diagramme de dépendance ressemblera à ceci:

image

Mais à quoi ressemblera maintenant le code assembleur de ces dépendances?

 import TextProcessor import ShortenerClient import Mailer import TextPipeline class ProcessorWithClient(TextProcessor): def get_shortener_client(self) -> ShortenerClient: return ShortenerClient(api_key='123') class PipelineWithDependencies(TextPipeline): def get_text_processor(self, text: str) -> ProcessorWithClient: return ProcessorWithClient(text) def get_mailer(self, email: str) -> Mailer: return Mailer(email) pipeline = PipelineWithDependencies( email='abc@def.com', text=' 1: https://ya.ru  2: https://google.com' ) pipeline.process_and_mail() 

L'avez-vous remarqué? Nous TextProcessor abord de la classe TextProcessor pour y insérer le ShortenerClient , puis nous TextPipeline du TextPipeline pour y insérer notre TextProcessor substitué (ainsi que Mailer ). Nous avons plusieurs niveaux de redéfinition séquentielle. Déjà compliqué.

Pourquoi tous les cadres sont-ils organisés de cette manière? Oui, car il ne convient qu'aux frameworks.

  • Tous les niveaux du cadre sont clairement définis et leur nombre est limité. Par exemple, dans Django, vous pouvez remplacer FormField pour l'insérer dans une substitution d'un Form , pour insérer un formulaire dans une substitution de View . C’est tout. Trois niveaux.
  • Chaque cadre sert un objectif. Cette tâche est clairement définie.
  • Chaque cadre possède une documentation détaillée qui décrit comment et quoi hériter; quoi et avec quoi combiner.

Pouvez-vous identifier et documenter clairement et sans ambiguïté votre logique métier? Surtout l'architecture des niveaux auxquels il fonctionne? Non. Malheureusement, l'approche de Raymond Hettinger ne s'adapte pas à la logique métier.

Retour à l'approche du front


À plusieurs niveaux de difficulté, une approche simple l'emporte. Il semble plus simple - et plus facile à changer lorsque la logique change.

 import TextProcessor import ShortenerClient import Mailer import TextPipeline pipeline = TextPipeline( text_processor=TextProcessor( text=' 1: https://ya.ru  2: https://google.com', shortener_client=ShortenerClient(api_key='abc') ), mailer=Mailer('abc@def.com') ) pipeline.process_and_mail() 

Mais, lorsque le nombre de niveaux de logique augmente, même cette approche devient incommode. Nous devons impérativement initier un tas de classes, les passer les uns dans les autres. Je veux éviter de nombreux niveaux d'imbrication.

Essayons encore un appel.

Stockage d'instance global


Essayons de créer un dictionnaire global dans lequel se trouveront les instances des composants dont nous avons besoin. Et laissez ces composants s'obtenir grâce à l'accès à ce dictionnaire.

Appelons-le INSTANCE_DICT :

 # text_processor.py import INSTANCE_DICT class TextProcessor(AbstractTextProcessor): def __init__(self, text) -> None: self.text = text def process(self) -> str: shortener_client: AbstractClient = INSTANCE_DICT['Shortener'] # ...   

 # text_pipeline.py import INSTANCE_DICT class TextPipeline: def __init__(self) -> None: self.text_processor: AbstractTextProcessor = INSTANCE_DICT[ 'TextProcessor'] self.mailer: AbstractMailer = INSTANCE_DICT['Mailer'] def process_and_mail(self) -> None: processed_text = self.text_processor.process() self.mailer.send_text(text=processed_text) 

L'astuce consiste à mettre nos objets dans ce dictionnaire avant d'y accéder . C'est ce que nous ferons dans controller.py :

 # controller.py import INSTANCE_DICT import TextProcessor import ShortenerClient import Mailer import TextPipeline INSTANCE_DICT['Shortener'] = ShortenerClient('123') INSTANCE_DICT['Mailer'] = Mailer('abc@def.com') INSTANCE_DICT['TextProcessor'] = TextProcessor(text=' : https://ya.ru') pipeline = TextPipeline() pipeline.process_and_mail() 

Avantages de travailler avec un dictionnaire global:

  • pas de magie du capot moteur et des cadres DI supplémentaires;
  • une liste plate de dépendances dans lesquelles vous n'avez pas besoin de gérer l'imbrication;
  • tous les bonus DI: tests simples, indépendance, protection des composants contre les pannes lorsque d'autres composants changent.

Bien sûr, au lieu de créer INSTANCE_DICT - INSTANCE_DICT , vous pouvez utiliser une sorte de framework DI; mais l'essence de cela ne changera pas. Le cadre offrira une gestion plus flexible des instances; il vous permettra de les créer sous forme de singletones ou de bundles, comme une usine; mais l'idée restera la même.

Peut-être qu'à un moment donné, cela ne me suffira pas, et je continue de choisir une sorte de cadre.

Et, peut-être, tout cela est inutile, et il est plus facile de s'en passer: écrire des importations directes et ne pas créer d'interfaces abstraites inutiles.

Quelle est votre expérience avec la gestion des dépendances en python? Et en général - est-ce nécessaire, ou est-ce que j'invente un problème de l'air?

Source: https://habr.com/ru/post/fr461511/


All Articles