Как стать автором
Обновить

[Python Intermediate] Урок 1. Конфигурация приложения

Время на прочтение9 мин
Количество просмотров30K

Алоха!

Задуманная мною серия статей-уроков будет полезна прежде всего тем, кто уже знает основы Python, но находится в начале пути и не может структурировать обрывки знаний. Если ты уже отучился на одном из бесчисленных курсов или близок к его завершению, то это для тебя! Автору знакомы твои сомнения и терзания, поскольку ему довелось побывать по обе стороны баррикад — и в качестве студента такого курса, и в качестве преподавателя много лет спустя.

Впервые устроившись на работу python-разработчиком, я вдруг обнаружил, что совершенно не понимаю, как мне подступиться к кодовой базе, которую необходимо было дорабатывать и поддерживать. «Как оно вообще запускается?» — атаковал меня панический вопрос. Знания, полученные на онлайн-курсе, оказались, к сожалению, почти бесполезны. По крайней мере, на начальном этапе. Именно поэтому в рамках первой статьи предлагаю поговорить о двух вещах: распространённой ныне бизнес-задаче и конфигурации приложения.

Описание задачи

Представим проблему, с которой современному разработчику приходится сталкиваться всё чаще. Пусть имеется некая старинная система, которая перестала устраивать владельца, из-за чего тот решает нанять или самостоятельно собрать IT-команду для создания более совершенного продукта. С заделом на будущее, разумеется.

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

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

Итак, поехали!

Так выглядит система изначально
Так выглядит система изначально

Как система работает сейчас:

  1. Фронтенд (или приложение на смартфоне) передаёт запрос на бэкенд.

  2. Бэкенд работает с хранилищем: сохраняет или забирает данные.

  3. Бэкенд возвращает ответ о результате операции фронтенду (или приложению на смартфоне).

Допустим, в инфраструктуре гипотетического сервиса в качестве хранилища данных используется реляционная СУБД MySQL. В новой версии мы, конечно же, захотим переехать на более прогрессивную и удобную PostgreSQL. К тому же пожелания заказчика и его планы на будущее вынуждают нас это сделать. Но помимо самого переезда, нам следует ещё заложить фундамент для грядущих изменений, то есть оптимизировать структуру хранения данных или, если совсем грубо, переработать таблицы, с которыми предстоит взаимодействовать.

Соответственно, просто подменить одну БД другой мы не сможем, так как старое приложение попросту не умеет работать с новым форматом.

Подменить одну БД другой не получится из-за разницы в форматах и структуре хранения данных
Подменить одну БД другой не получится из-за разницы в форматах и структуре хранения данных

Хочешь не хочешь, а придётся безотлагательно испекать свеженький бэкенд. Только остаётся вопрос: каким образом мы будем сообщать ему о добавлении, изменении или удалении данных в MySQL, ничего при этом не поломав в текущей схеме? Один бэкенд другим вот так сразу не заменишь, значит, сколько-то им придётся работать параллельно, пока дряхлый слон, наконец, не окажется полностью съеденным.

Параллельное существование двух бэкендов: старого и нового
Параллельное существование двух бэкендов: старого и нового

Решение такой распространённой проблемы заключается в организации связи между двумя приложениями посредством оповещений. Пусть старый бэкенд отправляет сообщение с информацией о событии всякий раз, когда с фронтенда поступает запрос, подразумевающий мутацию данных. А новый бэкенд будет обрабатывать их и запускать нужную операцию. То есть в легаси нам потребуются минимальные правки типа: «Когда поступит запрос, отправь объект оговорённого формата в RabbitMQ (он же «кролик»), а потом работай как работал».

RabbitMQ — программный брокер сообщений на основе стандарта AMQP; связующее программное обеспечение, ориентированное на обработку сообщений (Википедия).

Добавляем промежуточное звено между бэкендами — брокер сообщений
Добавляем промежуточное звено между бэкендами — брокер сообщений

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

Надеюсь, с постановкой всё более-менее ясно. Если что, не стесняйся задавать вопросы в комментариях. После прочтения статьи, разумеется. 😉

Переменные окружения

Переменная среды (англ. environment variable) — текстовая переменная операционной системы, хранящая какую-либо информацию — например, данные о настройках системы (Википедия).

Прежде чем приступать к конфигурации приложения, давай сначала разберёмся с внешними сервисами. Напомню: наш гипотетический бэкенд, который мы готовим на замену старому, зависим от RabbitMQ и PostgreSQL, без них его существование попросту не имеет смысла. А для того, чтобы успешно наладить «общение» с внешним элементом инфраструктуры, нам потребуются параметры для подключения, они же креды (англ. credentials — реквизиты для входа). Которые мы будем хранить в переменных окружения.

