Iniekcja zależności: sposób Pythona

Wzorce DI w Pythonie dla czystego, testowalnego kodu

Page content

Iniekcja zależności (DI) to fundamentalny wzorzec projektowy, który promuje czysty, testowalny i utrzyjmalny kod w aplikacjach Pythona.

Nie ważne, czy tworzysz API REST z FastAPI, czy implementujesz testy jednostkowe, czy pracujesz z funkcjami AWS Lambda, zrozumienie iniekcji zależności znacząco poprawi jakość Twojego kodu.

pakiety pythona

Co to jest iniekcja zależności?

Iniekcja zależności to wzorzec projektowy, w którym komponenty otrzymują swoje zależności z zewnętrznych źródeł zamiast tworzyć je wewnętrznie. Ten podejście rozdzielają komponenty, co sprawia, że Twój kod jest bardziej modułowy, testowalny i utrzyjmalny.

W Pythonie, iniekcja zależności jest szczególnie potężna ze względu na dynamiczną naturę języka oraz wsparcie dla protokołów, klas bazowych i typowania „duck typing”. Flexybilność Pythona oznacza, że możesz implementować wzorce DI bez ciężkich frameworków, choć frameworki są dostępne, gdy są potrzebne.

Dlaczego używać iniekcji zależności w Pythonie?

Poprawiona testowalność: Poprzez iniekcję zależności możesz łatwo zastąpić rzeczywiste implementacje mockami lub testowymi podwójnymi. To umożliwia napisanie testów jednostkowych, które są szybkie, izolowane i nie wymagają usług zewnętrznych, takich jak bazy danych lub API. Gdy piszesz kompleksowe testy jednostkowe, iniekcja zależności sprawia, że zamiana rzeczywistych zależności na testowe podwójne jest trywialna.

Lepsza utrzyjmalność: Zależności stają się jawne w Twoim kodzie. Gdy spojrzysz na konstruktor, od razu widzisz, co wymaga komponent. To sprawia, że kod jest łatwiejszy do zrozumienia i modyfikacji.

Słabsze sprzężenie: Komponenty zależą od abstrakcji (protokołów lub ABC), a nie od konkretnych implementacji. To oznacza, że możesz zmieniać implementacje bez wpływu na kod zależny.

Flexybilność: Możesz konfigurować różne implementacje dla różnych środowisk (rozwijanie, testowanie, produkcja) bez zmiany logiki biznesowej. To jest szczególnie przydatne, gdy wdrażasz aplikacje Pythona na różnych platformach, czy to AWS Lambda czy tradycyjne serwery.

Iniekcja przez konstruktor: Pythonowy sposób

Najczęstszy i najbardziej idiomiczny sposób implementacji iniekcji zależności w Pythonie to iniekcja przez konstruktor – przekazywanie zależności jako parametr w metodzie __init__.

Prosty przykład

Oto prosty przykład pokazujący iniekcję przez konstruktor:

from typing import Protocol
from abc import ABC, abstractmethod

# Zdefiniuj protokół dla repozytorium
class UserRepository(Protocol):
    def find_by_id(self, user_id: int) -> 'User | None':
        ...
    
    def save(self, user: 'User') -> 'User':
        ...

# Usługa zależy od protokołu repozytorium
class UserService:
    def __init__(self, repo: UserRepository):
        self.repo = repo
    
    def get_user(self, user_id: int) -> 'User | None':
        return self.repo.find_by_id(user_id)

Ten wzorzec czyni jasnym, że UserService wymaga UserRepository. Nie można utworzyć UserService bez dostarczenia repozytorium, co zapobiega błędom w czasie działania z brakującymi zależnościami.

Wiele zależności

Gdy komponent ma wiele zależności, po prostu dodaj je jako parametry konstruktora:

class EmailService(Protocol):
    def send(self, to: str, subject: str, body: str) -> None:
        ...

class Logger(Protocol):
    def info(self, msg: str) -> None:
        ...
    
    def error(self, msg: str, err: Exception) -> None:
        ...

class OrderService:
    def __init__(
        self,
        repo: OrderRepository,
        email_svc: EmailService,
        logger: Logger,
        payment_svc: PaymentService,
    ):
        self.repo = repo
        self.email_svc = email_svc
        self.logger = logger
        self.payment_svc = payment_svc

Użycie Protokołów i Klas Bazowych

Jednym z kluczowych zasad przy implementacji iniekcji zależności jest Zasada Odwrócenia Zależności (DIP): moduły wysokiego poziomu nie powinny zależeć od modułów niskiego poziomu; oba powinny zależeć od abstrakcji.

W Pythonie możesz definiować abstrakcje za pomocą Protokołów (strukturalnego typowania) lub Klas Bazowych (ABC) (typowania nazwowego).

