Внедрение зависимостей в Python

Шаблоны проектирования DI для Python: чистый и тестируемый код

Содержимое страницы

Внедрение зависимостей (DI) — это фундаментальный шаблон проектирования, который способствует созданию чистого, тестируемого и поддерживаемого кода в приложениях на Python.

Будь то создание REST API с FastAPI, реализация модульных тестов или работа с функциями AWS Lambda, понимание внедрения зависимостей значительно улучшит качество вашего кода.

python packages

Что такое внедрение зависимостей?

Внедрение зависимостей — это шаблон проектирования, при котором компоненты получают свои зависимости из внешних источников, а не создают их внутри. Этот подход делает компоненты более модульными, тестируемыми и поддерживаемыми.

В Python внедрение зависимостей особенно мощное благодаря динамической природе языка и поддержке протоколов, абстрактных базовых классов и утиной типизации. Гибкость Python позволяет реализовывать шаблоны DI без тяжелых фреймворков, хотя фреймворки доступны при необходимости.

Почему использовать внедрение зависимостей в Python?

Улучшенная тестируемость: Внедряя зависимости, вы можете легко заменять реальные реализации на моки или тестовые двойники. Это позволяет писать модульные тесты, которые быстрые, изолированные и не требуют внешних сервисов, таких как базы данных или API. При написании комплексных модульных тестов, внедрение зависимостей делает тривиальной замену реальных зависимостей на тестовые двойники.

Лучшая поддерживаемость: Зависимости становятся явными в вашем коде. Когда вы смотрите на конструктор, вы сразу видите, что требуется компоненту. Это делает кодовую базу легче для понимания и модификации.

Слабая связанность: Компоненты зависят от абстракций (протоколов или ABC) вместо конкретных реализаций. Это означает, что вы можете изменять реализации без влияния на зависимый код.

Гибкость: Вы можете настроить разные реализации для разных сред (разработка, тестирование, продакшн) без изменения бизнес-логики. Это особенно полезно при развертывании приложений Python на разных платформах, будь то AWS Lambda или традиционные серверы.

Внедрение через конструктор: Python-стиль

Самый распространенный и идиоматический способ реализации внедрения зависимостей в Python — это внедрение через конструктор, то есть принятие зависимостей в качестве параметров в методе __init__.

Базовый пример

Вот простой пример, демонстрирующий внедрение через конструктор:

from typing import Protocol
from abc import ABC, abstractmethod

# Определяем протокол для репозитория
class UserRepository(Protocol):
    def find_by_id(self, user_id: int) -> 'User | None':
        ...
    
    def save(self, user: 'User') -> 'User':
        ...

# Сервис зависит от протокола репозитория
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)

Этот шаблон делает понятным, что UserService требует UserRepository. Вы не можете создать UserService без предоставления репозитория, что предотвращает ошибки времени выполнения из-за отсутствия зависимостей.

Множественные зависимости

Когда компонент имеет несколько зависимостей, просто добавьте их в качестве параметров конструктора:

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

Использование протоколов и абстрактных базовых классов

Одним из ключевых принципов при реализации внедрения зависимостей является Принцип инверсии зависимостей (DIP): высокоуровневые модули не должны зависеть от низкоуровневых модулей; оба должны зависеть от абстракций.

В Python вы можете определять абстракции с использованием либо Протоколов (структурная типизация), либо Абстрактных базовых классов (ABC) (номинальная типизация).

Протоколы (Python 3.8+)

Протоколы используют структурную типизацию — если объект имеет требуемые методы, он удовлетворяет протоколу:

from typing import Protocol

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

# Любой класс с методом process_payment удовлетворяет этому протоколу
class CreditCardProcessor:
    def process_payment(self, amount: float) -> bool:
        # Логика кредитной карты
        return True

class PayPalProcessor:
    def process_payment(self, amount: float) -> bool:
        # Логика PayPal
        return True

# Сервис принимает любой PaymentProcessor
class OrderService:
    def __init__(self, payment_processor: PaymentProcessor):
        self.payment_processor = payment_processor

Абстрактные базовые классы

ABC используют номинальную типизацию — классы должны явно наследоваться от 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

