Расскажу про нашу библиотеку 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 - кажется, это то, что нам нужно, чтобы меньше плодить атрибутов в классе.

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


  1. danilovmy
    24.12.2024 22:08

    чет много что недоделано, для такой маленькой библиотеки:

    1. Сериализатор строки конфига сохраняет объект, а HistoryEvent не пишет.

    2. Менеждмент команда для строки конфига сохраняет объект, а HistoryEvent не пишет

    3. А если пишет, потому как где-то висит сигнал post_save (я не нашел), то в админке он делает это дважды.

    4. HistoryEvent повторяет модель django_admin_log, почему не использовали ее, поскольку фактически сейчас у вас две таблицы django_admin_log и liveconfigs_historyevent содержат одну и ту же информацию.

    5. Ставить DRF только для одного ViewSet это непонятно. Чем не устроил простой GCBV?

    6. Удаление конфигов вместо действия администратора в менеджменте. Это за пределами моего понимания. Т.е. в админке создать можно, удалить можно, а вот "Удалить конфиги, которые есть в БД, но нет в коде'" - это можно только через командную строку. Мне кажется это странным.

    В итоге, по реализации вы все же остановились на создать и проинициализировать объект Django, я про инстанс класса ConfigRow. Поскольку, без инстанса данных вы не получите. Но потом:

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

    Ну вы как нить определитесь, Инстанциировать или не инстанциировать.

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

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

    @jandor В любом случае спасибо, критиковать, как я, каждый может, а вот проект опубликовать и статью о нем написать - это редкость!