Dependency Injection: En Python-approach

Pythons DI-mönster för ren, testbar kod

Sidinnehåll

Dependency injection (DI) är en grundläggande designmönster som främjar ren, testbar och underhållbar kod i Python-program.

Vare sig du bygger REST API:er med FastAPI, implementerar enhets tester eller arbetar med AWS Lambda funktioner, så kommer förståelsen för dependency injection att betydligt förbättra kvaliteten på din kod.

python packages

Vad är dependency injection?

Dependency injection är ett designmönster där komponenter får sina beroenden från externa källor snarare än att skapa dem internt. Detta sätt gör att komponenterna är mer oberoende, vilket gör att din kod blir mer modulär, testbar och underhållbar.

I Python är dependency injection särskilt kraftfull eftersom språket har en dynamisk karaktär och stöd för protokoll, abstrakta basklasser och duck typing. Python:s flexibilitet innebär att du kan implementera DI-mönster utan tunga ramverk, även om ramverk finns tillgängliga när det behövs.

Varför använda dependency injection i Python?

Bättre testbarhet: Genom att injicera beroenden kan du enkelt ersätta verkliga implementeringar med mockar eller testdubbler. Detta gör att du kan skriva enhets tester som är snabba, isolerade och inte kräver externa tjänster som databaser eller API:er. När du skriver omfattande enhets tester, gör dependency injection det trivialt att byta ut verkliga beroenden mot testdubbler.

Bättre underhållbarhet: Beroenden blir explicita i din kod. När du tittar på en konstruktor, ser du direkt vad en komponent kräver. Detta gör att kodbasen blir lättare att förstå och modifiera.

Lösn koppling: Komponenter beroer på abstraktioner (protokoll eller ABCs) snarare än konkreta implementeringar. Detta innebär att du kan ändra implementeringar utan att påverka beroende kod.

Flexibilitet: Du kan konfigurera olika implementeringar för olika miljöer (utveckling, testning, produktion) utan att behöva ändra din affärslogik. Detta är särskilt användbart när du distribuerar Python-program till olika plattformar, oavsett om det är AWS Lambda eller traditionella servrar.

Konstruktörsinjektion: Python:s sätt

Det vanligaste och mest idiomatiska sättet att implementera dependency injection i Python är genom konstruktörsinjektion – att ta emot beroenden som parametrar i __init__-metoden.

Grundläggande exempel

Här är ett enkelt exempel som demonstrerar konstruktörsinjektion:

from typing import Protocol
from abc import ABC, abstractmethod

# Definiera ett protokoll för repository
class UserRepository(Protocol):
    def find_by_id(self, user_id: int) -> 'User | None':
        ...
    
    def save(self, user: 'User') -> 'User':
        ...

# Service beroer på repository-protokollet
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)

Detta mönster gör det klart att UserService kräver en UserRepository. Du kan inte skapa en UserService utan att ange ett repository, vilket förhindrar körningsfel från saknade beroenden.

Flera beroenden

När en komponent har flera beroenden, lägg helt enkelt till dem som konstruktörsparametrar:

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

Använda protokoll och abstrakta basklasser

En av de viktigaste principerna när man implementerar dependency injection är Dependency Inversion Principle (DIP): högre nivåmoduler bör inte bero på lägre nivåmoduler; båda bör bero på abstraktioner.

I Python kan du definiera abstraktioner med hjälp av antingen protokoll (strukturtypning) eller abstrakta basklasser (ABCs) (namntypning).

Protokoll (Python 3.8+)

Protokoll använder strukturtypning – om ett objekt har de nödvändiga metoderna, uppfyller det protokollet:

from typing import Protocol

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

# Vare sig klassen har process_payment-metoden uppfyller den detta protokoll
class CreditCardProcessor:
    def process_payment(self, amount: float) -> bool:
        # Kreditkortlogik
        return True

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

# Service accepterar någon PaymentProcessor
class OrderService:
    def __init__(self, payment_processor: PaymentProcessor):
        self.payment_processor = payment_processor

Abstrakta basklasser

