Фабричні методи проти ін'єкційних фреймворків у Python - що чистіше?


9

Що я зазвичай роблю в своїх додатках - це те, що я створюю всі свої послуги / дао / репо / клієнтів, використовуючи заводські методи

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

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

І коли я створюю додаток, я це роблю

service = Service.from_env()

що створює всі залежності

і в тестах, коли я не хочу використовувати справжній db, я просто роблю DI

service = Service(db=InMemoryDatabse())

Я вважаю, що це досить далеко від чистої / шестигранної архітектури, оскільки Сервіс знає, як створити Базу даних і знає, який тип бази даних вона створює (може бути також InMemoryDatabse або MongoDatabase)

Я думаю, що в чистій / шестигранній архітектурі я мав би

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

І я б створив рамку для інжекторів

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

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

І мої запитання:

  • Невже мій спосіб поганий? Це вже не чиста архітектура?
  • Які переваги використання ін’єкцій?
  • Чи варто турбуватися та використовувати рамки для ін'єкцій?
  • Чи існують інші кращі способи відділення домену від зовнішнього?

Відповіді:


1

Існує декілька основних цілей у техніці введення залежностей, включаючи (але не обмежуючись ними):

  • Опускання муфти між частинами вашої системи. Таким чином ви можете змінити кожну частину з меншими зусиллями. Див. "Висока згуртованість, низька муфта"
  • Дотримуватись більш жорстких правил щодо відповідальності. Одне об'єднання повинно робити лише одне на своєму рівні абстракції. Інші сутності повинні бути визначені як залежності до цього. Див. "IoC"
  • Кращий досвід тестування. Явні залежності дозволяють вам заглушити різні частини вашої системи деяким примітивним тестовим поведінкою, яке має такий же загальнодоступний API, як і ваш виробничий код. Див. "Макети не заглушки"

Інша річ, яку слід пам’ятати, - це те, що ми зазвичай покладаємось на абстракції, а не на реалізацію. Я бачу дуже багато людей, які використовують DI для введення лише конкретної реалізації. Є велика різниця.

Тому що, коли ви вводите і покладаєтесь на реалізацію, немає різниці в тому, який метод ми використовуємо для створення об’єктів. Це просто не має значення. Наприклад, якщо ви робите ін’єкції requestsбез належних абстракцій, вам все одно знадобиться щось подібне з тими ж методами, підписами та поверненнями. Ви взагалі не зможете замінити цю реалізацію. Але, коли ти робиш ін'єкцію, fetch_order(order: OrderID) -> Orderце означає, що все може бути всередині. requests, база даних, що завгодно.

Підводячи підсумки:

Які переваги використання ін’єкцій?

Основна перевага полягає в тому, що вам не доведеться збирати свої залежності вручну. Однак це пов'язано з величезною вартістю: ви використовуєте складні, навіть магічні інструменти для вирішення проблем. Одного разу чи іншої складності будете відбивати вас.

Чи варто турбуватися та використовувати рамки для ін'єкцій?

Ще одна річ, зокрема, про injectрамки. Мені не подобається, коли про це знають об’єкти, куди я ввожу щось. Це деталізація реалізації!

Як, наприклад, у світовій Postcardмоделі доменів це знають?

Я б рекомендував використовувати punqдля простих випадків і dependenciesдля складних.

injectтакож не забезпечує чіткого поділу "залежностей" та властивостей об'єкта. Як було сказано, однією з головних цілей DI є виконання більш жорстких обов'язків.

Навпаки, дозвольте мені показати, як punqпрацює:

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)

Подивитися? У нас навіть конструктора немає. Ми декларативно визначаємо наші залежності та punqавтоматично їх вводимо. І ми не визначаємо конкретних реалізацій. Тільки протоколи, які слід виконувати. Цей стиль називається "функціональними об'єктами" або SRP- стилями класів.

Потім визначаємо сам punqконтейнер:

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

І використовуйте:

from project.implemented import container

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

Подивитися? Зараз наші класи не мають уявлення, хто і як їх створює. Ні декораторів, ні особливих цінностей.

Детальніше про класи в стилі SRP читайте тут:

Чи існують інші кращі способи відділення домену від зовнішнього?

Можна використовувати функціональні концепції програмування замість імперативних. Основна ідея введення залежності від функції полягає в тому, що ви не називаєте речі, які залежать від контексту, якого у вас немає. Ви плануєте запланувати ці дзвінки на потім, коли контекст присутній. Ось як можна проілюструвати введення залежності залежно від простих функцій:

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

Єдина проблема з цією схемою полягає в тому _award_points_for_letters, що складно скласти.

Ось чому ми створили спеціальну обгортку, щоб допомогти композиції (вона входить до складу 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

Наприклад, RequiresContextє спеціальний .mapметод складати себе з чистою функцією. І це все. Як результат, у вас є просто прості функції та помічники з простим API. Ніякої магії, ніякої зайвої складності. І як бонус все правильно набрано та сумісне mypy.

Детальніше про цей підхід читайте тут:


0

Початковий приклад досить близький до "правильного" чистого / шестигранного. Не вистачає ідеї про Composite Root, і ви можете робити чисту / шестигранну без будь-якої рамки інжектора. Без цього ви зробите щось на кшталт:

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

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

який іде через чистий / ванільний / бідний чоловік DI, залежно від того, з ким ти спілкуєшся. Абстрактний інтерфейс не є абсолютно необхідним, оскільки ви можете розраховувати на типінг качок або структурний набір тексту.

Хочете ви чи ні використовувати рамку DI - це питання думки та смаку, але є й інші простіші альтернативи введенню на зразок пункту, які ви могли б розглянути, якщо ви вирішите піти цим шляхом.

https://www.cosmicpython.com/ - хороший ресурс, який детально розглядає ці проблеми.


0

ви можете використовувати іншу базу даних, і ви хочете мати гнучкість зробити це простим способом. З цієї причини я вважаю, що введення залежності є кращим способом налаштування вашої служби

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.