Config-manager: универсальное решение для настройки приложений на Rust
От проекта к проекту много сил уходит на конфигурирование продуктов. Рассказываем, как решить проблему с config-manager для Rust.
1К открытий2К показов
Привет! Меня зовут Михаил Михайлов, младший системный программист технологической и научно-исследовательской компании «Криптонит». В этой статье я расскажу, как автоматизировать процесс сборки конфигурации приложения из различных источников и упростить код с помощью собственного решения — крейта config-manager.
Мотивация
В какой-то момент мы — команда Rust-разработчиков компании «Криптонит» — поняли, что, переходя от проекта к проекту, много времени и сил тратим на написание кода, реализующего конфигурирование наших продуктов. Разными разработчиками пишется одно и то же. Мы решили положить этому конец и разработать универсальное решение.
Обычно, настраивая проекты в Kubernetes, мы берём параметры приложения преимущественно из Environment и ConfigMap. Помимо этого, бывает удобно отлаживать программу, запуская её с различными параметрами командной строки. То есть в большинстве наших проектов необходимо брать данные из трёх источников одновременно: конфигурационных файлов, параметров командной строки и переменных окружения. При этом важен приоритет тех или иных источников.
Таким образом перед нами встают две задачи: получение данных и их правильное слияние, что является довольно трудоёмкой и монотонной работой.
Хотелось создать инструмент, который автоматизирует этот процесс, но при этом оставит полный контроль пользователю.
Существующие решения
Данная задача стоит перед программистами буквально в каждом проекте, однако ни одно из существующих решений полностью нас не устраивало.
К примеру, крейт clap, который предоставляет гибкую работу с параметрами командной строки (а с версии 3.0 и с переменными окружения), имеет несколько недостатков:
- не работает с конфигурационными файлами,
- не позволяет выбрать приоритеты источников.
С крейтами ниже схожая проблема: оба работают с двумя источниками, но не поддерживают третий, что для нас недопустимо.
abscissa:
- работает с командной строкой, конфигурационными файлами,
- не поддерживает переменные окружения.
config-rs:
- работает с конфигурационными файлами, переменными окружения;
- не поддерживает командную строку.
Нашей команде приходилось использовать совокупность перечисленных крейтов, и это создавало одни и те же неудобства. Каждый параметр необходимо получать из разных источников, используя разные API, а это приводило не только к разрастанию кода, но и к необходимости править его в нескольких местах при каждом изменении набора параметров.
Принципы
Перейдём к нашему проекту. Можно сказать, что крейт config-manager состоит из одного макроса. Почему мы выбрали макросы в качестве решения проблемы?
- Любая конфигурация обычно имеет вид структуры с публичными полями, которые нужно заполнять при инициализации.
- Одной из первых и главных особенностей было то, что имя переменной окружения, из которой читается значение, по умолчанию совпадает с именем соответствующего поля (это справедливо и для других источников — командной строки и файла). То есть по умолчанию, если поле называется version, то значение будет браться из переменной окружения с именем version.
Поэтому мы решили использовать derive макрос — им легко аннотировать структуру, и он имеет доступ к именам полей. Чтобы пользователю задействовать весь функционал библиотеки, необходимо лишь аннотировать структуру. Это делает код компактнее, красивее и удобнее в сопровождении.
На одной из итераций мы заметили, что derive-macro не всегда выглядит достаточно опрятно, поэтому решили использовать attribute-macro, который «под капотом» вызывал бы старый derive.
Коротко о том, какой код генерируют наши макросы:
- создаётся код, который собирает три источника в три соответствующих HashMap,
- начинается инициализация структуры.
Каждое поле инициализируется отдельно:
- определяется приоритет источников,
- в соответствующих HashMap ищутся нужные ключи,
- если нигде не найдено — ставится значение по умолчанию,
- если нет значения по умолчанию — выдаётся ошибка.
Стандартное применение макроса:
В данном примере аннотация file (3 строчка) означает, что будет использоваться конфигурационный файл формата TOML, расположенный по адресу “./config.toml”
Разберём порядок сборки для каждого поля:
Значение поля path сначала будет искаться в командной строке (–path -p), а затем в переменной окружения с именем APP_PATH. Если ни то, ни другое не было найдено, будет выдана ошибка инициализации.
Значение поля delay будет искаться в следующем порядке: командная строка (–delay), далее — конфигурационный файл, после — переменная окружения (path). Если нигде не было найдено значение — будет присвоено значение по умолчанию (1).
Данный код заменяет порядка 50 строк, необходимых для реализации аналогичного поведения.
Возможности
Показательным результатом создания этого крейта для нашей команды стало его внедрение в один из самых крупных продуктов «Криптонита». Модуль, отвечающий за настройки проекта, ужался практически вдвое (до внедрения было порядка 3000 строк, после — около 1700), и код стал гораздо читабельнее.
Кстати, бо́льшая часть планирования и итераций крейта была связана не с написанием кода или реализацией основного функционала. Мы долго продумывали, как это должно подаваться пользователю, а также разрабатывали дополнительные возможности, которые предоставляли бы ему максимально гибкий функционал и сохраняли при этом чистоту получаемого кода.
Благодаря этому, крейт, который изначально должен был полностью опираться на крейты clap, config-rs и serde, в итоге превратился в собственное решение — config-manager.
Перечислим некоторые достоинства реализации:
I. Простое получение необходимых механизмов через “#[config]”.
II. Кодогенерация на основе структур данных:
- поддержка практически любых типов полей, единственное условие — поле должно реализовывать “serde::Deserialize”,
- управление приоритетами источников при слиянии,
- определение имён значений для источников,
- гибкое задание значения по умолчанию, значением по умолчанию может выступать любой валидный Rust код, в том числе вызов функции,
- пользовательские методы десериализации полей из источников, можно привязать свой код десериализации к каждому полю,
- поддержка вложенных структур, что крайне полезно для модульных проектов с большими наборами параметров конфигурации,
- валидация типов на этапе компиляции.
III. Пользовательский контроль над ошибками получения конфигурации.
Пользователь получает результат сборки посредством вызова метода try_parse у аннотированной структуры, который возвращает Result, содержащий экземпляр структуры, либо ошибку сборки
IV. Достаточно широкий спектр форматов конфигурационных файлов.
Поддержка форматов json, toml, yaml и ron
V. Возможность задать префикс для имён переменных окружения.
VI. Гибкий механизм работы с командной строкой.
Поддерживаются атрибуты clap: long, short, help и т.д.
Узнать больше о крейте можно на crates-io и в репозитории «Криптонита», а изучить все подробности можно, прочитав наш cookbook — там же есть наглядные тесты и примеры.
Макросы
По своему опыту использования процедурных макросов я понял, что это не такая страшная вещь, как кажется изначально. Основная проблема — крайне скудное количество статей на эту тему.
Однако, крейты proc_macro2 и quote предоставляют довольно дружелюбный интерфейс для кодогенерации: не нужно возиться с каждым токеном и самому разбирать/собирать их в синтаксическое дерево.
Тут каждое специальное слово или символ Rust является классом с довольно интуитивными полями и методами — только взглянув на страницу документации, можно быстро сориентироваться и написать нужный код.
Дальнейшая разработка проекта полностью зависит от отзывов пользователей: нам необходимо увидеть, что нужно добавить или убрать, чтобы продукт стал удобнее для конечного потребителя. Так что я буду благодарен за любую обратную связь.
Крейт на Crates.io и на GitHub.Я на Github и соавтор крейта — Николай Пахарев, системный разработчик в «Криптоните».
1К открытий2К показов