Расскажу про нашу библиотеку 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 - кажется, это то, что нам нужно, чтобы меньше плодить атрибутов в классе.
danilovmy
чет много что недоделано, для такой маленькой библиотеки:
Сериализатор строки конфига сохраняет объект, а HistoryEvent не пишет.
Менеждмент команда для строки конфига сохраняет объект, а HistoryEvent не пишет
А если пишет, потому как где-то висит сигнал post_save (я не нашел), то в админке он делает это дважды.
HistoryEvent повторяет модель django_admin_log, почему не использовали ее, поскольку фактически сейчас у вас две таблицы django_admin_log и liveconfigs_historyevent содержат одну и ту же информацию.
Ставить DRF только для одного ViewSet это непонятно. Чем не устроил простой GCBV?
Удаление конфигов вместо действия администратора в менеджменте. Это за пределами моего понимания. Т.е. в админке создать можно, удалить можно, а вот "Удалить конфиги, которые есть в БД, но нет в коде'" - это можно только через командную строку. Мне кажется это странным.
В итоге, по реализации вы все же остановились на создать и проинициализировать объект Django, я про инстанс класса ConfigRow. Поскольку, без инстанса данных вы не получите. Но потом:
Ну вы как нить определитесь, Инстанциировать или не инстанциировать.
Еще вижу, что вы планируете перейти на Annotated, чтобы меньше плодить атрибутов в классе. Не забывайте, что в случае Annotated вам надо будет плодить их где-то еще.
На всякий случай напомню, что в Django есть еще класс "AppConfig", который я очень уважаю для хранения настроек одного приложения, и, похоже, что для вашего случая работа с ним подходит больше, чем отдельный подкласс BaseConfig. Но я не вижу всей картинки и могу ошибаться.
@jandor В любом случае спасибо, критиковать, как я, каждый может, а вот проект опубликовать и статью о нем написать - это редкость!