Python Dependency Management: Ein Vergleich der Ansätze

Bild

Ich schreibe seit fünf Jahren in Python, von denen die letzten drei Jahre mein eigenes Projekt entwickelt haben. Meistens hilft mir mein Team dabei. Und mit jeder Version und mit jeder neuen Funktion versuchen wir zunehmend sicherzustellen, dass das Projekt nicht durch nicht unterstützten Code zu einem Chaos wird. Wir kämpfen mit zyklischen Importen, gegenseitigen Abhängigkeiten, weisen wiederverwendbare Module zu und bauen die Struktur neu auf.

Leider gibt es in der Python-Community kein universelles Konzept für „gute Architektur“, sondern nur das Konzept für „Pythonalität“. Daher müssen wir uns die Architektur selbst einfallen lassen. Unter dem Strich ist Longrid mit Überlegungen zur Architektur und vor allem zum Abhängigkeitsmanagement auf Python anwendbar.

django.setup ()


Ich beginne mit einer Frage an die Dzhangisten. Schreiben Sie oft diese beiden Zeilen?

import django django.setup() 

Sie müssen die Datei von hier aus starten, wenn Sie mit Django-Objekten arbeiten möchten, ohne den Django-Webserver selbst zu starten. Dies gilt für Modelle und Tools zum Arbeiten mit Zeit ( django.utils.timezone ), django.urls.reverse ( django.urls.reverse ) und vieles mehr. Wenn dies nicht getan wird, erhalten Sie eine Fehlermeldung:

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

Ich schreibe ständig diese beiden Zeilen. Ich bin ein großer Fan von Auswurfcode. Ich mag es, eine separate .py Datei zu erstellen, Dinge darin zu drehen, herauszufinden - und sie dann in das Projekt einzubetten.

Und diese Konstante django.setup() nervt mich sehr. Erstens werden Sie es leid, es überall zu wiederholen; und zweitens dauert die Django-Initialisierung einige Sekunden (wir haben einen großen Monolithen), und wenn Sie dieselbe Datei 10, 20, 100 Mal neu starten, verlangsamt dies nur die Entwicklung.

Wie werde ich django.setup() los? Sie müssen Code schreiben, der minimal von Django abhängt.

Wenn wir beispielsweise einen Client einer externen API schreiben, können wir ihn von django abhängig machen:

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

oder es kann unabhängig von Django sein:

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

Im zweiten Fall ist der Konstruktor umständlicher, aber alle Manipulationen mit dieser Klasse können durchgeführt werden, ohne die gesamte dzhangovskoy-Maschinerie zu laden.

Tests werden auch einfacher. Wie django.conf.settings eine Komponente, die von django.conf.settings Einstellungen von django.conf.settings abhängt? Sperren Sie sie einfach mit dem Dekorateur @override_settings . Und wenn die Komponente von nichts abhängt, gibt es nichts, was nass werden könnte: Sie hat die Parameter an den Konstruktor übergeben - und sie gesteuert.

Abhängigkeitsmanagement


Die django Abhängigkeitsgeschichte ist das auffälligste Beispiel für ein Problem, auf das ich jeden Tag django : Probleme mit dem Abhängigkeitsmanagement in Python - und die Gesamtarchitektur von Python-Anwendungen.

Die Beziehung zum Abhängigkeitsmanagement in der Python-Community ist gemischt. Drei Hauptlager können unterschieden werden:

  • Python ist eine flexible Sprache. Wir schreiben wie wir wollen, je nachdem was wir wollen. Wir scheuen keine zyklischen Abhängigkeiten, Attributsubstitution für Klassen zur Laufzeit usw.

  • Python ist eine spezielle Sprache. Es gibt idiomatische Möglichkeiten, Architektur und Abhängigkeiten zu erstellen. Die Datenübertragung auf und ab des Aufrufstapels wird von Iteratoren, Coroutinen und Kontextmanagern durchgeführt.

    Klassenbericht zu diesem Thema und Beispiel
    Brandon Rhodes, Dropbox: Heben Sie Ihr IO hoch .

    Beispiel aus dem Bericht:

     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 


  • Pythons Flexibilität ist eine zusätzliche Möglichkeit, sich in den Fuß zu schießen. Sie benötigen strenge Regeln für die Verwaltung von Abhängigkeiten. Ein gutes Beispiel sind die russischen Dry-Python- Typen. Es gibt immer noch einen weniger hardcore Ansatz - Django-Struktur für Skalierbarkeit und Langlebigkeit , aber die Idee ist die gleiche.

