Начиная с первых версий .NET Core для сборки приложений, компания Microsoft предоставляет простой и удобный интерфейс командной строки (.NET CLI). Его возможности покрывают большинство потребностей по сборке, упаковке и тестированию приложений. Несмотря на это, по мере роста приложения, увеличения количества его составных частей/сборок/пакетов, усложнения процессов тестирования и развертывания, рядом с проектом часто появляются такие файлы сценариев как build.ps1, build.sh, build.cmd или даже полноценные инфраструктуры автоматизации построения приложений. В статье TeamCity C# script runner была предложена еще одна альтернатива - сценарии C#, которые особенно полезны, когда необходимо эффективно автоматизировать какой либо аспект сборки силами .NET разработчиков или  DevOps, знакомыми с синтаксисом C#. Тогда же была упомянута идея расширить встроенный API сценариев для более глубокой интеграции с TeamCity и для поддержки наиболее частых вариантов использования. Предполагая, что API сценариев чаще всего будет задействован при сборке приложений, мы в первую очередь решили расширить именно его. В этой статье будут приведены примеры использования этого API.

Расширение API прежде всего коснулось следующего:

  • запуск произвольных процессов синхронно и асинхронно;

  • запуск процессов .NET CLI и получение детализированных информации об ошибках, предупреждениях и тестах;

  • запуск процессов в Docker контейнерах;

  • создание сценариев с использованием опыта ASP.NET Core в плане композиции и внедрения зависимостей.

Этот API вместе с произвольным набором .NET библиотек позволяет достаточно просто и гибко реализовать сложные сценарии с минимальными усилиями. Очевидно, что сложные сценарии требуют больше затрат на развитие, поддержку и контроль регрессии. Возможность отладки сценариев и модульные тесты уменьшают эти затраты. Для этого, помимо пакета TeamCity.csi с инструментом для запуска сценариев, был добавлен пакет TeamCity.CSharpInteractive. Он позволяет использовать API сценариев из обычных консольных приложений .NET. Ещё один пакет TeamCity.CSharpInteractive.Templates с шаблоном проекта поможет с быстрым созданием подобных консольных приложений. Установка шаблона "build" выполняется командой .NET CLI:

dotnet new -i TeamCity.CSharpInteractive.Templates

Рассмотрим пример, в котором есть некое решение, и его нужно собрать. Воспользуемся сценариями C#. Первым делом создадим директорию, например Build1, и в ней выполним команду .NET CLI для создания проекта по шаблону "build":

dotnet new build

В директории Build1 появится проект консольного приложения. Это приложение при его выполнении будет собирать решение в родительской директории - ..\Build1. Новый проект уже содержит ссылку на NuGet пакет TeamCity.CSharpInteractive, и изначально включает два файла.

Файл Program.cs содержит код сценария:

using HostApi;

return new DotNetBuild().Build().ExitCode ?? 1;

и файл Program.csx  - “точку входа” для запуска этого сценария:

#load "Program.cs"

Program.csx, используя специальный REPL оператор сценариев #load, загружает код из файла Program.cs. Выражение using HostApi; добавляет пространство имен HostApi для использования API сценариев. Выражение new DotNetBuild() создает экземпляр соответствующей .NET CLI команде dotnet build, а метод расширения Build() выполняет эту команду, собирает приложение в текущей рабочей директории и возвращает результат выполнения, включая статистику сборки.

Но вместе с этим и Program.cs сам по себе является точкой входа консольного .NET приложения, представленного операторами верхнего уровня. Для его отладки в среде разработки первым делом необходимо указать рабочую директорию, относительно которой оно будет запущено и, следовательно, какие рабочие директории будут у порожденных им процессов.  Например, если вы хотите собрать проект в директории c:\Projects\MySolution, эту директорию и нужно указать в качестве рабочей директории для отладки. Запуск "шаблонного" консольного приложения Build1 выполнит процесс dotnet build, результатом его выполнения будет детальная статистика: сообщения от SDK, включая статистику по тестированию, при ее наличии. Если статистика сборки не нужна, то вместо метода расширения Build() для запуска процесса dotnet build можно воспользоваться методом расширения Run(), который просто выполнит процесс и вернет код выхода. Каждый метод расширения Build() и Run() имеет свой асинхронный аналог с постфиксом Async для запуска процессов асинхронно.