Protokoły (Python 3.8+)

Protokoły używają strukturalnego typowania – jeśli obiekt ma wymagane metody, spełnia protokół:

from typing import Protocol

class PaymentProcessor(Protocol):
    def process_payment(self, amount: float) -> bool:
        ...

# Każdy klasa z metodą process_payment spełnia ten protokół
class CreditCardProcessor:
    def process_payment(self, amount: float) -> bool:
        # Logika karty kredytowej
        return True

class PayPalProcessor:
    def process_payment(self, amount: float) -> bool:
        # Logika PayPal
        return True

# Usługa akceptuje dowolny PaymentProcessor
class OrderService:
    def __init__(self, payment_processor: PaymentProcessor):
        self.payment_processor = payment_processor

Klasy Bazowe

ABCs używają typowania nazwowego – klasy muszą jawne dziedziczyć z ABC:

from abc import ABC, abstractmethod

class PaymentProcessor(ABC):
    @abstractmethod
    def process_payment(self, amount: float) -> bool:
        pass

class CreditCardProcessor(PaymentProcessor):
    def process_payment(self, amount: float) -> bool:
        return True

Kiedy używać Protokołów vs ABCs: Używaj Protokołów, gdy chcesz strukturalne typowanie i elastyczność. Używaj ABC, gdy musisz wymusić hierarchię dziedziczenia lub dostarczyć domyślnych implementacji.

Przykład z życia: Abstrakcja bazy danych

Gdy pracujesz z bazami danych w aplikacjach Pythona, często musisz abstrahować operacje bazy danych. Oto jak iniekcja zależności pomaga:

from typing import Protocol, Optional
from contextlib import contextmanager

class Database(Protocol):
    @contextmanager
    def transaction(self):
        ...
    
    def execute(self, query: str, params: dict) -> None:
        ...
    
    def fetch_one(self, query: str, params: dict) -> Optional[dict]:
        ...

# Repozytorium zależy od abstrakcji
class UserRepository:
    def __init__(self, db: Database):
        self.db = db
    
    def find_by_id(self, user_id: int) -> Optional['User']:
        result = self.db.fetch_one(
            "SELECT * FROM users WHERE id = :id",
            {"id": user_id}
        )
        if result:
            return User(**result)
        return None

Ten wzorzec umożliwia wymianę implementacji bazy danych (PostgreSQL, SQLite, MongoDB) bez zmiany kodu repozytorium.

Wzorzec Korzenia Kompozycji

Korzeń Kompozycji to miejsce, w którym łączysz wszystkie zależności w punkcie wejścia aplikacji (zwykle main.py lub fabryce aplikacji). To centralizuje konfigurację zależności i czyni graf zależności jawny.

def create_app() -> FastAPI:
    app = FastAPI()
    
    # Inicjalizacja zależności infrastruktury
    db = init_database()
    logger = init_logger()
    
    # Inicjalizacja repozytoriów
    user_repo = UserRepository(db)
    order_repo = OrderRepository(db)
    
    # Inicjalizacja usług z zależnościami
    email_svc = EmailService(logger)
    payment_svc = PaymentService(logger)
    user_svc = UserService(user_repo, logger)
    order_svc = OrderService(order_repo, email_svc, logger, payment_svc)
    
    # Inicjalizacja obsługi HTTP
    user_handler = UserHandler(user_svc)
    order_handler = OrderHandler(order_svc)
    
    # Podłączenie tras
    app.include_router(user_handler.router)
    app.include_router(order_handler.router)
    
    return app

Ten podejście czyni jasnym, jak jest zbudowana Twoja aplikacja i skąd pochodzą zależności. Jest szczególnie wartościowe, gdy tworzysz aplikacje zgodne z zasadami architektury czystej, gdzie musisz koordynować wiele warstw zależności.

Frameworki Iniekcji Zależności

Dla większych aplikacji z złożonym grafem zależności, zarządzanie zależnościami ręcznie może stać się uciążliwe. Python ma kilka frameworków DI, które mogą pomóc:

Dependency Injector

Dependency Injector to popularny framework, który oferuje podejście oparte na kontenerze do iniekcji zależności.

Instalacja:

pip install dependency-injector

Przykład:

from dependency_injector import containers, providers
from dependency_injector.wiring import inject, Provide

class Container(containers.DeclarativeContainer):
    # Konfiguracja
    config = providers.Configuration()
    
    # Baza danych
    db = providers.Singleton(
        Database,
        connection_string=config.database.url
    )
    
    # Repozytoria
    user_repository = providers.Factory(
        UserRepository,
        db=db
    )
    
    # Usługi
    user_service = providers.Factory(
        UserService,
        repo=user_repository
    )

# Użycie
container = Container()
container.config.database.url.from_env("DATABASE_URL")
user_service = container.user_service()