Es gibt mehrere Artikel zum Abhängigkeitsmanagement in Python ( Beispiel 1 , Beispiel 2 ), die jedoch alle darauf abzielen, die Dependency Injection-Frameworks einer Person zu bewerben. Dieser Artikel ist ein neuer Eintrag zum gleichen Thema, aber diesmal ist es ein reines Gedankenexperiment ohne Werbung. Dies ist ein Versuch, ein Gleichgewicht zwischen den drei oben genannten Ansätzen zu finden, auf einen zusätzlichen Rahmen zu verzichten und ihn „pythonisch“ zu machen.

Ich habe kürzlich Clean Architecture gelesen - und ich scheine zu verstehen, welchen Wert die Abhängigkeitsinjektion in Python hat und wie sie implementiert werden kann. Ich habe das am Beispiel meines eigenen Projekts gesehen. Kurz gesagt, dies schützt den Code vor dem Brechen, wenn sich ein anderer Code ändert .

Ausgangsdaten


Es gibt einen API-Client, der HTTP-Anforderungen für den Service Shortener ausführt:

 # 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'] 

Und es gibt ein Modul, das alle Links im Text verkürzt. Dazu verwendet er den Shortener-API-Client:

 # 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 

Die Logik der Codeausführung befindet sich in einer separaten Steuerdatei (nennen wir es einen Controller):

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

Alles arbeitet. Der Prozessor analysiert den Text, verkürzt die Links mit einem Shortener und gibt das Ergebnis zurück. Die Abhängigkeiten sehen folgendermaßen aus:

Bild

Das Problem


Hier ist das Problem: Die TextProcessor Klasse hängt von der TextProcessor Klasse ab - und bricht ab, wenn sich die ShortenerClient Schnittstelle ändert .

Wie kann das passieren?

Angenommen, wir haben in unserem Projekt beschlossen, shorten_link zu verfolgen, und der shorten_link Methode das Argument callback_url hinzugefügt. Dieses Argument bezeichnet die Adresse, an die Benachrichtigungen gesendet werden sollen, wenn Sie auf einen Link klicken.

Die ShortenerClient.shorten_link Methode sah folgendermaßen aus:

 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'] 

Und was passiert? Und es stellt sich heraus, dass wir beim Start eine Fehlermeldung erhalten:

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

Das heißt, wir haben den Shortener gewechselt, aber nicht er hat gebrochen, sondern sein Kunde:

Bild

Also was? Nun, die aufrufende Datei ist kaputt gegangen, wir haben sie repariert. Was ist das Problem?

Wenn dies in einer Minute gelöst ist - sie gingen und korrigierten -, dann ist dies natürlich überhaupt kein Problem. Wenn die Klassen wenig Code enthalten und Sie sie selbst unterstützen (dies ist Ihr Nebenprojekt, dies sind zwei kleine Klassen desselben Subsystems usw.), können Sie dort anhalten.

Probleme beginnen, wenn:

  • Die aufrufenden und aufgerufenen Module haben viel Code.
  • Verschiedene Module werden von verschiedenen Personen / Teams unterstützt.

Wenn Sie die ShortenerClient Klasse schreiben und Ihr Kollege TextProcessor schreibt, TextProcessor eine beleidigende Situation auf: Sie haben den Code geändert, aber er ist TextProcessor . Und es brach an einem Ort, den Sie im Leben noch nicht gesehen haben, und jetzt müssen Sie sich hinsetzen und den Code eines anderen verstehen.

Noch interessanter ist es, wenn Ihr Modul an mehreren Orten und nicht an einem verwendet wird. und Ihre Bearbeitung wird den Code auf dem Haufen von Dateien brechen.

Daher kann das Problem wie folgt formuliert werden: Wie kann der Code so organisiert werden, dass beim ShortenerClient der ShortenerClient Schnittstelle ShortenerClient selbst ShortenerClient und nicht seine Verbraucher (von denen es viele geben kann)?

Die Lösung hier ist:

  • Klassenkonsumenten und die Klasse selbst müssen sich auf eine gemeinsame Schnittstelle einigen. Diese Schnittstelle sollte zum Gesetz werden.
  • Wenn die Klasse nicht mehr ihrer Schnittstelle entspricht, sind dies ihre Probleme und keine Verbraucherprobleme.

Bild

Frieren Sie die Schnittstelle ein


