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, изменить одно поле, записать обратно. Зато пользователь никогда не ищет, где лежит файл, и не ломает его синтаксис.
В следующем посте перейдём к доставке: как превратить проект в инструмент, который ставится одной командой.
А вот схема приоритетов:

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