Métodos de fábrica vs framework de injeção no Python - o que é mais limpo?


9

O que geralmente faço em meus aplicativos é que crio todos os meus serviços / 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())

E quando eu crio um aplicativo, eu faço

service = Service.from_env()

o que cria todas as dependências

e em testes quando eu não quero usar db real eu apenas faço DI

service = Service(db=InMemoryDatabse())

Suponho que isso esteja muito longe da arquitetura limpa / hexadecimal, pois o Service sabe como criar um banco de dados e sabe qual o tipo de banco de dados que ele cria (pode ser também InMemoryDatabse ou MongoDatabase)

Eu acho que na arquitetura limpa / hexadecimal eu teria

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

E eu montaria a estrutura do injetor para fazer

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

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

E minhas perguntas são:

  • Meu caminho é realmente ruim? Não é mais uma arquitetura limpa?
  • Quais são os benefícios do uso de injeção?
  • Vale a pena se preocupar e usar o framework de injeção?
  • Existem outras maneiras melhores de separar o domínio do lado de fora?

Respostas:


1

Existem vários objetivos principais na técnica de injeção de dependência, incluindo (mas não limitado a):

  • Abaixando o acoplamento entre partes do seu sistema. Dessa forma, você pode alterar cada parte com menos esforço. Consulte "Coesão alta, baixo acoplamento"
  • Aplicar regras mais estritas sobre responsabilidades. Uma entidade deve fazer apenas uma coisa em seu nível de abstração. Outras entidades devem ser definidas como dependências para esta. Veja "IoC"
  • Melhor experiência de teste. Dependências explícitas permitem stub diferentes partes do seu sistema com algum comportamento de teste primitivo que possui a mesma API pública que o seu código de produção. Consulte "Zomba de stubs"

A outra coisa a ter em mente é que geralmente devemos confiar em abstrações, não em implementações. Eu vejo muitas pessoas que usam o DI para injetar apenas uma implementação específica. Há uma grande diferença.

Porque quando você injeta e confia em uma implementação, não há diferença em qual método usamos para criar objetos. Isso simplesmente não importa. Por exemplo, se você injetar requestssem abstrações adequadas, você ainda precisará de algo semelhante com os mesmos métodos, assinaturas e tipos de retorno. Você não seria capaz de substituir esta implementação. Mas, quando você injeta fetch_order(order: OrderID) -> Order, significa que qualquer coisa pode estar dentro. requests, banco de dados, qualquer que seja.

Para resumir:

Quais são os benefícios do uso de injeção?

O principal benefício é que você não precisa montar suas dependências manualmente. No entanto, isso tem um custo enorme: você está usando ferramentas complexas e até mágicas para resolver problemas. Um dia ou outro de complexidade, você reagirá.

Vale a pena se preocupar e usar o framework de injeção?

Mais uma coisa sobre o injectframework em particular. Não gosto quando objetos em que injeto algo sabem disso. É um detalhe de implementação!

Como em um Postcardmodelo de domínio mundial , por exemplo, sabe disso?

Eu recomendaria usar punqpara casos simples e dependenciespara casos complexos.

injecttambém não impõe uma separação limpa de "dependências" e propriedades de objetos. Como foi dito, um dos principais objetivos da DI é reforçar as responsabilidades.

Por outro lado, deixe-me mostrar como 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)

Vejo? Nós ainda não temos um construtor. Definimos declarativamente nossas dependências e punqas injetamos automaticamente. E não definimos nenhuma implementação específica. Apenas protocolos a seguir. Esse estilo é chamado de "objetos funcionais" ou classes estilizadas com SRP .

Em seguida, definimos o punqpróprio contêiner:

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

E use-o:

from project.implemented import container

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

Vejo? Agora, nossas aulas não têm idéia de quem e como as cria. Sem decoradores, sem valores especiais.

Leia mais sobre as classes no estilo SRP aqui:

Existem outras maneiras melhores de separar o domínio do lado de fora?

Você pode usar conceitos de programação funcional em vez de conceitos imperativos. A idéia principal da injeção de dependência de função é que você não chama coisas que dependem do contexto que não possui. Você agenda essas chamadas para mais tarde, quando o contexto estiver presente. Veja como você pode ilustrar a injeção de dependência com apenas funções 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

O único problema com esse padrão é que _award_points_for_lettersserá difícil compor.

Por isso, criamos um invólucro especial para ajudar a composição (faz parte do 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 exemplo, RequiresContextpossui um .mapmétodo especial para se recompor com uma função pura. E é isso. Como resultado, você tem apenas funções simples e auxiliares de composição com API simples. Sem mágica, sem complexidade extra. E como um bônus, tudo é digitado e compatível corretamente mypy.

Leia mais sobre essa abordagem aqui:


0

O exemplo inicial está bem próximo de um clean / hex "adequado". O que está faltando é a idéia de uma raiz de composição, e você pode limpar / hexadecimal sem qualquer estrutura de injetor. Sem ele, você faria 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 passa pelo DI da Pure / Vanilla / Poor Man, dependendo de com quem você fala. Uma interface abstrata não é absolutamente necessária, pois você pode confiar na digitação de pato ou na estrutura.

Se você deseja ou não usar uma estrutura de DI é uma questão de opinião e bom gosto, mas existem outras alternativas mais simples para injetar como punq que você poderia considerar, se optar por seguir esse caminho.

https://www.cosmicpython.com/ é um bom recurso que analisa esses problemas em profundidade.


0

convém usar um banco de dados diferente e ter a flexibilidade de fazê-lo de maneira simples; por esse motivo, considero a injeção de dependência uma maneira melhor de configurar seu serviço

Ao utilizar nosso site, você reconhece que leu e compreendeu nossa Política de Cookies e nossa Política de Privacidade.
Licensed under cc by-sa 3.0 with attribution required.