Основы архитектуры для джунов: построение масштабируемых и чистых приложений на python (Туториал)
Когда речь идет о создании масштабируемых и поддерживаемых приложений, понимание таких важных понятий, как принципы чистого кода, архитектурные паттерны и SOLID практики проектирования, имеет решающее значение. Изучив эти принципы, новички получат представление о построении надежных, гибких и легко тестируемых приложений, что позволит им сохранить ясность кодовой базы и возможность ее сопровождения по мере роста их проектов.
Немного теории чистого кода
Прежде чем погрузиться в архитектуру, я хотел бы ответить на несколько часто задаваемых вопросов:
В чем преимущества указания типов в python?
Каковы причины разделения приложения на слои?
Каковы преимущества использования ООП?
Каковы недостатки использования глобальных переменных или синглтонов?
Не стесняйтесь пропустить теоретические разделы, если вы уже знаете ответы, и переходите непосредственно к разделу «Создание программы».
Всегда указывайте типы
Аннотация типов значительно улучшает код, повышая его ясность, надежность и ремонтопригодность:
Безопасность типов: Аннотации типов помогают выявить несоответствие типов на ранней стадии, что уменьшает количество ошибок и гарантирует, что ваш код будет вести себя так, как ожидается.
Самодокументированный код: Подсказки типов повышают удобочитаемость кода и выступают в роли встроенной документации, уточняя ожидаемые типы входных и выходных данных функций.
Повышение качества кода: Использование подсказок типов способствует улучшению дизайна и архитектуры, способствуя продуманному планированию и реализации структур данных и интерфейсов.
Улучшенная поддержка инструментов: Такие инструменты, как mypy, используют аннотации типов для статической проверки типов, выявляя потенциальные ошибки до начала выполнения, тем самым упрощая процесс разработки и тестирования.
Поддержка современных библиотек: FastAPI, Pydantic и другие библиотеки используют аннотации типов для автоматизации валидации данных, генерации документации и уменьшения дублирования кода.
Преимущества типизированных классов данных перед простыми структурами данных: Типизированные классы данных улучшают читаемость, работу со структурированными данными и безопасность типов по сравнению с массивами и кортежами. Они используют атрибуты вместо строковых ключей, что минимизирует ошибки из-за опечаток и улучшает автодополнение кода. Датаклассы также обеспечивают четкое определение структур данных, поддерживают значения по умолчанию, упрощают сопровождение и отладку кода.
Почему нам нужно разделить приложение на слои
Разделение приложения на слои повышает ремонтопригодность, масштабируемость и гибкость. Ключевые причины для этой стратегии включают:
Разделение забот
Каждый слой фокусируется на определенном аспекте, что упрощает разработку, отладку и сопровождение.
Возможность повторного использования
Масштабируемость
Удобство обслуживания
Улучшенная совместная работа
Гибкость и адаптируемость
Изменения в технологиях или дизайне могут быть реализованы в определенных слоях. В адаптации нуждаются только затронутые слои, остальные остаются незатронутыми.
Тестируемость
Использование многоуровневой архитектуры дает значительные преимущества в скорости разработки, оперативном управлении и долгосрочном обслуживании, делая системы более надежными, управляемыми и адаптируемыми к изменениям.
Глобальные константы против инжектируемых параметров
При разработке программного обеспечения выбор между использованием глобальных констант и применением инъекции зависимостей (DI) может существенно повлиять на гибкость, сопровождаемость и масштабируемость приложений. В этом анализе рассматриваются недостатки глобальных констант и противопоставляются им преимущества, обеспечиваемые инъекцией зависимостей.
Глобальные константы
Фиксированная конфигурация: Глобальные константы статичны и не могут динамически адаптироваться к различным средам или требованиям без изменения кодовой базы. Такая жесткость ограничивает их использование в различных сценариях работы.
Ограниченный объем тестирования: Тестирование становится сложным при использовании глобальных констант, поскольку их нелегко переопределить. Разработчикам может потребоваться изменять глобальное состояние или использовать сложные обходные пути, чтобы приспособиться к различным сценариям тестирования, что повышает риск ошибок.
Уменьшение модульности: Опора на глобальные константы снижает модульность, поскольку компоненты становятся зависимыми от конкретных значений, установленных глобально. Такая зависимость снижает возможность повторного использования компонентов в различных проектах или контекстах.
Высокая связанность: Глобальные константы интегрируют специфическое поведение и конфигурации непосредственно в кодовую базу, что затрудняет адаптацию или развитие приложения без значительных изменений.
Скрытые зависимости: Подобно глобальным переменным, глобальные константы скрывают зависимости внутри приложения. Становится неясно, какие части системы зависят от этих констант, что усложняет понимание и сопровождение кода.
Трудности сопровождения и рефакторинга: Со временем использование глобальных констант может привести к проблемам с обслуживанием. Рефакторинг такой кодовой базы рискован, поскольку изменения констант могут случайно затронуть разные части приложения.
Дублирование состояния на уровне модуля: В Python код на уровне модуля может выполняться несколько раз, если импорт происходит по разным путям (например, абсолютный и относительный). Это может привести к дублированию глобальных экземпляров и трудноотслеживаемым ошибкам в обслуживании.
Инжектируемые параметры
Динамическая гибкость и настраиваемость: Инъекция зависимостей позволяет динамически настраивать компоненты, делая приложения адаптируемыми к изменяющимся условиям без необходимости изменения кода.
Улучшенная тестируемость: DI улучшает тестируемость, позволяя внедрять моки или альтернативные конфигурации во время тестирования, эффективно изолируя компоненты от внешних зависимостей и обеспечивая более надежные результаты тестирования.
Увеличение модульности и возможности повторного использования: Компоненты становятся более модульными и пригодными для повторного использования, поскольку они спроектированы так, чтобы работать с любыми инжектируемыми параметрами, соответствующими ожидаемым интерфейсам. Такое разделение задач повышает переносимость компонентов в различные части приложения или даже в разные проекты.
Низкая связанность: Инжектируемые параметры способствуют низкой связанности, отделяя логику системы от ее конфигурации. Такой подход облегчает обновление и внесение изменений в приложение.
Явное декларирование зависимостей: В DI компоненты явно объявляют о своих зависимостях, обычно через параметры конструктора или сеттеры. Такая ясность облегчает понимание, поддержку и расширение системы.
Масштабируемость и управление сложностью: По мере роста приложений DI помогает управлять сложностью, локализуя проблемы и отделяя конфигурацию от использования, что способствует эффективному масштабированию и обслуживанию больших систем.
Процедурное программирование против ООП
Использование объектно-ориентированного программирования (ООП) и инъекции зависимостей (DI) может значительно повысить качество и сопровождаемость кода по сравнению с процедурным подходом с глобальными переменными и функциями. Вот простое сравнение, демонстрирующее эти преимущества:
Процедурный подход: Глобальные переменные и функции
# Global configuration
database_config = {
'host': 'localhost',
'port': 3306,
'user': 'user',
'password': 'pass'
}
def connect_to_database():
print(f"Connecting to database on {database_config['host']}...")
# Assume connection is made
return "database_connection"
def fetch_user(database_connection, user_id):
print(f"Fetching user {user_id} using {database_connection}")
# Fetch user logic
return {'id': user_id, 'name': 'John Doe'}
# Usage
db_connection = connect_to_database()
user = fetch_user(db_connection, 1)
Дублирование кода:
database_config
должен передаваться или обращаться глобально в нескольких функциях.Трудности тестирования: Имитация подключения к базе данных или конфигурации предполагает манипулирование глобальным состоянием, что чревато ошибками.
Высокая связанность: Функции напрямую зависят от глобального состояния и конкретных реализаций.
ООП + DI-подход
from typing import Dict, Optional
from abc import ABC, abstractmethod
class DatabaseConnection(ABC):
@abstractmethod
def connect(self):
pass
@abstractmethod
def fetch_user(self, user_id: int) -> Dict:
pass
class MySQLConnection(DatabaseConnection):
def __init__(self, config: Dict[str, str]):
self.config = config
def connect(self):
print(f"Connecting to MySQL database on {self.config['host']}...")
# Assume connection is made
def fetch_user(self, user_id: int) -> Dict:
print(f"Fetching user {user_id} from MySQL")
return {'id': user_id, 'name': 'John Doe'}
class UserService:
def __init__(self, db_connection: DatabaseConnection):
self.db_connection = db_connection
def get_user(self, user_id: int) -> Dict:
return self.db_connection.fetch_user(user_id)
# Configuration and DI
config = {
'host': 'localhost',
'port': 3306,
'user': 'user',
'password': 'pass'
}
db = MySQLConnection(config)
db.connect()
user_service = UserService(db)
user = user_service.get_user(1)
Уменьшено дублирование кода: Конфигурация базы данных инкапсулируется в объекте подключения.
Возможности DI: Легко заменить
MySQLConnection
на другой класс подключения к базе данных, напримерPostgresConnection
, не изменяя кодUserService
.Энкапсуляция и абстракция: Детали реализации того, как извлекаются пользователи или как подключается база данных, скрыты от глаз.
Удобство моков и тестирования:
UserService
можно легко протестировать, внедрив заглушкуDatabaseConnection
.Управление временем жизни объекта: Жизненным циклом соединений с базой данных можно управлять более детально (например, с помощью менеджеров контекста).
Использование принципов ООП: Демонстрирует наследование (абстрактный базовый класс), полиморфизм (реализация абстрактных методов) и протоколы (интерфейсы, определенные
DatabaseConnection
).
Благодаря структурированию приложения с использованием ООП и DI, код становится более модульным, его легче тестировать, и он становится гибким к изменениям, таким как замена зависимостей или изменение конфигурации.
Создание программы
Все примеры и более подробную информацию с комментариями вы можете найти в репозитории
Начало нового проекта
Небольшой чек-лист:
1. Управление проектами и зависимостями с помощью Poetry
poetry new python-app-architecture-demo
Эта команда создаст минимальную структуру директории: отдельные папки для приложения и тестов, файл метаинформации проекта pyproject.toml
, лок файлы зависимостей и конфигурации гита.
2. Контроль версий с помощью Git
Инициализируйте гит:
git init
Добавьте файл .gitignore
для исключения ненужных файлов из вашего репозитория. Используйте стандартный .gitignore
, предоставленный GitHub, и добавьте остальные исключения, такие как .DS_Store
для macOS и папки редакторов (.idea
, .vscode
, .zed
, etc):
wget -O .gitignore https://raw.githubusercontent.com/github/gitignore/main/Python.gitignore
echo .DS_Store >> .gitignore
3. Управление зависимостями
Установите зависимости вашего проекта с помощью poetry:
poetry add fastapi pytest aiogram
Вы можете установить все зависимости позже, используя:
poetry install
Обратитесь к официальной документации каждой библиотеки, если вам нужны более конкретные инструкции.
4. Файлы конфигурации
Создайте файл config.py
для централизации настроек приложения — это распространенный и эффективный подход.
Установите переменные окружения для секретов и настроек:
touch .env example.env
.env
содержит конфиденциальные данные и должен быть git-ignored, в то время как example.env
содержит placeholder или значения по умолчанию и хранится в репозитории.
5. Точка входа приложения
Определите точку входа вашего приложения в main.py
:
python_app_architecture/main.py:
def run():
print('Hello, World!')
if __name__ == '__main__': # avoid run on import
run()
Сделайте свой проект пригодным для использования в качестве библиотеки и разрешите программный доступ, импортировав функцию run
в __init__.py
:
python_app_architecture/init.py
from .main import run
Включите прямое выполнение проекта с помощью Poetry, добавив ярлык в __main__.py
. Это позволит вам использовать команду poetry run python python_app_architecture
вместо более длинной poetry run python python_app_architecture/main.py
.
python_app_architecture/main.py:
from .main import run
run()
Определение каталогов и слоев
Дисклеймер:
Конечно, все приложения разные, и их архитектура будет отличаться в зависимости от целей и задач. Я не говорю, что это единственно правильный вариант, но мне кажется, что он достаточно средний и подходит для значительной части проектов. Постарайтесь сосредоточиться на основных подходах и идеях, а не на конкретных примерах.
Теперь давайте настроим директории для различных слоев приложения.
Как правило, имеет смысл версионировать API (например, создавая подкаталоги типа api/v1
), но мы пока будем действовать проще и опустим этот шаг.
.
├── python_app_architecture_demo
│ ├── coordinator.py
│ ├── entities
│ ├── general
│ ├── mappers
│ ├── providers
│ ├── repository
│ │ └── models
│ └── services
│ ├── api_service
│ │ └── api
│ │ ├── dependencies
│ │ ├── endpoints
│ │ └── schemas
│ └── telegram_service
└── tests
app
entities — структуры данных всего приложения. Чисто носители данных без логики.
general — чемодан с инструментами. Папка для общих утилит, помощников и оберток библиотек.
mappers — специалисты по преобразованию данных, таких как модели баз данных в сущности, или между различными форматами данных. Хорошей практикой является инкапсуляция мапперов в границах их использования, вместо того чтобы держать их глобальными. Например, маппер models-entities может быть частью модуля репозитория. Другой пример: маппер schemas-entities должен оставаться внутри сервиса апи и быть его приватным инструментом.
providers — основа бизнес-логики. Провайдеры реализуют основную логику приложения, но остаются независимыми от деталей интерфейса, обеспечивая абстрактность и изолированность своих операций.
repositores — библиотекари. Хранители доступа к данным, абстрагирующие сложности взаимодействия с бд.
services — каждый сервис действует как (почти) автономное подприложение, организуя свою специфическую область бизнес-логики и делегируя основные задачи провайдерам. Такая конфигурация обеспечивает централизованную и единообразную логику всего приложения
api_service — управляет внешними коммуникациями по http/s, структурированными вокруг фреймворка FastAPI.
dependencies — основные инструменты и помощники, необходимые для различных частей вашего API, интегрированные с помощью системы DI FastAPI
endpoints — конечные точки http интерфейса
schemas — определения структур данных для запросов и ответов апи
telegram_service — работает аналогично сервису апи, предоставляя тот же функционал в другом интерфейсе, но без дублирования кода бизнес-логики за счет вызова тех же провайдеров, чир использует апи сервис.
tests — директория предназначена исключительно для тестирования и содержит весь тестовый код, сохраняя четкое разделение с логикой приложения.
Связь между слоями будет выглядеть примерно так:
Обратите внимание, что entities — не активные компоненты, а лишь структуры данных, которые передаются между слоями:
Помните, что слои не связаны напрямую, а зависят только от абстракций. Реализации передаются с помощью инъекции зависимостей:
Такая гибкая структура позволяет легко добавить функциональность, например, изменить базу данных, создать сервис или подключить новый интерфейс без лишних изменений и дублирования кода, так как логика каждого модуля находится на своем слое:
В то же время вся логика отдельного сервиса инкапсулируется внутри него:
Изучение кода
Эндпоинт
Начнем с конечной точки:
# api_service/api/endpoints/user.py
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from entities.user import UserCreate
from ..dependencies.providers import (
user_provider, # 1
UserProvider # 2
)
router = APIRouter()
@router.post("/register")
async def register(
user: UserCreate, # 3
provider: Annotated[UserProvider, Depends(user_provider)] # 4
):
provider.create_user(user) # 5
return {"message": "User created!"}
Импортируем вспомогательную функцию инъекции зависимостей (мы рассмотрим ее через минуту)
Импортируем UserProvider protocol для аннотации типа
Конечная точка требует, чтобы тело запроса содержало схему
UserCreate
в формате jsonПараметр
provider
в функцииregister
представляет собой экземпляр реализацииUserProvider
, инжектируемый FastAPI с помощью механизмаDepends
.В метод
create_user
функцииUserProvider
передаются распарсенные данные пользователя. Это демонстрирует четкое разделение проблем, когда уровень API делегирует бизнес-логику уровню провайдера, придерживаясь принципа, что интерфейсные уровни не должны содержать бизнес-логику.
UserProvider
Теперь давайте посмотрим на бизнес-логику:
# providers/user_provider.py
from typing import Protocol, runtime_checkable, Callable
from typing_extensions import runtime_checkable
from repository import UserRepository
from providers.mail_provider import MailProvider
from entities.user import UserCreate
@runtime_checkable
class UserProvider(Protocol): # 1
def create_user(self, user: UserCreate): ...
@runtime_checkable
class UserProviderOutput(Protocol): # 2
def user_provider_created_user(self, provider: UserProvider, user: UserCreate): ...
class UserProviderImpl: # 3
def __init__(self,
repository: UserRepository, # 4
mail_provider: MailProvider, # 4
output: UserProviderOutput | None, # 5
on_user_created: Callable[[UserCreate], None] | None # 6
):
self.repository = repository
self.mail_provider = mail_provider
self.output = output
self.on_user_created = on_user_created
# Implementation
def create_user(self, user: UserCreate): # 7
self.repository.add_user(user) # 8
self.mail_provider.send_mail(user.email, f"Welcome, {user.name}!") # 9
if output := self.output: # unwraping the optional
output.user_provider_created_user(self, user) # 10
# 11
if on_user_created := self.on_user_created:
on_user_created(user)
Определение интерфейса:
UserProvider
— это протокол, определяющий методcreate_user
, который должен реализовать любой класс, придерживающийся этого протокола. Он служит формальным контрактом для функциональности создания пользователя.Протокол наблюдателя:
UserProviderOutput
служит в качестве наблюдателя (или делегата), который получает уведомление о создании пользователя. Этот протокол обеспечивает свободное соединение и улучшает событийно-ориентированную архитектуру приложения.Реализация протокола:
UserProviderImpl
реализует логику создания пользователя, но ему не нужно явно декларировать свою приверженностьUserProvider
из-за динамической природы Python и использования утиной типизации.Основные зависимости: Конструктор принимает
UserRepository
иMailProvider
— оба определены как протоколы — в качестве параметров. Полагаясь исключительно на эти протоколы,UserProviderImpl
остается отделенным от конкретных реализаций, иллюстрируя принципы Dependency Injection, где провайдер не зависит от базовых деталей, взаимодействуя только через определенные контракты.Опциональный делегат вывода: Конструктор принимает необязательный экземпляр
UserProviderOutput
, который, если он предоставлен, будет уведомлен по завершении создания пользователя.Функция обратного вызова: В качестве альтернативы делегату вывода можно передать вызываемую функцию
on_user_created
для обработки дополнительных действий после создания пользователя, обеспечивая гибкость реакции на события.Центральная бизнес-логика: Метод
create_user
инкапсулирует основную бизнес-логику для добавления пользователя, демонстрируя отделение от обработки API.Взаимодействие с репозиторием: Использует
UserRepository
для абстрагирования операций с базой данных (например, добавление пользователя), гарантируя, что провайдер не будет напрямую манипулировать базой данных.Расширенная бизнес-логика: Вовлекает отправку электронной почты через
MailProvider
, иллюстрируя, что обязанности провайдера могут выходить за рамки простых CRUD-операций.Уведомление о событиях: Если предоставлен делегат вывода, он уведомляет его о событии создания пользователя, используя паттерн наблюдателя для повышения интерактивности и модульной реакции на события.
Исполнение обратного вызова: Опционально выполняет функцию обратного вызова, обеспечивая простой метод расширения функциональности без сложных иерархий классов или зависимостей.
Зависимости FastAPI
Хорошо, но как инстанцировать провайдер и внедрить его? Давайте посмотрим на код инъекции, реализованный с помощью DI-движка FastAPI:
# services/api_service/api/dependencies/providers.py
from typing import Annotated
from fastapi import Request, Depends
from repository import UserRepository
from providers.user_provider import UserProvider, UserProviderImpl
from providers.mail_provider import MailProvider
from coordinator import Coordinator
from .database import get_session, Session
import config
def _get_coordinator(request: Request) -> Coordinator:
# private helper function
# NOTE: You can pass the DIContainer in the same way
return request.app.state.coordinator
def user_provider(
session: Annotated[Session, Depends(get_session)], # 1
coordinator: Annotated[Coordinator, Depends(_get_coordinator)] # 2
) -> UserProvider: # 3
# UserProvider's lifecycle is bound to short endpoint's lifecycle, so it's safe to use strong references here
return UserProviderImpl( # 4
repository=UserRepository(session), # 5
mail_provider=MailProvider(config.mail_token), # 6
output=coordinator, # 7
on_user_created=coordinator.on_user_created # 8
# on_user_created: lambda: coordinator.on_user_created() # add a lambda if the method's signature is not compatible
)
Получение сессии базы данных через систему инъекций зависимостей FastAPI, гарантируя, что каждый запрос имеет чистую сессию.
Получение из состояния приложения экземпляра
Coordinator
, который отвечает за управление более широкими задачами на уровне приложения и выступает в качестве менеджера событий.Примечание: функция возвращает протокол, но не точную реализацию.
Конструирование экземпляра
UserProviderImpl
путем инжекции всех необходимых зависимостей. Это демонстрирует практическое применение инъекции зависимостей для сборки сложных объектов.Инициализация
UserRepository
с сессией, полученной из DI-системы FastAPI. Этот репозиторий обрабатывает все операции по сохранению данных, абстрагируя взаимодействие с базой данных от провайдера.Настройка
MailProvider
с помощью конфигурационного токена.Инжектирование
Coordinator
в качестве выходного протокола. При этом предполагается, чтоCoordinator
реализует протоколUserProviderOutput
, что позволяет ему получать уведомления о создании пользователя.Назначает метод из
Coordinator
в качестве обратного вызова, который будет выполняться при создании пользователя. Это позволяет запускать дополнительные операции или уведомления в качестве побочного эффекта процесса создания пользователя.
Такой структурированный подход гарантирует, что UserProvider
оснащен всеми необходимыми инструментами для эффективного выполнения своих задач, придерживаясь при этом принципов свободной связи и высокой связности.
Координатор
Класс Coordinator выступает в роли главного оркестратора в вашем приложении, управляя различными сервисами, взаимодействиями, событиями, устанавливая начальное состояние и внедряя зависимости. Вот подробное описание его ролей и функциональных возможностей на основе предоставленного кода:
# coordinator.py
from threading import Thread
import weakref
import uvicorn
import config
from services.api_service import get_app as get_fastapi_app
from entities.user import UserCreate
from repository.user_repository import UserRepository
from providers.mail_provider import MailProvider
from providers.user_provider import UserProvider, UserProviderImpl
from services.report_service import ReportService
from services.telegram_service import TelegramService
class Coordinator:
def __init__(self):
self.users_count = 0 # 1
self.telegram_service = TelegramService( # 2
token=config.telegram_token,
get_user_provider=lambda session: UserProviderImpl(
repository=UserRepository(session),
mail_provider=MailProvider(config.mail_token),
output=self,
on_user_created=self.on_user_created
)
)
self.report_service = ReportService(
get_users_count = lambda: self.users_count # 3
)
# Coordinator's Interface
def setup_initial_state(self):
fastapi_app = get_fastapi_app()
fastapi_app.state.coordinator = self # 4
# 5
fastapi_thread = Thread(target=lambda: uvicorn.run(fastapi_app))
fastapi_thread.start()
# 6
self.report_service.start()
self.telegram_service.start()
# UserProviderOutput Protocol Implementation
def user_provider_created_user(self, provider: UserProvider, user: UserCreate):
self.on_user_created(user)
# Event handlers
def on_user_created(self, user):
print("User created: ", user)
self.users_count += 1
# 7
if self.users_count >= 10_000:
self.report_service.interval_seconds *= 10
elif self.users_count >= 10_000_000:
self.report_service.stop() # 8
Некоторые состояния могут быть общими для разных провайдеров, служб, слоев и всего приложения.
Сборка реализаций и внедрение зависимостей
Здесь следует помнить о круговых ссылках, тупиках и утечках памяти, подробности см. в полном коде.
Передайте экземпляр координатора в состояние приложения FastAPI, чтобы вы могли обращаться к нему в конечных точках через DI-систему FastAPI.
Запустить все сервисы в отдельных потоках
Уже запускается в отдельном потоке внутри сервиса
Некоторая кросс-сервисная логика, просто для примера
Пример управления сервисами из координатора
Этот оркестратор централизует управление и связь между различными компонентами, повышая управляемость и масштабируемость приложения. Он эффективно координирует действия между сервисами, обеспечивая адекватную реакцию приложения на изменения состояния и взаимодействие с пользователем. Этот шаблон проектирования очень важен для поддержания чистого разделения задач и обеспечения более надежного и гибкого поведения приложения.
Контейнер DI
Однако в крупномасштабных приложениях ручное использование DI может привести к появлению значительного количества шаблонного кода. Именно тогда на помощь приходит DI Container. DI Containers, или Dependency Injection Containers, — это мощные инструменты, используемые при разработке программного обеспечения для управления зависимостями в приложении. Они служат в качестве центрального места, где регистрируются и управляются объекты и их зависимости. Когда объекту требуется зависимость, DI-контейнер автоматически обрабатывает инстанцирование и предоставление этих зависимостей, гарантируя, что объекты получат все необходимые компоненты для эффективного функционирования. Такой подход способствует свободному соединению, улучшает тестируемость и общую сопровождаемость кодовой базы за счет абстрагирования сложной логики управления зависимостями от бизнес-логики приложения. DI-контейнеры упрощают процесс разработки, автоматизируя и централизуя конфигурацию зависимостей компонентов.
Для python существует множество библиотек, предоставляющих различные реализации DI Container, я просмотрел почти все из них и записал лучшие IMO
python-dependency-injector — автоматизирован, основан на классах, имеет различные варианты жизненного цикла, такие как Singleton или Factory
lagom — интерфейс словаря с автоматическим разрешением
dishka — хороший контроль области видимости через менеджер контекста
that-depends — поддержка контекстных менеджеров (объекты должны быть закрыты в конце), встроенная интеграция fastapi
punq — более классический подход с методами
register
иresolve
.rodi — классический, простой, автоматический
main.py
В завершение обновим файл main.py:
# main.py
from coordinator import Coordinator
def run(): # entry point, no logic here, only run the coordinator
coordinator = Coordinator()
coordinator.setup_initial_state()
if __name__ == '__main__':
run()
Заключение
Чтобы получить полное представление об обсуждаемых архитектурных и реализационных стратегиях, полезно просмотреть все файлы в репозитории. Несмотря на ограниченный объем кода, каждый файл снабжен содержательными комментариями и дополнительными деталями, которые позволяют глубже понять структуру и функциональность приложения. Изучение этих аспектов улучшит ваше знакомство с системой, гарантируя, что вы будете хорошо подготовлены к эффективной адаптации или расширению приложения.
Этот подход универсален для различных приложений на Python. Он эффективен для бэкенд-серверов без состояния, например, построенных с помощью FastAPI, но его преимущества особенно ярко проявляются в приложениях без фреймворка и приложениях, управляющих состоянием. Сюда относятся настольные приложения (как с графическим интерфейсом, так и с командной строкой), а также системы, управляющие физическими устройствами, например IoT-устройствами, робототехникой, дронами и другими технологиями, ориентированными на аппаратное обеспечение.
Кроме того, я рекомендую прочитать книгу Чистый код Роберта Мартина. Краткое содержание и основные выводы вы можете найти здесь.
Показанный подход проверен на практике и используется для основных программ хаба и колонки системы умного дома MajorDom, детали которой я периодически пишу в телеграм.