Métodos de fábrica vs marco de inyección en Python: ¿qué es más limpio?

9

Lo que generalmente hago en mis aplicaciones es que creo todos mis servicios / dao / repo / clients usando métodos de fábrica

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

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

Y cuando creo una aplicación hago

service = Service.from_env()

lo que crea todas las dependencias

y en las pruebas cuando no quiero usar db real solo hago DI

service = Service(db=InMemoryDatabse())

Supongo que está bastante lejos de la arquitectura limpia / hexadecimal ya que el Servicio sabe cómo crear una Base de Datos y sabe qué tipo de base de datos crea (también podría ser InMemoryDatabse o MongoDatabase)

Supongo que en arquitectura limpia / hexadecimal tendría

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

Y configuraría el marco del inyector para hacer

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

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

Y mis preguntas son:

  • ¿Mi camino es realmente malo? ¿Ya no es una arquitectura limpia?
  • ¿Cuáles son los beneficios de usar inyectar?
  • ¿Vale la pena molestarse y usar el marco de inyección?
  • ¿Hay otras formas mejores de separar el dominio del exterior?
Ala Głowacka
fuente

Respuestas:

1

Existen varios objetivos principales en la técnica de inyección de dependencia, que incluyen (entre otros):

  • Bajar el acoplamiento entre partes de su sistema. De esta manera puede cambiar cada parte con menos esfuerzo. Consulte "Alta cohesión, bajo acoplamiento"
  • Hacer cumplir normas más estrictas sobre las responsabilidades. Una entidad debe hacer solo una cosa en su nivel de abstracción. Otras entidades deben definirse como dependencias de esta. Ver "IoC"
  • Mejor experiencia de prueba. Las dependencias explícitas le permiten tropezar diferentes partes de su sistema con un comportamiento de prueba primitivo que tiene la misma API pública que su código de producción. Ver "Los simulacros no son"

La otra cosa a tener en cuenta es que usualmente confiaremos en abstracciones, no en implementaciones. Veo a muchas personas que usan DI para inyectar solo una implementación particular. Hay una gran diferencia

Porque cuando inyectas y confías en una implementación, no hay diferencia en el método que usamos para crear objetos. Simplemente no importa. Por ejemplo, si inyecta requestssin las abstracciones adecuadas, aún necesitaría algo similar con los mismos métodos, firmas y tipos de retorno. No podrá reemplazar esta implementación en absoluto. Pero, cuando se inyecta fetch_order(order: OrderID) -> Order, significa que cualquier cosa puede estar adentro. requests, base de datos, lo que sea.

Para resumir las cosas:

¿Cuáles son los beneficios de usar inyectar?

El principal beneficio es que no tiene que ensamblar sus dependencias manualmente. Sin embargo, esto tiene un costo enorme: está utilizando herramientas complejas, incluso mágicas, para resolver problemas. Un día u otra complejidad te contraatacará.

¿Vale la pena molestarse y usar el marco de inyección?

Una cosa más sobre el injectmarco en particular. No me gusta cuando los objetos donde inyecto algo lo saben. ¡Es un detalle de implementación!

¿Cómo, en un Postcardmodelo de dominio mundial , por ejemplo, sabe esto?

Recomendaría usar punqpara casos simples y dependenciescomplejos.

injecttampoco impone una separación limpia de "dependencias" y propiedades de objeto. Como se dijo, uno de los principales objetivos de DI es imponer responsabilidades más estrictas.

En contraste, déjame mostrarte cómo punqfunciona:

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)

¿Ver? Ni siquiera tenemos un constructor. Definitivamente definimos nuestras dependencias y las punqinyectaremos automáticamente. Y no definimos implementaciones específicas. Solo protocolos a seguir. Este estilo se llama "objetos funcionales" o clases con estilo SRP .

Luego definimos el punqcontenedor en sí:

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

Y úsalo:

from project.implemented import container

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

¿Ver? Ahora nuestras clases no tienen idea de quién y cómo los crea. Sin decoradores, sin valores especiales.

Lea más sobre las clases de estilo SRP aquí:

¿Hay otras formas mejores de separar el dominio del exterior?

Puede usar conceptos de programación funcional en lugar de los imperativos. La idea principal de la inyección de dependencia de funciones es que no se llaman cosas que dependen del contexto que no se tiene. Usted programa estas llamadas para más tarde, cuando el contexto está presente. Así es como puede ilustrar la inyección de dependencia con funciones simples:

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

El único problema con este patrón es que _award_points_for_lettersserá difícil de componer.

Es por eso que hicimos un envoltorio especial para ayudar a la composición (es parte de 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

Por ejemplo, RequiresContexttiene un .mapmétodo especial para componerse con una función pura. Y eso es. Como resultado, tiene funciones simples y ayudantes de composición con API simple. Sin magia, sin complejidad extra. Y como beneficio adicional, todo está correctamente escrito y es compatible mypy.

Lea más sobre este enfoque aquí:

sobolevn
fuente
0

El ejemplo inicial está bastante cerca de una limpieza / hexágono "adecuado". Lo que falta es la idea de una raíz de composición, y puede hacer clean / hex sin ningún marco de inyector. Sin ella, harías algo como:

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

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

que pasa por Pure / Vanilla / Poor Man's DI, dependiendo de con quién hables. Una interfaz abstracta no es absolutamente necesaria, ya que puede confiar en la tipificación de pato o la tipificación estructural.

Si desea o no usar un marco DI es una cuestión de opinión y gusto, pero hay otras alternativas más simples para inyectar como punq que podría considerar, si elige seguir ese camino.

https://www.cosmicpython.com/ es un buen recurso que analiza estos problemas en profundidad.

ejung
fuente
0

es posible que desee usar una base de datos diferente y que desee tener la flexibilidad para hacerlo de una manera simple, por esta razón, considero que la inyección de dependencia es una mejor manera de configurar su servicio

Kederrac
fuente