ABCs använder namntypning – klasser måste explicit arvta från ABC:en:

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

När att använda protokoll vs ABCs: Använd protokoll när du vill ha strukturtypning och flexibilitet. Använd ABCs när du behöver tvinga arvshierarkier eller ge standardimplementeringar.

Verklighetsbaserat exempel: Databasabstraktion

När du arbetar med databaser i Python-program, kommer du ofta behöva abstrahera databasoperationer. Här är hur dependency injection hjälper:

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]:
        ...

# Repository beroer på abstraktionen
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

Detta mönster gör det möjligt att byta databasimplementeringar (PostgreSQL, SQLite, MongoDB) utan att behöva ändra din repository-kod.

Kompositionens rotmönster

Kompositionens rot är där du samlar ihop alla dina beroenden vid programmet:s startpunkt (ofta main.py eller din applikationsfabrik). Detta centraliserar beroendekonfigurationen och gör beroendegrafen explicit.

def create_app() -> FastAPI:
    app = FastAPI()
    
    # Initiera infrastrukturberoenden
    db = init_database()
    logger = init_logger()
    
    # Initiera repositories
    user_repo = UserRepository(db)
    order_repo = OrderRepository(db)
    
    # Initiera tjänster med beroenden
    email_svc = EmailService(logger)
    payment_svc = PaymentService(logger)
    user_svc = UserService(user_repo, logger)
    order_svc = OrderService(order_repo, email_svc, logger, payment_svc)
    
    # Initiera HTTP-hanterare
    user_handler = UserHandler(user_svc)
    order_handler = OrderHandler(order_svc)
    
    # Koppla upp rutter
    app.include_router(user_handler.router)
    app.include_router(order_handler.router)
    
    return app

Detta sätt gör det tydligt hur din applikation är strukturerad och var beroenden kommer ifrån. Det är särskilt värdefullt när man bygger applikationer enligt rengörningsarkitekturprinciper, där du behöver koordinera flera nivåer av beroenden.

Dependency injectionramverk

För större applikationer med komplexa beroendegrafer kan det bli tråkigt att hantera beroenden manuellt. Python har flera DI-ramverk som kan hjälpa:

Dependency Injector

Dependency Injector är ett populärt ramverk som tillhandahåller en containerbaserad metod för dependency injection.

Installation:

pip install dependency-injector

Exempel:

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

class Container(containers.DeclarativeContainer):
    # Konfiguration
    config = providers.Configuration()
    
    # Databas
    db = providers.Singleton(
        Database,
        connection_string=config.database.url
    )
    
    # Repositories
    user_repository = providers.Factory(
        UserRepository,
        db=db
    )
    
    # Tjänster
    user_service = providers.Factory(
        UserService,
        repo=user_repository
    )

# Användning
container = Container()
container.config.database.url.from_env("DATABASE_URL")
user_service = container.user_service()

Injector

Injector är ett lättviktigt bibliotek inspirerat av Googles Guice, med fokus på enkelhet.

Installation:

pip install injector

Exempel:

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)

När att använda ramverk

Använd ett ramverk när:

  • Din beroendegraf är komplett med många beroende komponenter
  • Du har flera implementeringar av samma gränssnitt som behöver väljas utifrån konfiguration
  • Du vill ha automatisk beroendelösning
  • Du bygger en stor applikation där manuell anslutning blir felbenägen

Behåll manuell DI när:

  • Din applikation är liten eller medelstor
  • Din beroendegraf är enkel och lätt att följa
  • Du vill hålla beroenden minimala och explicita
  • Du föredrar explicit kod över ramverksmagi

Testning med dependency injection

En av de primära fördelarna med dependency injection är förbättrad testbarhet. Här är hur DI gör testning enklare:

Enhets testexempel

from unittest.mock import Mock
import pytest

# Mockimplementering för testning
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

# Testa med mock
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"

Detta test kör snabbt, kräver inte en databas och testar din affärslogik i isolation. När du arbetar med enhets test i Python, gör dependency injection det enkelt att skapa testdubbler och verifiera interaktioner.

