Створюємо Command-Line Interface додаток за допомогою .NET Core і розповсюджуємо як .NET tools

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті.

Привіт, мене звати Антон. Я працюю .NET-розробником у компанії Sitecore Ukraine у команді Developer Experience, яка створює додатки як для сторонніх Sitecore-розробників, так і для власних потреб компанії, що допомагають полегшити розробку та DevOps-процеси.

Більшість із нас використовують Command-line interface (CLI-) додатки щодня, але мало хто замислювався над створенням власних, окрім примітивних програм на перших курсах університету.

У цій статті я розповім про .NET Core Global Tools і наведу кілька прикладів імплементації простої програми.

Навіщо мені створювати власний CLI-додаток

Однією з головних переваг консольного додатку є те, що він може приймати всі вхідні дані як аргументи, а отже не потребує взаємодії з користувачем. Як результат, це дозволяє використовувати CLI-додатки для автоматизації процесів, які виконуються у повністю автоматичному режимі (наприклад CI/CD).

Переваги розповсюдження у форматі .NET tool

З релізом .NET Core 2.1 SDK ми отримали нову фічу під назвою .NET Core Global Tools, яка дає нам змогу створювати кросплатформені CLI-додатки на базі .NET Core.

.NET tool — це спеціальний nuget package, що містить консольний додаток, який можна встановити як global tool чи local tool. Завдяки такому формату пакування ви отримуєте всі переваги одного з найпоширеніших менеджерів пакетів. Серед них:

  1. Повна підтримка роботи з версіями:
    1. Зберігання необмеженої кількості версій вашого додатку.
    2. Використання різних версій додатку для різних продуктів.
    3. «Замороження» використання певної версії додатку, яка сумісна з певною версією вашого продукту. При цьому можна продовжувати розробку додатку для наступних версій продукту і вносити зміни, які могли б зламати попередні версії продукту.
    4. Використання Pre-release версій, щоб показати, що ця версія додатку ще в роботі.
  2. Використання nuget.org як способу розповсюдження вашого додатку:
    1. При застосуванні nuget.org ви можете використовувати ваш додаток у будь-якому процесі (на локальному комп’ютері, на сервері в локальній мережі або ж навіть при хмарних обчисленнях). Головне, щоб процес мав доступ до інтернету.
    2. Не потрібно знаходити місце (локальна папка, папка зі спільним доступом на сервері...) для зберігання додатку.
  3. Якщо ж ви не хочете робити ваш додаток публічним, у вас є опції розміщення його на локальному nuget сервері, або ж (у найгіршому разі) в папці на диску вашого комп’ютера, або сервера в мережі.
  4. Набір інструментів від Microsoft сам визначить, які dll-файли (окрім вашого) потрібно встановити, щоб додаток працював на якомусь конкретному комп’ютері. Отже, пакування та інсталяція додатку стають набагато простішими.

Приклад встановлення як Global Tool

dotnet tool install -g myfirstcli

Результат:

You can invoke the tool using the following command: myfirstcli
Tool 'myfirstcli' (version '1.0.0') was successfully installed.

Приклад встановлення як Local Tool

Щоб встановити засіб лише для локального доступу (для поточного каталогу та підкаталогів), його необхідно додати до файлу маніфесту засобу. Щоб створити файл маніфесту засобу, виконайте наступну команду:

dotnet new tool-manifest

Результат:

The template "Dotnet local tool manifest file" was created successfully.

Локально у директорії .config ви знайдете dotnet-tools.json файл з наступним :

{
 "version": 1,
 "isRoot": true, 
 "tools": {
          } 
}


dotnet tool install myfirstcli:

Результат:

You can invoke the tool from this directory using the following commands: 'dotnet tool run myfirstcli' or 'dotnet myfirstcli'.
Tool 'myfirstcli' (version '1.0.0') was successfully installed. Entry is added to the manifest file C:\examples\Test\.config\dotnet-tools.json.
dotnet-tools.json:
{
  "version": 1,
  "isRoot": true,
  "tools": {
    "myfirstcli": {
      "version": "1.0.0",
      "commands": [
      "myfirstcommand"
     ]
    }
  }
}

Створюємо власний .NET tool application

Далі я наведу приклад створення звичайного консольного додатку CopyrightsCheck.Cli, який можна використовувати у CI/CD процесах. Головним функціоналом програми буде перевірка копірайт-хедерів у заданих файлах і створення файлу з репортом.

Для створення .NET tool application треба використовувати шаблон консольного додатку. Простіше за все створити його з терміналу за допомогою наступної команди:

dotnet new console -n myfirstcli -f net6.0

Ця команда створює консольний проєкт з потрібною назвою і версією .NET. Наступним кроком буде конфігурування проєкту як .NET tool. Ключовим налаштуванням тут буде true, яке допоможе пакувати додаток як .NET tool, що дасть вам можливість надалі легко встановити наш додаток за допомогою команди dotnet tool install :

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net6.0</TargetFramework>
    <PackAsTool>true</PackAsTool>
    <ToolCommandName>copyrightsManager</ToolCommandName>
	<ItemGroup>
