Saubere Architektur mit den Augen eines Python-Entwicklers

Hallo! Mein Name ist Eugene, ich bin ein Python-Entwickler. In den letzten anderthalb Jahren begann unser Team, die Prinzipien der sauberen Architektur aktiv anzuwenden und sich vom klassischen MVC-Modell zu entfernen. Und heute werde ich darüber sprechen, wie wir dazu gekommen sind, was es uns gibt und warum die direkte Übertragung von Ansätzen von anderen PLs nicht immer eine gute Lösung ist.



Python ist seit über sieben Jahren mein primäres Entwicklungswerkzeug. Wenn sie mich fragen, was mir an ihm am besten gefällt, antworte ich, dass dies seine hervorragende Lesbarkeit ist . Die erste Bekanntschaft begann mit der Lektüre des Buches „Programming a Collective Mind“ . Ich war an den darin beschriebenen Algorithmen interessiert, aber alle Beispiele waren in einer Sprache, die mir damals noch nicht vertraut war. Dies war nicht üblich (Python war im maschinellen Lernen noch kein Mainstream), Auflistungen wurden oft in Pseudocode oder unter Verwendung von Diagrammen geschrieben. Aber nach einer kurzen Einführung in die Sprache schätzte ich ihre Prägnanz: Alles war einfach und klar, nichts überflüssig und ablenkend, nur das Wesentliche des beschriebenen Prozesses. Der Hauptvorteil davon ist das erstaunliche Design der Sprache, der sehr intuitive syntaktische Zucker. Diese Ausdruckskraft wurde in der Community immer geschätzt. Was ist " import this ", das unbedingt auf den ersten Seiten eines Lehrbuchs vorhanden ist: Es sieht aus wie ein unsichtbarer Aufseher, der Ihre Handlungen ständig bewertet. In Foren hat es sich für Anfänger gelohnt, CamelCase im Variablennamen in der Liste zu verwenden, sodass sich der Diskussionswinkel sofort in Richtung der Redewendung des vorgeschlagenen Codes mit Verweisen auf PEP8 verschob.
Das Streben nach Eleganz und die kraftvolle Dynamik der Sprache haben viele Bibliotheken mit einer wirklich reizvollen API geschaffen. 

Obwohl Python leistungsstark ist, ist es nur ein Tool, mit dem Sie ausdrucksstarken, selbstdokumentierenden Code schreiben können. Dies garantiert jedoch weder dies noch die PEP8-Konformität. Wenn unser scheinbar einfacher Online-Shop auf Django anfängt, Geld zu verdienen und infolgedessen Funktionen aufzupumpen, stellen wir irgendwann fest, dass dies nicht so einfach ist und selbst grundlegende Änderungen immer mehr Aufwand erfordern. und vor allem wächst dieser Trend . Was ist passiert und wann ist alles schief gelaufen?

Schlechter Code


Schlechter Code folgt nicht PEP8 oder erfüllt nicht die Anforderungen der zyklomatischen Komplexität. Schlechter Code ist in erster Linie unkontrollierte Abhängigkeiten , die dazu führen, dass eine Änderung an einer Stelle des Programms zu unvorhersehbaren Änderungen an anderen Stellen führt. Wir verlieren die Kontrolle über den Code. Die Erweiterung der Funktionalität erfordert eine detaillierte Untersuchung des Projekts. Ein solcher Code verliert seine Flexibilität und widersetzt sich sozusagen Änderungen, während das Programm selbst „zerbrechlich“ wird. 

Saubere Architektur


Die gewählte Architektur der Anwendung sollte dieses Problem vermeiden, und wir sind nicht die Ersten, die darauf stoßen: In der Java-Community wurde lange Zeit über die Erstellung eines optimalen Anwendungsdesigns diskutiert.

