Config-manager: универсальное решение для настройки приложений на Rust

От проекта к проекту много сил уходит на конфигурирование продуктов. Рассказываем, как решить проблему с config-manager для Rust.

1К открытий1К показов

Привет! Меня зовут Михаил Михайлов, младший системный программист технологической и научно-исследовательской компании «Криптонит». В этой статье я расскажу, как автоматизировать процесс сборки конфигурации приложения из различных источников и упростить код с помощью собственного решения — крейта config-manager.

Мотивация

В какой-то момент мы — команда Rust-разработчиков компании «Криптонит» — поняли, что, переходя от проекта к проекту, много времени и сил тратим на написание кода, реализующего конфигурирование наших продуктов. Разными разработчиками пишется одно и то же. Мы решили положить этому конец и разработать универсальное решение.

Обычно, настраивая проекты в Kubernetes, мы берём параметры приложения преимущественно из Environment и ConfigMap. Помимо этого, бывает удобно отлаживать программу, запуская её с различными параметрами командной строки. То есть в большинстве наших проектов необходимо брать данные из трёх источников одновременно: конфигурационных файлов, параметров командной строки и переменных окружения. При этом важен приоритет тех или иных источников.

Config-manager: универсальное решение для настройки приложений на Rust 1

Таким образом перед нами встают две задачи: получение данных и их правильное слияние, что является довольно трудоёмкой и монотонной работой.

Хотелось создать инструмент, который автоматизирует этот процесс, но при этом оставит полный контроль пользователю.

Существующие решения

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

К примеру, крейт clap, который предоставляет гибкую работу с параметрами командной строки (а с версии 3.0 и с переменными окружения), имеет несколько недостатков:

  1. не работает с конфигурационными файлами,
  2. не позволяет выбрать приоритеты источников.

С крейтами ниже схожая проблема: оба работают с двумя источниками, но не поддерживают третий, что для нас недопустимо.

abscissa:

  1. работает с командной строкой, конфигурационными файлами,
  2. не поддерживает переменные окружения.

config-rs:

  1. работает с конфигурационными файлами, переменными окружения;
  2. не поддерживает командную строку.

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

Принципы

Перейдём к нашему проекту. Можно сказать, что крейт config-manager состоит из одного макроса. Почему мы выбрали макросы в качестве решения проблемы?

  1. Любая конфигурация обычно имеет вид структуры с публичными полями, которые нужно заполнять при инициализации.
  2. Одной из первых и главных особенностей было то, что имя переменной окружения, из которой читается значение, по умолчанию совпадает с именем соответствующего поля (это справедливо и для других источников — командной строки и файла). То есть по умолчанию, если поле называется version, то значение будет браться из переменной окружения с именем version.

Поэтому мы решили использовать derive макрос — им легко аннотировать структуру, и он имеет доступ к именам полей. Чтобы пользователю задействовать весь функционал библиотеки, необходимо лишь аннотировать структуру. Это делает код компактнее, красивее и удобнее в сопровождении.

На одной из итераций мы заметили, что derive-macro не всегда выглядит достаточно опрятно, поэтому решили использовать attribute-macro, который «под капотом» вызывал бы старый derive.

Config-manager: универсальное решение для настройки приложений на Rust 2

Коротко о том, какой код генерируют наши макросы:

  1. создаётся код, который собирает три источника в три соответствующих HashMap,
  2. начинается инициализация структуры.

Каждое поле инициализируется отдельно:

  1. определяется приоритет источников,
  2. в соответствующих HashMap ищутся нужные ключи,
  3. если нигде не найдено — ставится значение по умолчанию,
  4. если нет значения по умолчанию — выдаётся ошибка.

Стандартное применение макроса:

			use config_manager::config;

#[config(file(format = "toml", default = "./config.toml"))]
struct ApplicationConfig {
    #[source(clap(long, short = 'p'), env = "APP_PATH")]
    path: String,
    #[source(clap, config, env, default = 1)]
    delay: u64,
}
		
В данном примере аннотация 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. Кодогенерация на основе структур данных:

  1. поддержка практически любых типов полей, единственное условие — поле должно реализовывать “serde::Deserialize”,
  2. управление приоритетами источников при слиянии,
  3. определение имён значений для источников,
  4. гибкое задание значения по умолчанию, значением по умолчанию может выступать любой валидный Rust код, в том числе вызов функции,
  5. пользовательские методы десериализации полей из источников, можно привязать свой код десериализации к каждому полю,
  6. поддержка вложенных структур, что крайне полезно для модульных проектов с большими наборами параметров конфигурации,
  7. валидация типов на этапе компиляции.

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К открытий1К показов