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

Немного теории чистого кода

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

  • В чем преимущества указания типов в python?

  • Каковы причины разделения приложения на слои?

  • Каковы преимущества использования ООП?

  • Каковы недостатки использования глобальных переменных или синглтонов?

Не стесняйтесь пропустить теоретические разделы, если вы уже знаете ответы, и переходите непосредственно к разделу «Создание программы».

Всегда указывайте типы

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

  1. Безопасность типов: Аннотации типов помогают выявить несоответствие типов на ранней стадии, что уменьшает количество ошибок и гарантирует, что ваш код будет вести себя так, как ожидается.

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

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

  4. Улучшенная поддержка инструментов: Такие инструменты, как mypy, используют аннотации типов для статической проверки типов, выявляя потенциальные ошибки до начала выполнения, тем самым упрощая процесс разработки и тестирования.

  5. Поддержка современных библиотек: FastAPI, Pydantic и другие библиотеки используют аннотации типов для автоматизации валидации данных, генерации документации и уменьшения дублирования кода.

  6. Преимущества типизированных классов данных перед простыми структурами данных: Типизированные классы данных улучшают читаемость, работу со структурированными данными и безопасность типов по сравнению с массивами и кортежами. Они используют атрибуты вместо строковых ключей, что минимизирует ошибки из-за опечаток и улучшает автодополнение кода. Датаклассы также обеспечивают четкое определение структур данных, поддерживают значения по умолчанию, упрощают сопровождение и отладку кода.

Почему нам нужно разделить приложение на слои

Разделение приложения на слои повышает ремонтопригодность, масштабируемость и гибкость. Ключевые причины для этой стратегии включают:

Разделение забот

  • Каждый слой фокусируется на определенном аспекте, что упрощает разработку, отладку и сопровождение.

Возможность повторного использования

  • Слои можно повторно использовать в различных частях приложения или в других проектах. Исключается дублирование кода.

Масштабируемость

  • Позволяет различным слоям масштабироваться независимо друг от друга в зависимости от потребностей.

Удобство обслуживания

  • Упрощает обслуживание за счет локализации общих функций в отдельных слоях.

Улучшенная совместная работа

  • Команды могут работать над разными слоями независимо друг от друга.

Гибкость и адаптируемость

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

Тестируемость

  • Каждый слой можно тестировать независимо, что упрощает модульное тестирование и отладку.

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

Глобальные константы против инжектируемых параметров

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

Глобальные константы

  1. Фиксированная конфигурация: Глобальные константы статичны и не могут динамически адаптироваться к различным средам или требованиям без изменения кодовой базы. Такая жесткость ограничивает их использование в различных сценариях работы.

  2. Ограниченный объем тестирования: Тестирование становится сложным при использовании глобальных констант, поскольку их нелегко переопределить. Разработчикам может потребоваться изменять глобальное состояние или использовать сложные обходные пути, чтобы приспособиться к различным сценариям тестирования, что повышает риск ошибок.

  3. Уменьшение модульности: Опора на глобальные константы снижает модульность, поскольку компоненты становятся зависимыми от конкретных значений, установленных глобально. Такая зависимость снижает возможность повторного использования компонентов в различных проектах или контекстах.

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

  5. Скрытые зависимости: Подобно глобальным переменным, глобальные константы скрывают зависимости внутри приложения. Становится неясно, какие части системы зависят от этих констант, что усложняет понимание и сопровождение кода.

  6. Трудности сопровождения и рефакторинга: Со временем использование глобальных констант может привести к проблемам с обслуживанием. Рефакторинг такой кодовой базы рискован, поскольку изменения констант могут случайно затронуть разные части приложения.

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

