Metody fabryczne vs wstrzykiwanie frameworku w Pythonie - co jest czystsze?

9

To, co zwykle robię w swoich aplikacjach, to to, że tworzę wszystkie moje usługi / dao / repo / klientów przy użyciu metod fabrycznych

class Service:
    def init(self, db):
        self._db = db

    @classmethod
    def from_env(cls):
        return cls(db=PostgresDatabase.from_env())

A kiedy tworzę aplikację, robię

service = Service.from_env()

co tworzy wszystkie zależności

aw testach, gdy nie chcę używać prawdziwej bazy danych, po prostu wykonuję DI

service = Service(db=InMemoryDatabse())

Przypuszczam, że jest to dalekie od architektury czystej / szesnastkowej, ponieważ Service wie, jak utworzyć bazę danych i wie, jaki typ bazy danych tworzy (może to być również InMemoryDatabse lub MongoDatabase)

Myślę, że w czystej architekturze / hex miałbym

class DatabaseInterface(ABC):
    @abstractmethod
    def get_user(self, user_id: int) -> User:
        pass

import inject
class Service:
    @inject.autoparams()
    def __init__(self, db: DatabaseInterface):
        self._db = db

I skonfigurowałbym szkielet wtryskiwacza do zrobienia

# in app
inject.clear_and_configure(lambda binder: binder
                           .bind(DatabaseInterface, PostgresDatabase()))

# in test
inject.clear_and_configure(lambda binder: binder
                           .bind(DatabaseInterface, InMemoryDatabse()))

A moje pytania to:

  • Czy moja droga jest naprawdę zła? Czy to już nie jest czysta architektura?
  • Jakie są zalety stosowania zastrzyku?
  • Czy warto zawracać sobie głowę i korzystać z ramek wstrzykiwania?
  • Czy istnieją inne lepsze sposoby oddzielenia domeny od zewnątrz?
Ala Głowacka
źródło

Odpowiedzi:

1

Istnieje kilka głównych celów techniki wstrzykiwania zależności, w tym (ale nie tylko):

  • Opuszczanie sprzęgła między częściami twojego systemu. W ten sposób możesz zmienić każdą część przy mniejszym wysiłku. Patrz „Wysoka kohezja, niskie sprzężenie”
  • Egzekwowanie surowszych zasad dotyczących obowiązków. Jedna istota musi zrobić tylko jedną rzecz na poziomie abstrakcji. Inne podmioty muszą być zdefiniowane jako zależności od tego. Zobacz „IoC”
  • Lepsze doświadczenie w testowaniu. Jawne zależności pozwalają na wycieranie różnych części systemu za pomocą niektórych prymitywnych zachowań testowych, które mają ten sam publiczny interfejs API niż kod produkcyjny. Zobacz „Mocks arent 'stubs”

Inną rzeczą, o której należy pamiętać, jest to, że zwykle będziemy polegać na abstrakcjach, a nie implementacjach. Widzę wielu ludzi, którzy używają DI do wstrzykiwania tylko określonej implementacji. Jest duża różnica.

Ponieważ kiedy wstrzykujesz i polegasz na implementacji, nie ma różnicy w jakiej metodzie używamy do tworzenia obiektów. To po prostu nie ma znaczenia. Na przykład, jeśli wstrzykujesz requestsbez odpowiednich abstrakcji, nadal potrzebujesz czegoś podobnego z tymi samymi metodami, podpisami i typami zwrotów. W ogóle nie będziesz w stanie zastąpić tej implementacji. Ale wstrzyknięcie fetch_order(order: OrderID) -> Orderoznacza, że ​​wszystko może być w środku. requests, baza danych, cokolwiek.

Podsumowując:

Jakie są zalety stosowania zastrzyku?

Główną zaletą jest to, że nie trzeba ręcznie konfigurować zależności. Jednak wiąże się to z ogromnymi kosztami: używasz złożonych, nawet magicznych narzędzi do rozwiązywania problemów. Pewnego dnia złożoność cię odeprze.

Czy warto zawracać sobie głowę i korzystać z ramek wstrzykiwania?

W szczególności jeszcze jedna rzecz o injectframeworku. Nie lubię, kiedy przedmioty, do których coś wstrzykuję, wiedzą o tym. To szczegół implementacji!

Jak Postcardna przykład w modelu domeny światowej wie o tym?

Polecam używać punqdo prostych i dependencieszłożonych przypadków .

injectnie wymusza również czystego oddzielenia „zależności” i właściwości obiektu. Jak powiedziano, jednym z głównych celów DI jest egzekwowanie surowszych obowiązków.

Natomiast pokażę, jak punqdziała:

from typing_extensions import final

from attr import dataclass

# Note, we import protocols, not implementations:
from project.postcards.repository.protocols import PostcardsForToday
from project.postcards.services.protocols import (
   SendPostcardsByEmail,
   CountPostcardsInAnalytics,
)

@final
@dataclass(frozen=True, slots=True)
class SendTodaysPostcardsUsecase(object):
    _repository: PostcardsForToday
    _email: SendPostcardsByEmail
    _analytics: CountPostcardInAnalytics

    def __call__(self, today: datetime) -> None:
        postcards = self._repository(today)
        self._email(postcards)
        self._analytics(postcards)