Когда использовать Протоколы или ABC: Используйте Протоколы, когда вам нужна структурная типизация и гибкость. Используйте ABC, когда вам нужно наследовать иерархии или предоставлять реализации по умолчанию.

Пример из реального мира: Абстракция базы данных

При работе с базами данных в приложениях Python вам часто нужно абстрагировать операции с базой данных. Вот как помогает внедрение зависимостей:

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

# Репозиторий зависит от абстракции
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

Этот шаблон позволяет вам менять реализации баз данных (PostgreSQL, SQLite, MongoDB) без изменения кода репозитория.

Шаблон Composition Root

Composition Root — это место, где вы собираете все ваши зависимости в точке входа приложения (обычно main.py или ваша фабрика приложения). Это централизует настройку зависимостей и делает граф зависимостей явным.

def create_app() -> FastAPI:
    app = FastAPI()
    
    # Инициализация зависимостей инфраструктуры
    db = init_database()
    logger = init_logger()
    
    # Инициализация репозиториев
    user_repo = UserRepository(db)
    order_repo = OrderRepository(db)
    
    # Инициализация сервисов с зависимостями
    email_svc = EmailService(logger)
    payment_svc = PaymentService(logger)
    user_svc = UserService(user_repo, logger)
    order_svc = OrderService(order_repo, email_svc, logger, payment_svc)
    
    # Инициализация HTTP-хендлеров
    user_handler = UserHandler(user_svc)
    order_handler = OrderHandler(order_svc)
    
    # Подключение маршрутов
    app.include_router(user_handler.router)
    app.include_router(order_handler.router)
    
    return app

Этот подход делает понятным, как устроено ваше приложение и откуда берутся зависимости. Он особенно ценен при создании приложений, следующих принципам чистой архитектуры, где нужно координировать несколько уровней зависимостей.

Фреймворки внедрения зависимостей

Для крупных приложений с сложными графами зависимостей управление зависимостями вручную может стать громоздким. В Python есть несколько фреймворков DI, которые могут помочь:

Dependency Injector

Dependency Injector — это популярный фреймворк, предоставляющий контейнерный подход к внедрению зависимостей.

Установка:

pip install dependency-injector

Пример:

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

class Container(containers.DeclarativeContainer):
    # Конфигурация
    config = providers.Configuration()
    
    # База данных
    db = providers.Singleton(
        Database,
        connection_string=config.database.url
    )
    
    # Репозитории
    user_repository = providers.Factory(
        UserRepository,
        db=db
    )
    
    # Сервисы
    user_service = providers.Factory(
        UserService,
        repo=user_repository
    )

# Использование
container = Container()
container.config.database.url.from_env("DATABASE_URL")
user_service = container.user_service()

Injector

Injector — это легковесная библиотека, вдохновленная Google’s Guice, с акцентом на простоту.

Установка:

pip install injector

Пример:

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)

Когда использовать фреймворки

Используйте фреймворк, когда:

  • Ваш граф зависимостей сложный с множеством взаимозависимых компонентов
  • У вас есть несколько реализаций одного и того же интерфейса, которые нужно выбирать на основе конфигурации
  • Вы хотите автоматическое разрешение зависимостей
  • Вы создаете крупное приложение, где ручное подключение становится ошибкоопасным

Оставайтесь на ручном DI, когда:

  • Ваше приложение небольшое или среднего размера
  • Граф зависимостей простой и легко читаемый
  • Вы хотите минимизировать зависимости и сделать их явными
  • Вы предпочитаете явный код вместо магии фреймворка

Тестирование с внедрением зависимостей

Одним из основных преимуществ внедрения зависимостей является улучшенная тестируемость. Вот как DI упрощает тестирование:

Пример модульного тестирования

from unittest.mock import Mock
import pytest

# Моковая реализация для тестирования
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

# Тест с использованием мока
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"

Этот тест выполняется быстро, не требует базы данных и тестирует бизнес-логику в изоляции. При работе с модульным тестированием в Python, внедрение зависимостей упрощает создание тестовых дублей и проверку взаимодействий.

Использование фикстур pytest

Фикстуры pytest отлично работают с внедрением зависимостей:

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