<PackageReference Include="System.CommandLine.Hosting" Version="0.3.0-alpha.21216.1" />
<PackageReference Include="Spectre.Console" Version="0.43.1-preview.0.2" />
	</ItemGroup>
  </PropertyGroup>
</Project>

У нашому тестовому проєкті ми будемо використовувати System.CommandLine бібліотеку. Вона є дуже потужним інструментом, що допомагає створювати та інтерпретувати команди нашого консольного додатку. System.CommandLine.Hosting допомагає налаштувати наш додаток та Spectre.Console — вона знадобиться, щоб зробити вашу консоль більш виразною.

Program.cs :

var parser = BuildCommandLine()
    .UseHost(_ => Host.CreateDefaultBuilder(args), (builder) =>
    {
        builder
            .ConfigureServices(services =>
            {
                services.TryAddSingleton<ICopyrightsService, CopyrightsService>();
            })
            .ConfigureLogging(loggingBuilder => loggingBuilder.ClearProviders())
            .UseCommandHandler<CopyrightsCheckCommand, CopyrightsCheckCommand.Handler>();

    }).UseDefaults().Build();

return await parser.InvokeAsync(args);

static CommandLineBuilder BuildCommandLine()
{
    var root = new RootCommand("Use this tool to work with copyrights.");
    var copyrightsCheckCommand = new CopyrightsCheckCommand();
    root.AddCommand(copyrightsCheckCommand);

    return new CommandLineBuilder(root);
}

Як видно з коду program.cs файла (так, це новий спрощений синтаксис .net 6.0, не дивуйтесь), конфігурація відбувається у розширенні UseHost для CommandLineBuilder, де ми можемо налаштувати сервіси, роботу з конфігураційними файлами тощо. У статичному методі BuildCommandLine налаштовується головна команда.

Далі розглянемо реалізацію команди CopyrightsCheckCommand, яку ми додали в головному файлі:

CopyrightsCheckCommand:

class CopyrightsCheckCommand : Command
    {
        public CopyrightsCheckCommand() : base("check", "Checks copyrights header in selected files.")
        {
            AddOption(new Option<string>(new[] { "--copyright", "-c" }, "Copyright text.") { IsRequired = true });
            AddOption(new Option<string[]>(new[] { "--includePaths", "-ip" }, "Included paths.") { IsRequired = true });
            AddOption(new Option<string[]>(new[] { "--excludePaths", "-ep" }, "Excluded paths."));
            AddOption(new Option<string>(new[] { "--extensions", "-e" }, "File extensions.") { IsRequired = true });
            AddOption(new Option<string>(new[] { "--result", "-r" }, "Copyrights check results directory."));
        }

        public new class Handler : ICommandHandler
        {
            public string Copyright { get; set; }
            public string[] IncludePaths { get; set; }
            public string[] ExcludePaths { get; set; }
            public string[] Extensions { get; set; }
            public string Result { get; set; }

            private readonly ICopyrightsService _copyrightsService;

            public Handler(ICopyrightsService copyrightsService)
            {
                _copyrightsService = copyrightsService;
            }
            public async Task<int> InvokeAsync(InvocationContext context)
            {

                var copyrightsCheckResults = await _copyrightsService.CheckCopyrights(IncludePaths, ExcludePaths, Extensions, Copyright);

                AnsiConsole.Write(
                    new FigletText($"Copyrights Check Result:")
                        .Centered()
                        .Color(Color.Aqua));

                var copyrightsCheckTable = new Table()
                    .Border(TableBorder.HeavyEdge)
                    .BorderColor(Color.Green)
                    .AddColumn(new TableColumn("File Name"))
                    .AddColumn(new TableColumn("File Directory"))
                    .AddColumn(new TableColumn("Copyrights Check Result").Centered());

                foreach (var copyrightsCheckResult in copyrightsCheckResults)
                {
                    copyrightsCheckTable.AddRow(
                        copyrightsCheckResult.FileName,
                        copyrightsCheckResult.FileDirectory,
                        copyrightsCheckResult.CheckResult ? $"[blue]{copyrightsCheckResult.CheckResult}[/]" : $"[red]{copyrightsCheckResult.CheckResult}[/]");
                }

                AnsiConsole.Write(copyrightsCheckTable);

                if (!string.IsNullOrWhiteSpace(Result))
                {
                    var resultFileName = $"{Result}{Path.DirectorySeparatorChar}copyrightsCheckResult.json";
                    await using FileStream createStream = File.Create(resultFileName);
                    await JsonSerializer.SerializeAsync(createStream, copyrightsCheckResults);
                    AnsiConsole.Write(new Text($"Copyrights check result saved to file: {resultFileName}", new Style(Color.Aqua)));
                }

                return copyrightsCheckResults.All(res => res.CheckResult) ? 0 : 1;
            }
        }
} 

Як бачите, всі налаштування містяться всередині команди, чи це назва та опис команди, чи конфігурування додаткових опцій, весь цей функціонал можливий за допомоги System.CommandLine бібліотеки. Це дає справді чистий вигляд консольних команд, які легко розширювати і тестувати, позбавляє потрібності «парсити» усе за допомогою кастомного коду. Також зверніть увагу, як легко вивести дані у табличному вигляді за допомогою Spectre.Console.