Инжектируемые параметры

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

  2. Улучшенная тестируемость: DI улучшает тестируемость, позволяя внедрять моки или альтернативные конфигурации во время тестирования, эффективно изолируя компоненты от внешних зависимостей и обеспечивая более надежные результаты тестирования.

  3. Увеличение модульности и возможности повторного использования: Компоненты становятся более модульными и пригодными для повторного использования, поскольку они спроектированы так, чтобы работать с любыми инжектируемыми параметрами, соответствующими ожидаемым интерфейсам. Такое разделение задач повышает переносимость компонентов в различные части приложения или даже в разные проекты.

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

  5. Явное декларирование зависимостей: В DI компоненты явно объявляют о своих зависимостях, обычно через параметры конструктора или сеттеры. Такая ясность облегчает понимание, поддержку и расширение системы.

  6. Масштабируемость и управление сложностью: По мере роста приложений 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 - библиотекари. Хранители доступа к данным, абстрагирующие сложности взаимодействия с бд.

      • models - определения локальных структур базы данных, не путать с сущностями entities.

    • 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!"}
  1. Импортируем вспомогательную функцию инъекции зависимостей (мы рассмотрим ее через минуту)

  2. Импортируем UserProvider protocol для аннотации типа

  3. Конечная точка требует, чтобы тело запроса содержало схему UserCreate в формате json

  4. Параметр provider в функции register представляет собой экземпляр реализации UserProvider, инжектируемый FastAPI с помощью механизма Depends.

  5. В метод 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)
  1. Определение интерфейса: UserProvider - это протокол, определяющий метод create_user, который должен реализовать любой класс, придерживающийся этого протокола. Он служит формальным контрактом для функциональности создания пользователя.

  2. Протокол наблюдателя: UserProviderOutput служит в качестве наблюдателя (или делегата), который получает уведомление о создании пользователя. Этот протокол обеспечивает свободное соединение и улучшает событийно-ориентированную архитектуру приложения.

  3. Реализация протокола: UserProviderImpl реализует логику создания пользователя, но ему не нужно явно декларировать свою приверженность UserProvider из-за динамической природы Python и использования утиной типизации.

  4. Основные зависимости: Конструктор принимает UserRepository и MailProvider - оба определены как протоколы - в качестве параметров. Полагаясь исключительно на эти протоколы, UserProviderImpl остается отделенным от конкретных реализаций, иллюстрируя принципы Dependency Injection, где провайдер не зависит от базовых деталей, взаимодействуя только через определенные контракты.

  5. Опциональный делегат вывода: Конструктор принимает необязательный экземпляр UserProviderOutput, который, если он предоставлен, будет уведомлен по завершении создания пользователя.

  6. Функция обратного вызова: В качестве альтернативы делегату вывода можно передать вызываемую функцию on_user_created для обработки дополнительных действий после создания пользователя, обеспечивая гибкость реакции на события.

  7. Центральная бизнес-логика: Метод create_user инкапсулирует основную бизнес-логику для добавления пользователя, демонстрируя отделение от обработки API.

  8. Взаимодействие с репозиторием: Использует UserRepository для абстрагирования операций с базой данных (например, добавление пользователя), гарантируя, что провайдер не будет напрямую манипулировать базой данных.

  9. Расширенная бизнес-логика: Вовлекает отправку электронной почты через MailProvider, иллюстрируя, что обязанности провайдера могут выходить за рамки простых CRUD-операций.

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

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

Зависимости 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
    )
  1. Получение сессии базы данных через систему инъекций зависимостей FastAPI, гарантируя, что каждый запрос имеет чистую сессию.

  2. Получение из состояния приложения экземпляра Coordinator, который отвечает за управление более широкими задачами на уровне приложения и выступает в качестве менеджера событий.

  3. Примечание: функция возвращает протокол, но не точную реализацию.

  4. Конструирование экземпляра UserProviderImpl путем инжекции всех необходимых зависимостей. Это демонстрирует практическое применение инъекции зависимостей для сборки сложных объектов.

  5. Инициализация UserRepository с сессией, полученной из DI-системы FastAPI. Этот репозиторий обрабатывает все операции по сохранению данных, абстрагируя взаимодействие с базой данных от провайдера.

  6. Настройка MailProvider с помощью конфигурационного токена.

  7. Инжектирование Coordinator в качестве выходного протокола. При этом предполагается, что Coordinator реализует протокол UserProviderOutput, что позволяет ему получать уведомления о создании пользователя.

  8. Назначает метод из 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
  1. Некоторые состояния могут быть общими для разных провайдеров, служб, слоев и всего приложения.

  2. Сборка реализаций и внедрение зависимостей

  3. Здесь следует помнить о круговых ссылках, тупиках и утечках памяти, подробности см. в полном коде.

  4. Передайте экземпляр координатора в состояние приложения FastAPI, чтобы вы могли обращаться к нему в конечных точках через DI-систему FastAPI.

  5. Запустить все сервисы в отдельных потоках

  6. Уже запускается в отдельном потоке внутри сервиса

  7. Некоторая кросс-сервисная логика, просто для примера

  8. Пример управления сервисами из координатора

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