Общие шаблоны и лучшие практики

1. Использование сегрегации интерфейсов

Сохраняйте протоколы и интерфейсы небольшими и сосредоточенными на том, что действительно нужно клиенту:

# Хорошо: Клиенту нужно только чтение пользователей
class UserReader(Protocol):
    def find_by_id(self, user_id: int) -> Optional['User']:
        ...
    
    def find_by_email(self, email: str) -> Optional['User']:
        ...

# Отдельный интерфейс для записи
class UserWriter(Protocol):
    def save(self, user: 'User') -> 'User':
        ...
    
    def delete(self, user_id: int) -> bool:
        ...

2. Проверка зависимостей в конструкторах

Конструкторы должны проверять зависимости и выдавать четкие ошибки, если инициализация не удалась:

class UserService:
    def __init__(self, repo: UserRepository):
        if repo is None:
            raise ValueError("репозиторий пользователей не может быть None")
        self.repo = repo

3. Использование аннотаций типов

Аннотации типов делают зависимости явными и помогают с поддержкой IDE и статической проверкой типов:

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. Избегайте чрезмерного внедрения

Не внедряйте зависимости, которые являются действительно внутренними деталями реализации. Если компонент создает и управляет своими собственными вспомогательными объектами, это нормально:

# Хорошо: Внутренний помощник не требует внедрения
class UserService:
    def __init__(self, repo: UserRepository):
        self.repo = repo
        # Внутренний кеш - не требует внедрения
        self._cache: dict[int, User] = {}

5. Документируйте зависимости

Используйте строки документации для документирования того, почему нужны зависимости и какие есть ограничения:

class UserService:
    """UserService обрабатывает бизнес-логику, связанную с пользователями.

    Аргументы:
        repo: UserRepository для доступа к данным. Должен быть потокобезопасным
            если используется в параллельных контекстах.
        logger: Логгер для отслеживания ошибок и отладки.
    """
    def __init__(self, repo: UserRepository, logger: Logger):
        self.repo = repo
        self.logger = logger

Внедрение зависимостей с FastAPI

FastAPI имеет встроенную поддержку внедрения зависимостей через механизм 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

Система внедрения зависимостей FastAPI автоматически обрабатывает граф зависимостей, что упрощает создание чистых, поддерживаемых API.

Когда НЕ использовать внедрение зависимостей

Внедрение зависимостей - мощный инструмент, но оно не всегда необходимо:

Пропустите DI для:

  • Простых объектов значений или классов данных
  • Внутренних вспомогательных функций или утилит
  • Разовых скриптов или небольших утилит
  • Когда прямое инстанцирование понятнее и проще

Пример, когда НЕ использовать DI:

# Простой dataclass - не требует DI
from dataclasses import dataclass

@dataclass
class Point:
    x: float
    y: float

# Простая утилита - не требует DI
def format_currency(amount: float) -> str:
    return f"${amount:.2f}"

Интеграция с экосистемой Python

Внедрение зависимостей работает плавно с другими паттернами и инструментами Python. При создании приложений, использующих Python пакеты или фреймворки модульного тестирования, вы можете внедрять эти сервисы в свою бизнес-логику:

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"Сгенерирован отчет {report_id}")
        return pdf

Это позволяет заменять реализации или использовать моки во время тестирования.

Асинхронное внедрение зависимостей

Синтаксис async/await Python хорошо работает с внедрением зависимостей:

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]

Заключение

Внедрение зависимостей - это основа для написания поддерживаемого, тестируемого кода на Python. Следуя паттернам, описанным в этой статье - внедрение через конструктор, проектирование на основе протоколов и паттерн композиционного корня - вы создадите приложения, которые легче понять, протестировать и модифицировать.

Начните с ручного внедрения через конструктор для небольших и средних приложений, и рассмотрите фреймворки вроде dependency-injector или injector по мере роста графа зависимостей. Помните, что цель - ясность и тестируемость, а не сложность ради сложности.

Для дополнительных ресурсов по разработке на Python, ознакомьтесь с нашей Python Cheatsheet для быстрого доступа к синтаксису Python и общим паттернам.

Полезные ссылки

Внешние ресурсы