Добавление сохраняемых параметров в приложения CLI в .NET

  • TL;DR
  • Введение
  • Реализация хранения конфигурации
    • Тестирование команд конфигурации
  • Использование конфигурации в бизнес-логике
  • Сохраняемые параметры
    • Демонстрация
  • Резюме
  • Ссылка

TL;DR

Посмотрите, как реализовать функцию сохраняемых параметров в приложении CLI/консоли. Например, Azure CLI предлагает сохраняемые параметры, которые позволяют хранить значения параметров для дальнейшего использования. Вы узнаете, как использовать System.CommandLine. Он предоставляет строительные блоки, которые делают функциональность композитной и многократно используемой.

Исходный код: https://github.com/NikiforovAll/cli-persistent-parameters-example

Введение

Сохраняемые параметры улучшают общий опыт разработчика. Поскольку предыдущие значения сохраняются и готовы к повторному использованию при следующем выполнении команды. Легко понять преимущества этой функции, посмотрев, как это делает az:

# Turn persisted parameters on.
az config param-persist on

# Create a resource group.
az group create --name RGName --location westeurope

# Create an Azure storage account in the resource group omitting "--location" and "--resource-group" parameters.
az storage account create 
  --name sa3fortutorial 
  --sku Standard_LRS
Вход в полноэкранный режим Выход из полноэкранного режима

Как вы можете предположить, здесь задействовано какое-то состояние. Поэтому прежде чем мы рассмотрим реализацию сохраняемых параметров, давайте рассмотрим, как реализовать конфигурацию в целом.

Как Azure CLI, так и AWS CLI используют специальный файл, созданный в известном каталоге. Формат файла должен быть легким и понятным для человека. Для этого подойдет язык разметки Ini.

Например:

Вот как может выглядеть ~/.azure/config:

───────┬────────────────────────────────────────────────────────────────────────
       │ File: /home/oleksii_nikiforov/.azure/config
───────┼────────────────────────────────────────────────────────────────────────
   1   │ [cloud]
   2   │ name = AzureCloud
   3   │ 
   4   │ [core]
   5   │ first_run = yes
   6   │ output = jsonc
   7   │ only_show_errors = false
   8   │ error_recommendation = on
   9   │ no_color = True
  10   │ disable_progress_bar = false
  11   │ collect_telemetry = no
───────┴────────────────────────────────────────────────────────────────────────
Войти в полноэкранный режим Выход из полноэкранного режима

Реализация хранения конфигурации

Моя цель — повторить поведение az CLI и сделать реализацию многоразовой, чтобы вы могли просто взять код и добавить его в свое приложение.

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

> az config -h

Group
    az config : Manage Azure CLI configuration.
        Available since Azure CLI 2.10.0.
        WARNING: This command group is experimental and under development. Reference and support
        levels: https://aka.ms/CLI_refstatus

Subgroups:
    param-persist : Manage parameter persistence.

Commands:
    get           : Get a configuration.
    set           : Set a configuration.
    unset         : Unset a configuration.
Войти в полноэкранный режим Выйти из полноэкранного режима

На высоком уровне структура может быть определена следующим образом:

// Program.cs
var root = new RootCommand();
root.Name = "clistore";

root.AddConfigCommands();

// ConfigCommands.cs
public static RootCommand AddConfigCommands(this RootCommand root)
{
    var command = new Command("config", "Manage CLI configuration");

    command.AddCommand(BuildGetCommand());
    command.AddCommand(BuildSetCommand());
    command.AddCommand(BuildUnsetCommand());

    root.AddCommand(command);
    return root;
}