env  # вывести список всех переменных окружения
export OUR_MOTTO="Make a tantrum great again"  # добавить переменную окружения
unset OUR_MOTTO  # удалить переменную окружения

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

Именно поэтому, кстати, очень важно заранее позаботиться о том, чтобы имена энвов (env — сокр. от environment variable) были понятными и не предполагали двусмысленности. Многие разработчики пренебрегают этим правилом и впадают в грех 😁, используя самые простые названия. В дальнейшем это может привести к неприятностям. Данте Алигьери не даст соврать.

Приведу пример. Допустим, нужно организовать подключение к БД в новом проекте. «Нет проблем! — скажет львиная доля погромистов. — Будем использовать connection_string и назовём её DB_URL!». Через полгода появляется задача, закрыть которую можно лишь при помощи инфы из старого хранилища. «Нет проблем! — услышим вновь. — Добавим набор параметров с маской MYSQL_DB_*». Проходит ещё полгода, и мы вдруг осознаём, что нам чертовски не хватает отдельной базы для иностранных пользователей. Тоже PostgreSQL. «Э-э-э, — почешет затылок горе-разработчик. — Ну пусть будет POSTGRES_DB_URL, почему бы и нет?»

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

  • DB_URL;

  • MYSQL_DB_HOST;

  • MYSQL_DB_PORT;

  • MYSQL_DB_NAME;

  • MYSQL_DB_PASSWORD;

  • MYSQL_DB_USER;

  • POSTGRES_DB_URL.

Теперь помоги Даше, которая вчера устроилась на работу в нашу команду, найти базу для старого бэкенда и основную базу для текущего сервиса. Смотри не запутайся сам! Через год-то.

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

Такие дела.

Семь бед — один ответ: «Костыль и велосипед!» ©
Семь бед — один ответ: «Костыль и велосипед!» ©

Pydantic

Сегодня для считывания переменных окружения многие питонисты продолжают использовать стандартный модуль os, оборачивая его самописными парсерами и валидаторами. Я же предлагаю обратить внимание на библиотеку pydantic (тык), чьё применение сэкономит тебе кучу времени. Она действительно удобна, потому что хорошо документирована и присутствует почти во всех современных разработках на Python. На неё опирается, например, популярный ныне фреймворк FastApi. Также к ней нередко прибегают для описания структуры данных, валидации и типизации сложных объектов. В общем, маст-хэв.

Для начала подготовим базовую модель с общими настройками:

# lesson_1/app/settings/base.py

from pathlib import Path
from pydantic import BaseSettings, Field, SecretStr

BASE_DIRECTORY = Path(__file__).absolute().parent.parent.parent

class AdvancedBaseSettings(BaseSettings):
    # Родительский объект с общими настройками.
    # Нужен для того, чтобы не описывать несколько раз одно и то же.

    class Config:
        allow_mutation = False  # Эта настройка делает объект неизменяемым.

Далее опишем параметры для подключения к PostgreSQL:

# lesson_1/app/settings/db.py

class ServiceDatabaseSettings(AdvancedBaseSettings):
    """
    Этим именем мы явно даём понять, что данная база является для сервиса основной.

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

    Префикс присоединяется спереди к имени атрибута,
    после чего ищет в списке соответствующую переменную:
        - service_db_host;
        - service_db_username;
        - service_db_name;
        - service_db_port;
    """
    host: str
    username: str
    password: SecretStr  # Пароль будет искать в файле (см. ниже)
    db_name: str = Field(..., env="service_db_name")  # Если не указать имя явно, то будет искать service_db_db_name
    port: int = Field(default="5432")

    class Config:
        env_prefix = "service_db_"
        secrets_dir = BASE_DIRECTORY / "secrets"  # директория, где хранится файл с паролем.

    @property
    def postgresql_url(self) -> str:
        """
        Запомни, падаван: строки легче конкатенировать, чем парсить!
        Это property (свойство) пригодится нам в будущем, когда будем подключаться к БД.
        """
        return f"postgresql://{self.username}:{self.password}@{self.host}:{self.port}/{self.db_name}"

Теперь займёмся параметрами подключения к брокеру сообщений:

# lesson_1/app/settings/mq.py