Реалізація ICopyrightsService :

class CopyrightsService : ICopyrightsService
    {
        private readonly IFileManager _fileManager;
        public CopyrightsService(IFileManager fileManager)
        {
            _fileManager = fileManager;
        }

        public async Task<List<CopyrightsCheckResult>> CheckCopyrights(string[] includePaths, string[] excludePaths, string[] extensions, string copyrightsText)
        {
            var result = new List<CopyrightsCheckResult>();
            var extensionsPattern = string.Join('|', extensions);
            foreach (var includePath in includePaths)
            {
                if (_fileManager.DirectoryExist(includePath))
                {
                    var files = _fileManager.GetFiles(includePath, extensionsPattern);

                    foreach (var file in files)
                    {
                        if (excludePaths!=null && excludePaths.Any(file.Contains))
                        {
                            continue;
                        }

                        var fileInfo = new FileInfo(file);
                        using StreamReader reader = _fileManager.GetReader(file);
                        var line = await reader.ReadLineAsync();


                        result.Add(new CopyrightsCheckResult
                        {
                            CheckResult = line == copyrightsText,
                            FileName = fileInfo.Name,
                            FileDirectory = fileInfo.DirectoryName
                        });
                    }
                }
            }

            return result;
        }
}  

Результат виконання команди dotnet copyrightsManager -h:

Ми бачимо, що команда з’явились у нашому додатку.

Результат виконання команди dotnet copyrightsManager check -h:

Результат виконання команди:

dotnet copyrightsManager check -ip C:\examples\CopyrightsCheck\src 
-ep C:\examples\CopyrightsCheck\src\obj C:\examples\CopyrightsCheck\src\bin  -e *.cs 
-c "// Copyright (C) 2022 - All Rights Reserved" -r C:\examples:

Також ми отримали файл copyrightsCheckResult.json з результатами перевірки, який можна додати до білд артефактів:

[
    {
        "FileName": "Program.cs",
        "FileDirectory": "C:\\examples\\CopyrightsCheck\\src",
        "CheckResult": false
    },
    {
        "FileName": "CopyrightsCheckCommand.cs",
        "FileDirectory": "C:\\examples\\CopyrightsCheck\\src\\Commands",
        "CheckResult": true
    },
    {
        "FileName": "CopyrightsCheckResult.cs",
        "FileDirectory": "C:\\examples\\CopyrightsCheck\\src\\Models",
        "CheckResult": false
    },
    {
        "FileName": "CopyrightsService.cs",
        "FileDirectory": "C:\\examples\\CopyrightsCheck\\src\\Services",
        "CheckResult": false
    },
    {
        "FileName": "ICopyrightsService.cs",
        "FileDirectory": "C:\\examples\\CopyrightsCheck\\src\\Services",
        "CheckResult": true
    }
]

Повний перелік команд, потрібних для використання додатку на CI:

dotnet new tool-manifest
dotnet nuget add source -n MyPackageSource   https://api.mynugetserver.org/v3/index.json
dotnet tool install copyrightsManager.cli
dotnet copyrightsManager check -ip .\src -ep .\src\obj .\src\bin  -e *cs -c "// Copyright (C) 2022 - All Rights Reserved" -r .\artifacts

Ось так, з мінімальним об’ємом коду, ми створили CLI-додаток на .NET 6.0. Звичайно, написати консольний додаток можна без використання спеціальних фреймворків, але така реалізація буде містити набагато більше коду, його буде важко підтримувати, розширяти і тестувати.

У наступній статті я спробую розповісти, як зробити плагін модель для CLI-додатку, яка дозволить пакувати команди в окремі плагіни та динамічно підключати їх до основного додатку.

Посилання:

Сподобалась стаття? Натискай «Подобається» внизу. Це допоможе автору виграти подарунок у програмі #ПишуНаDOU

👍ПодобаєтьсяСподобалось17
До обраногоВ обраному4
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Які шарпи нечитабельні все-таки, жесть

Нафига «.NET Core» для межплатформенных консольных программ, когда естъ стaрый-добрый C?

Ну или для жаждущих отстреливатъ себе ноги хипстеров — C++...

может потому что быстрее писать такие утилиты ?

Зачем старый добрый С, когда есть ASM?

У мене на проекті теж використується, з метою швидкого комплексного тестування всієї системи разом

Автор, а вы в своих проектах которые отдаете сторонним разработчикам тоже используете NuGet пакеты которые в альфа/бета версиях и у которых вполне возможны ломающие изменения?
Или же только для примера этот пакет взяли?
Если только для примера то хотелось бы узнать о том что вы для продакшена используете?

Что касается конкретно System.CommandLine библиотек — да, т.к. они пока только в альфа/бета версиях на данный момент, в остальном используем стабильные версии.

І я про це писав, можно було би і мене в посилання закинути dev.to/...​ent-with-copy-paster-46c8 :)

Ознайомився з Вашою статтею, класно що .Net Tools стає популярним!

Підписатись на коментарі