Injector

Injector to lekki biblioteka zainspirowana Google Guice, skupiająca się na prostocie.

Instalacja:

pip install injector

Przykład:

from injector import Injector, inject, Module, provider

class DatabaseModule(Module):
    @provider
    def provide_db(self) -> Database:
        return Database(connection_string="...")

class UserModule(Module):
    @inject
    def __init__(self, repo: UserRepository):
        self.repo = repo

injector = Injector([DatabaseModule()])
user_service = injector.get(UserService)

Kiedy używać frameworków

Użyj frameworku, gdy:

  • Twój graf zależności jest złożony z wielu wzajemnie zależnych komponentów
  • Masz wiele implementacji tej samej interfejsu, które należy wybrać na podstawie konfiguracji
  • Chcesz automatyczne rozwiązywanie zależności
  • Tworzysz dużą aplikację, w której ręczne podpinanie zależności staje się błędne

Zostań przy ręcznej DI, gdy:

  • Twoja aplikacja jest mała do średniej wielkości
  • Graf zależności jest prosty i łatwy do śledzenia
  • Chcesz zachować zależności minimalne i jawne
  • Preferujesz jawny kod nad magią frameworku

Testowanie z Iniekcją Zależności

Jednym z głównych korzyści z iniekcji zależności jest poprawiona testowalność. Oto jak DI ułatwia testowanie:

Przykład testu jednostkowego

from unittest.mock import Mock
import pytest

# Przykładowa implementacja do testowania
class MockUserRepository:
    def __init__(self):
        self.users = {}
        self.error = None
    
    def find_by_id(self, user_id: int) -> Optional['User']:
        if self.error:
            raise self.error
        return self.users.get(user_id)
    
    def save(self, user: 'User') -> 'User':
        if self.error:
            raise self.error
        self.users[user.id] = user
        return user

# Test z użyciem mocka
def test_user_service_get_user():
    mock_repo = MockUserRepository()
    mock_repo.users[1] = User(id=1, name="John", email="john@example.com")
    
    service = UserService(mock_repo)
    
    user = service.get_user(1)
    assert user is not None
    assert user.name == "John"

Ten test działa szybko, nie wymaga bazy danych i testuje logikę biznesową izolowanie. Gdy pracujesz z testowaniem jednostkowym w Pythonie, iniekcja zależności ułatwia tworzenie testowych podwójnych i weryfikację interakcji.

Użycie pytest Fixtures

Fixtury pytest działają bardzo dobrze z iniekcją zależności:

@pytest.fixture
def mock_user_repository():
    return MockUserRepository()

@pytest.fixture
def user_service(mock_user_repository):
    return UserService(mock_user_repository)

def test_user_service_get_user(user_service, mock_user_repository):
    user = User(id=1, name="John", email="john@example.com")
    mock_user_repository.users[1] = user
    
    result = user_service.get_user(1)
    assert result.name == "John"

Powszechne wzorce i najlepsze praktyki

1. Używaj segregacji interfejsów

Każdy protokół i interfejs powinien być mały i skupiony na tym, czego klient naprawdę potrzebuje:

# Dobrze: Klient potrzebuje tylko odczytywać użytkowników
class UserReader(Protocol):
    def find_by_id(self, user_id: int) -> Optional['User']:
        ...
    
    def find_by_email(self, email: str) -> Optional['User']:
        ...

# Odrębny interfejs do zapisu
class UserWriter(Protocol):
    def save(self, user: 'User') -> 'User':
        ...
    
    def delete(self, user_id: int) -> bool:
        ...

2. Waliduj zależności w konstruktorach

Konstruktory powinny walidować zależności i wznosić wyraźne błędy, jeśli inicjalizacja nie powiedzie się:

class UserService:
    def __init__(self, repo: UserRepository):
        if repo is None:
            raise ValueError("repozytorium użytkowników nie może być None")
        self.repo = repo

3. Używaj wskazówek typu

Wskazówki typu czynią zależności jawne i pomagają w obsłudze IDE oraz statycznym sprawdzaniu typów:

from typing import Protocol, Optional

class UserService:
    def __init__(
        self,
        repo: UserRepository,
        logger: Logger,
        email_service: EmailService,
    ) -> None:
        self.repo = repo
        self.logger = logger
        self.email_service = email_service

4. Unikaj nadmiernej iniekcji

Nie iniekcjonuj zależności, które są naprawdę wewnętrznymi szczegółami implementacji. Jeśli komponent tworzy i zarządza swoimi własnymi pomocniczymi obiektami, to jest w porządku:

# Dobrze: Wewnętrzny pomocnik nie wymaga iniekcji
class UserService:
    def __init__(self, repo: UserRepository):
        self.repo = repo
        # Wewnętrzny cache - nie wymaga iniekcji
        self._cache: dict[int, User] = {}