Bereits im Jahr 2000 brachte Robert Martin (auch bekannt als Onkel Bob) in seinem Artikel „ Design and Design Principles “ fünf Prinzipien für das Design von OOP-Anwendungen unter dem denkwürdigen Akronym SOLID zusammen. Diese Prinzipien wurden von der Community gut aufgenommen und gingen weit über das Java-Ökosystem hinaus. Trotzdem sind sie sehr abstrakt. Später gab es mehrere Versuche, ein allgemeines Anwendungsdesign zu entwickeln, das auf SOLID-Prinzipien basiert. Dazu gehören: "Sechseckige Architektur", "Ports und Adapter", "Bulbous-Architektur" und alle haben viele Gemeinsamkeiten, wenn auch unterschiedliche Implementierungsdetails. Und 2012 wurde ein Artikel von demselben Robert Martin veröffentlicht, in dem er seine eigene Version mit dem Titel „ Clean Architecturevorschlug .



Laut Onkel Bob ist Architektur in erster Linie „ Grenzen und Barrieren “. Es ist notwendig, die Anforderungen klar zu verstehen und die Softwareschnittstellen einzuschränken, um die Kontrolle über die Anwendung nicht zu verlieren. Dazu ist das Programm in Ebenen unterteilt. Beim Wechsel von einer Schicht zur anderen können nur Daten übertragen werden (einfache Strukturen und DTO- Objekte können als Daten fungieren ) - dies ist die Regel der Grenzen. Eine andere am häufigsten zitierte Formulierung, dass „die Anwendung schreien sollte “, bedeutet, dass die Hauptsache in der Anwendung nicht das verwendete Framework oder die Datenspeichertechnologie ist, sondern was diese Anwendung tatsächlich tut, welche Funktion sie ausführt - die Geschäftslogik der Anwendung . Daher haben die Schichten keine lineare Struktur, sondern eine Hierarchie . Daher zwei weitere Regeln:

  • Die Prioritätsregel für die innere Schicht - Es ist die innere Schicht, die die Schnittstelle bestimmt, über die sie mit der Außenwelt interagiert.
  • Abhängigkeitsregel - Abhängigkeiten sollten von der inneren zur äußeren Schicht gerichtet werden.

Die letzte Regel ist in der Python-Welt ziemlich untypisch. Um ein kompliziertes Geschäftslogikszenario anzuwenden, müssen Sie immer auf externe Dienste (z. B. eine Datenbank) zugreifen. Um diese Abhängigkeit zu vermeiden, muss die Geschäftslogikschicht selbst die Schnittstelle deklarieren, über die sie mit der Außenwelt interagiert. Diese Technik wird als " Abhängigkeitsinversion " (der Buchstabe D in SOLID) bezeichnet und ist in Sprachen mit statischer Typisierung weit verbreitet. Laut Robert Martin ist dies der Hauptvorteil, der mit der OOP einherging .

Diese drei Regeln sind die Essenz von Clean Architecture:

  • Grenzübergangsregel;
  • Abhängigkeitsregel;
  • Die Prioritätsregel der inneren Schicht.

Die Vorteile dieses Ansatzes umfassen:

  • Einfache Prüfung - Die Schichten sind isoliert, sie können ohne Affenflicken getestet werden. Sie können die Beschichtung je nach Grad ihrer Wichtigkeit für verschiedene Schichten granular einstellen.
  • Einfache Änderung der Geschäftsregeln , da alle an einem Ort gesammelt werden, nicht über das Projekt verteilt sind und nicht mit Code auf niedriger Ebene gemischt werden.
  • Unabhängigkeit von externen Agenten : Das Vorhandensein von Abstraktionen zwischen Geschäftslogik und Außenwelt ermöglicht es Ihnen in bestimmten Fällen, externe Quellen zu ändern, ohne die internen Ebenen zu beeinflussen. Dies funktioniert, wenn Sie die Geschäftslogik nicht an die spezifischen Funktionen externer Agenten gebunden haben, z. B. Datenbanktransaktionen.
  • , , , .

, . . , . « Clean Architecture».

Python


Dies ist eine Theorie, Beispiele für die praktische Anwendung finden sich im Originalartikel, in den Berichten und im Buch von Robert Martin. Sie basieren auf mehreren gängigen Entwurfsmustern aus der Java-Welt: Adapter, Gateway, Interactor, Fasade, Repository, DTO usw.

