Этот факт меняет правила игры

Принцип инверсии зависимостей в веб-разработке Python

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

В архитектуре программного обеспечения некоторые принципы, называемые принципами проектирования SOLID, были выдвинуты Робертом К. Мартином. Это одни из самых известных принципов проектирования в объектно-ориентированном программном обеспечении. SOLID — это мнемоническая аббревиатура следующих пяти принципов:

  • Принцип единой ответственности
  • Открытый/Закрытый Принцип
  • Принцип замены Лисков
  • Принцип разделения интерфейса
  • Принцип инверсии зависимости

Последнее нас и интересует в этой статье. При проектировании программной системы самым важным является предметная область. Домен — это красивое слово для проблемы, которую вы пытаетесь решить. Правила домена — это правила реального мира. Они не связаны ни с каким веб-сервером, веб-фреймворком или базой данных. Если вы не знакомы с этими понятиями, ознакомьтесь с этой статьей Роберта С. Мартина.

Давайте на секунду задумаемся о том, как мы обычно разрабатываем веб-проект. Первое, что мы чувствуем себя обязанными сделать, — это выбрать веб-фреймворк, веб-сервер и базу данных. Затем переводим домен в код с уже принятыми этими решениями. Теперь мы ограничены в своих решениях всякий раз, когда нам приходится думать о бизнес-требованиях. Если вы часто ищете, как это сделать в Django, как добиться этого в Postgres и т. д., это означает, что вы, вероятно, приняли решение слишком рано.

ИМХО, сначала выбрать веб-фреймворк или базу данных - это то же самое, что спроектировать автомобиль и заняться интерьером, если он будет из ткани или кожи, вместо того, чтобы сначала заниматься наиболее важными частями, такими как двигатель или дизайн. Хорошая программная архитектура максимизирует количество непринятых решений.

Ладно, повременим с решением по БД, но как? Когда вы устанавливаете веб-фреймворк в стиле батарей, для запуска веб-сервера требуется база данных. Иногда он также настраивает для вас что-то вроде SQLite. Здесь вы должны знать о некоторых понятиях, таких как модули высокого уровня, модули низкого уровня и абстракции.

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

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

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

Ваш домен не должен зависеть от того, хранятся ли ваши данные в базе данных SQL или NoSQL. Это также не должно зависеть от того, что данные хранятся в базе данных, а не в FileSystem. Домен заинтересован только в том, чтобы эти данные находились в постоянном хранилище и были доступны извне, когда это необходимо.

Модули более высокого уровня не должны зависеть от модулей более низкого уровня. Оба должны зависеть от абстракций.

База данных — это просто устройство ввода-вывода. Он предоставляет некоторые полезные инструменты для сортировки, запросов и отчетов, но они являются вспомогательными по отношению к архитектуре системы. Роберт С. Мартин

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

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

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

Давайте посмотрим на код без внедрения зависимостей:

Проверьте приведенный выше простой код, поскольку он отправляет HTTP-запрос на удаленный сервер для создания нового задания, предоставляя file_url, где сохраняются все данные. Если бы мы применяли здесь принцип инверсии зависимостей, наша функция не должна была бы знать, что файл загружен на S3. В конце концов, единственная часть, которая интересна для нашей функции, это тот факт, что файл загружается куда-то до того, как мы делаем запрос, и у нас есть file_url обратно.

Применим здесь принцип инверсии зависимостей.

def create_new_job(data, blob_storage_client: BlobStorageClient):
    file_url = blob_storage_client.save_data(data)
    return requests.post(
        url='some_url',
        json={'file_url': file_url}
    )

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

class BlobStorageClient(ABC):
    @abstractmethod
        def save_data(self, data) -> str:
            raise NotImplementedError()

Просто это абстрактный класс в Python с абстрактным методом с именем save_data. Теперь наш S3Client будет реализовывать этот интерфейс.

Мы внедрили BlobStorageClient для сервиса S3. Следует иметь в виду, что внутри реализации интерфейса нормально иметь код, зависящий от третьих сторон, таких как boto3.

Теперь, когда вы запускаете функцию create_new_job, вы создаете s3client и предоставляете его в качестве параметра функции.

s3client = S3Client()
create_new_job(data='some-data', blob_storage_client=s3client)

Тестирование

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

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

Мы можем реализовать еще один BlobStorageClient следующим образом:

class TestBlobStorageClient(BlobStorageClient):
    is_called = False
    def save_data(self, data):
        self.is_called = True

Пока мы тестируем, вместо внедрения клиента s3 мы можем внедрить этот тестовый клиент:

def test_create_new_job():
    test_client = TestBlobStorageClient()
    create_new_job('some-data', test_client)
    
    assert test_client.is_called

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

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

Шаблон репозитория