5. Dokumentuj zależności

Używaj docstringów do dokumentowania, dlaczego są potrzebne zależności i jakie są ograniczenia:

class UserService:
    """UserService obsługuje logikę biznesową związana z użytkownikami.
    
    Args:
        repo: UserRepository do dostępu do danych. Musi być bezpieczny dla wątków,
            jeśli jest używany w kontekście współbieżnym.
        logger: Logger do śledzenia błędów i debugowania.
    """
    def __init__(self, repo: UserRepository, logger: Logger):
        self.repo = repo
        self.logger = logger

Iniekcja zależności w FastAPI

FastAPI ma wbudowaną obsługę iniekcji zależności poprzez mechanizm Depends:

from fastapi import FastAPI, Depends
from typing import Annotated

app = FastAPI()

def get_user_repository() -> UserRepository:
    db = get_database()
    return UserRepository(db)

def get_user_service(
    repo: Annotated[UserRepository, Depends(get_user_repository)]
) -> UserService:
    return UserService(repo)

@app.get("/users/{user_id}")
def get_user(
    user_id: int,
    service: Annotated[UserService, Depends(get_user_service)]
):
    user = service.get_user(user_id)
    if not user:
        raise HTTPException(status_code=404)
    return user

System iniekcji zależności FastAPI obsługuje graf zależności automatycznie, co ułatwia budowanie czystych, utrzyjmalnych API.

Kiedy NIE używać Iniekcji Zależności

Iniekcja zależności to potężny narzędzie, ale nie zawsze jest konieczna:

Pomiń DI w przypadku:

  • Prostych obiektów wartościowych lub klas danych
  • Wewnętrznych funkcji pomocniczych lub narzędzi
  • Skryptów jednorazowych lub małych narzędzi
  • Kiedy bezpośrednie tworzenie instancji jest wyraźniejsze i prostsze

Przykład kiedy NIE używać DI:

# Prosta klasa danych - nie ma potrzeby DI
from dataclasses import dataclass

@dataclass
class Point:
    x: float
    y: float

# Prosty narzędzie - nie ma potrzeby DI
def format_currency(amount: float) -> str:
    return f"${amount:.2f}"

Integracja z ekosystemem Pythona

Iniekcja zależności działa płynnie z innymi wzorcami i narzędziami Pythona. Gdy budujesz aplikacje, które używają pakietów Pythona lub frameworków testowania jednostkowego, możesz wstrzyknąć te usługi do Twojej logiki biznesowej:

class ReportService:
    def __init__(
        self,
        pdf_generator: PDFGenerator,
        repo: ReportRepository,
        logger: Logger,
    ):
        self.pdf_generator = pdf_generator
        self.repo = repo
        self.logger = logger
    
    def generate_report(self, report_id: int) -> bytes:
        report_data = self.repo.get_by_id(report_id)
        pdf = self.pdf_generator.generate(report_data)
        self.logger.info(f"Generated report {report_id}")
        return pdf

To umożliwia wymianę implementacji lub użycie mocków podczas testowania.

Asynchroniczna Iniekcja Zależności

Sytaksa async/await w Pythonie działa dobrze z iniekcją zależności:

from typing import Protocol
import asyncio

class AsyncUserRepository(Protocol):
    async def find_by_id(self, user_id: int) -> Optional['User']:
        ...
    
    async def save(self, user: 'User') -> 'User':
        ...

class AsyncUserService:
    def __init__(self, repo: AsyncUserRepository):
        self.repo = repo
    
    async def get_user(self, user_id: int) -> Optional['User']:
        return await self.repo.find_by_id(user_id)
    
    async def get_users_batch(self, user_ids: list[int]) -> list['User']:
        tasks = [self.repo.find_by_id(uid) for uid in user_ids]
        results = await asyncio.gather(*tasks)
        return [u for u in results if u is not None]

Podsumowanie

Iniekcja zależności to fundament pisania utrzyjmalnego, testowalnego kodu w Pythonie. Przyjmując wzorce omawiane w tym artykule – iniekcja przez konstruktor, projekt oparty na protokołach oraz wzorzec korzenia kompozycji – utworzysz aplikacje, które są łatwiejsze do zrozumienia, testowania i modyfikacji.

Zacznij od ręcznej iniekcji przez konstruktor dla aplikacji małych do średnich, a rozważ frameworki takie jak dependency-injector lub injector, gdy graf zależności rośnie. Pamiętaj, że celem jest przejrzystość i testowalność, a nie złożoność dla własnej woli.

Dla więcej zasobów związanych z rozwojem w Pythonie, sprawdź nasz Python Cheatsheet dla szybkiego odniesienia do składni Pythona i typowych wzorców.

Przydatne linki

Zewnętrzne zasoby