В .NET уже несколько лет существует библиотека System.CommandLine, позволяющая быстро создавать CLI-приложения. Несмотря на то, что библиотека ещё в стадии beta, её активно используют и сами разработчики Microsoft, например, в утилите dotnet из пакета .NET SDK.

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

Реализация простого CLI-приложения

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

Опции и аргументы определяются совместно через обобщённый тип Option<T>:

var left = new Option<double>(
    aliases: new[] { "-l", "--left" },
    description: "Left operand")
    {
        IsRequired = true,
        Arity = ArgumentArity.ExactlyOne
    };

var right = new Option<double>(
    aliases: new[] { "-r", "--right" },
    description: "Right operand")
    {
        IsRequired = true,
        Arity = ArgumentArity.ExactlyOne
    };

Полностью исходный код можно найти в нашем GitLab-репозитории.

Подкоманды могут определять как группу других подкоманд, так и непосредственно действие, выполняемое подкомандой.

Определим для начала подкоманду sum:

var sum = new Command("sum", "Sum of the two numbers");
sum.Add(left);
sum.Add(right);
sum.SetHandler((l, r) => Console.WriteLine(l + r), left, right);

Разделение на подкоманды и группы подкоманд очень условное, т.к. с точки зрения C# оба вида определяются через класс Command. Например, нам ничто не мешает определить действие для подкоманды math:

var math = new Command("math", "Mathematical operations");
math.Add(sum);
math.SetHandler(() => Console.Write("Math command handler"));

Помимо примитивных типов, библиотека также поддерживает массивы, списки, FileInfo, DirectoryInfo и другие. Полный список можно узнать, заглянув в документацию. Если есть необходимость привязать опции к кастомному типу, то можно воспользоваться встроенным механизмом привязки через тип BinderBase<T>. Определим операцию вычитания, используя этот способ:

public record Subtract(double Left, double Right)
{
    public double Calc()
    {
        var result = Left - Right;
        File.WriteAllText("result.txt", $"{result}");
        return result;
    }
}

public class SubtractBinder : BinderBase<Subtract>
{
    private readonly Option<double> _left;
    private readonly Option<double> _right;

    public SubtractBinder(Option<double> left, Option<double> right) =>
        (_left, _right) = (left, right);

    protected override Subtract GetBoundValue(BindingContext bindingContext) =>
        new Subtract(
            bindingContext.ParseResult.GetValueForOption(_left),
            bindingContext.ParseResult.GetValueForOption(_right));
}

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

subtract.SetHandler(
    (sub) => Console.WriteLine(sub.Calc()), 
        new SubtractBinder(left, right));

Осталось определить Root command через одноимённый класс RootCommand:

var root = new RootCommand("CLI app example");
root.Add(math);
await root.InvokeAsync(args);

Теперь можно собрать приложение и проверить работу:

dotnet run - math // вывод: Math command handler
dotnet run - math sum -l 4 -r 7 // вывод: 13
dotnet run - math subtract -l 10 -r 3 // вывод: 7

Внедрение зависимостей

Жизненный цикл CLI-приложения выглядит следующим образом:

  1. Вызывается некоторая команда.
  2. Запускается процесс.
  3. Происходит обработка данных, возвращается результат.
  4. Процесс завершается.

Классические контейнеры зависимостей не рекомендуется использовать, поскольку зависимости одной команды могут быть совершенно не нужны для другой команды, а инициализация всех зависимостей может увеличить время запуска CLI-приложения. Вместо этого можно воспользоваться механизмом внедрения зависимостей для конкретного обработчика. Для этого снова понадобится класс BinderBase<T>. Определим новый класс ResultWriter, который будет записывать результат математической операции в файл:

public class ResultWriter
{
    public void Write(double result) => File.WriteAllText("result.txt", $"{result}");
}

Теперь создадим класс ResultWriterBinder. Этот класс инкапсулирует экземпляр ResultWriter:

public class ResultWriterBinder : BinderBase<ResultWriter>
{
    private readonly ResultWriter _resultWriter = new ResultWriter();

    protected override ResultWriter GetBoundValue(BindingContext bindingContext) => _resultWriter;
}

Теперь определим операцию умножения и внедрим туда экземпляр ResultWriter:

var multiply = new Command("multiply", "Multiply one number by another");
multiply.Add(left);
multiply.Add(right);
multiply.SetHandler((left, right, resultWriter) =>
{
    var result = left * right;
    resultWriter.Write(result);
    Console.WriteLine(result);
}, left, right, new ResultWriterBinder());

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

Кастомизация вывода

Справка генерируется автоматически из описаний, которые использовались при инициализации опций и команд. Например, команда cli-app math sum -h отобразит следующее:

Description:
  Sum of the two numbers

Usage:
  cli-app math sum [options]

Options:
  -l, --left <left> (REQUIRED)      Left operand
  -r, --right <right> (REQUIRED)    Right operand
  -?, -h, --help                    Show help and usage information

При желании можно заменить любой раздел справки, например, Description или создать новый. Добавим новую строку с текстом «This is a new section» в начало справки:

using System.CommandLine.Builder;
using System.CommandLine.Help;
using System.CommandLine.Parsing;

// остальной код
var parser = new CommandLineBuilder(root)
    .UseDefaults()
    .UseHelp(ctx =>
        ctx.HelpBuilder.CustomizeLayout(x =>
            HelpBuilder.Default
                .GetLayout()
                .Prepend(d =>
                    Console.WriteLine("This is a new section"))))
    .Build();

Тогда вывод станет следующим:

This is a new section

Description:
  Sum of the two numbers

// остальной текст

Если требуется улучшить внешний вид вывода данных в целом, то сделать это можно, например, написав кастомный способ отображения при помощи методов и свойств класса Console, таких как SetCursorPosition, ForegroundColor, BackgroundColor и т.д. Либо воспользоваться 3rd-party библиотеками:

  1. ShellProgressBar. Простая библиотека для отображения прогресса в командном окне.

  2. Spectre.Console. Мощная библиотека для создания красивых консольных приложений, которая имеет множество компонентов, упрощающих создание интерфейса. Кстати, обложка для статьи была сделана с помощью этой библиотеки.

  3. ConsoleGUI. Позволяет создавать полноценный GUI на основе консоли. Судя по всему, автор вдохновлялся WPF, так как в ней содержатся привычные для этого фреймворка компоненты: TextBox, CheckBox, DataGrid, DockPanel и другие.

Заключение

Библиотека System.CommandLine является полезным инструментом для создания CLI-приложений. Она даёт разработчикам гибкий инструментарий для работы командами и опциями, что позволяет сократить время разработки и создать удобный и функциональный пользовательский интерфейс.