Wie sieht das Reparieren einer Schnittstelle in Python aus? Dies ist eine abstrakte Klasse:

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

Wenn wir jetzt von dieser Klasse erben und vergessen, eine Methode zu implementieren, erhalten wir eine Fehlermeldung:

 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 

Das reicht aber nicht. Eine abstrakte Klasse erfasst nur die Namen von Methoden, nicht jedoch deren Signatur.

mypy Sie ein zweites Tool zur Überprüfung der Signatur mypy Dieses zweite Tool ist mypy . Es hilft dabei, die Signaturen geerbter Methoden zu überprüfen. Dazu müssen wir der Schnittstelle Anmerkungen hinzufügen:

 # 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' 

Wenn wir diesen Code jetzt mit mypy , erhalten wir aufgrund des zusätzlichen Arguments callback_url eine Fehlermeldung:

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

Jetzt haben wir eine zuverlässige Möglichkeit, die Klassenschnittstelle festzuschreiben.

Abhängigkeitsinversion


Nachdem wir die Schnittstelle getestet haben, müssen wir sie an einen anderen Ort verschieben, um die Abhängigkeit des Verbrauchers von der Datei shortener_client.py vollständig zu beseitigen. Sie können die Benutzeroberfläche beispielsweise direkt auf den Consumer ziehen - in eine Datei mit dem TextProcessor Prozessor:

 # 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 

Und das wird die Richtung der Sucht ändern! Jetzt besitzt der TextProcessor die Interaktionsschnittstelle, TextProcessor davon abhängt und nicht umgekehrt.

Bild

Mit einfachen Worten können wir das Wesen unserer Transformation wie folgt beschreiben:

  • TextProcessor sagt: Ich bin ein Prozessor und an der Textkonvertierung beteiligt. Ich möchte nichts über den Verkürzungsmechanismus wissen: Das geht mich nichts an. Ich möchte die shorten_link Methode ziehen, damit sie alles für mich shorten_link . Geben Sie mir bitte ein Objekt, das nach meinen Regeln spielt. Entscheidungen darüber, wie ich interagiere, werden von mir getroffen, nicht von ihm.
  • ShortenerClient sagt: Es scheint, dass ich nicht in einem Vakuum existieren kann und sie erfordern ein bestimmtes Verhalten von mir. Ich werde TextProcessor fragen, was ich TextProcessor muss, um nicht zu brechen.

Mehrere Verbraucher


Wenn mehrere Module Verkürzungslinks verwenden, sollte die Schnittstelle nicht in einer von ihnen, sondern in einer separaten Datei platziert werden, die sich über den anderen Dateien befindet und eine höhere Hierarchie aufweist:

Bild

Steuerkomponente


Wenn Verbraucher ShortenerClient nicht importieren, wer importiert es dann und erstellt ein Klassenobjekt? Es sollte eine Steuerungskomponente sein - in unserem Fall ist es controller.py .

Der einfachste Ansatz ist eine einfache Abhängigkeitsinjektion, die Abhängigkeitsinjektion „in die Stirn“. Wir erstellen Objekte im aufrufenden Code, übertragen ein Objekt auf ein anderes. Gewinn

 # 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()) 

Python-Ansatz


Es wird angenommen, dass ein eher „pythonischer“ Ansatz die Abhängigkeitsinjektion durch Vererbung ist.

Raymond Hettinger spricht in seinem Super-Super-Bericht ausführlich darüber.

Um den Code an diesen Stil anzupassen, müssen Sie den TextProcessor leicht ändern, damit er vererbbar wird:

 # 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 

Und dann erben Sie es im aufrufenden Code:

 # 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()) 

Das zweite Beispiel ist in populären Frameworks allgegenwärtig:

  • Bei Django werden wir ständig geerbt. Wir definieren Methoden der klassenbasierten Ansicht, Modelle, Formen neu. Mit anderen Worten, fügen Sie unsere Abhängigkeiten in die bereits debuggte Arbeit des Frameworks ein.
  • In DRF das Gleiche. Wir erweitern Ansichten, Serializer und Berechtigungen.
  • Usw. Es gibt viele Beispiele.

Das zweite Beispiel sieht hübscher und vertrauter aus, nicht wahr? Lassen Sie es uns entwickeln und sehen, ob diese Schönheit erhalten bleibt.

Python-Entwicklung


