Простое управление настройками приложения в проекте на django

в 13:15, , рубрики: python, pythonic, конфиги, настройки

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

Про какие настройки речь?

  • Говорим тут только о бизнес-настройках приложения и немного о технических

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

  • Не говорим о настройках пользователя

История и предпосылки

Когда-то давно, еще в 2019 году, мы писали для заказчика бота-ассистента-секретаря с обработкой естественного языка вот с такими вводными:

  • нас 3 бэкендера

  • фронта у проекта нет, фронтендера тем более

  • весь бэк состоит из нескольких контейнеров - django, celery, celery-beat, redis, postgres, nginx. При этом django, celery и celery-beat раскатываются из одного образа, кодовая база у них одна

  • языковые модели большие и работают из оперативной памяти

  • сервис рестартует около минуты, за это время пользователи начинают переживать

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

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

Дополнительные соображения

  • Отдельный (микро)сервис не стоит делать, иначе за ним тоже нужно будет следить.

  • Пользователей относительно немного.

  • Эксперименты с выкаткой на часть пользователей нам не нужны

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

  • У нас несколько стендов и нет никакого желания добавлять настройки на них руками, поэтому настройки в админке должны появляться “сами”, вместе с документацией и значением по-умолчанию. Значит, они должны быть в самом сервисе.

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

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

Пример

Так мы пришли к тому, что решили описывать настройки в самом сервисе в виде атрибутов класса (с небольшой метаклассово-дескрипторной магией для работы с бд). На тот момент не было функционала Annotated, а теперь мы внимательно на него смотрим.

class Config(BaseConfig):
    USE_NEW_FEATURE: bool = False

    USE_NEW_FEATURE_DESCRIPTION = “Включена ли новая фича, которую мы разрабатывали два года”

    SOME_VALUE: float  = 42.1

    SOME_VALUE_DESCRIPTION = “Новая настройка для старой фичи, по-умолчанию 42.1, значение должно быть больше 10”

    SOME_VALUE_VALIDATORS = [greater_than(10)]

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

Как это устроено внутри

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

При чтении из дескриптора:

  • если в кеше есть значение, то возвращаем его и ничего больше не делаем

  • иначе,

    • обновляем метаданные о настройке в БД по необходимости. Поэтому можно и не добавлять отдельный шаг при выкатке "добавить все настройки на стенд"

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

Подробнее можно посмотреть вот тут

А почему именно так?

Почему бы не value = get_value(‘some-value’)

Потому что при этом остаются следующие проблемы и вопросы:

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

  • где хранится “источник” документации и кто за него отвечает?

  • какой тип у полученного значения? как об этом быстро узнать?

Почему не что-нибудь вроде USE_FEATURE = BooleanFlag(False, “Description”)

Теряем информацию о настоящем типе самого значения при разработке. В рантайме там будет boolean. Например, для поддержки подобного поведения полей в django у pycharm вообще должна быть платная версия.

Почему бы не хранить описание настроек в yml/json/где-то еще и не читать их в рантайме?

Теряем информацию о настоящих типах для линтера, есть возможность огрести в рантайме.

Почему бы не хранить описание настройки в docstring класса?

class UseNewFeature(BooleanConfig):

    “”””Включена ли новая фича, которую мы делали-делали и, наконец, доделали“”””

    default = False

    

class SomeValueConfig(FloatConfig):

    “”””Очень важное значение, которое должно быть больше 10“”””

    default = 42.1

    validators = [greater_than(10)]

Непонятно, как такое использовать. Инстанцировать класс? Обращаться к атрибуту класса, например UseNewFeature.value? Выглядит не очень красиво.

Сейчас можно спокойно двигаться в сторону Annotated

Почему не unleash, flagsmith, growthbook или flipt

Это рабочие, хорошо зарекомендовавшие себя решения, но:

  • это отдельные сервисы, за которыми нужно следить

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

  • они заточены на эксперименты и частичную раскатку нового функционала, а у нас такой потребности не было

  • кто отвечает за документацию значений и где источник правды?

  • как настраивать сложную валидацию?

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

boolean_flag = flipt_client.evaluation.boolean(

EvaluationRequest(

  namespace_key="default",

  flag_key="flag_boolean",

  entity_id="entity",

  context={"fizz": "buzz"},

)

)

Почему не django-waffle

На это есть множество причин, ниже приведу некоторые из них:

  • Это feature-flipper.

  • Он поддерживает только on/off для флагов. 

  • В нем есть только boolean-значения. 

  • Он, может ответить лишь “да” или “нет”. 

  • Он не может хранить int, float, string или произвольный json

  • Позволяет только включать и выключать фичи. 

Почему не django-constance

Очень близкая к нам библиотека, но заточена она под “оживление” settings. Кроме того, синтаксис добавления настроек у нее менее удобный:

CONSTANCE_CONFIG = OrderedDict([

('SITE_NAME', ('My Title', 'Website title')),

('SITE_DESCRIPTION', ('', 'Website description')),

('THEME', ('light-blue', 'Website theme')),

('THE_ANSWER', (42, 'The answer')),

])

Итоги и выводы

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

  • Документировать настройки нужно сразу

  • Хранить описание настроек в самом сервисе — хорошо

  • Хранить описание настроек в виде кода — хорошо

  • Для настроек нужен нормальный поиск

  • Настройки нужно уметь выводить из эксплуатации

  • Писать велосипеды иногда полезно

Что еще хотим сделать:

  • «Заморозка» значений

  • Асинхронная работа

  • Перейти на Annotated - кажется, это то, что нам нужно, чтобы меньше плодить атрибутов в классе.

Автор: jandor

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js