NUKE Umieszczanie aplikacji na środowiskach i ich kompilacja zazwyczaj nie są problemami programistów.
Od tego są inni pracownicy, którzy mają tam swoje Jenksins-y i tym podobne narzędzie służące do automatyzacji budowania, testowania, i wdrażania aplikacji na środowiska testowe. Jenkins to jeden z najpopularniejszych systemów CI/CD. A co to jest ten CI/CD?
To skóry oznaczające odpowiednio Continuous Integration i Continuous Delivery.
Continous integration : ciągła integracja polega na tym, że zespół programistów wprowadza zmiany w kodzie, te zmiany przechodzą przez testy, po czym są one późnień integrowane z innymi zmianami i są one umieszczane np. do środowisk testowych.
Continuous Delivery : czyli ciągle dostarczanie polega na przygotowaniu struktury serwerowej, aby nasze zmiany się znalazły na środowiskach testowy albo nawet na produkcji.
Istnieje jeszcze inne pojęcie jak Continous Testing, które mówi o ciągłym testowaniu albo przez automaty, albo przez ludzi.
Wszystkie te filozofie mówią o zwiększaniu jakości kodu i procesu wdrażania poprzez zwiększenie ich częstotliwości, a żeby zwiększyć częstotliwość scalania zmian w kodzie i jego testowania to trzeba ten proces jak najbardziej jest to możliwe zautomatyzować.
Zazwyczaj programiści się tym nie interesują, bo bądźmy szczerze to nie jest na twojej głowie, jakie komendy CMD są odpalane i czy to jest PowerShell, czy innym język jak BASH, aby twoje zmiany przeszły przez tę rurę procesów, aby w końcu trafić na inne środowisko testowe albo nawet produkcyjne.
Istnieją jednak frameworki, które pozwoliłby Ci napisać tę logikę wdrażania aplikacji napisać w C#. Oto one :
- Cake.NET
- Nuke
- Bullseye z SimpleExec
Chciałem przetestować każdy z nich, ale miałem problemy z uruchomieniem prostego przykładu w Cake.NET więc w tym wpisie skupię tylko na rozwiązaniu NUKE.
Zanim jednak przejdziemy do Nuke musimy stworzyć prosty projekt, który będziemy mówiąc slagowo po angielsku deplojować.
Oto proces tworzenia takiego projektu demo od zera. Zaczynam od Blank Solution.
Do niego dodaje pusty projekt ASP.NET Core Empty
Stwórzmy proste API do gier, które przejdzie przez nasze procesy wdrażania.
Nasze API zawiera tylko klasę domenową, która reprezentuje grę. Szczerze mówiąc idąc z duchem czasu C# 9.0 taka klasa może być rekordem.
public record Game(int Id, string Name, int Score, int Year);
//public class Game
//{
// public Game(int id, string name, int score, int year)
// {
// Id = id;
// Name = name;
// Score = score;
// Year = year;
// }
// public string Name { get; set; }
// public int Score { get; set; }
// public int Year { get; set; }
// public int Id { get; set; }
//}
Do stworzenia prostego, API nie potrzebujemy kontrolerów. W klasie Startup możemy powiedzieć, że dla adresu "/games" zwracamy wszystkie gry, a dla ścieżki "/game/{id}" jedną grę o danym ID.
public class Startup
{
private static readonly List<Game> Games = new()
{
new(1, "Settlers", 70, 1993),
new(2, "Dune II", 80, 1993),
new(3, "Silent Hill 2", 87, 2001),
new(4, "Resident Evil", 73, 1996),
};
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
app.UseRouting();
app.UseEndpoints(endpoints =>
{
endpoints.Map("/games", async context =>
{
await context.Response.WriteAsJsonAsync(Games);
});
endpoints.Map("/game/{id}", async context =>
{
var id = int.Parse(context.GetRouteValue("id").ToString());
var game = Games.FirstOrDefault(g => g.Id == id);
await context.Response.WriteAsJsonAsync(game);
});
});
}
public void ConfigureServices(IServiceCollection services)
{
}
}
Możemy sprawdzić, czy nasze API w ASP.NET Core działa, ale nie jest to ważne dla reszty wpisu.
Do naszej solucji teraz będziemy dodawać testy. Na temat TDD ma całą serię wpisów.
Wybieram framework XUnit do testów.
Nazywam go odpowiednio.
Nasz projekt testowy będzie zawierał tylko jedno sprawdzenie.
Pamiętaj my się skupiamy na tym, aby to miało sens. Chodzi bardziej o to, aby w naszym procesie wdrażania aplikacji napisać logikę, która będzie uruchamiać te testy.
public class TestGame
{
[Fact]
public void CanCreateGame()
{
Game game = new(1, "A", 100, 2000);
var (a, b, c, d) = game;
Assert.Equal(1, a);
Assert.Equal("A", b);
Assert.Equal(100, c);
Assert.Equal(2000, d);
}
}
Sam test jak widzisz sprawdza tylko, czy można poprawnie utworzyć obiekt Gry.
Do projektu dodajemy jeszcze test funkcjonalny.
Ten test będzie uruchamiać aplikację pod localhost, aby sprawdzić, czy punkt RestApi działają poprawnie.
public class GameEndpointTest
{
private static readonly HttpClient _httpClient = new()
{
BaseAddress = new("http://localhost:5000")
};
[Fact]
public async Task ListGames()
{
var games = await _httpClient.GetFromJsonAsync<List<Game>>("/games");
Assert.NotNull(games);
Assert.Equal(4, games.Count);
}
[Fact]
public async Task GetGame()
{
var game = await _httpClient.GetFromJsonAsync<Game>("/game/1");
Assert.NotNull(game);
Assert.Equal(1, game.Id);
}
}
Mamy więc nasz projekt demo. Pora utworzyć projekt NUKE, który będzie zawierał logikę budowania naszej aplikacji.
Najpierw do narzędzi i poleceń dotnet musimy zainstalować globalnie framework Nuke.
dotnet tool install Nuke.GlobalTool --global
Gdy uruchomimy polecenie NUKE na ścieżce naszego projektu to zostanie zapytany oto, czy chcesz utworzyć projekt kompilacyjny. Wybieramy "Y"
Potem NUKE pyta się o nazwę naszego projektu.
Potem on się pyta o wersję oraz jaki konkretny projekt powinien być domyślny.
Pyta mnie on się czy chce mieć podstawowy kod tutorial, który będzie mi pokazywał podstawy takiego projektu NUKE.
Potem odpowiadam na następujące pytania tak jak widać to na obrazku. Projekty testowe są w tym samym folderze więc wybieram "same as source"
W pytaniu o GIT w sumie mógłbym wybrać cokolwiek.
Podsumowując o to wszystkie odpowiedzi na te pytania.
Po tym procesie możemy sobie gratulować, ponieważ utworzyliśmy nasz projekt NUKE.
Co potrafi NUKE
Projekt NUKE jak widzisz ma folder "boot" w nim znajdują się 3 podobne skrypty, które uruchamiają ten cały proces wdrażania, który zaraz napiszemy w C#
Nas interesuje klasa Build.
class Build : NukeBuild
{
/// Support plugins are available for:
/// - JetBrains ReSharper https://nuke.build/resharper
/// - JetBrains Rider https://nuke.build/rider
/// - Microsoft VisualStudio https://nuke.build/visualstudio
/// - Microsoft VSCode https://nuke.build/vscode
public static int Main () => Execute<Build>(x => x.Compile);
[Parameter("Configuration to build - Default is 'Debug' (local) or 'Release' (server)")]
readonly Configuration Configuration = IsLocalBuild ? Configuration.Debug : Configuration.Release;
[Solution] readonly Solution Solution;
[GitRepository] readonly GitRepository GitRepository;
Target Clean => _ => _
.Before(Restore)
.Executes(() =>
{
});
Target Restore => _ => _
.Executes(() =>
{
DotNetRestore(s => s
.SetProjectFile(Solution));
});
Target Compile => _ => _
.DependsOn(Restore)
.Executes(() =>
{
DotNetBuild(s => s
.SetProjectFile(Solution)
.SetConfiguration(Configuration)
.EnableNoRestore());
});
}
Widzimy tutaj wiele delegat Target, które definiują kroki naszej rury wdrażania aplikacji. Domyślnie robimy :
- Czyszczenie projektu zbudowanych już dll-ek
- Przywrócenia projektu, czyli pobraniu brakujących paczek NuGet
- Na koniec kompilujemy nasz projekt według konfiguracji, którą przekażemy jako argument w wierszu poleceń.
Aby te kroki wykonały się w odpowiedniej kolejności w tych delegatach także mówimy które kroki są zależne, od których poprzez "DependsOn". Możemy też skorzystać z "Before", aby powiedzieć co musi się wykonać przed danym krokiem.
Zobaczmy jak ten przykładowy projekt Nuke działa. Uruchamiamy polecenie "nuke" na naszym folderze projektowym.
Na razie tylko dostajemy ostrzeżenia od NUKE, że nie mamy skonfigurowanego Git-a w projekcie,a poza tym, jak widzisz mamy informację o tym, jakie fazy wdrażania aplikacji zrobiliśmy i ile one trwały.
Co możemy jeszcze zrobić? Wpiszmy polecenie nuke --help
Dostajemy teraz informację o procesie wdrażania naszej aplikacji. Jakie mamy kroki. Jakie parametry ta aplikacja wdrażająca przyjmuje.
Mamy też domyślnie opcję od NUKE jak możliwość uruchamiania tego procesu bez logo "--no-logo".
To, co teraz widzisz jest zależne od kodu w C#. Mogę np. szybko dodać swój własny parametry. Musi on mieć tylko atrybut "Parameter".
class Build : NukeBuild
{
public static int Main() => Execute<Build>(x => x.Compile);
[Parameter("MyParameter Cezary Walenciuk")]
readonly string MyParameter;
[Parameter("Configuration to build - Default is 'Debug' (local) or 'Release' (server)")]
readonly Configuration Configuration = IsLocalBuild ? Configuration.Debug : Configuration.Release;
Gdy wpiszę ponownie polecenie "nuke --help" wtedy zobaczę, że mój parametr też został dodany.
Mój parametr jest typu string. Jak to wygląda dla bardziej złożonego typu. Możesz zobaczyć jak jest napisana klasa Configuration.
[TypeConverter(typeof(TypeConverter<Configuration>))]
public class Configuration : Enumeration
{
public static Configuration Debug = new Configuration { Value = nameof(Debug) };
public static Configuration Release = new Configuration { Value = nameof(Release) };
public static implicit operator string(Configuration configuration)
{
return configuration.Value;
}
}
Ta klasa zawiera prosty operator konwersji z typu string na typ Configuration. Czyli jak napiszemy Debug to dostaniemy obiekt Configuration z wartością Debug, a jeśli napiszemy Relase to dostaniem obiekt Configuration z wartością Relase.
Co jeszcze Nuke potrafi? Gdy wpiszesz polecenie "nuke --plan" możesz zobaczyć obecny plan wdrażania swojej aplikacji w formie grafu.
Jak i jego opis :
Dodajmy nowy krok "Test" oraz jego zależność do kroku "Compile" i zobaczmy jak teraz nasz graf wygląda.
Target Clean => _ => _
.Before(Restore)
.Executes(() =>
{
});
Target Test => _ => _
.Before(Restore)
.Executes(() =>
{
Logger.Normal("Starting Test");
});
Target Restore => _ => _
.Executes(() =>
{
DotNetRestore(s => s
.SetProjectFile(Solution));
});
Target Compile => _ => _
.DependsOn(Restore,Test)
.Executes(() =>
{
DotNetBuild(s => s
.SetProjectFile(Solution)
.SetConfiguration(Configuration)
.EnableNoRestore());
});
Jak widzimy Test musi się wykonać przed Restore, a Compile jest zależne od kroku "Testowego"
Do każdego kroku możesz także dodać swoje informacje na temat procesu. Jak na przykład dodałem prosty komunikat "Starting testing".
Możesz też zacząć proces wdrażania zaczynając od pewnego punktu w procesie. Musisz go tylko podać.
To ci tylko pokazuje, jaka jest różnica pomiędzy określeniem, że coś jest zależne, a tym, że coś musi się wykonać przed.
Powiedziałem CMD, że chce zacząć od procesu kompilacji, ale proces testowania i restore i tak się wykonał. Natomiast krok Clean został pominięty.
Nuke uznaje, że Clean może zostać pominięty, ponieważ użyłem definicji na innych krokach względem niego poprzez "before".
Natomiast jeśli krok jak Compile jest zależny, czyli DependOn to i tak one się uruchomią.
Kolejność możesz deklarować jeszcze w inny sposób. Możesz ustalić odpowiednie wyzwalacze.
Możesz powiedzieć, że odpowiedni krok powinien wykonać się po czymś lub po czymś, jeśli dany krok zakończył się błędem.
Możesz też ustawić fakt, że dany krok wymaga twojego parametru. Kto wie może wysyłasz tam jakie dane konfiguracyjne i logiczne jest to, że bez nich nie uruchomisz danego kroku.
Działa to w taki sposób, że teraz po wpisaniu polecenia NUKE dostane także pytanie o podanie parametru
Metoda OnlyWhenDynamic sprawdzić wartość logiczną naszego parametru i wykona dany krok, jeśli ten parametr będzie prawdą.
OnlyWhenStatic działa podobnie tylko jest on dla pól statycznych, które się nie zmieniają.
Mamy tutaj także metody jak Unlisted() : która sprawi, że dany krok nie wyświetli się w pomocy NUKE.
Gdybyś chciał wyrzucić błąd lub przerwać dany krok to możesz zrobić to w taki sposób poprzez klasę statyczną ControlFlow.
Możesz też skorzystać ze zbudowanych zdarzeń, które mogą zajść w twoim procesie budowania aplikacji.
Gdybyś chciał wywołać inne polecenia w trakcie działania tej rury Continuous Integration to nie ma problemu. Oto przykład wywołania polecenia "git status" przy kroku Clean.
[PathExecutable]
readonly Tool Git;
Target Clean => _ => _
.Before(Restore)
.Executes(() =>
{
Git("status");
});
A co jeśli chcesz wykonać jakąś akcję GitHub na podstawie pliku YAML.
[CheckBuildProjectConfigurations]
[ShutdownDotNetAfterServerBuild]
[GitHubActions("build-and-test",GitHubActionsImage.WindowsLatest,OnPushBranches =new[] { "master"})]
class Build : NukeBuild
Niestety na ten temat wiem za mało, aby rozwinąć taki przykład.
Wracamy do Tutorialu
Skasuj nasz krok test i napiszmy porządne kroki, które uruchomią nasz test jednostkowy.
Target UnitTest => _ => _
.DependsOn(Compile)
.Executes(() =>
{
DotNetTest(s => s.SetProjectFile(RootDirectory / "GamesAPI.UnitTest")
.EnableNoRestore()
.EnableNoBuild());
});
Po kompilacji naszej aplikacji chce uruchomić Test dlatego jest "DependsOn". Teraz jest to także nasz ostatni krok aplikacji więc musi coś jeszcze zmienić w kodzie. Niech UnitTest będzie domyślnym startem naszego procesu, który rozpocznie to całe drzewo działania.
class Build : NukeBuild
{
public static int Main() => Execute<Build>(x => x.UnitTest);
Teraz mogę zobaczyć jak po kompilacji aplikacji uruchamia się test.
Analogicznie dodajmy krok testu funkcjonalnego
Target FunctionalTest => _ => _
.DependsOn(UnitTest)
.Executes(() =>
{
DotNetTest(s => s.SetProjectFile(RootDirectory / "GamesAPI.FunctionalTest")
.EnableNoRestore()
.EnableNoBuild());
});
Ustalmy też ostatni proces wykonawczy.
class Build : NukeBuild
{
public static int Main() => Execute<Build>(x => x.FunctionalTest);
Na chwilę obecną mamy problem z testem funkcjonalnym, ponieważ potrzebuje on uruchomionej aplikacji pod adresem localhost.
Musi więc dodać krok, który uruchomi nasze API poprzez polecenie dotnet.
Target StartAPI => _ => _
.Executes(() =>
{
ProcessTasks.StartProcess("dotnet", "run", RootDirectory / "GamesAPI");
});
Teraz gdy mamy zależność kroków to warto ją zadeklarować.
Target FunctionalTest => _ => _
.DependsOn(UnitTest,StartAPI)
.Executes(() =>
{
DotNetTest(s => s.SetProjectFile(RootDirectory / "GamesAPI.FunctionalTest")
.EnableNoRestore()
.EnableNoBuild());
});
Potrzebujemy też krok, który zatrzyma nasze API. NUKE za nas tego nie zrobi.
Musi więc przetrzymać proces w zmiennej tak, abyś mogli go zabić w kroku "StopAPI".
private IProcess _ApiProcess;
Target StartAPI => _ => _
.Executes(() =>
{
_ApiProcess = ProcessTasks.StartProcess("dotnet", "run", RootDirectory / "GamesAPI");
});
Target StopAPI => _ => _
.Executes(() =>
{
_ApiProcess.Kill();
});
Target FunctionalTest => _ => _
.After(UnitTest)
.DependsOn(StartAPI)
.Triggers(StopAPI)
.Executes(() =>
{
DotNetTest(s => s.SetProjectFile(RootDirectory / "GamesAPI.FunctionalTest")
.EnableNoRestore()
.EnableNoBuild());
});
W kodzie też mówimy, że krok FunctionaTest wyzwala później krok StopAPI. Zobaczmy, czy teraz wszystko działa
Zobaczmy jak wygląda nasz diagram działania aplikacji.
To tyle, jeśli chodzi o ten przykład. Korzystając z NUKE możesz także wywołać API Azura czy GitHub Action więc można napisać w nim jeszcze bardziej złożoną logikę wdrożeniową.
Możesz też napisać akcję, która stworzy paczkę NuGet na końcu tego procesu.
Jak jednak widzisz w C# można napisać swoją logikę procesu Continuous Integration, Continuous Delivery i Continous Testing.