In der Geschäftslogik gibt es normalerweise mehr als zwei Komponenten. Angenommen, unser TextProcessor ist keine unabhängige Klasse, sondern nur eines der TextPipeline Elemente, die den Text verarbeiten und an die E-Mail TextPipeline :

 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) 

Wenn wir die TextPipeline von den verwendeten Klassen isolieren TextPipeline , müssen wir das gleiche Verfahren wie zuvor TextPipeline :

  • Die TextPipeline Klasse deklariert Schnittstellen für die verwendeten Komponenten.
  • gebrauchte Komponenten müssen sich an diese Schnittstellen anpassen;
  • Ein externer Code fügt alles zusammen und wird ausgeführt.

Das Abhängigkeitsdiagramm sieht folgendermaßen aus:

Bild

Aber wie sieht der Assembler-Code dieser Abhängigkeiten jetzt aus?

 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() 

Hast du es bemerkt? Wir erben zuerst die TextProcessor Klasse, um den TextPipeline darin einzufügen, und erben dann die TextPipeline , um unseren neu definierten TextProcessor (sowie Mailer ) darin einzufügen. Wir haben mehrere Ebenen der sequentiellen Neudefinition. Schon kompliziert.

Warum sind alle Frameworks so organisiert? Ja, weil es nur für Frameworks geeignet ist.

  • Alle Ebenen des Frameworks sind klar definiert und ihre Anzahl ist begrenzt. In Django können Sie beispielsweise FormField überschreiben, um es in eine Überschreibung eines Form einzufügen, um ein Formular in eine Überschreibung von View einzufügen. Das ist alles. Drei Ebenen.
  • Jedes Framework dient einem Zweck. Diese Aufgabe ist klar definiert.
  • Jedes Framework verfügt über eine detaillierte Dokumentation, in der beschrieben wird, wie und was geerbt werden soll. was und mit was zu kombinieren.

Können Sie Ihre Geschäftslogik klar und eindeutig identifizieren und dokumentieren? Besonders die Architektur der Ebenen, auf denen es funktioniert? Ich nicht. Leider lässt sich der Ansatz von Raymond Hettinger nicht auf die Geschäftslogik übertragen.

Zurück zum Stirnansatz


Bei mehreren Schwierigkeitsgraden gewinnt ein einfacher Ansatz. Es sieht einfacher aus - und ist einfacher zu ändern, wenn sich die Logik ändert.

 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() 

Wenn jedoch die Anzahl der Logikebenen zunimmt, wird selbst ein solcher Ansatz unpraktisch. Wir müssen unbedingt eine Reihe von Klassen initiieren und sie ineinander übergehen. Ich möchte viele Verschachtelungsebenen vermeiden.

Versuchen wir noch einen Anruf.

Globaler Instanzspeicher


Versuchen wir, ein globales Wörterbuch zu erstellen, in dem die Instanzen der benötigten Komponenten liegen. Und lassen Sie diese Komponenten sich gegenseitig durch Zugriff auf dieses Wörterbuch erreichen.

Nennen wir es 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) 

Der Trick besteht darin , unsere Objekte in dieses Wörterbuch aufzunehmen, bevor auf sie zugegriffen wird . Dies werden wir in controller.py tun:

 # 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() 

Vorteile der Arbeit mit einem globalen Wörterbuch:

  • keine Motorhaubenmagie und zusätzliche DI-Rahmen;
  • eine flache Liste von Abhängigkeiten, in denen Sie die Verschachtelung nicht verwalten müssen;
  • Alle DI-Boni: einfaches Testen, Unabhängigkeit, Schutz der Komponenten vor Ausfällen, wenn andere Komponenten geändert werden.

Anstatt INSTANCE_DICT , können Sie natürlich eine Art DI-Framework verwenden. aber das Wesen davon wird sich nicht ändern. Das Framework bietet eine flexiblere Verwaltung von Instanzen. Er erlaubt Ihnen, sie in Form von Singletones oder Bündeln wie eine Fabrik zu erstellen. aber die Idee wird gleich bleiben.

Vielleicht reicht mir das irgendwann nicht mehr und ich wähle immer noch einen Rahmen.

Und vielleicht ist all dies unnötig und es ist einfacher, darauf zu verzichten: Schreiben Sie direkte Importe und erstellen Sie keine unnötigen abstrakten Schnittstellen.

Welche Erfahrungen haben Sie mit dem Abhängigkeitsmanagement in Python gemacht? Und im Allgemeinen - ist es notwendig oder erfinde ich ein Problem aus der Luft?

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


All Articles