Was ist mit Python? Wie gesagt, Laconicism wird in der Python-Community geschätzt. Was in anderen Wurzeln geschlagen hat, ist weit davon entfernt, dass es bei uns Wurzeln schlagen wird. Als ich mich vor drei Jahren zum ersten Mal diesem Thema zuwandte, gab es nicht viele Materialien zum Thema "Saubere Architektur in Python", aber der erste Link in Google war das Leonardo Giordani- Projekt : Der Autor beschreibt ausführlich den Prozess der Erstellung einer API für eine Immobiliensuchwebsite mithilfe der TDD-Methode. Anwendung von Clean Architecture.
Leider ist dieses Beispiel trotz der gewissenhaften Erklärung und der Befolgung aller Kanons von Onkel Bob ziemlich beängstigend

Die Projekt-API besteht aus einer Methode: Abrufen einer Liste mit einem verfügbaren Filter. Ich denke, dass selbst für einen unerfahrenen Entwickler der Code für ein solches Projekt nicht mehr als 15 Zeilen umfassen wird. In diesem Fall nahm er sechs Päckchen. Sie können sich auf ein nicht ganz erfolgreiches Layout beziehen, und dies ist wahr, aber in jedem Fall ist es für jemanden schwierig, die Wirksamkeit dieses Ansatzes anhand dieses Projekts zu erklären .

Es gibt ein ernsthafteres Problem. Wenn Sie den Artikel nicht lesen und sofort mit dem Studium des Projekts beginnen, ist es ziemlich schwer zu verstehen. Betrachten Sie die Implementierung der Geschäftslogik:

from rentomatic.response_objects import response_objects as res

class RoomListUseCase(object):
   def __init__(self, repo):
       self.repo = repo
   def execute(self, request_object):
       if not request_object:
           return res.ResponseFailure.build_from_invalid_request_object(
               request_object)
       try:
           rooms = self.repo.list(filters=request_object.filters)
           return res.ResponseSuccess(rooms)
       except Exception as exc:
           return res.ResponseFailure.build_system_error(
               "{}: {}".format(exc.__class__.__name__, "{}".format(exc)))

Die RoomListUseCase-Klasse, die die Geschäftslogik implementiert (der Geschäftslogik nicht sehr ähnlich, oder?) Des Projekts, wird vom Repo- Objekt initialisiert . Aber was ist ein Repo ? Aus dem Kontext können wir natürlich verstehen, dass repo die Repository-Vorlage für den Zugriff auf Daten implementiert. Wenn wir uns den Hauptteil von RoomListUseCase ansehen, verstehen wir, dass es eine Listenmethode geben muss, deren Eingabe eine Liste von Filtern ist, die bei der Ausgabe nicht klar ist in ResponseSuccess. Und wenn das Szenario komplexer ist, mit mehrfachem Zugriff auf die Datenquelle? Es stellt sich heraus, zu verstehen, was Repo ist, man kann sich nur auf die Implementierung beziehen. Aber wo befindet sie sich? Es liegt in einem separaten Modul, das in keiner Weise mit RoomListUseCase verknüpft ist. Um zu verstehen, was passiert, müssen Sie zur oberen Ebene (der Ebene des Frameworks) aufsteigen und sehen, was beim Erstellen des Objekts der Eingabe der Klasse zugeführt wird.

Sie könnten denken, dass ich die Nachteile der dynamischen Typisierung aufführe, aber das ist nicht ganz richtig. Es ist eine dynamische Eingabe, mit der Sie ausdrucksstarken und kompakten Code schreiben können . Die Analogie zu Microservices kommt in den Sinn, wenn wir einen Monolithen in Microservices schneiden, nimmt das Design zusätzliche Steifigkeit an, da innerhalb des Microservices alles möglich ist (PL, Frameworks, Architektur), aber es muss der deklarierten Schnittstelle entsprechen. Also hier: als wir unser Projekt in Ebenen unterteilt haben,Die Beziehungen zwischen den Ebenen müssen mit dem Vertrag übereinstimmen , während der Vertrag innerhalb der Ebene optional ist. Andernfalls müssen Sie einen ziemlich großen Kontext in Ihrem Kopf behalten. Denken Sie daran, ich sagte, dass das Problem mit schlechtem Code Abhängigkeiten sind, und so rutschen wir ohne explizite Schnittstelle wieder dorthin zurück, wo wir weg wollten - ohne offensichtliche Ursache-Wirkungs-Beziehungen .