Använda pytest fixtures

pytest fixtures fungerar utmärkt med dependency injection:

@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"

Vanliga mönster och bästa praxis

1. Använd gränssnittssegregering

Håll protokoll och gränssnitt små och fokuserade på vad klienten faktiskt behöver:

# Bra: Klienten behöver bara läsa användare
class UserReader(Protocol):
    def find_by_id(self, user_id: int) -> Optional['User']:
        ...
    
    def find_by_email(self, email: str) -> Optional['User']:
        ...

# Separat gränssnitt för skrivning
class UserWriter(Protocol):
    def save(self, user: 'User') -> 'User':
        ...
    
    def delete(self, user_id: int) -> bool:
        ...

2. Validera beroenden i konstruktörer

Konstruktörer bör validera beroenden och lyfta upp tydliga fel om initiering misslyckas:

class UserService:
    def __init__(self, repo: UserRepository):
        if repo is None:
            raise ValueError("user repository kan inte vara None")
        self.repo = repo

3. Använd typindikeringar

Typindikeringar gör beroenden explicita och hjälper till med IDE-stöd och statisk typkontroll:

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. Undvik överinjektion

Injicera inte beroenden som verkligen är inre implementeringsdetaljer. Om en komponent skapar och hanterar sina egna hjälpare, är det okej:

# Bra: Inre hjälpare behöver inte injektion
class UserService:
    def __init__(self, repo: UserRepository):
        self.repo = repo
        # Inre cache – behöver inte injektion
        self._cache: dict[int, User] = {}

5. Dokumentera beroenden

Använd docstrings för att dokumentera varför beroenden behövs och några begränsningar:

class UserService:
    """UserService hanterar användarrelaterad affärslogik.
    
    Args:
        repo: UserRepository för dataåtkomst. Måste vara trådsäkert
            om använd i samtidiga sammanhang.
        logger: Logger för felspårning och felsökning.
    """
    def __init__(self, repo: UserRepository, logger: Logger):
        self.repo = repo
        self.logger = logger

Dependency injection med FastAPI

FastAPI har inbyggd stöd för dependency injection via dess Depends-mekanism:

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

FastAPI:s dependency injection-system hanterar beroendegrafen automatiskt, vilket gör det enkelt att bygga ren, underhållbar API:er.

När inte att använda dependency injection

Dependency injection är ett kraftfullt verktyg, men det är inte alltid nödvändigt:

Skipa DI för:

  • Enkla värdeobjekt eller dataklasser
  • Inre hjälpfunktioner eller verktyg
  • Enkel skript eller små verktyg
  • När direkt instansiering är tydligare och enklare

Exempel på när inte att använda DI:

# Enkel dataklass – ingen behov av DI
from dataclasses import dataclass

@dataclass
class Point:
    x: float
    y: float

# Enkel verktyg – ingen behov av DI
def format_currency(amount: float) -> str:
    return f"${amount:.2f}"

Integration med Python-ekosystemet

Dependency injection fungerar sömlöst med andra Python-mönster och verktyg. När du bygger applikationer som använder Python-paket eller enhets testramverk, kan du injicera dessa tjänster i din affärslogik:

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

Detta gör det möjligt att byta ut implementeringar eller använda mockar under testning.

Asynkron dependency injection

Pythons async/await-syntax fungerar bra med dependency injection:

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]

Slutsats

Dependency injection är en grundpelare för att skriva underhållbar, testbar Python-kod. Genom att följa mönster som konstruktörsinjektion, protokollbaserad design och kompositionens rotmönster, kommer du att skapa program som är enklare att förstå, testa och modifiera.

Börja med manuell konstruktörsinjektion för små till medelstora applikationer, och överväg ramverk som dependency-injector eller injector när din beroendegraf växer. Kom ihåg att målet är tydlighet och testbarhet, inte komplexitet för sin egen skull.

För fler Python-utvecklingsresurser, se vårt Python Cheatsheet för snabb referens på Python-syntax och vanliga mönster.

Nytta länkar

Externa resurser