Комментарии 48
Пример с глобальным объектом settings, который еще и сам читает переменные окружения выглядит ну уж слишком сомнительно в вакууме
class ServiceSettings(pydantic_settings.BaseSettings):
service_name: str = "micro-service",
service_description: str = "Micro service description"
database_url: str = "your-database-url"
database_read_timeout: int = 5
redis_url: str = "your-redis-url"
...
# используется, чтобы загружать переменные из файла .env
model_config = pydantic_settings.SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
populate_by_name=True,
)
settings = ServiceSettings()
Как вы в реальных проектах переключаете .env
файлы под разные окружения? Как настраиваете окружение на проде, где .env
файла просто нет (или у вас есть)?
Из моей практики pydantic_settings
вообще никаких плюсов не дает - можно вполне обойтись pydantic.BaseModel
, если нужна валидация конфига, либо обычным dataclass
, если валидация не нужна
У нас может быть немного нестандартный подход, так как мы практически не используем .env
файлы. Вся разработка и тестирование проходят через docker compose, поэтому необходимые переменные обычно хранятся в самих настройках в виде значений по умолчанию. Если же возникает необходимость запустить сервис вне Compose, можно использовать .env
файл, но это скорее исключение, а не рекомендованная практика.
На средах у нас k8s, и там переменные передаются через конфиг мапы, а чувствительные данные — через vault. Далее данные из эти источников попадают в переменные окружения переменными окружения, откуда их уже считывает объект settings.
Отлично, тогда снова возникает вопрос, какую пользу несет pydantic_settings[dotenv]
, если можно обойтись просто без этих двух зависимостей?
А вытаскивать переменные руками из окружения - довольно неудобная и избыточная практика. Намного проще, когда они вытаскиваются по названию автоматически и лежат сразу в одном месте. У тебя так не возникает вопросов, откуда та или иная переменная, ты сразу знаешь
Смотри, как могу Settings(**os.environ)
- https://github.com/MultiDirectoryLab/MultiDirectory/pull/489/files
Тогда тебе придется еще и dotenv где-то вызывать. А если тебе надо какие-то префиксы добавить в свои сеттинги? Это нужно в том случае, если у тебя много сервисов, и атрибуты настроек имеют одинаковое название.
В твоем примере тебе придется называть свои переменные по-разному или везде писать алиас, а с использование pydantic-settings достаточно один атрибут в конфиг добавить.
Ну, я в принципе против того, чтобы у нас был глобальный объект `from app.settings import settings`, который можно импортнуть в любом месте кода - потом очень сложно следить, какое поле конфига где используется.
Запутанные конфиги с алиасами тоже как будто не упрошают восприятие кода. Если же юзать только один алиас для всего приложения целиком - то никакой проблемы не вижу
Не вижу ничего плохого в том, что у нас есть объект, отвечающий за переменные из енвов. Его крайне удобно использовать и мокать в случае чего.
Я про запутанные конфиги и не говорил, в BaseSettings
у тебя есть конфиг, в котором можно указать префикс для всех полей в настройках.
Также твой подход через **os.environ
удобен в том случае, у тебя основной объект настроек не содержит в себе вложенные настройки. В случае pydantic_settings
такие вложенные настройки так же будут подтягиваться из енвов.
Не надо вызывать dotenv. Переменные окружения должны быть известны к моменту старта приложения, а не модифицироваться внутри него. Они должны прилетать из окружения, а не заполняться приложением самим себе.
Если же мы рассматривает дотэнв просто как конфиг, то имеет смысл взять полноценный формат конфигов, такой как toml/yaml. Там хотя бы иерархия и стандарт есть, в то время как диалекты дотэнва разные, а файл плоский
Ну не соглашусь, переменные окружения никому ничего не должны. Ведь бывают случаи, когда ты хочешь их из .env вытащить, вполне нормальная практика, многие так делают, и ничего криминального в ней нет. И как раз в этом случае придется вызывать dotenv.
По поводу формата енвов уже соглашусь, иметь какой-то более понятный форматы было бы намного лучше.
Но в конечном счете, pydantic_settings позволяют тебе довольно гибко загружать нужные переменные, как из окружения, так и из .env файликов. Обязательно ли всегда использовать именно этот пакет? Нет, конечно, но сам по себе пакет предоставляет много функций и отказываться от него в пользу более простого решения только ради того, чтобы .lock файл сократить, как будто нет смысла.
Ведь бывают случаи, когда ты хочешь их из .env вытащить, вполне нормальная практика, многие так делают, и ничего криминального в ней нет.
Обычно это делает инстурмент запуска. Криминальное тут то, что если уже какой-то инструмент прочитал энвы до того как ты загрузил .env, будет несоответствие. Если так хочется юзать dotenv, то проще настроить запуск программы с помощью команды dotenv: `doetnv python app.py`. Как раз будет вам и .энв и мне переменные окружения заполненные до запуска.
Но в конечном счете, pydantic_settings позволяют тебе довольно гибко загружать нужные переменные, как из окружения, так и из .env файликов.
А вы пробовали настроить выбор откуда грузить не на момент написания кода, а в рантайме? Например, в тестах юзать дефолты и ничего не грузить, если передан параметр --config - грузить по указанному пути и не юзать переменные окружения, а если ничего не указали - грузить .энв и переменные окружения? В общем, любую кастомную логику.
Мое мнение, что если у нас штук 5 переменных окружения - проще прочитать руками. Если сильно больше - имеет смысл подумать над использованием нормального конфига.
Вот только pydantic settings делает очень неявные предполжения и может прочитать переменную окружения UsEr
вместо USER
Согласен, это может иногда путать, и мне это тоже не нравится, честно говоря. Но такие приколы легко отлавливать на этапе ревью
Там есть опция case_sensitive для таких случаев, это вроде бы закрывает вопрос.
Но если быть откровенным, мы за 5 лет работы с таким подходом (ну ладно, года за 4, потому что до этого мы таскали/писали вот такие пакеты — https://github.com/xfenix/envcast) ни разу на таком не подрывались, чтобы кто-то заводил переменные с одинаковыми именами в разном регистре. При том, что у нас на приложение может быть легко около двух сотен переменных, например.
Я не буду вдаваться в крайности и говорить, что pydantic-settings однозначно надо выкинуть (хотя хочется так говорить), но определенно пользоваться надо им очень аккуратно, а преподносится он как готовое решение. Мои рекомендации
Под каждый источник данных иметь свой класс PydanticSettings со своей настройкой алиасов и т.п. Это позволит нормально переключакться между ними.
В конечных классах использовать просто параметры инита или свои датаклассы. При инстанциировании перекладывать из пидантика в них. Это позволит менять источники данных без изменения бл и адаптеров.
Включать case_sensitive и прочие опции
Не использовать вложенные сеттинги для тех источников, которые нативно это не умеют.
При загрузке энва явно передавать путь.
Проектировать формат и структуру конфигурации, а не полагаться на библиотеку
Что такое «определенно пользоваться надо им очень аккуратно»? Можно чуть подробнее? Потому что это отдаёт оккультным посылом. Мы много лет им пользуемся и это довольно простая и дубовая штука. Ты её описываешь пока скорее как нечто тайно-опасное, что мне кажется преувеличением.
«преподносится он как готовое решение» — это готовое решение, которому много лет. К тому же в статье специально много примечаний вроде «В нашей концепции» или «Сразу хотим предупредить! Несмотря на то, что эти рекомендации применяются в наших командах и кажутся нам довольно полезными, они вовсе не являются единственно верными». Т.е. если есть, на мой взгляд, очень сильно преувеличенные опасения на тему pydantic-settings (стабильного проекта), то можно просто им не пользоваться. Наш опыт многих энтерпрайзных сервисов и многих лет в продакшене показывает, что проблем с этой штукой просто нет и достаточно даже странно слышать такие опасения на тему этого маленького и довольно незначительного и безобидного проекта.
Я не могу сказать, что я согласен с такими строгими рестрикциями. Ещё раз сошлюсь на наш опыт, у нас сотни переменных (на один сервис, а всего их многие тысячи, мы храним их в волте и закидываем через конфигмепы и секреты) и десятки (скорее уже сотни сервисов) и тысячи инстансов, которые нас окружают везде, которые спокойно работают на подходе, изложенном в статье. Т.е. на мой вкус это это просто избыточные рестрикции, которые никак не обоснованы.
Раскрой, пожалуйста, откуда растут твои опасения и как часто это «стреляет» в продакшене? Потому что на нашем скейле мы такого и не встречаем.
При загрузке энва явно передавать путь.
Путь к чему? Это переменные окружения. В нашей трактовке. Со всем уважением, у нас это работает.
Не использовать вложенные сеттинги для тех источников, которые нативно это не умеют.
Мы не предлагали. В статье нет такого
Проектировать формат и структуру конфигурации, а не полагаться на библиотеку
Абстрактный посыл, на мой взгляд. Что значит «проектировать» в данном контексте?
Под каждый источник данных иметь свой класс PydanticSettings со своей настройкой алиасов и т.п. Это позволит нормально переключакться между ними.
Что такое источник? О каких алиасах речь идет?
В зависимости от проекта требования к работе с настройками могут быть разные. Часто хочется грузить их из конфига, из параметров командной строки, из конфига путь к которому в параметрах командной строке, из переменных окружения, из всего сразу или ещё как. Не для всех приложений это актуально (как я понял - не для вас), но такие требования существуют.
При этом для разных источников настроек мы хотим разное их представление. То, что в переменных окружения лежит как MYAPP_DB_ADDR
, в файле будет параметром addr
в секции [db]
или ещё как.
На самом деле тут ситуаци такова, что чем больше у вас микросервисов, тем более унифицированы ваши подходы и тем выгоднее вам иметь меньше фич, но работающих единообразно. Но для другой компании удобным может оказаться чуточку другой набор фич.
А сомнительно почему? pydantic_settings не даёт плюсов почему?
У нас этот «паттерн» работает хорошо. Ты всегда знаешь, что в project_name лежит settings.py из которого ты достаешь глобальный объект с настройками, которые могут потребляться из любого окружения. Чем это хорошо:
— унифицировано
— наглядно
— просто
Наш кейс — разработка приложений.
В бонус ты можешь сделать вот такой автоматически обновляемый каталог env переменных (если вдруг хочешь в маркдауне): https://github.com/xfenix/spellcheck-microservice/tree/main?tab=readme-ov-file#config-options
https://github.com/xfenix/spellcheck-microservice/blob/main/.github/workflows/pipeline.yml#L97
У нас этот «паттерн» работает хорошо. Ты всегда знаешь, что в project_name лежит settings.py из которого ты достаешь глобальный объект с настройками, которые могут потребляться из любого окружения. Чем это хорошо:
— унифицировано
— наглядно
— просто
И вот эти все плюсы достижимы и без pydantic_settings
, вот и все
Например, вот тот же конфиг, но без лишних зависимостей
class ServiceSettings(BaseModel):
service_name: str = "micro-service",
service_description: str = "Micro service description"
database_url: str = "your-database-url"
database_read_timeout: int = 5
redis_url: str = "your-redis-url"
...
settings = ServiceSettings(**os.environ)
Странно, я не могу понять почему «слишком сомнительно».
Ты же знаешь преимущества пакета — это опции настройки, енв файлы, case_sensitive, env_prefix. Ну то есть в чём претензия, раскрой, пожалуйста. Что мы используем для парсинга енвов предназначенный для этого пакет? Может лучше написать Самуелю, что тебе не нравится его пакет?
Для нашего кейса это и справедливо и осмысленно. Мы используем время от времени .env файлы (у Миши может и нет, но в других командах бывает), используем и префиксы так же. Плюс мы иногда делаем иерархические настройки, которые можно удобно настраивать. Зачем ради этого писать лишний код я не понимаю, кроме того экономия на размере пакета (а он небольшой) нам действительно не актуальна, у нас и так довольно жирные образы.
но уже нет возможно разделить по префиксам в тех же IaC чартах env
Так сетингс будет тянуть не только из файла но и из окружения енвы, и у каждого инстанса свои енвы
Не очень понял использование репозиториев AdvancedAlchemy
class PostRepository(SQLAlchemyAsyncRepository[Post]):
"""Repository for managing blog posts."""
model_type = Post
# уже реализовано в родительском классе репозитория
async def add(...)
async def add_many(...)
async def list(...)
async def update(...)
async def delete(...)
...
Репозиторий должен возрващать доменные объекты приложения, полностью инкапсулируя модели алхимии внутри себя. Если я не путаю, AdvancedAlchemy репозиторий отдает в качестве ответов модели алхимии.
Если вы делаете свои надстройки над SQLAlchemyAsyncRepository
, то тема с заворачиванием ее результата в доменные объекты не раскрыта. Опять же - там очень примитивная логика внутри. Если мы наследуемся от этих generic репозиториев, то какую пользу мы вообще получаем? Не проще реализовать репу с 0?
Я бы не был так строг по отношению к моделям, но вот интерфейс метода list максимально неявный, что просто лишает этот репозиторий преимуществ по сравнению с использованием голого session
Тут стоит сказать, наверное, что любая ОРМ приносит за собой какой-то непонятный интерфейс. Намного проще писать на голом SQL, если нам не нравятся кривые интерфейсы. Но тогда мы лишаемся преимуществ, которые приносят ОРМ.
Мне кажется, что подобные решения это всегда какой-то трейдофф. И в данном случае пользы больше, чем неудобств, на мой взгляд.
SQL Alchemy преподносит как минимум три существенных преимущества:
Query builder. Это не часть ORM, это просто способ безопасно строить динамический SQL
Data Mapper. В отличие от Active Record мы можем маппить на доменные модели. Либо использовать модели алхимии как доменные. Во втором случае сама модель (инстанс), в целом, все ещё просто отображает данные, в БД она сам не пойдет, хотя класс и выглядит грязнее.
UoW - мы можем трекать изменения моделей, добавлять в буфер новые чтобы потом разом сохранить.
Первое (query builder) это хорошая фича для реализации слоя работы с БД, в БЛ ей не место. Второе (mapper) позволяет организовывать связь данных в БД и в БЛ. А UoW - это уже часть логики работы с данными, а именно когда они будут попадать в базу данных.
Ни одна из этих крутых фич не требует вам в бизнес логике использовать термины базы данных для построения запросов. Вы все ещё можете сделать `def find(name: str| None, price: int | None)`, а превращение этого в SQL скрыть внутри репозитория, не ломая вышеназванные фичи. А подход с GenericRepo - это как раз та сущность, что ничего толком не дает, а только загрязняет логику - мы переносим часть QueryBuilder в БЛ).
Ну так базовый репозиторий не обязывает использовать термины базы данных для построения запросов в бизнес логике. Более того, в бизнес логике запросы как раз строить не стоит. Если нужен какой-нибудь кастомный def find(name: str| None, price: int | None)
, то его всегда можно написать внутри репозитория.
Рассмотрим пример. В репозитории из advanced-alchemy
есть метод, get_one_or_none(...)
. Как следует из названия, он получает сущность, если нашел ее по фильтрам или возвращает None
в ином случае. Я бы не сказал, что это термин базы данных, а в бизнес логике такое использовать крайне удобно.
Давайте подумаем какие методы у нас доступны в advanced-alchemy? Почти все позволяют указывать непонятные параметры (**kwargs), то есть очень неконкретные. Что же у нас вместо этого есть в алхимии
- session.get()
- получить по айди
- session.add()
- добавить объект
- session.delete()
- удалить объект
Бери и юзай. А если нужны кастомные методы типа list
с фильтрами (а кому нужен list без фильтров непонятно) - сделай конкретную обертку над execute.
Я не буду отрицать, что там есть интересные идеи, но они сделаны слишком обобщенно, что честно говоря непонятно что нам это дает.
Тут, наверное, моя недоработка, стоило показать аннотациями, что репозитории возвращают. Если ты под "доменными объектами" имеешь в виду модельки алхимии, то все так и есть, как ты описал.
По поводу наследования базового репозитория вот, что могу сказать: это строго говоря необязательно, но если ты понимаешь, что есть какие-то методы, которые стоит пошарить несколькими репозиториями, то всегда можно сделать свой базовый репозиторий на основе предоставленного либой. Естественно, эти методы должны работать с теми же объектами, что и базовыый репозиторий. В данном случае это модели алхимии
Стоит ли писать репы с 0? А стоит ли изобратать свой собственный велосипед? Я считаю, что если есть какая-то рабочая штука, закрывающая мои нужды, то нет смысла утруждать себя и писать собственную.
Если ты под "доменными объектами" имеешь в виду модельки алхимии, то все так и есть, как ты описал.
Нет, репозиторий не должен как раз вовзращать модельки алхимии. Пусть возвращает имеено что объекты вашего приложения, а модельки алхимии остаются инкапсулироваными внутри
Звучит как некая вкусовщина, ведь мы сами определяем и договариваемся внутри команды, что будут возвращать наши репозитории. Не вижу ничего плохого в том, что они будут возвращать те сущности, с которыми работают (модельки алхимии).
Если нам это надо сконвертить в тот тип, который необходим приложению, то это всегда можно сделать внутри сервиса. Не вижу смысла смешивать репозитории, которые работают с таблицами и бизнес-представление наших сущностей.
Если нам это надо сконвертить в тот тип, который необходим приложению, то это всегда можно сделать внутри сервиса.
звучит как нарушение изоляции слоев. "Сервис" - это и есть "приложение". Мы для этого и выделяем шлюзы к БД, чтобы дальше работать с удобными типами. Иначе опять же вопрос - а зачем тогда эта "абстракция"?
А у нас нет слоев, так что мы ничего не нарушаем.
Шлюзов у нас тоже нет, расскажи мне, о чем ты говоришь здесь?
Я не знаю, зачем тут "эта абстракция", я про нее ничего не говорил.
Поясни, пожалуйста, в чем проблема возвращать модели алхимии (по сути дата-маппер) из репозиториев, которые работают с БД?
Учитывай, что ненужных слоев и шлюзов здесь нет.
Прошу прощения, я почему-то думаю по дефолту, что люди придерживаются слоистой архитектуры с выделением соотетствующих абстракций. И тут есть варианты куда у нас направлена связь между БЛ и БД. Если слоев нет, хотелось бы конечно понимать как устроено деление приложения на компоненты.
Репозиторий - это просто один и видов "шлюзов" для доступа к БД. Я решил использовать более широкий термин потому, что не уверен что мы под репозиторием понимаем одно и то же.
Я в рамках данной дискуссии не против возврата модели алхимии, я просто пытаюсь понять кто занимается конвертациями (и сколько их), обычно это задача шлюза для доступа к внешней системе (к БД, например), чтобы бизнес логика занималась своими задачами
Окей, понял вопрос
В данном случае репозиторий не занимается конвертацией. Он просто возвращает объект(ы) алхимии.
Если переводить на язык слоистой архитектуры, то этот объект алхимии может являться «доменным объектом». Этот объект далее можно использовать в своих сервисах.
В нашем случае сервис, как написано в статье, выполняет бизнес логику. Внутри этого сервиса осуществляется конвертация в нужную для бизнес-сценария схему 1 раз. Если говорить с точки зрения слоистой архитектуры, то сервис и эти схемы являются компонентами одного и того же слоя.
Но как я сказал, слоистой архитектуры в той интерпретации, в которой она часто приводится в книжках (с кружочками и стрелочкой зависимостей, направленной внутрь), у нас нет.
Поэтому и слоев в той интерпретации из книжек у нас тоже нет. Воспринимай слово «слой» в моем ответе как некое множество объектов, которые выполняют свою функцию на определенном этапе обработки бизнес-сценария. Или, если по-простому, на этапе обработки запроса пользователя (упрощенный пример).
Так что в какой-то мере можно сказать, что мы следуем слоистой архитектуре. Но я нарочно не использовал термину оттуда в данной статье, так как полностью мы ей, конечно, не следуем.
Ведь мы не можем определить наш домен так, чтобы он не зависел не от чего. В конечном счете, он зависит от Python.
Как насчёт неконстстентности результатов вот этого кода
async def delete_user(self, user_id: str) -> None: await self.user_repository.delete_user(user_id) await self.kafka_producer.send_message(user_id)
В репозитории допустим выполнилось а в кафке чтото сломалось по пути.
result = await asyncio.gather(self.user_repository.delete_user(user_id) , self.kafka_producer.send_message(user_id) , return_exceptions=True)
Привет! Это абсолютно синтетический пример. Его смысл в том, чтобы продемонстрировать, что использование клиентов значительно упрощает код и повышает его тестируемость.
В реальной жизни такие куски кода стоит покрывать ретраями. Если же хотим очень высокую надежность, то можно использовать паттерн transactional outbox
Да проблема транзакционности. Миша выше уже описал, это фиксится transactional outbox'ом в некоторых случаях (мы это применяем иногда), просто в гайд все не внесёшь, не было желания его раздувать
Можно еще кэшировать, если частые обращения
from functools import lru_cache
class ServiceSettings(pydantic_settings.BaseSettings):
...
@lru_cache
def get_service_settings() -> ServiceSettings:
return ServiceSettings()
service_settings: ServiceSettings = get_service_settings()
Всегда удивляло использование @lru_cache
как реализацию ленивых глобалов. Я бы сильно подумал и скорее всего вместо таких фокусов взял и воспользовался Depedency Injection (просто при старте передал эти настройки куда надо)
Может, меня сейчас загрызут в комментах, но я считаю, что настройки - это константы, а константы можно просто импортировать в любое нужное место.
Не вижу смысла делать lru_cache, ведь можно просто оставить это в памяти
Настройки - не константы. Они гузятся из внешних источников, часто не сразу в момент иницализации кода, а где-то после старта. В тестах часто мы их переопределяем, а некоторые объекты изначально работавшими с одним экземпляром настроек начинают работать с несколькими. Импртирование же в любой модуль всех настроек целиком усложняет потом поиск какие же настройки реально нужны
Наш архитектурный подход к Python приложениям