class EventBrokerSettings(AdvancedBaseSettings):
    # Здесь имя говорит о наличии событий и некоем посреднике (брокере).
    # В данном случае любой мало-мальски опытный разработчик поймёт,
    # что речь идёт об инфраструктурном элементе, посредством которого
    # сервис получает на вход сообщения, инициирующие запуск тех или иных операций.
    host: str
    username: str
    password: SecretStr
    port: int = Field(default='5672')
    vhost: str = Field(default='/')

    class Config:
        env_prefix = "event_broker_"
        secrets_dir = BASE_DIRECTORY / "secrets"

    @property
    def amqp_url(self) -> str:
        return f'amqp://{self.username}:{self.password}@{self.host}:{self.port}/{self.vhost}'

Инициализируем описанные сеттинги:

# lesson_1/app/settings/db.py
service_database_settings = ServiceDatabaseSettings()

# lesson_1/app/settings/mq.py
event_broker_settings = EventBrokerSettings()

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

В общем, операция типа from app.settings.<module_name> import <object_name> будет использована тобой ещё не раз.

Запуск сервиса

Самые внимательные и дотошные наверняка задались вопросом: а зачем инициализировать два отдельных объекта в двух отдельных модулях? Почему бы их в качестве атрибутов не объединить в класс AppSettings?

# Вариант 1
class AppSettings(BaseSettings):
    service_database_settings = ServiceDatabaseSettings()
    event_broker_settings = EventBrokerSettings()

Или вообще, почему бы не создать один большой объект и не напихать туда всевозможных настроек? Кому и зачем это разделение вообще нужно?

# Вариант 2
class AppSettings(BaseSettings):
    db_host: str
    db_username: str
    db_password: SecretStr
    db_name: str
    db_port: int = Field(default="5432")

    amqp_host: str
    amqp_username: str
    amqp_password: SecretStr
    amqp_port: int = Field(default='5672')
    amqp_vhost: str = Field(default='/')

Чтобы аргументированно ответить на эти возражения, вернёмся к самому началу.

Итак, у нас на текущий момент крутятся два бэкенда параллельно. Один принимает запросы от фронтенда и работает со старой базой MySQL, а второй принимает от него ивенты (англ. event — событие) и работает со свеженькой PostgreSQL.

Но если ты помнишь, мы не собирались останавливаться на достигнутом и намеревались по кусочкам переносить функциональность из старого бэкенда в новый. Разумеется, основными функциональными единицами в данном примере являются обработчики поступающих с фронтенда запросов. То есть, чтобы инициировать переезд на новую кодовую базу, нам в дополнение к уже развёрнутому консьюмеру (англ. consumer — потребитель) придётся поднимать ещё один экземпляр нашего приложения с API. Это тот самый интерфейс, куда в дальнейшем будет стучаться богомерзкий фронтенд.

В результате наших действий новый бэкенд окажется развёрнут дважды.

Теперь давай разберёмся: какие переменные окружения нужны консьюмеру? Коннект к RabbitMQ, чтобы получать ивенты от старого бэкенда. И коннект к PostgreSQL, чтобы писать туда данные.

А что нужно апишке? Только коннект к PostgreSQL. Зачем ей кролик?

Вот и представь, что случится, если ты запилишь один объект со всеми настройками разом. Ты захочешь поднять API, но твой код заставит тебя прописать креды не только для БД, но и для RabbitMQ. А если вообразить систему посложнее, где речь идёт не только о кролике? Ведь архитектура современного IT-проекта состоит из гигантского количества связанных между собой сервисов.

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

Резюме

На этом пока всё. С кодом проекта и описанием того, как его запустить, ты можешь ознакомиться по ссылке. Поиграйся, поковыряйся в настройках, попробуй создать свои, пока фиктивные — будет полезно.

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

Не переключайся!

Тезисы для запоминания

  1. Слона едят по кусочкам.

  2. Но гадина с хоботом чаще всего оказывается вовсе не слоном, а внеземным чудовищем. Поэтому будь осторожен, не верь никому.

  3. За «говорящие» имена тебя возблагодарят потомки — не ленись.

  4. И коллеги-современники тоже возблагодарят. Или как минимум не будут ругать, что тоже очень хорошо.

  5. Соблюдай принцип единственной ответственности даже в Python. Даже в том, что вроде бы не касается ООП. Не толкай все значения и операции в один объект, иначе итог будет как от кофе три в одном 💩.

Теги:
Хабы:
Всего голосов 22: ↑21 и ↓1+20
Комментарии11

Публикации

Истории

Работа

Data Scientist
60 вакансий
Python разработчик
136 вакансий

Ближайшие события