repo RoomListUseCase, execute — . - , , . - . , , , repo .

Im Allgemeinen habe ich damals Clean Architecture in einem neuen Projekt aufgegeben und erneut die klassische MVC angewendet. Nachdem er die nächsten Kegel gefüllt hatte, kehrte er ein Jahr später zu dieser Idee zurück, als wir endlich begannen, Dienste in Python 3.5+ zu starten. Wie Sie wissen, brachte er Typanmerkungen und Datenklassen mit: Zwei leistungsstarke Tools zur Beschreibung der Benutzeroberfläche. Basierend auf ihnen habe ich einen Prototyp des Dienstes entworfen, und das Ergebnis war bereits viel besser: Die Ebenen bröckelten nicht mehr, obwohl immer noch viel Code vorhanden war, insbesondere bei der Integration in das Framework. Dies reichte jedoch aus, um diesen Ansatz in kleinen Projekten anzuwenden. Allmählich tauchten Frameworks auf, die sich auf die maximale Verwendung von Typanmerkungen konzentrierten: Apistar (jetzt Starlette), geschmolzenes Framework. Das pydantic / FastAPI-Bundle ist mittlerweile weit verbreitet, und die Integration in solche Frameworks ist viel einfacher geworden. So würde das obige Beispiel von restomatic / services.py aussehen:

from typing import Optional, List
from pydantic import BaseModel

class Room(BaseModel):
   code: str
   size: int
   price: int
   latitude: float
   longitude: float

class RoomFilter(BaseModel):
   code: Optional[str] = None
   price_min: Optional[int] = None
   price_max: Optional[int] = None

class RoomStorage:
   def get_rooms(self, filters: RoomFilter) -> List[Room]: ...

class RoomListUseCase:
   def __init__(self, repo: RoomStorage):
       self.repo = repo
   def show_rooms(self, filters: RoomFilter) -> List[Room]:
       rooms = self.repo.get_rooms(filters=filters)
       return rooms

RoomListUseCase - eine Klasse, die die Geschäftslogik des Projekts implementiert. Sie sollten nicht darauf achten, dass die Methode show_rooms lediglich RoomStorage aufruft (dieses Beispiel habe ich nicht erstellt). Im wirklichen Leben kann es auch eine Rabattberechnung geben, bei der eine Liste basierend auf Werbung usw. eingestuft wird. Das Modul ist jedoch autark. Wenn wir dieses Szenario in einem anderen Projekt verwenden möchten, müssen wir RoomStorage implementieren. Und was dafür benötigt wird, ist direkt vom Modul aus gut sichtbar. Im Gegensatz zum vorherigen Beispiel ist eine solche Ebene autark , und beim Ändern muss nicht der gesamte Kontext berücksichtigt werden. Von nicht-systemischen Abhängigkeiten nur pydantisch, warum, wird es im Plug-In des Frameworks deutlich. Keine AbhängigkeitenEine weitere Möglichkeit, die Lesbarkeit von Code zu verbessern, und nicht der zusätzliche Kontext. Selbst ein unerfahrener Entwickler kann verstehen, was dieses Modul tut.

Ein Geschäftslogikszenario muss keine Klasse sein. Nachfolgend finden Sie ein Beispiel für ein ähnliches Szenario in Form einer Funktion:

def rool_list_use_case(filters: RoomFilter, repo: RoomStorage) -> List[Room]:
   rooms = repo.get_rooms(filters=filters)
   return rooms


Und hier ist die Verbindung zum Framework:

from typing import List
from fastapi import FastAPI, Depends
from rentomatic import services, adapters
app = FastAPI()

def get_use_case() -> services.RoomListUseCase:
   return services.RoomListUseCase(adapters.MemoryStorage())

@app.post("/rooms", response_model=List[services.Room])
def rooms(filters: services.RoomFilter, use_case=Depends(get_use_case)):
   return use_case.show_rooms(filters)