Репозитории — это классы или компоненты, которые инкапсулируют логику, необходимую для доступа к источникам данных. Проще говоря, репозитории отвечают за связь с БД или любым другим решением для постоянного хранения, которое у вас есть. Вместо прямого взаимодействия с базой данных вы абстрагируете ее через репозитории шаблонов.

Допустим, вы часто чувствуете необходимость получить какого-либо пользователя по имени пользователя. Теперь первое, что вы собираетесь сделать, это, вероятно, установить и настроить ORM и запросить пользователя. Пример с SQLAlchemy:

def get_user_by_username(username: str) -> User | None:
    stmt = select(User).where(User.username == username)
    user = session.execute(stmt).scalars().first()
    
    return user

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

При классическом подходе или подходе, основанном на фреймворке, вы часто будете в ситуации, похожей на изображение до. В этом случае вы получаете доступ к БД напрямую из своего домена (для доменно-ориентированного дизайна читайте этот раздел).

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

Чтобы избежать этого, в программной инженерии есть простое правило. Большинство проблем в программной инженерии можно решить, добавив еще один уровень абстракции.

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

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

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

Хорошо, как добиться этой косвенности? Примените шаблон репозитория.

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

class UserRepo:
    def __init__(self, session):
        self.session = session

Мы создали класс, который принимает сеанс SQLAlchemy внутри конструктора. Теперь мы будем использовать этот сеанс для доступа к БД.

class UserRepo:
    def __init__(self, session):
        self.session = session
    def get_by_username(self, username: str) -> User | None:
        return self.session.query(User).filter_by(
            username=username).first()

Мы добавили метод get_by_username. Довольно просто. Теперь, когда мне нужно получить пользователя по имени пользователя, мне нужно импортировать этот репозиторий, создать экземпляр, указав сеанс и использовать этот метод.

repo = UserRepo(session)
user = repo.get_by_username('klement')

Теперь головоломку завершит модель предметной области. Вы можете заметить, что метод get_by_username возвращает экземпляр User или None. Теперь эта модель предметной области является просто представлением этой сущности в нашей конкретной проблеме. Давайте посмотрим на несколько примеров того, как разные фреймворки определяют модель предметной области.

В SQLAlchemy модель предметной области может быть написана следующим образом, если вы будете следовать обычному руководству:

class User(Base):
    id = Column(UUID(as_uuid=True), primary_key=True)
    username = Column(String(50), unique=True, nullable=False)
    first_name = Column(String(150))
    last_name = Column(String(150))
    email = Column(String(255))
    is_active = Column(Boolean, nullable=False)
    date_joined = Column(DateTime, nullable=False)
    last_login = Column(DateTime)

Эта доменная модель полна зависимостей ORM; вам даже не нужно знать о SQLAlchemy, чтобы понять это.

Вот как это выглядит, если мы проверим пример из Django:

Тот еще хуже. В Django модели также используются из шаблонов, и внутри Fields для шаблонов есть некоторые конфигурации, такие как пустая опция или help_text и т. д. Модель предметной области, определяющая вспомогательный текст при вводе, — это слишком много. За это должен нести ответственность уровень представления, а не домен.

Примените принцип инверсии зависимостей

Если мы применим DIP с помощью SQLAlchemy, это будет результат:

Таким образом, ORM зависит от нашей модели предметной области, которая представляет собой чистый класс Python без каких-либо внешних зависимостей. Когда мы вызываем start_mappers, SQLAlchemy прикрепляет некоторые частные атрибуты к классу User, например __table__, поэтому, когда вы используете его внутри запроса с сеансом SQLAlchemy, он знает, что этот класс представляет пользовательскую таблицу в базе данных.

Вся бизнес-логика, связанная с сущностью User, будет находиться внутри класса User. Бизнес-логика, которая будет использовать более одной сущности, будет находиться внутри Services или вариантов использования в зависимости от того, что вы решите использовать. Важно то, что теперь мы инвертировали зависимость. База данных теперь зависит от наших моделей предметной области, а не наоборот.

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

Если вы окажетесь в положении, когда ваша бизнес-группа говорит вам внести существенные изменения в ваши модели, и вы обнаружите, что ограничены базой данных, вы, вероятно, допустили ошибку в своей архитектуре. Домен должен быть написан на чистом Python, Java, C# или любом другом языке, который вы предпочитаете, и он должен быть тем, от чего все зависит, а не наоборот. Вы должны как можно лучше отразить реальную проблему в коде, не беспокоясь о внешних зависимостях.

«Хорошая программная архитектура максимизирует количество непринятых решений». — Роберт С. Мартин

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

Рекомендации

Подробнее о принципах SOLID: Блог Clean Coder от Robert C. Martin

Паттерны и техники, применяемые в Python: Космический Python

Отличная статья Microsoft о шаблоне Repository