Widzieć? Nie mamy nawet konstruktora. Deklaracyjnie definiujemy nasze zależności i punqautomatycznie je wprowadzamy. I nie definiujemy żadnych konkretnych implementacji. Tylko protokoły do ​​naśladowania. Ten styl nazywa się „obiektami funkcjonalnymi” lub klasami stylizowanymi SRP .

Następnie definiujemy punqsam kontener:

# project/implemented.py

import punq

container = punq.Container()

# Low level dependencies:
container.register(Postgres)
container.register(SendGrid)
container.register(GoogleAnalytics)

# Intermediate dependencies:
container.register(PostcardsForToday)
container.register(SendPostcardsByEmail)
container.register(CountPostcardInAnalytics)

# End dependencies:
container.register(SendTodaysPostcardsUsecase)

I użyj tego:

from project.implemented import container

send_postcards = container.resolve(SendTodaysPostcardsUsecase)
send_postcards(datetime.now())

Widzieć? Teraz nasze klasy nie mają pojęcia, kto i jak je tworzy. Bez dekoratorów, bez specjalnych wartości.

Przeczytaj więcej o klasach w stylu SRP tutaj:

Czy istnieją inne lepsze sposoby oddzielenia domeny od zewnątrz?

Możesz użyć koncepcji programowania funkcjonalnego zamiast koniecznych. Główną ideą wstrzykiwania zależności funkcji jest to, że nie wywołujesz rzeczy zależnych od kontekstu, którego nie masz. Połączenia te zaplanujesz na później, gdy kontekst będzie obecny. Oto jak zilustrować wstrzykiwanie zależności za pomocą prostych funkcji:

from django.conf import settings
from django.http import HttpRequest, HttpResponse
from words_app.logic import calculate_points

def view(request: HttpRequest) -> HttpResponse:
    user_word: str = request.POST['word']  # just an example
    points = calculate_points(user_words)(settings)  # passing the dependencies and calling
    ...  # later you show the result to user somehow

# Somewhere in your `word_app/logic.py`:

from typing import Callable
from typing_extensions import Protocol

class _Deps(Protocol):  # we rely on abstractions, not direct values or types
    WORD_THRESHOLD: int

def calculate_points(word: str) -> Callable[[_Deps], int]:
    guessed_letters_count = len([letter for letter in word if letter != '.'])
    return _award_points_for_letters(guessed_letters_count)

def _award_points_for_letters(guessed: int) -> Callable[[_Deps], int]:
    def factory(deps: _Deps):
        return 0 if guessed < deps.WORD_THRESHOLD else guessed
    return factory

Jedynym problemem związanym z tym wzorem jest trudność _award_points_for_lettersdo skomponowania.

Dlatego stworzyliśmy specjalne opakowanie, które pomaga w kompozycji (jest to część returns:

import random
from typing_extensions import Protocol
from returns.context import RequiresContext

class _Deps(Protocol):  # we rely on abstractions, not direct values or types
    WORD_THRESHOLD: int

def calculate_points(word: str) -> RequiresContext[_Deps, int]:
    guessed_letters_count = len([letter for letter in word if letter != '.'])
    awarded_points = _award_points_for_letters(guessed_letters_count)
    return awarded_points.map(_maybe_add_extra_holiday_point)  # it has special methods!

def _award_points_for_letters(guessed: int) -> RequiresContext[_Deps, int]:
    def factory(deps: _Deps):
        return 0 if guessed < deps.WORD_THRESHOLD else guessed
    return RequiresContext(factory)  # here, we added `RequiresContext` wrapper

def _maybe_add_extra_holiday_point(awarded_points: int) -> int:
    return awarded_points + 1 if random.choice([True, False]) else awarded_points

Na przykład RequiresContextma specjalną .mapmetodę komponowania się z czystą funkcją. I to wszystko. W rezultacie masz tylko proste funkcje i pomocniki przy składaniu z prostym API. Bez magii, bez dodatkowej złożoności. A jako bonus wszystko jest poprawnie wpisane i zgodne mypy.

Przeczytaj więcej o tym podejściu tutaj:

sobolevn
źródło
0

Początkowy przykład jest bardzo zbliżony do „właściwego” clean / hex. Brakuje pomysłu na rootowanie kompozycji i możesz zrobić czysty / hex bez żadnej struktury inżektora. Bez tego zrobiłbyś coś takiego:

class Service:
    def __init__(self, db):
        self._db = db

# In your app entry point:
service = Service(PostGresDb(config.host, config.port, config.dbname))

który pochodzi z DI Pure / Vanilla / Poor Man, w zależności od tego, z kim rozmawiasz. Abstrakcyjny interfejs nie jest absolutnie konieczny, ponieważ możesz polegać na pisaniu kaczek lub pisaniu strukturalnym.

To, czy chcesz używać frameworka DI, jest kwestią opinii i gustu, ale istnieją inne prostsze alternatywy do wstrzykiwania, takie jak punq, które możesz rozważyć, jeśli zdecydujesz się pójść tą ścieżką.

https://www.cosmicpython.com/ to dobry zasób, który szczegółowo analizuje te problemy.

ejung
źródło
0

możesz chcieć użyć innej bazy danych i chcesz mieć elastyczność, aby to zrobić w prosty sposób, dlatego uważam zastrzyk zależności za lepszy sposób na skonfigurowanie usługi

kederrac
źródło