private static Command BuildGetCommand()
{
    var get = new Command("get", "Get a configuration");
    var getpath = new Argument<string?>(
        "key",
        () => default,
        @"The configuration to get. If not provided, all sections and configurations
will be listed. If `section` is provided, all configurations under the
specified section will be listed. If `<section>.<key>` is provided, only
the corresponding configuration is shown.");
    get.AddArgument(getpath);

    return get;
}

private static Command BuildSetCommand(CliConfigurationProvider configurationProvider)
{
    var set = new Command("set", "Set a configuration");
    var setpath = new Argument<string[]>(
        "key",
        "Space-separated configurations in the form of <section>.<key>=<value>.");
    set.AddArgument(setpath);

    return set;
}

private static Command BuildUnsetCommand(CliConfigurationProvider configurationProvider)
{
    var unset = new Command("unset", "Unset a configuration");

    var unsetpath = new Argument<string[]>(
        "key",
        "The configuration to unset, in the form of <section>.<key>.");
    unset.AddArgument(unsetpath);

    return unset;
}
Вход в полноэкранный режим Выход из полноэкранного режима

Теперь нам нужен фактический код, который обрабатывает команды. Но перед этим нам нужно откуда-то взять конфигурацию. На мой взгляд, у нас уже есть необходимый и де-факто стандартный способ работы с конфигурацией, а именно — IConfiguration. Остается только использовать возможности Dependency Injection из System.CommandLine и определить BinderBase<IConfiguration>. Более подробную информацию см. на сайте https://docs.microsoft.com/en-us/dotnet/standard/commandline/dependency-injection.

public class CliConfigurationProvider : BinderBase<IConfiguration>
{
    public static CliConfigurationProvider Create(string storeName = "clistore") =>
        new(storeName);

    public CliConfigurationProvider(string storeName) => StoreName = storeName;

    public string StoreName { get; }

    public string ConfigLocationDir => Path.Combine(
        Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
        $".{StoreName.TrimStart('.')}");

    public string ConfigLocation => Path.Combine(ConfigLocationDir, "config");

    protected override IConfiguration GetBoundValue(BindingContext bindingContext) => GetConfiguration();

    public IConfiguration GetConfiguration()
    {
        var configuration = new ConfigurationBuilder()
            .AddIniFile(ConfigLocation, optional: true)
            .AddEnvironmentVariables(StoreName.ToUpperInvariant())
            .Build();

        return configuration;
    }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Как вы можете заметить, мы используем ConfigurationBuilder для составления конфигурации не только из файла конфигурации, но и из переменных окружения префикса. В результате мы можем переопределять параметры, например: CLISTORE_MyConfigKey.

Обработчик команды config get. Обратите внимание, что в качестве параметра передается IConfiguration:

var get = new Command("get", "Get a configuration");
var getpath = new Argument<string?>("key");
get.AddArgument(getpath);

get.SetHandler((string? path, IConfiguration configuration) =>
{
    var output = new Dictionary<string, object[]>();

    foreach (var config in configuration.GetChildren())
    {
        output[config.Key] = config.GetChildren()
            .Select(x => new { Name = x.Key, x.Value })
            .ToArray();
    }

    if (output.Any())
    {
        Console.WriteLine(JsonSerializer.Serialize(output, new JsonSerializerOptions()
        {
            WriteIndented = true,
            PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
        }));
    }

    return Task.CompletedTask;
}, getpath, CliConfigurationProvider.Create(root.Name));

return get;
Вход в полноэкранный режим Выйти из полноэкранного режима

На стороне записи нам нужно иметь возможность манипулировать файлом конфигурации. Я пропущу подробности парсинга. В основном, мы загружаем файл из известного места и можем вызвать Save(path), когда будем готовы.

public class CliConfigurationProvider : BinderBase<IConfiguration>
{
    // ... skipped
    public IniFile LoadIniFile()
    {
        var ini = new IniFile();

        Directory.CreateDirectory(ConfigLocationDir);

        if (File.Exists(ConfigLocation))
        {
            ini.Load(ConfigLocation);
        }

        return ini;
    }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Обработчик команды config set.

var configurationProvider = CliConfigurationProvider.Create(root.Name);
var set = new Command("set", "Set a configuration");
var setpath = new Argument<string[]>("key");
set.AddArgument(setpath);

set.SetHandler((string[] path) =>
{
    var ini = configurationProvider.LoadIniFile();

    foreach (var p in path)
    {
        var keyvalue = p.Split('=');
        var (key, value) = (keyvalue[0], keyvalue[^1]);

        var sectionKey = key[..key.IndexOf('.')];
        var configKey = key[(key.IndexOf('.') + 1)..];
        ini[sectionKey][configKey] = value;
    }
    ini.Save(configurationProvider.ConfigLocation);
    return Task.CompletedTask;
}, setpath);

return set;
Вход в полноэкранный режим Выход из полноэкранного режима

Тестирование команд конфигурации

Мы можем использовать Verify для выполнения тестирования моментальных снимков и проверки правильности вывода программы. Для того чтобы облегчить и упростить работу с перехватом и вызовом вывода процесса, я использовал CliWrap.

Вот несколько тестов. Я включил содержимое *.verfied.txt в качестве комментариев. Более подробную информацию можно найти в разделе Тесты.

[UsesVerify]
public class StoreCommands_Specs
{
    // kinda ugly, but I can live with it
    private const string relativeSourcePath = "../../../../../src";

    public StoreCommands_Specs()
    {
        // cleanup, runs every test, concurrent test execution is disabled
        EnsureDeletedConfigFolder();
    }

    [Fact]
    public async Task Help_text_is_displayed_for_config()
    {
        var stdOutBuffer = Execute("config", "--help");

        await Verify(stdOutBuffer.ToString());
    }
    // Description:
    // Manage CLI configuration
    // Usage:
    // clistore config [command] [options]
    // Options:
    // -?, -h, --help  Show help and usage information
    // Commands:
    // get <key>    Get a configuration []
    // set <key>    Set a configuration
    // unset <key>  Unset a configuration

    [Fact]
    public async Task Get_config_is_performed_on_populated_config()
    {
        Execute("config", "set", "core.target=my_value");
        Execute("config", "set", "core.is_populated=true");
        Execute("config", "set", "extra.another_section=false");
        var stdOutBuffer = Execute("config", "get");

        await Verify(stdOutBuffer.ToString());
    }
    // {
    // "core": [
    //     {
    //     "name": "is_populated",
    //     "value": "true"
    //     },
    //     {
    //     "name": "target",
    //     "value": "my_value"
    //     }
    // ],
    // "extra": [
    //     {
    //     "name": "another_section",
    //     "value": "false"
    //     }
    // ]
    // }

    private static (CommandResult, StringBuilder, StringBuilder) Execute(params string[] command)
    {
        var stdOutBuffer = new StringBuilder();
        var stdErrBuffer = new StringBuilder();

        var result = Cli.Wrap("dotnet")
            .WithStandardOutputPipe(PipeTarget.ToStringBuilder(stdOutBuffer))
            .WithStandardErrorPipe(PipeTarget.ToStringBuilder(stdErrBuffer))
            .WithArguments(args => args
                .Add("run")
                .Add("--project")
                .Add(relativeSourcePath)
                .Add("--")
                .Add(command))
            .WithValidation(CommandResultValidation.None)
            .ExecuteAsync().ConfigureAwait(false).GetAwaiter().GetResult();

        return stdOutBuffer;
    }

    private static void EnsureDeletedConfigFolder()
    {
        var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
            ".clistore",
            "config");
        if (File.Exists(path))
        {
            File.Delete(path);
        }
    }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Вот содержимое файла ~/.clistore/config после запуска теста:


       │ File: /home/oleksii_nikiforov/.clistore/config
───────┼────────────────────────────────────────────────────────────────────────
   1   │ [core]
   2   │ target=my_value
   3   │ is_populated=true
   4   │ 
   5   │ [extra]
   6   │ another_section=false
   7   │ 
───────┴────────────────────────────────────────────────────────────────────────
Вход в полноэкранный режим Выход из полноэкранного режима

Использование конфигурации в бизнес-логике

Допустим, мы хотим вывести «Hello, {target}» и принимаем {target} в качестве опции/аргумента. Если опция не предоставлена, мы должны проверить хранилище конфигурации на наличие значений по умолчанию.

Первый подход заключается в прямом использовании BinderBase<IConfiguration> и чтении из IConfiguration.

// Program.csharp
root.AddConfigCommands(out var configProvider);
root.AddCommand(GreetFromConfigCommand(configProvider));
// GreetCommandsFactory.cs
static void Greet(string target) => Console.WriteLine($"Hello, {target}");

public static Command GreetFromConfigCommand(CliConfigurationProvider configProvider)
{
    var command = new Command("greet-from-config", "Demonstrates how to use IConfiguration from DI container");
    var targetOption = new Option<string?>("--target");
    targetOption.IsRequired = false;
    command.AddOption(targetOption);
    command.SetHandler((string? target, IConfiguration configuration) =>
        Greet(target ?? configuration["core:target"]), targetOption, configProvider);

    return command;
}
Вход в полноэкранный режим Выйти из полноэкранного режима

Второй способ заключается в предоставлении значения по умолчанию фабрики getDefaultValue. Обратите внимание, что это может замедлить работу встроенных предложений:

// Program.csharp
root.AddConfigCommands(out var configProvider);
root.AddCommand(GreetFromDefaultValueCommand(configProvider));
// GreetCommandsFactory.cs
public static Command GreetFromDefaultValueCommand(CliConfigurationProvider configProvider)
{
    var command = new Command("greet-from-default-value", "Demonstrates how to provide default value to an option");
    var targetOptionWithDefault = new Option<string>("--target", getDefaultValue: () =>
    {
        // note, this is evaluate preemptively which may slow down autocompletion
        var configuration = configProvider.GetConfiguration();
        return configuration["core:target"];
    });
    command.AddOption(targetOptionWithDefault);
    command.SetHandler((string target) => Greet(target), targetOptionWithDefault);

    return command;
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Преимущество вышеописанного подхода в том, что вам не нужно беспокоиться о конфигурации в коде обработчика.

А обработчик можно упростить до command.SetHandler(Greet, targetOptionWithDefault), используя группы методов C#.

Тесты:

[Fact]
public async Task GreetFromConfig_greets_with_populated_target_config()
{
    Execute("config", "set", "core.target=World");
    var stdOutBuffer = Execute("greet-from-config");

    await Verify(stdOutBuffer.ToString());
}
// Hello, World


[Fact]
public async Task GreetFromDefaultValue_greets_with_default_value_from_populated_target_config()
{
    Execute("config", "set", "core.target=World");
    var stdOutBuffer = Execute("greet-from-default-value");

    await Verify(stdOutBuffer.ToString());
}
// Hello, World

Вход в полноэкранный режим Выход из полноэкранного режима

Сохраняемые параметры

Теперь мы хорошо понимаем, как реализовать приложения CLI с поддержкой состояния. Мы можем посмотреть, как добавить функцию постоянных параметров. Начнем с ожидаемого поведения и соответствующего теста:

[Fact]
public async Task GreetFromPersisted_greets_with_persisted_params_value()
{
    Execute("greet-from-persisted", "--target", "World");
    var stdOutBuffer = Execute("greet-from-persisted");

    await Verify(stdOutBuffer.ToString());
}
// Hello, World
Вход в полноэкранный режим Выход из полноэкранного режима

Параметр --target хранится как результат предыдущего успешного вызова. Мы можем организовать специальный раздел в конфигурации для хранения ранее использованных значений.

Для определения сохраняемых параметров мы можем использовать BinderBase<T> в качестве декоратора к Option<T>. Например:

public class PersistedOptionProvider<T> : BinderBase<T?>
{
    // skipped 

    protected override T? GetBoundValue(BindingContext bindingContext)
    {
        if (!bindingContext.ParseResult.HasOption(_option))
        {
            var ini = _configProvider.LoadIniFile();
            string text = ini[CliConfigurationProvider.PersistedParamsSection][_option.Name].ToString();
            var value = (T)TypeDescriptor.GetConverter(typeof(T))
                .ConvertFromString(text)!;

            return value;
        }

        return bindingContext.ParseResult.GetValueForOption(_option);
    }
}
Войти в полноэкранный режим Выйти из полноэкранного режима

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

Вот обработчик для сохраняемых параметров greeter:

public static Command GreetFromPersistedCommand(CliConfigurationProvider configProvider)
{
    var command = new Command("greet-from-persisted", "Demonstrates how to use persisted parameters");
    var targetOption = new Option<string>("--target");
    targetOption.IsRequired = false;
    command.AddOption(targetOption);

    command.SetHandler(Greet, new PersistedOptionProvider<string>(targetOption, configProvider));

    return command;
}
Войти в полноэкранный режим Выход из полноэкранного режима

Это решает задачу со стороны чтения. А что насчет стороны записи? В конце концов, мы хотим сохранить параметры после успешного завершения.

К счастью, System.CommandLine предоставляет удобную абстракцию — промежуточное ПО. Мы можем использовать его в CommandLineBuilder.AddMiddleware.

var root = new RootCommand();
root.Name = "clistore";

var commandLineBuilder = new CommandLineBuilder(root);

commandLineBuilder.AddMiddleware(async (context, next) => {/*implementation goes here*/});
commandLineBuilder.UseDefaults();

var parser = commandLineBuilder.Build();
await parser.InvokeAsync(args);
Вход в полноэкранный режим Выйти из полноэкранного режима

Довольно распространенной практикой является упаковка регистрации промежуточного ПО в методы расширения. Вот как записать сохраняемые параметры, используя подход middleware.

///<summary>
/// Stores provided options registered with CliConfigurationProvider.RegisterPersistedOption
///</summary>
public static CommandLineBuilder AddPersistedParametersMiddleware(
    this CommandLineBuilder builder, CliConfigurationProvider configProvider)
{
    return builder.AddMiddleware(async (context, next) =>
    {
        Lazy<IniFile> config = new(() => configProvider.LoadIniFile());
        var parseResult = context.ParseResult;

        await next(context);

        bool newValuesAdded = false;
        foreach (var option in configProvider.PersistedOptions)
        {
            if (parseResult.HasOption(option))
            {
                config.Value[CliConfigurationProvider.PersistedParamsSection][option.Name] =
                    parseResult.GetValueForOption(option)?.ToString();
                newValuesAdded = true;
            }
        }

        if (newValuesAdded)
        {
            config.Value.Save(configProvider.ConfigLocation);
        }
    });
}
Вход в полноэкранный режим Выход из полноэкранного режима

Демонстрация

17:32 $ dotnet run -- -h
Description:
  Shows proof of concept of how to store persistent configuration in a CLI apps

Usage:
  clistore [command] [options]

Options:
  --version       Show version information
  -?, -h, --help  Show help and usage information

Commands:
  config                    Manage CLI configuration
  greet-from-config         Demonstrates how to use IConfiguration from DI container
  greet-from-default-value  Demonstrates how to provide default value to an option
  greet-from-persisted      Demonstrates how to use persisted parameters

✘-1 ~/projects/configuration-builder/src [main|✔] 
17:32 $ dotnet run -- greet-from-persisted --target foo
Hello, foo
✘-1 ~/projects/configuration-builder/src [main|✔] 
17:32 $ dotnet run -- greet-from-persisted 
Hello, foo
✘-1 ~/projects/configuration-builder/src [main|✔] 
17:32 $ dotnet run -- greet-from-persisted --target bar
Hello, bar
Вход в полноэкранный режим Выход из полноэкранного режима

Резюме

Я показал вам, как использовать System.CommandLine и как реализовать функцию сохраняемых параметров в вашем CLI/консольном приложении. Предложенный код не является готовым к производству, но является достойной отправной точкой.

Ссылка

  • https://docs.microsoft.com/en-us/dotnet/standard/commandline/
  • https://docs.microsoft.com/en-us/cli/azure/param-persist-tutorial?tabs=azure-cli
  • https://github.com/VerifyTests/Verify
  • https://github.com/Tyrrrz/CliWrap

Оцените статью
devanswers.ru
Добавить комментарий