Немного теории
Прежде чем погрузиться в архитектуру, я хотел бы ответить на несколько часто задаваемых вопросов:
- В чем преимущества указания типов в python?
- Каковы причины разделения приложения на слои?
- Каковы преимущества использования ООП?
- Каковы недостатки использования глобальных переменных или синглтонов?
Не стесняйтесь пропустить теоретические разделы, если вы уже знаете ответы, и переходите непосредственно к разделу «Создание программы».
Всегда указывайте типы
Аннотация типов значительно улучшает код, повышая его ясность, надежность и ремонтопригодность:
- Безопасность типов: Аннотации типов помогают выявить несоответствие типов на ранней стадии, что уменьшает количество ошибок и гарантирует, что ваш код будет вести себя так, как ожидается.
- Самодокументированный код: Подсказки типов повышают удобочитаемость кода и выступают в роли встроенной документации, уточняя ожидаемые типы входных и выходных данных функций.
- Повышение качества кода: Использование подсказок типов способствует улучшению дизайна и архитектуры, способствуя продуманному планированию и реализации структур данных и интерфейсов.
- Улучшенная поддержка инструментов: Такие инструменты, как mypy, используют аннотации типов для статической проверки типов, выявляя потенциальные ошибки до начала выполнения, тем самым упрощая процесс разработки и тестирования.
- Поддержка современных библиотек: FastAPI, Pydantic и другие библиотеки используют аннотации типов для автоматизации валидации данных, генерации документации и уменьшения дублирования кода.
- Преимущества типизированных классов данных перед простыми структурами данных: Типизированные классы данных улучшают читаемость, работу со структурированными данными и безопасность типов по сравнению с массивами и кортежами. Они используют атрибуты вместо строковых ключей, что минимизирует ошибки из-за опечаток и улучшает автодополнение кода. Датаклассы также обеспечивают четкое определение структур данных, поддерживают значения по умолчанию, упрощают сопровождение и отладку кода.
Почему нам нужно разделить приложение на слои
Разделение приложения на слои повышает ремонтопригодность, масштабируемость и гибкость. Ключевые причины для этой стратегии включают:
Разделение забот
- Каждый слой фокусируется на определенном аспекте, что упрощает разработку, отладку и сопровождение.
Возможность повторного использования
- Слои можно повторно использовать в различных частях приложения или в других проектах. Исключается дублирование кода.
Масштабируемость
- Позволяет различным слоям масштабироваться независимо друг от друга в зависимости от потребностей.
Удобство обслуживания
- Упрощает обслуживание за счет локализации общих функций в отдельных слоях.
Улучшенная совместная работа
- Команды могут работать над разными слоями независимо друг от друга.
Гибкость и адаптируемость
- Изменения в технологиях или дизайне могут быть реализованы в определенных слоях. В адаптации нуждаются только затронутые слои, остальные остаются незатронутыми.
Тестируемость
- Каждый слой можно тестировать независимо, что упрощает модульное тестирование и отладку.
Использование многоуровневой архитектуры дает значительные преимущества в скорости разработки, оперативном управлении и долгосрочном обслуживании, делая системы более надежными, управляемыми и адаптируемыми к изменениям.
Глобальные константы против инжектируемых параметров
При разработке программного обеспечения выбор между использованием глобальных констант и применением инъекции зависимостей (DI) может существенно повлиять на гибкость, сопровождаемость и масштабируемость приложений. В этом анализе рассматриваются недостатки глобальных констант и противопоставляются им преимущества, обеспечиваемые инъекцией зависимостей.
Глобальные константы
- Фиксированная конфигурация: Глобальные константы статичны и не могут динамически адаптироваться к различным средам или требованиям без изменения кодовой базы. Такая жесткость ограничивает их использование в различных сценариях работы.
- Ограниченный объем тестирования: Тестирование становится сложным при использовании глобальных констант, поскольку их нелегко переопределить. Разработчикам может потребоваться изменять глобальное состояние или использовать сложные обходные пути, чтобы приспособиться к различным сценариям тестирования, что повышает риск ошибок.
- Уменьшение модульности: Опора на глобальные константы снижает модульность, поскольку компоненты становятся зависимыми от конкретных значений, установленных глобально. Такая зависимость снижает возможность повторного использования компонентов в различных проектах или контекстах.
- Высокая связанность: Глобальные константы интегрируют специфическое поведение и конфигурации непосредственно в кодовую базу, что затрудняет адаптацию или развитие приложения без значительных изменений.
- Скрытые зависимости: Подобно глобальным переменным, глобальные константы скрывают зависимости внутри приложения. Становится неясно, какие части системы зависят от этих констант, что усложняет понимание и сопровождение кода.
- Трудности сопровождения и рефакторинга: Со временем использование глобальных констант может привести к проблемам с обслуживанием. Рефакторинг такой кодовой базы рискован, поскольку изменения констант могут случайно затронуть разные части приложения.
- Дублирование состояния на уровне модуля: В Python код на уровне модуля может выполняться несколько раз, если импорт происходит по разным путям (например, абсолютный и относительный). Это может привести к дублированию глобальных экземпляров и трудноотслеживаемым ошибкам в обслуживании.
Инжектируемые параметры
- Динамическая гибкость и настраиваемость: Инъекция зависимостей позволяет динамически настраивать компоненты, делая приложения адаптируемыми к изменяющимся условиям без необходимости изменения кода.
- Улучшенная тестируемость: DI улучшает тестируемость, позволяя внедрять моки или альтернативные конфигурации во время тестирования, эффективно изолируя компоненты от внешних зависимостей и обеспечивая более надежные результаты тестирования.
- Увеличение модульности и возможности повторного использования: Компоненты становятся более модульными и пригодными для повторного использования, поскольку они спроектированы так, чтобы работать с любыми инжектируемыми параметрами, соответствующими ожидаемым интерфейсам. Такое разделение задач повышает переносимость компонентов в различные части приложения или даже в разные проекты.
- Низкая связанность: Инжектируемые параметры способствуют низкой связанности, отделяя логику системы от ее конфигурации. Такой подход облегчает обновление и внесение изменений в приложение.
- Явное декларирование зависимостей: В DI компоненты явно объявляют о своих зависимостях, обычно через параметры конструктора или сеттеры. Такая ясность облегчает понимание, поддержку и расширение системы.
- Масштабируемость и управление сложностью: По мере роста приложений DI помогает управлять сложностью, локализуя проблемы и отделяя конфигурацию от использования, что способствует эффективному масштабированию и обслуживанию больших систем.
Процедурное программирование против ООП
Использование объектно-ориентированного программирования (ООП) и инъекции зависимостей (DI) может значительно повысить качество и сопровождаемость кода по сравнению с процедурным подходом с глобальными переменными и функциями. Вот простое сравнение, демонстрирующее эти преимущества:
Процедурный подход: Глобальные переменные и функции
- Дублирование кода:
database_config
должен передаваться или обращаться глобально в нескольких функциях. - Трудности тестирования: Имитация подключения к базе данных или конфигурации предполагает манипулирование глобальным состоянием, что чревато ошибками.
- Высокая связанность: Функции напрямую зависят от глобального состояния и конкретных реализаций.
ООП + DI-подход
- Уменьшено дублирование кода: Конфигурация базы данных инкапсулируется в объекте подключения.
- Возможности DI: Легко заменить
MySQLConnection
на другой класс подключения к базе данных, напримерPostgresConnection
, не изменяя кодUserService
. - Энкапсуляция и абстракция: Детали реализации того, как извлекаются пользователи или как подключается база данных, скрыты от глаз.
- Удобство моков и тестирования:
UserService
можно легко протестировать, внедрив заглушкуDatabaseConnection
. - Управление временем жизни объекта: Жизненным циклом соединений с базой данных можно управлять более детально (например, с помощью менеджеров контекста).
- Использование принципов ООП: Демонстрирует наследование (абстрактный базовый класс), полиморфизм (реализация абстрактных методов) и протоколы (интерфейсы, определенные
DatabaseConnection
).
Благодаря структурированию приложения с использованием ООП и DI, код становится более модульным, его легче тестировать, и он становится гибким к изменениям, таким как замена зависимостей или изменение конфигурации.
Создание программы
Все примеры и более подробную информацию с комментариями вы можете найти в репозитории
Начало нового проекта
Небольшой чек-лист:
1. Управление проектами и зависимостями с помощью Poetry
Эта команда создаст минимальную структуру директории: отдельные папки для приложения и тестов, файл метаинформации проекта pyproject.toml
, лок файлы зависимостей и конфигурации гита.
2. Контроль версий с помощью Git
Инициализируйте гит:
Добавьте файл .gitignore
для исключения ненужных файлов из вашего репозитория. Используйте стандартный .gitignore
, предоставленный GitHub, и добавьте остальные исключения, такие как .DS_Store
для macOS и папки редакторов (.idea
, .vscode
, .zed
, etc):
3. Управление зависимостями
Установите зависимости вашего проекта с помощью poetry:
Вы можете установить все зависимости позже, используя:
Обратитесь к официальной документации каждой библиотеки, если вам нужны более конкретные инструкции.
4. Файлы конфигурации
Создайте файл config.py
для централизации настроек приложения – это распространенный и эффективный подход.
Установите переменные окружения для секретов и настроек:
.env
содержит конфиденциальные данные и должен быть git-ignored, в то время как example.env
содержит placeholder или значения по умолчанию и хранится в репозитории.
5. Точка входа приложения
Определите точку входа вашего приложения в main.py
:
python_app_architecture/main.py:
Сделайте свой проект пригодным для использования в качестве библиотеки и разрешите программный доступ, импортировав функцию run
в __init__.py
:
python_app_architecture/init.py
Включите прямое выполнение проекта с помощью Poetry, добавив ярлык в __main__.py
. Это позволит вам использовать команду poetry run python python_app_architecture
вместо более длинной poetry run python python_app_architecture/main.py
.
python_app_architecture/main.py:
Определение каталогов и слоев
Дисклеймер:Конечно, все приложения разные, и их архитектура будет отличаться в зависимости от целей и задач. Я не говорю, что это единственно правильный вариант, но мне кажется, что он достаточно средний и подходит для значительной части проектов. Постарайтесь сосредоточиться на основных подходах и идеях, а не на конкретных примерах.
Теперь давайте настроим директории для различных слоев приложения.
Как правило, имеет смысл версионировать API (например, создавая подкаталоги типа api/v1
), но мы пока будем действовать проще и опустим этот шаг.
- appentities – структуры данных всего приложения. Чисто носители данных без логики.general - чемодан с инструментами. Папка для общих утилит, помощников и оберток библиотек.mappers - специалисты по преобразованию данных, таких как модели баз данных в сущности, или между различными форматами данных. Хорошей практикой является инкапсуляция мапперов в границах их использования, вместо того, чтобы держать их глобальными. Например, маппер models-entities может быть частью модуля репозитория. Другой пример: маппер schemas-entities должен оставаться внутри сервиса апи и быть его приватным инструментом.providers - основа бизнес-логики. Провайдеры реализуют основную логику приложения, но остаются независимыми от деталей интерфейса, обеспечивая абстрактность и изолированность своих операций.repositores - библиотекари. Хранители доступа к данным, абстрагирующие сложности взаимодействия с бд.models - определения локальных структур базы данных, не путать с сущностями entities.services - каждый сервис действует как (почти) автономное подприложение, организуя свою специфическую область бизнес-логики и делегируя основные задачи провайдерам. Такая конфигурация обеспечивает централизованную и единообразную логику всего приложенияapi_service - управляет внешними коммуникациями по http/s, структурированными вокруг фреймворка FastAPI.dependencies - основные инструменты и помощники, необходимые для различных частей вашего API, интегрированные с помощью системы DI FastAPIendpoints - конечные точки http интерфейсаschemas - определения структур данных для запросов и ответов апиtelegram_service - работает аналогично сервису апи, предоставляя тот же функционал в другом интерфейсе, но без дублирования кода бизнес-логики за счет вызова тех же провайдеров, чир использует апи сервис.
- entities – структуры данных всего приложения. Чисто носители данных без логики.
- general – чемодан с инструментами. Папка для общих утилит, помощников и оберток библиотек.
- mappers – специалисты по преобразованию данных, таких как модели баз данных в сущности, или между различными форматами данных. Хорошей практикой является инкапсуляция мапперов в границах их использования, вместо того, чтобы держать их глобальными. Например, маппер models-entities может быть частью модуля репозитория. Другой пример: маппер schemas-entities должен оставаться внутри сервиса апи и быть его приватным инструментом.
- providers – основа бизнес-логики. Провайдеры реализуют основную логику приложения, но остаются независимыми от деталей интерфейса, обеспечивая абстрактность и изолированность своих операций.
- repositores – библиотекари. Хранители доступа к данным, абстрагирующие сложности взаимодействия с бд.models - определения локальных структур базы данных, не путать с сущностями entities.
- models – определения локальных структур базы данных, не путать с сущностями entities.
- services – каждый сервис действует как (почти) автономное подприложение, организуя свою специфическую область бизнес-логики и делегируя основные задачи провайдерам. Такая конфигурация обеспечивает централизованную и единообразную логику всего приложенияapi_service - управляет внешними коммуникациями по http/s, структурированными вокруг фреймворка FastAPI.dependencies - основные инструменты и помощники, необходимые для различных частей вашего API, интегрированные с помощью системы DI FastAPIendpoints - конечные точки http интерфейсаschemas - определения структур данных для запросов и ответов апиtelegram_service - работает аналогично сервису апи, предоставляя тот же функционал в другом интерфейсе, но без дублирования кода бизнес-логики за счет вызова тех же провайдеров, чир использует апи сервис.
- api_service – управляет внешними коммуникациями по http/s, структурированными вокруг фреймворка FastAPI.dependencies - основные инструменты и помощники, необходимые для различных частей вашего API, интегрированные с помощью системы DI FastAPIendpoints - конечные точки http интерфейсаschemas - определения структур данных для запросов и ответов апи
- dependencies – основные инструменты и помощники, необходимые для различных частей вашего API, интегрированные с помощью системы DI FastAPI
- endpoints – конечные точки http интерфейса
- schemas – определения структур данных для запросов и ответов апи
- telegram_service – работает аналогично сервису апи, предоставляя тот же функционал в другом интерфейсе, но без дублирования кода бизнес-логики за счет вызова тех же провайдеров, чир использует апи сервис.
- tests – директория предназначена исключительно для тестирования и содержит весь тестовый код, сохраняя четкое разделение с логикой приложения.
Связь между слоями будет выглядеть примерно так:
Обратите внимание, что entities - не активные компоненты, а лишь структуры данных, которые передаются между слоями:
Помните, что слои не связаны напрямую, а зависят только от абстракций. Реализации передаются с помощью инъекции зависимостей:
Такая гибкая структура позволяет легко добавить функциональность, например, изменить базу данных, создать сервис или подключить новый интерфейс без лишних изменений и дублирования кода, так как логика каждого модуля находится на своем слое:
В то же время вся логика отдельного сервиса инкапсулируется внутри него:
Изучение кода
Эндпоинт
Начнем с конечной точки:
- Импортируем вспомогательную функцию инъекции зависимостей (мы рассмотрим ее через минуту)
- Импортируем UserProvider protocol для аннотации типа
- Конечная точка требует, чтобы тело запроса содержало схему
UserCreate
в формате json - Параметр
provider
в функцииregister
представляет собой экземпляр реализацииUserProvider
, инжектируемый FastAPI с помощью механизмаDepends
. - В метод
create_user
функцииUserProvider
передаются распарсенные данные пользователя. Это демонстрирует четкое разделение проблем, когда уровень API делегирует бизнес-логику уровню провайдера, придерживаясь принципа, что интерфейсные уровни не должны содержать бизнес-логику.
UserProvider
Теперь давайте посмотрим на бизнес-логику:
- Определение интерфейса:
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:
- Получение сессии базы данных через систему инъекций зависимостей FastAPI, гарантируя, что каждый запрос имеет чистую сессию.
- Получение из состояния приложения экземпляра
Coordinator
, который отвечает за управление более широкими задачами на уровне приложения и выступает в качестве менеджера событий. - Примечание: функция возвращает протокол, но не точную реализацию.
- Конструирование экземпляра
UserProviderImpl
путем инжекции всех необходимых зависимостей. Это демонстрирует практическое применение инъекции зависимостей для сборки сложных объектов. - Инициализация
UserRepository
с сессией, полученной из DI-системы FastAPI. Этот репозиторий обрабатывает все операции по сохранению данных, абстрагируя взаимодействие с базой данных от провайдера. - Настройка
MailProvider
с помощью конфигурационного токена. - Инжектирование
Coordinator
в качестве выходного протокола. При этом предполагается, чтоCoordinator
реализует протоколUserProviderOutput
, что позволяет ему получать уведомления о создании пользователя. - Назначает метод из
Coordinator
в качестве обратного вызова, который будет выполняться при создании пользователя. Это позволяет запускать дополнительные операции или уведомления в качестве побочного эффекта процесса создания пользователя.
Такой структурированный подход гарантирует, что UserProvider
оснащен всеми необходимыми инструментами для эффективного выполнения своих задач, придерживаясь при этом принципов свободной связи и высокой связности.
Координатор
Класс Coordinator выступает в роли главного оркестратора в вашем приложении, управляя различными сервисами, взаимодействиями, событиями, устанавливая начальное состояние и внедряя зависимости. Вот подробное описание его ролей и функциональных возможностей на основе предоставленного кода:
- Некоторые состояния могут быть общими для разных провайдеров, служб, слоев и всего приложения.
- Сборка реализаций и внедрение зависимостей
- Здесь следует помнить о круговых ссылках, тупиках и утечках памяти, подробности см. в полном коде.
- Передайте экземпляр координатора в состояние приложения 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:
Заключение
Чтобы получить полное представление об обсуждаемых архитектурных и реализационных стратегиях, полезно просмотреть все файлы в репозитории. Несмотря на ограниченный объем кода, каждый файл снабжен содержательными комментариями и дополнительными деталями, которые позволяют глубже понять структуру и функциональность приложения. Изучение этих аспектов улучшит ваше знакомство с системой, гарантируя, что вы будете хорошо подготовлены к эффективной адаптации или расширению приложения.
Этот подход универсален для различных приложений на Python. Он эффективен для бэкенд-серверов без состояния, например, построенных с помощью FastAPI, но его преимущества особенно ярко проявляются в приложениях без фреймворка и приложениях, управляющих состоянием. Сюда относятся настольные приложения (как с графическим интерфейсом, так и с командной строкой), а также системы, управляющие физическими устройствами, например IoT-устройствами, робототехникой, дронами и другими технологиями, ориентированными на аппаратное обеспечение.
Кроме того, я настоятельно рекомендую прочитать книгу Чистый код Роберта Мартина для дальнейшего обогащения. Краткое содержание и основные выводы вы можете найти здесь. Этот ресурс предоставит вам основополагающие принципы и практики, которые крайне важны для поддержания высоких стандартов в разработке программного обеспечения.
Автор: Марк Паркер
Телеграм-канал: t.me/parker_is_typing
Комментарии