Контейнер 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, детали которой я периодически пишу в телеграм.


Комментарии (13)


  1. Vasjen
    18.06.2024 17:51
    +12

    Вот почему вместо толкового гайда, особенно если у вас есть понимания и опыт (считай есть экспертиза), вываливать сгенерированный нейронкой контент, в котором практическая польза около нуля? Ну это так, риторический вопрос.

    Практический вопрос - зачем эти паттерны и архитектурные ухищрения нужны в принципе, и в пайтоне в частности? Какую проблему вы предлагаете решать с таким подходом? Я лишь вижу перегруженный код, который сложнее рефакторить и сопровождать в будущем, а если я был бы начинающим разработчиком, то я в принципе не понял зачем это все нужно?


    1. kompilainenn2
      18.06.2024 17:51
      +5

      Хорошо, что есть комментарии, их можно сразу прочитать и понять, стоит ли статья того, чтобы тратить на нее время. Спасибо вам


    1. SUNsung
      18.06.2024 17:51
      +2

      Что бы была публикация на хабре вестимо!

      Это как с научными работами - важен не результат, а процесс.


    1. Tim_86
      18.06.2024 17:51

      Почему нейронкой? В упор не вижу признаков ИИ в тексте. Статья хорошая и практичная, сохранил в закладки.

      зачем эти паттерны и архитектурные ухищрения нужны в принципе, и в пайтоне в частности?

      На этот вопрос ответ в статье даётся, и довольно подробный.


      1. Karington
        18.06.2024 17:51
        +2

        В статье ответ на вопрос "Зачем это нужно?" такой:

        "Посмотрите как хорошо, вы можете подменить коннекшен базы mssql, на коннекшен postgres"

        А в реальных проектах зачем вам менять коннекшен ms на pg? У вас две одинаковые базы и вы каждый день подкидываете монетку и определяете из какой базы вы потащите данные? У них же даже диалекты разные, толку то от смены типа коннекшена, если прямой sql запрос выполнится либо в одной, либо в другой системе, а в другой упадёт с синтаксической ошибкой?


        1. Kreastr
          18.06.2024 17:51

          Там SQLAlchemy наверное это все абстрагирует. Но в целом вопрос справедлив зачем нужна такая модульность. Для небольшого проекта это оверинжиниринг КМК. Но вот если вы пишете продукт, который потом еще где-то интегрируется а то и не в одном месте и с конфликиующими условиями, то модульность бд может быть реальным юзкейсом. В любом случае это не уровень "все новички пишите так".


      1. Vasjen
        18.06.2024 17:51
        +1

        Почему нейронкой? В упор не вижу признаков ИИ в тексте.

        Да, я слышал, что нейронки могут уже успешно проходить тест Тьюринга.

        Спросил у GPT4o два вопроса, сами сравните результаты.

        В чем преимущества указания типов в python?

        Указание типов в Python имеет несколько преимуществ, которые могут значительно улучшить качество и поддерживаемость кода. Вот основные из них:

        1. Улучшение читаемости и документации:

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

          • Это особенно полезно в больших проектах или при работе в команде, где код читают и используют другие разработчики.

        2. Раннее обнаружение ошибок:

          • Статические анализаторы кода, такие как mypy, могут использовать аннотации типов для проверки соответствия типов во время разработки. Это позволяет обнаружить ошибки, связанные с типами, до выполнения кода.

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

        3. Улучшение автодополнения и рефакторинга:

          • Современные IDE и текстовые редакторы, такие как PyCharm, VSCode и другие, могут использовать аннотации типов для улучшения автодополнения и навигации по коду.

          • Это делает процесс написания кода более удобным и быстрым, а также помогает избежать ошибок при рефакторинге.

        4. Повышение производительности:

          • Хотя аннотации типов сами по себе не влияют на производительность выполнения кода, они могут помочь оптимизировать код. Например, компиляторы и интерпретаторы могут использовать информацию о типах для оптимизации выполнения.

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

        5. Совместимость с другими инструментами:

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

        Почему нам нужно разделить приложение на слои

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

        1. Модульность и повторное использование:

          • Разделение на слои позволяет изолировать различные части приложения, что делает их более независимыми и модульными.

          • Это упрощает повторное использование кода, так как каждый слой может быть использован в других проектах или компонентах.

        2. Упрощение тестирования:

          • Слои позволяют легче тестировать отдельные компоненты приложения. Например, можно тестировать бизнес-логику независимо от пользовательского интерфейса или базы данных.

          • Это способствует более эффективному написанию модульных тестов и интеграционных тестов.

        3. Улучшение поддержки и расширяемости:

          • Четкое разделение на слои упрощает понимание и поддержку кода. Разработчики могут легко вносить изменения в один слой, не затрагивая другие.

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

        4. Разделение ответственности:

          • Каждый слой отвечает за свою конкретную задачу. Например, слой представления отвечает за отображение данных пользователю, слой бизнес-логики — за обработку данных и принятие решений, а слой данных — за взаимодействие с базой данных.

          • Это способствует более четкому и логичному распределению обязанностей между различными частями приложения.

        5. Масштабируемость и производительность:

          • Разделение на слои позволяет легче масштабировать приложение. Например, можно отдельно масштабировать слой данных, если он становится узким местом.

          • Это также помогает оптимизировать производительность, так как можно фокусироваться на оптимизации конкретных слоев.

        6. Безопасность:

          • Слои могут помочь улучшить безопасность приложения, так как можно изолировать критически важные компоненты и ограничить доступ к ним.

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

        7. Совместная работа и управление проектом:

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

          • Это также помогает в управлении зависимостями и интеграцией различных частей приложения.

        Пример типичной многослойной архитектуры:

        1. Слой представления (Presentation Layer):

          • Отвечает за отображение данных пользователю и взаимодействие с ним.

          • Примеры: веб-интерфейсы, мобильные приложения, консольные интерфейсы.

        2. Слой бизнес-логики (Business Logic Layer):

          • Содержит основную логику приложения, правила и алгоритмы.

          • Примеры: обработка данных, принятие решений, выполнение бизнес-правил.

        3. Слой доступа к данным (Data Access Layer):

          • Отвечает за взаимодействие с базой данных или другими источниками данных.

          • Примеры: ORM (Object-Relational Mapping), репозитории, API для работы с базой данных.

        4. Слой данных (Data Layer):

          • Содержит сами данные и механизмы их хранения.

          • Примеры: базы данных, файловые системы, внешние API.

        Такое разделение помогает создать более структурированное и управляемое приложение, что в конечном итоге приводит к более качественному и надежному программному обеспечению.

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


        1. Tim_86
          18.06.2024 17:51

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


    1. Kreastr
      18.06.2024 17:51

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


      1. user87644
        18.06.2024 17:51

        А понимание зарабатывается только шишками набитыми на собственных плохо написанных программах.

        Тут беда в том, что очень многим вообще плевать, как написана программа, если она работает, и лично автор сможет там что-то починить. Так можно говнокодить годами, наплевав на архитектуру


        1. Kreastr
          18.06.2024 17:51
          +1

          Рискуя нарваться на еще большее количество минусов без аргументации хочу спросить: а не должно быть наплевать? Если софт удовлетворяет всем поставленным требованиям, то что еще надо? Уточню сразу, что пригодность к расширееию, тестируемость и документация это все требования к софту которые чаще всего есть, но которых может и не быть в силу специфики конкретной задачи.


          1. user87644
            18.06.2024 17:51

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

            С этим не спорю, моя мысль в том, что понимание не всегда(никогда не) сваливается из неоткуда в процессе кодинга, а берется из других источников, например, книг.


            1. Kreastr
              18.06.2024 17:51

              Но ведь книги тоже кто-то написал? Я бы не стал отказывать программистам в способности самостоятельно без книг прийти к правильным выводам. Кроме того мне кажется книги не помогут тем, кто в первую очередь не осознает наличие проблемы, решение которой в книге изложено.