Так как проект в директории  Build1 - это обычное консольное .NET приложение, его можно выполнить из командной строки, например так: dotnet run --project Build1 или, используя C# Script tool, и “точку входа” для запуска C# сценария: dotnet csi Build1\Program.csx. За аналогичные действия на TeamCity отвечает команда run в .NET runner или C# Script runner для сценария Build1\Program.csx. Это позволяет без дополнительных усилий собирать решения локально, и использовать этот же сценарий на TeamCity всего лишь в одном шаге конфигурации сборки. Хочется отметить, что статистика выполнения на рабочей машине и в TeamCity, а именно: список ошибок, предупреждений, тестов - будет совпадать.

Немного модифицируем сценарий Build1 в следующем примере Build2. Теперь приложение собирается с определённой версией и конфигурацией, переданными через свойства “version” и "configuration". В случае успешной сборки параллельно выполняются два одинаковых набора тестов: на текущей машине и в Linux контейнере "mcr.microsoft.com/dotnet/sdk:6.0". Перед запуском в контейнере текущая директория процесса монтируется в "/project", на нее же будет указывать и рабочая директория для процесса внутри контейнера:

using HostApi;
using NuGet.Versioning;

var configuration = Property.Get("configuration", "Release");
var version = NuGetVersion.Parse(Property.Get("version", "1.0.0-dev", true));

var result = new DotNetBuild()
    .WithConfiguration(configuration)
    .AddProps(("version", version.ToString()))
    .Build();

Assertion.Succeed(result);

var test = new DotNetTest()
    .WithConfiguration(configuration)
    .WithNoBuild(true);

var testInContainer = new DockerRun(
        test.WithExecutablePath("dotnet"),
        $"mcr.microsoft.com/dotnet/sdk:6.0")
    .WithPlatform("linux")
    .AddVolumes((Environment.CurrentDirectory, "/project"))
    .WithContainerWorkingDirectory("/project");

var results = await Task.WhenAll(
    test.BuildAsync(),
    testInContainer.BuildAsync()
);

Assertion.Succeed(results);

В файл Tools.cs вынесены вспомогательные методы: Property.Get для получения параметров и Assertion.Succeed для проверки результатов выполнения .NET CLI команд. В последнем анализируется код выхода и статистика тестов, и если код выхода не 0, выполнение сценария прекращается с информацией об ошибке и списком неудачных тестов. DotNetBuild, DotNetTest, DockerRun представляют процессы и их параметры. Этих параметров достаточно много, и в них можно запутаться, но среда разработки подсказывает их назначение. 

Для выполнения консольного проекта и его отладки достаточно того, чтобы он просто собирался. Для выполнения REPL (read-evaluate-print-loop) сценария Program.csx необходимо загрузить весь исполняемый код в правильной последовательности. Так как код в Program.cs зависит от кода в Tools.cs, код из Tools.cs должен быть загружен до кода из Program.cs, используя REPL операторы #load:

#load "Tools.cs"
#load "Program.cs"

Если же проект зависит, например, от пакета MyPackage версии 1.2.3, то для корректной работы сценария необходимо добавить REPL оператор #r "nuget: MyPackage, 1.2.3" до использования любых его типов. Для загрузки сборки используется похожий оператор вида #r "MyAssembly.dll”.

Оба примера выше просты и стоимость их поддержки невелика. Этого нельзя сказать про множество других проектов, со сложной логикой построения, множеством сборок, пакетов, других артефактов, разнообразием тестов, выполняемых при различных условиях. Часто они полагаются на сценарии PowerShell, Bash, сложные MSBuild проекты, Cake, Nuke для описания и контроля процесса построения. Поэтому немного усложним Build2 в следующем примере Build3. Точка входа в приложение Program.cs теперь выглядит так:

using Microsoft.Extensions.DependencyInjection;
using NuGet.Versioning;

var configuration = Property.Get("configuration", "Release");
var version = NuGetVersion.Parse(Property.Get("version", "1.0.0-dev", true));