Mit der Funktion get_use_case implementiert FastAPI das Abhängigkeitsinjektionsmuster . Wir müssen uns nicht um die Serialisierung von Daten kümmern, die gesamte Arbeit wird von FastAPI in Verbindung mit pydantic erledigt. Leider sind die Daten nicht immer Format Business - Logik geeignet für Live - Übertragung im Restaurant <und, im Gegenteil, die Business - Logik nicht weiß , wo die Daten kamen - mit Schrägstrichen, Anfrage Körper, Cookies, etc . In diesem Fall hat der Hauptteil der Raumfunktion eine bestimmte Konvertierung von Eingabe- und Ausgabedaten. In den meisten Fällen reicht jedoch eine solche einfache Proxy-Funktion aus, wenn wir mit der API arbeiten. 

, , , RoomStorage. , 15 , , , .

Ich habe die Ebene der Geschäftslogik absichtlich nicht getrennt, wie das kanonische Modell der sauberen Architektur nahe legt. Die Room-Klasse sollte sich in der Ebene der Entity-Domänenregion befinden, die die Domänenregion darstellt. In diesem Beispiel ist dies jedoch nicht erforderlich. Durch die Kombination von Entity- und UseCase-Ebenen hört das Projekt nicht auf, eine Clean Architecture-Implementierung zu sein. Robert Martin selbst hat wiederholt gesagt, dass die Anzahl der Schichten sowohl nach oben als auch nach unten variieren kann . Gleichzeitig erfüllt das Projekt die Hauptkriterien von Clean Architecture:

  • Grenzüberschreitungsregel: pydantische Modelle, bei denen es sich im Wesentlichen um DTOs handelt, überschreiten Grenzen ;
  • Abhängigkeitsregel : Die Geschäftslogikschicht ist unabhängig von anderen Schichten.
  • Die Prioritätsregel der inneren Schicht : Es ist die Geschäftslogikschicht, die die Schnittstelle (RoomStorage) definiert, über die die Geschäftslogik mit der Außenwelt interagiert.

Heute arbeiten mehrere Projekte unseres Teams, die mit dem beschriebenen Ansatz umgesetzt wurden, an dem Produkt. Ich versuche auf diese Weise auch die kleinsten Dienstleistungen zu organisieren. Es trainiert gut - Fragen, über die ich vorher nicht nachgedacht hatte, tauchen auf. Was ist hier beispielsweise Geschäftslogik? Dies ist beispielsweise bei weitem nicht immer offensichtlich, wenn Sie eine Art Proxy schreiben. Ein weiterer wichtiger Punkt ist zu lernen, anders zu denken.. Wenn wir eine Aufgabe erhalten, denken wir normalerweise über die Frameworks und die verwendeten Dienste nach und darüber, ob eine Warteschlange erforderlich ist, in der es besser ist, diese Daten zu speichern, die zwischengespeichert werden können. Bei dem Ansatz, der eine saubere Architektur vorschreibt, müssen wir zuerst die Geschäftslogik implementieren und erst dann die Interaktion mit der Infrastruktur implementieren, da laut Robert Martin die Hauptaufgabe der Architektur darin besteht, den Moment zu verzögern, in dem die Verbindung mit einer solchen hergestellt wird Die Infrastrukturschicht ist ein wesentlicher Bestandteil Ihrer Anwendung.

Im Allgemeinen sehe ich eine günstige Perspektive für die Verwendung von Clean Architecture in Python. Die Form unterscheidet sich jedoch höchstwahrscheinlich erheblich von der Art und Weise, wie sie in anderen PLs akzeptiert wird. In den letzten Jahren hat das Interesse am Thema Architektur in der Gemeinde deutlich zugenommen. Bei der letzten PyCon gab es also mehrere Berichte über die Verwendung von DDD, und die Leute aus den Trockenlabors sollten separat notiert werden . In unserem Unternehmen setzen viele Teams den beschriebenen Ansatz bereits in gewissem Maße um. Wir alle machen das Gleiche, wir sind gewachsen, unsere Projekte sind gewachsen, die Python-Community muss damit arbeiten, den gemeinsamen Stil und die gemeinsame Sprache definieren, die zum Beispiel einst für alle Django wurden.

All Articles