CLI приложения #21. Где живут настройки

Представьте, что ваша утилита ходит на сервер. Адрес, токен, таймаут — всё это нужно передавать при каждом запуске:

myapp sync --server https://api.example.com --timeout 60 --token abc123

Один раз написать можно. Двадцать раз в день — уже нет. Значит, нужны настройки, которые задаются однажды и дальше применяются сами.

Первый вопрос — где их хранить. Напрашивается положить файл рядом с исполняемым, но так делать не стоит. Программа может лежать в Program Files, куда обычному пользователю нет записи. А если её поставили как dotnet tool, то при обновлении папка меняется целиком, вместе с вашим конфигом.

Правильное место — каталог пользовательских данных, и в каждой системе он свой:

Windows   %APPDATA%\myapp\config.json
Linux     ~/.config/myapp/config.json
macOS     ~/.config/myapp/config.json

Хорошая новость: .NET знает про эти различия и сам подставит нужный путь.

internal static class ConfigPath
{
    public static string Get()
    {
        var baseDir = Environment.GetFolderPath(
            Environment.SpecialFolder.ApplicationData,
            Environment.SpecialFolderOption.Create);

        return Path.Combine(baseDir, "myapp", "config.json");
    }
}

Параметр SpecialFolderOption.Create создаст папку, если её ещё нет.

Теперь второй вопрос, и он интереснее. Настройки приходят из нескольких мест сразу: что-то зашито в коде, что-то лежит в файле, что-то задано переменной окружения, а что-то пользователь написал прямо в команде. Кто кого перебивает?

Схема приоритетов#

Логика простая: чем ближе источник к моменту запуска, тем он главнее. Аргумент командной строки — это то, что человек набрал прямо сейчас, значит он и побеждает.

Опишем сами настройки:

internal sealed record AppSettings
{
    public string Server { get; init; } = "https://localhost:5001";
    public int Timeout { get; init; } = 30;
    public string? Token { get; init; }
}

Значения прямо в объявлении — это первый уровень, дефолты. Дальше накладываем остальные слои:

internal static class SettingsLoader
{
    public static AppSettings Load(string? serverFromCli, int? timeoutFromCli)
    {
        var settings = new AppSettings();

        // 2. файл конфигурации
        var path = ConfigPath.Get();
        if (File.Exists(path))
        {
            var fromFile = JsonSerializer
                .Deserialize<AppSettings>(File.ReadAllText(path));

            if (fromFile is not null)
                settings = fromFile;
        }

        // 3. переменные окружения
        var envServer = Environment.GetEnvironmentVariable("MYAPP_SERVER");
        if (!string.IsNullOrEmpty(envServer))
            settings = settings with { Server = envServer };

        var envToken = Environment.GetEnvironmentVariable("MYAPP_TOKEN");
        if (!string.IsNullOrEmpty(envToken))
            settings = settings with { Token = envToken };

        // 4. аргументы командной строки
        if (serverFromCli is not null)
            settings = settings with { Server = serverFromCli };

        if (timeoutFromCli is not null)
            settings = settings with { Timeout = timeoutFromCli.Value };

        return settings;
    }
}

Здесь пригодилось ключевое слово with из записей: каждый слой создаёт копию с изменённым полем, не трогая остальные.

Обратите внимание на типы параметров: string? и int?. Опции в команде тоже объявляются без значений по умолчанию:

var serverOption = new Option<string?>("--server")
{
    Description = "Адрес сервера"
};

Это важный приём. Если задать опции значение по умолчанию прямо в System.CommandLine, то потом невозможно отличить «пользователь ничего не указал» от «пользователь указал ровно столько же». А нам нужно именно это: null означает «данного слоя нет, бери из предыдущего».

Важно!

Токены и пароли в файле настроек хранить не стоит, и в примере выше Token подтягивается только из переменной окружения. Файл лежит открытым текстом, легко попадает в резервные копии и случайно показывается на демонстрации экрана. Для секретов есть переменные окружения, системное хранилище ключей и отдельные файлы с ограниченными правами доступа — но не общий конфиг.

Остался последний штрих: пользователю надо как-то этот файл заполнять. Руками лезть в %APPDATA% неудобно, поэтому утилиты обычно умеют редактировать настройки сами — через те самые подкоманды из четвёртого поста:

myapp config set server https://api.example.com
myapp config get server
myapp config list

Реализация простая: прочитать JSON, изменить одно поле, записать обратно. Зато пользователь никогда не ищет, где лежит файл, и не ломает его синтаксис.

В следующем посте перейдём к доставке: как превратить проект в инструмент, который ставится одной командой.


А вот схема приоритетов:

Приоритет: дефолты → config.json → окружение → аргументы

Схема приоритетов настроек