await 
    GetService<IServiceCollection>()
        .AddSingleton(_ => new Settings(configuration, version))
        .AddSingleton<IRoot, Root>()
        .AddSingleton<IBuild, Build>()
        .AddSingleton<ICreateImage, CreateImage>()
    .BuildServiceProvider()
    .GetRequiredService<IRoot>()
    .BuildAsync();

Для того, чтобы собирать подзадачи в более крупные задачи здесь используется композиция. Для контроля управления зависимостями и создания корня композиции применяется знакомая многим библиотека Microsoft Dependency Injection. Вместо того, чтобы придумывать DSL для “оркестрации" задач и процессов, как сделано, например, в Cake и Nuke, пока предлагается максимально простой API. А как его использовать: запускать процессы/задачи последовательно, асинхронно, параллельно, использовать машину состояний и т. п., полностью зависит от автора проекта, так как:

  • любой DSL нужно изучать,

  • вероятно у автора есть уже свои предпочтения,

  • любой DSL имеет ограничения, и часто приходится с ним бороться, чтобы сделать что-либо выходящее за его возможности.

GetService из первой строчки после await — это статическая функция для доступа ко всему набору сервисов API сценариев. Выражение GetService<IServiceCollection>() получает коллекцию IServiceCollection, она уже содержит весь этот же набор сервисов. Следующие набор выражений AddSingleton<>() регистрируют несколько реализаций задач в эту коллекцию. Далее вызов функции BuildServiceProvider() cоздает экземпляр IServiceProvider, содержащий сервисы и задачи из коллекции выше, а вызов GetRequiredService<IRoot>() создает корень композиции IRoot - “главную" задачу. И в заключении асинхронно вызывает её единственную функцию BuildAsync() из файла Root.cs, которая играет роль диспетчера и, используя свойство "target", выбирает и выполняет подзадачу:

public Task<string> BuildAsync() =>
	  Property.Get(_properties, "target", "Build") switch
    {
  	    "Build" => _build.BuildAsync(),
        "CreateImage" => _createImage.BuildAsync(),
        _ => throw new ArgumentOutOfRangeException()
    };

где:

Build” это, по сути, весь предыдущий сценарий из примера Build2

  • собирает приложение,

  • выполняет тесты параллельно,

  • дополнительно публикует бинарные файлы приложения в директорию,

  • возвращает путь к этой директории в качестве результата.

CreateImage” используя бинарные файлы приложения, создает Docker образ

  • выполняет “Build”,

  • готовит образ для запуска приложения в Docker контейнере,

  • сохраняет его в .tar файл,

  • публикует его как артефакт TeamCity, если выполняется под TeamCity,

  • возвращает путь к файлу с образом в качестве результата.

Этот пример не требует изучения специализированных DSL. Все связи между задачами при передаче параметров и результатов - очевидны. Кроме того, код в этом примере имеет слабую связанность и лоялен к модульному тестированию вследствие того, что все нестабильные зависимости внедряются в экземпляры типов через конструктор. А использование таких статических методов расширения, как Build() или Run(), из предыдущих примеров заменено на вызовы соответствующих сервисов. Например, вместо кода в примере Build2:

new DotNetBuild()
    .WithConfiguration(configuration)
    .AddProps(("version", version.ToString()))
    .Build();

в примере Build3 используется немного модифицированный аналог:

var build = new DotNetBuild()
    .WithConfiguration(_settings.Configuration)
    .AddProps(("version", _settings.Version.ToString()));

_runner.RunAsync(build);

где _runner - это экземпляр сервиса IBuildRunner из API сценариев, внедренный через конструктор.

Подводя итог, API сценариев C# предоставляет следующие возможности:

  • использовать привычный синтаксис языка C#;

  • упростить создание нового проекта для построения приложения с помощью шаблона "build" и .NET CLI команды dotnet new build;

  • делать код сценариев простым и с низкой связанностью;

  • отлаживать код в среде разработки;

  • писать модульные тесты;

  • запускать код как кроссплатформенное консольное .NET приложение или как сценарий;

  • запускать приложения или сценарии как локально, так и на CI/CD сервере в одном шаге сборки TeamCity, при этом статистика сборки (ошибки, предупреждения, тесты) на TeamCity и локально будет совпадать.

Комментарии (0)