Języki programowania na platformie .NET / Programowanie .NET

Rok akademicki: 2026/27

Instrukcja laboratoryjna nr 1: MSIL, Zarządzanie Pamięcią i Ekosystem Narzędzi .NET (CLI, NuGet, Cake, Docker)

Prowadzący: Tomasz Goluch
Wersja: 1.2


1. Wprowadzenie

Cel: Zrozumienie procesu kompilacji kodu w ekosystemie .NET, architektury Common Intermediate Language (CIL/MSIL), mechanizmów środowiska uruchomieniowego (CLR) oraz ewolucji od klasycznego .NET Framework do wieloplatformowego .NET 8/9+.

W klasycznych językach kompilowanych bezpośrednio do kodu maszynowego (np. C, C++) wynikiem kompilacji jest plik binarny ściśle powiązany z architekturą procesora (np. x86, x64, ARM64) oraz interfejsem jądra konkretnego systemu operacyjnego. Platforma .NET wykorzystuje architekturę dwuetapowej kompilacji:

  1. Kompilacja statyczna do kodu pośredniego: Kod źródłowy (C#, F#, VB.NET) jest tłumaczony przez kompilator języka (kompilator Roslyn – csc.exe dla C#) na uniwersalny kod pośredni – CIL (ang. Common Intermediate Language, historycznie znany jako MSIL) oraz metadane precyzyjnie opisujące typy, składowe klas i atrybuty. Wynikiem kompilacji jest zestaw (ang. Assembly, plik .dll lub .exe).
  2. Kompilacja w locie (JIT – Just-In-Time): Podczas uruchamiania aplikacji maszyna wirtualna – środowisko uruchomieniowe CLR (ang. Common Language Runtime, we współczesnym .NET: CoreCLR) – ładuje zestaw, weryfikuje poprawność typów, a następnie za pomocą kompilatora JIT kompiluje instrukcje MSIL na natywny kod maszynowy procesora bezpośrednio przed wykonaniem danej metody.
[ Kod źródłowy C# (.cs) ] 
       │
       ▼ (Roslyn / csc.exe / dotnet build)
[ Zestaw .NET Assembly (.dll / .exe) ]
   ├── Metadane (Manifest, Typy, Sygnatury)
   └── Kod CIL / MSIL (Instrukcje oparte na stosie ewaluacyjnym)
       │
       ▼ (Środowisko uruchomieniowe CoreCLR)
[ Kompilator JIT (Just-In-Time) ] ──► [ Natywny kod maszynowy (x64 / ARM64) ]

🧠 Zrozumieć różnicę pokoleniową: .NET Framework vs .NET 8+
W klasycznym .NET Framework (wersje 1.0 – 4.8.1) środowisko było nierozerwalnie związane z systemem Windows, a wszystkie biblioteki systemowe znajdowały się w jednym, monolitycznym miejscu (mscorlib.dll, globalna pamięć podręczna GAC).
Od wersji .NET Core, a obecnie w .NET 8 (LTS) i .NET 9, platforma jest w pełni modułowa, otwartoźródłowa i wieloplatformowa. Zrezygnowano z globalnych rejestrów systemowych na rzecz izolowanych pakietów NuGet oraz zestawów referencyjnych (Ref Packs), eliminując problem konfliktów wersji (tzw. DLL Hell).


2. Środowisko Visual Studio Developer Command Prompt & PowerShell

Cel: Opanowanie niskopoziomowych narzędzi deweloperskich .NET, konfiguracja zmiennych środowiskowych i kompilacja manualna.

Standardowy wiersz poleceń systemu Windows (cmd.exe lub powershell.exe) nie ma w domyślnej zmiennej PATH dostępu do specjalistycznych kompilatorów i dezasemblerów dostarczanych wraz z Visual Studio. Developer Command Prompt / PowerShell to powłoka z zainicjalizowanymi zmiennymi środowiskowymi (INCLUDE, LIB, PATH), umożliwiającymi bezpośrednie wywoływanie narzędzi SDK. Wersja PowerShell pozwala dodatkowo na przekazywanie obiektów i danych pomiędzy kolejnymi poleceniami (cmdlets).

1. Uruchamianie środowiska deweloperskiego

⚠️ Uprawnienia administratora:
Zaleca się uruchamianie wiersza poleceń dewelopera z uprawnieniami administratora (Uruchom jako administrator). Będą one wymagane przez niektóre narzędzia systemowe (np. gacutil.exe przy obsłudze klasycznych aplikacji).


3. Przegląd kluczowych narzędzi inżynierii wstecznej i kompilacji

1. Kompilator języka C# (csc.exe) i silnik MSBuild (msbuild.exe)

Anatomia kompilacji: .NET Framework vs .NET 5+

W przypadku klasycznego .NET Framework kompilacja z wiersza poleceń była prosta, ponieważ kompilator domyślnie odwoływał się do monolitycznych bibliotek systemowych zainstalowanych w systemie operacyjnym:

csc.exe -out:JP_NET.Lab1a.dll Program.cs

W .NET 5+ zrezygnowano z jednej biblioteki systemowej na rzecz modularności. Funkcjonalności są podzielone na setki mniejszych zestawów (tzw. Contract Assemblies lub Ref Packs), co pozwala aplikacjom być lżejszymi i działać wieloplatformowo.

Ręczne wywołanie csc.exe dla aplikacji konsolowej .NET 5+ wymaga odcięcia bibliotek domyślnych (-noconfig, -nostdlib) oraz jawnego wskazania wszystkich referencji bibliotek bazowych za pomocą parametru -r (w wierszu poleceń CMD znak karetki ^ pozwala przenieść długie polecenie do kolejnej linii):

"C:\Program Files\Microsoft Visual Studio\2022\Enterprise\MSBuild\Current\Bin\Roslyn\csc.exe" ^
-noconfig ^
-nostdlib ^
-t:exe ^
-r:"C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.21\ref\net8.0\System.Runtime.dll" ^
-r:"C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.21\ref\net8.0\netstandard.dll" ^
-r:"C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.21\ref\net8.0\System.Console.dll" ^
-r:"C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.21\ref\net8.0\System.Runtime.InteropServices.dll" ^
-r:"C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.21\ref\net8.0\System.Reflection.dll" ^
-r:"C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.21\ref\net8.0\System.Diagnostics.FileVersionInfo.dll" ^
-out:Program.exe Program.cs

(Uwaga: Ścieżka wersji pakietu 8.0.21 zależy od aktualnie zainstalowanego SDK na Twojej stacji roboczej – sprawdź dokładny podkatalog w C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\).

Ponadto, aby środowisko CoreCLR potrafiło uruchomić plik Program.exe, w tym samym folderze musi znaleźć się plik konfiguracyjny Program.runtimeconfig.json:

{
  "runtimeOptions": {
    "tfm": "net8.0",
    "framework": {
      "name": "Microsoft.NETCore.App",
      "version": "8.0.0"
    }
  }
}

Uruchomienie następuje poprzez hosta poleceniem:

dotnet Program.exe

Powyższy przykład dobitnie obrazuje, jak ogromną pracę orkiestracyjną automatyzuje za programistę silnik MSBuild oraz CLI!

2. Baza symboli debugowania (*.pdb) i instrukcje nop

Podczas kompilacji w konfiguracji Debug (oraz Release) powstają pliki Program Database (*.pdb). Są to bazy danych zawierające informacje niezbędne do efektywnego debugowania oraz diagnostyki awarii aplikacji:

Dzięki plikom *.pdb, w przypadku wystąpienia nieobsłużonego wyjątku, ślad stosu (stack trace) zawiera precyzyjną nazwę pliku, numer wiersza oraz kolumnę, w której wystąpił błąd:

Rola instrukcji nop w trybie Debug:
W kodzie CIL zestawu skompilowanego w konfiguracji Debug kompilator celowo generuje nadmiarowe instrukcje nop (no operation). Umożliwiają one debugerowi stawianie pułapek (breakpoints, klawisz F9) na pustych liniach lub klamrach {} oraz ułatwiają krokowe przechodzenie kodu (F10/F11) bez zakłóceń ze strony mechanizmów optymalizacji. W konfiguracji Release kompilator usuwa instrukcje nop i optymalizuje operacje stosu.

3. IL Disassembler (ildasm.exe) i IL Assembler (ilasm.exe)

4. Konsolidator zestawów (al.exe) i moduły wieloplikowe – Kontekst historyczny

W klasycznym .NET kod źródłowy mógł być kompilowany do postaci modułów (.netmodule), które zawierały skompilowany kod CIL oraz definicje typów, lecz były całkowicie pozbawione własnego manifestu zestawu.

Konsolidator zestawów al.exe (Assembly Linker) pozwala połączyć wiele niezależnych modułów w jeden zestaw wieloplikowy (multi-file assembly) ze wspólnym manifestem oraz dołączać do niego zasoby satelickie (lokalizacyjne) lub komponenty COM:

⚠️ Uwaga architektoniczna:
Współczesny .NET (.NET 5+) odszedł od zestawów wieloplikowych na rzecz pojedynczych, autonomicznych bibliotek DLL. Narzędzie al.exe pozostaje jednak kluczowe w utrzymaniu systemów legacy .NET Framework oraz unaocznia fundamentalną regułę CLR: zestaw (assembly) to jednostka logiczna i granica wersjonowania, która fizycznie może składać się z wielu odrębnych plików modułów.

5. Narzędzie silnych nazw (sn.exe) vs Podpisywanie cyfrowe

Klucze o silnej nazwie (*.snk) służyły w .NET Framework do kryptograficznego podpisywania zestawów wyjściowych w celu weryfikacji tożsamości wydawcy i unikalności nazwy:

⚠️ Bezpieczeństwo kluczy:
Pliki kluczy prywatnych *.snk nigdy nie powinny trafiać do repozytoriów kodu Git. We współczesnym .NET podpisywanie silną nazwą nie jest już traktowane jako bariera bezpieczeństwa (zostało zastąpione podpisywaniem pakietów certyfikatami X.509 za pomocą dotnet nuget sign).

6. Global Assembly Cache (gacutil.exe)

GAC to centralny magazyn współdzielonych bibliotek w systemie Windows (C:\Windows\Microsoft.NET\assembly\GAC_MSIL). Wymagał bibliotek z silną nazwą. Pozwalał na współistnienie wielu różnych wersji tej samej biblioteki (np. 1.0.0.0 i 1.0.0.1) w jednym systemie:

gacutil /i MyLibrary.dll
gacutil /l MyLibrary
gacutil /u MyLibrary

4. Zarządzanie Pamięcią: Stos, Sterta, Boksowanie i Typy Span<T>

Cel: Zrozumienie kosztu alokacji pamięci na stercie zarządzanej, zjawiska boksowania w CIL oraz opanowanie wzorca zero-allocation za pomocą Span<T> i ReadOnlySpan<T>.

1. Stos (Stack) vs Sterta Zarządzana (Managed Heap)

2. Boksowanie (Boxing) w kodzie CIL

Jeśli przekażesz typ wartościowy (int) w miejsce oczekujące typu referencyjnego (object), kompilator wygeneruje instrukcję MSIL box:

ldc.i4.1
box [System.Runtime]System.Int32
call instance void MyClass::Process(object)

Instrukcja box powoduje alokację nowego obiektu na stercie zarządzanej i skopiowanie wartości ze stosu do sterty. Jest to ukryty koszt wydajnościowy, który w pętlach obciąża procesor i Garbage Collector (GC). Na architekturze 64-bitowej 4-bajtowa wartość int staje się wówczas 24-bajtowym obiektem na stercie (16 bajtów nagłówka: MethodTable Pointer i SyncBlockIndex + 4 bajty danych + 4 bajty wyrównania pamięci).

💡 Dlaczego środowisko CLR na to zezwala i jak unikać boksowania? (Kliknij, aby rozwinąć/zwinąć)

Dlaczego środowisko CLR w ogóle na to zezwala?

Kompletne techniki eliminacji boksowania podczas przekazywania parametrów:

  1. Metody generyczne ze specjalizacją JIT (<T>): Zamiast parametru object, metoda przyjmuje parametr generyczny T. Środowisko wykonawcze (JIT) generuje w pamięci osobną kopię kodu maszynowego dla każdego typu wartościowego (Monomorphization), a parametry są przekazywane bezpośrednio przez rejestry CPU lub stos:

    // ŹLE: Boksowanie do referencji
    public void Log(object value);
    
    // DOBRZE: Zero alokacji na stercie
    public void Log<T>(T value);
  2. Ograniczenia generyczne z prefiksem IL constrained.: Przekazanie struktury do metody przyjmującej interfejs (np. void Draw(IDrawable d)) wymusza boksowanie. Zastosowanie ograniczenia generycznego where T : struct, IDrawable rozwiązuje ten problem:

    // DOBRZE: JIT wywoła Draw() bezpośrednio na wartości ze stosu
    public void Render<T>(T shape) where T : struct, IDrawable
    {
        shape.Draw();
    }

    Pod maską w CIL: Kompilator emituje instrukcję constrained. !!T przed callvirt. Widząc, że T jest strukturą, silnik JIT dewirtualizuje wywołanie i pomija tworzenie instancji na stercie.

  3. Dedykowane przeciążenia metod (Method Overloading): Wzorzec stosowany w bibliotece standardowej (np. StringBuilder.Append lub Console.WriteLine). Zamiast jednej metody przyjmującej object, definiuje się wyspecjalizowane warianty dla popularnych typów wartościowych:

    public void Append(int value);
    public void Append(double value);
    public void Append(bool value);
    public void Append(ReadOnlySpan<char> value);
  4. Przekazywanie przez referencję tylko do odczytu (in, ref readonly): Przekazanie przez wartość zawsze oznacza pełną kopię wszystkich bajtów struktury. O ile małe struktury (do 8–16 bajtów) procesor może skopiować błyskawicznie w rejestrach, o tyle w przypadku większych struktur konieczne jest kopiowanie całego bloku pamięci na stosie. Słowo kluczowe in przekazuje wskaźnik zarządzany na stosie bez kopiowania i bez ucieczki na stertę:

    public void Transform(in Matrix4x4 matrix);
  5. Własna implementacja metod ToString() i GetHashCode() w strukturach: Wywołanie myStruct.ToString() lub myStruct.GetHashCode() na strukturze, która nie nadpisuje tych metod, zboksuje ją na stercie, ponieważ domyślna implementacja w System.ValueType wykorzystuje refleksję. Zawsze dodawaj jawny override:

    public struct Point
    {
        public int X, Y;
        public override string ToString() => $"({X}, {Y})"; // Brak boksowania
    }
  6. Handlery ciągów interpolowanych (InterpolatedStringHandler, C# 10+): Dawniej zapis $"Wartość: {42}" lub string.Format rzutował argumenty do tablicy object[]. Nowoczesny C# tłumaczy interpolację na wywołania handlera z generyczną metodą AppendFormatted<T>(T value), co eliminuje alokację przy formatowaniu liczb i struktur.

  7. Kolekcje generyczne: Bezwzględne zastąpienie przestarzałych kolekcji nietypowanych (ArrayList, Hashtable z System.Collections) ich generycznymi odpowiednikami z System.Collections.Generic (List<T>, Dictionary<TKey, TValue>).

  8. Modyfikator ref struct (C# 7.2+): Architektoniczny bezpiecznik dla typów wskaźnikowych (np. Span<T>). Wymusza na poziomie kompilatora istnienie instancji wyłącznie na stosie — próba rzutowania jej na object, interfejs czy użycia w domknięciu asynchronicznym kończy się błędem kompilacji, co całkowicie wyklucza ucieczkę na stertę.

3. Rewolucja bezalokacyjna: Span<T>, ReadOnlySpan<T> i modyfikator ref struct

Przez lata operacje na łańcuchach znaków i tablicach (np. parsowanie ramek sieciowych, podział tekstu string.Substring(), string.Split()) nieuchronnie alokowały dziesiątki obiektów pośrednich na stercie. Wprowadzenie typów Span<T> i ReadOnlySpan<T> w połączeniu z modyfikatorem ref struct umożliwiło przetwarzanie dowolnych ciągłych obszarów pamięci przy zerowej alokacji (Zero-Allocation Overhead).


5. Interfejs wiersza poleceń: .NET CLI

Cel: Poznanie możliwości interfejsu wiersza poleceń .NET, diagnostyka środowiska, zarządzanie cyklem życia aplikacji i publikacja natywna.

Interfejs .NET CLI (dotnet) stanowi zunifikowany punkt wejścia dla programistów na systemach Windows, Linux i macOS. Pozwala na pełną obsługę cyklu życia oprogramowania – od inicjalizacji szablonów, przez konfigurację kompilatora, aż po dystrybucję produkcyjną.

1. Diagnostyka środowiska

Sprawdzenie szczegółowych informacji o zainstalowanych pakietach SDK, środowiskach uruchomieniowych (runtimes) oraz architekturze maszyny:

dotnet --info
dotnet --list-sdks
dotnet --list-runtimes

2. Tworzenie i struktura nowego projektu

Wylistowanie dostępnych szablonów aplikacji:

dotnet new list

Utworzenie nowej aplikacji konsolowej opartej na .NET 8:

dotnet new console -f net8.0 --name MyConsoleApp
cd MyConsoleApp

Zawartość wygenerowanego pliku projektu MyConsoleApp.csproj:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>
</Project>

3. Wielo-platformowość (Multi-Targeting) i wersje języka

Zamieniając element <TargetFramework> na liczbę mnogą <TargetFrameworks>, możemy zdefiniować kompilację biblioteki dla wielu wersji środowiska jednocześnie:

<TargetFrameworks>net8.0;net9.0;net48</TargetFrameworks>

W przypadku konieczności użycia cech nowszej wersji języka C# (np. cech C# 12/13), możemy jawnie wymusić wersję kompilatora wewnątrz sekcji <PropertyGroup>:

<LangVersion>latest</LangVersion>

(Dopuszczalne wartości to m.in.: latest, preview, latestMajor lub konkretna wersja numeryczna, np. 12.0).

4. Uruchamianie i kompilacja

Kompilacja i uruchomienie aplikacji:

dotnet run -c Release

5. Strategie publikacji i wdrożenia aplikacji

Polecenie dotnet publish przygotowuje kompletną, zoptymalizowaną paczkę produkcyjną aplikacji. Platforma .NET 8/9 udostępnia cztery zasadnicze strategie dystrybucji kodu:

  1. Wdrożenie zależne od środowiska (Framework-Dependent Deployment – FDD):
    Domyślny i najlżejszy tryb publikacji. Wynikowy katalog zawiera wyłącznie skompilowane biblioteki aplikacji (.dll), wymagając obecności zainstalowanego środowiska uruchomieniowego .NET o zgodnej wersji na maszynie docelowej:

    dotnet publish -c Release -f net8.0
  2. Wdrożenie samowystarczalne (Self-Contained Deployment – SCD):
    Paczka zawiera aplikację wraz z kompletną maszyną wirtualną CoreCLR oraz wszystkimi wymaganymi bibliotekami bazowymi dla wskazanego identyfikatora platformy docelowej – RID (ang. Runtime Identifier, np. win-x64, linux-x64, osx-arm64). Aplikacja może zostać uruchomiona na systemie, na którym w ogóle nie zainstalowano platformy .NET:

    dotnet publish -c Release -f net8.0 -r linux-x64 --self-contained true
  3. Kompilacja hybrydowa Ahead-of-Time: ReadyToRun (R2R):
    Współczesny, wieloplatformowy odpowiednik mechanizmu NGEN z klasycznego .NET Framework. Jest to forma kompilacji wyprzedzającej (AOT), w której kompilator crossgen2 wstrzykuje prekompilowany kod maszynowy procesora bezpośrednio do zestawu obok standardowego kodu pośredniego CIL:

    • Zaleta: Drastycznie skraca czas rozruchu aplikacji (cold start) i redukuje opóźnienia, odciążając kompilator JIT podczas startu.
    • Koszt: Pliki binarne są 2–3 razy większe ze względu na obecność zarówno kodu CIL, jak i instrukcji maszynowych.
    • Tryb R2R można łączyć zarówno z wdrożeniem FDD, jak i SCD:
      dotnet publish -c Release -r win-x64 --no-self-contained -p:PublishReadyToRun=true
  4. Pełna kompilacja natywna (Native AOT):
    Zaawansowany mechanizm kompilacji bezpośrednio do monolitycznego, natywnego pliku wykonywalnego systemu operacyjnego (format PE/ELF/Mach-O). Wynikowy plik binarny jest całkowicie pozbawiony kodu MSIL oraz maszyny wirtualnej JIT:

    • Zapewnia natychmiastowy start, minimalne zużycie pamięci RAM oraz brak możliwości inżynierii wstecznej za pomocą ildasm.
    • Ograniczenia: Wymaga statycznej analizy kodu w czasie kompilacji; nie obsługuje dynamicznego ładowania zestawów przez Assembly.LoadFile, ogranicza klasyczną refleksję (Reflection) i wymaga odpowiednich adnotacji przycinania kodu (Trimming annotations).
    dotnet publish -c Release -r linux-x64 -p:PublishAot=true

6. Zarządzanie plikiem rozwiązania (Solution) i repozytorium Git

Inicjalizacja pliku .gitignore dla ekosystemu .NET/Visual Studio:

dotnet new gitignore

Utworzenie pliku rozwiązania (.sln) i dołączenie do niego projektu:

dotnet new sln --name MyCompanySolution
dotnet sln MyCompanySolution.sln add MyConsoleApp/MyConsoleApp.csproj
dotnet sln MyCompanySolution.sln list

6. Konteneryzacja aplikacji .NET w środowisku Docker

Cel: Praktyczne wdrożenie aplikacji .NET w odizolowanym kontenerze linuksowym przy użyciu wieloetapowego procesu budowania (Multi-stage build).

Znajomość CLI jest niezbędna podczas konteneryzacji. Nowoczesne podejście polega na rozdzieleniu etapu kompilacji (z użyciem ciężkiego obrazu SDK) od obrazu produkcyjnego (z użyciem odchudzonego obrazu samego runtime'u).

Wzorcowy plik Dockerfile:

# ETAP 1: Budowanie i publikacja aplikacji (Build Stage)
FROM [mcr.microsoft.com/dotnet/sdk:8.0](https://mcr.microsoft.com/dotnet/sdk:8.0) AS build
WORKDIR /src

# Kopiowanie pliku csproj i przywracanie pakietów (warstwa buforowana przez Dockera)
COPY ["MyConsoleApp.csproj", "./"]
RUN dotnet restore "MyConsoleApp.csproj"

# Kopiowanie reszty kodu i publikacja w wersji Release
COPY . .
RUN dotnet publish "MyConsoleApp.csproj" -c Release -o /app/publish --no-restore

# ETAP 2: Minimalistyczne środowisko uruchomieniowe (Runtime Stage)
FROM [mcr.microsoft.com/dotnet/runtime:8.0](https://mcr.microsoft.com/dotnet/runtime:8.0) AS final
WORKDIR /app

# Skopiowanie wyłącznie skompilowanych artefaktów z etapu 1
COPY --from=build /app/publish .

# Uruchomienie aplikacji
ENTRYPOINT ["dotnet", "MyConsoleApp.dll"]

Budowanie i testowe uruchomienie kontenera:

docker build -t my-console-app:1.0 .
docker run --rm my-console-app:1.0

7. Zarządzanie zależnościami (NuGet) i tworzenie własnych narzędzi CLI

Cel: Automatyzacja bibliotek oraz dystrybucja własnych programów jako narzędzi wiersza poleceń (.NET Global Tools).

1. Zarządzanie zależnościami NuGet w .NET CLI

W .NET 5+ zarządzanie zależnościami opiera się na deklaracjach <PackageReference> w plikach .csproj oraz zestawie poleceń CLI.

Podstawowe polecenia
Deterministyczne odtwarzanie zależności (packages.lock.json)

Aby zagwarantować, że kompilacja na serwerze CI użyje dokładnie tych samych wersji i sum kontrolnych pakietów co środowisko deweloperskie:

<PropertyGroup>
  <RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
  <RestoreLockedMode Condition="'$(ContinuousIntegrationBuild)' == 'true'">true</RestoreLockedMode>
</PropertyGroup>
Central Package Management (CPM)

W projektach wielomodułowych wersje bibliotek definiuje się centralnie w pliku Directory.Packages.props w katalogu głównym:

<Project>
  <PropertyGroup>
    <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
  </PropertyGroup>
  <ItemGroup>
    <PackageVersion Include="Newtonsoft.Json" Version="13.0.3"/>
    <PackageVersion Include="Microsoft.Extensions.Logging" Version="8.0.0"/>
  </ItemGroup>
</Project>

Dzięki temu w plikach .csproj podaje się wyłącznie referencję:

<ItemGroup>
  <PackageReference Include="Newtonsoft.Json"/>
</ItemGroup>

2. Tworzenie i instalacja własnego narzędzia (.NET Global Tool)

Platforma .NET pozwala w prosty sposób przekształcić zwykłą aplikację konsolową w narzędzie systemowe wywoływane wprost z poziomu terminala.

Do pliku projektu MyConsoleApp.csproj w elemencie <PropertyGroup> dodajemy wpisy:

<PackAsTool>true</PackAsTool>
<ToolCommandName>my-cli-tool</ToolCommandName>
<PackageOutputPath>./nupkg</PackageOutputPath>

Budowanie pakietu narzędzia:

dotnet pack

Instalacja narzędzia w systemie jako polecenia globalnego z lokalnego źródła:

dotnet tool install --global --add-source ./nupkg MyConsoleApp

Od tego momentu narzędzie można wywołać w dowolnym terminalu komendą:

my-cli-tool

Aktualizacja narzędzia po wprowadzeniu zmian w kodzie i podbiciu wersji:

dotnet tool update --global --add-source ./nupkg MyConsoleApp

Odinstalowanie narzędzia:

dotnet tool uninstall --global MyConsoleApp
💡 Narzędzia lokalne i plik manifestu dotnet-tools.json (Kliknij, aby rozwinąć/zwinąć)

Jeśli narzędzie ma być powiązane wyłącznie z konkretnym projektem/repozytorium (a nie instalowane globalnie w całym systemie operacyjnym):

  1. Utwórz plik manifestu w katalogu głównym projektu:

    dotnet new tool-manifest

    Polecenie utworzy plik konfiguracyjny .config/dotnet-tools.json.

  2. Zainstaluj narzędzie w kontekście lokalnym:

    dotnet tool install Cake.Tool
  3. Uruchom narzędzie lokalne:

    dotnet tool run dotnet-cake
    # lub składnia skrócona:
    dotnet cake
  4. Przywracanie narzędzi na innej maszynie / na serwerze CI:
    Po sklonowaniu repozytorium przez innego programistę wystarczy wykonać polecenie:

    dotnet tool restore

    Wszystkie narzędzia zdefiniowane w manifeście zostaną pobrane i będą natychmiast gotowe do użycia w kontekście repozytorium.


8. Automatyzacja potoków budowania: Cake (C# Make)

Cel: Poznanie podstawowych możliwości narzędzia Cake i pisanie scenariuszy CI/CD w czystym języku C#.

Cake (C# Make) to system wspomagający proces ciągłej integracji (CI/CD), który pozwala na orkiestrację kompilacji, czyszczenia folderów, uruchamiania testów jednostkowych oraz tworzenia pakietów instalacyjnych za pomocą silnika Roslyn i składni C#.


1. Podejście skryptowe: build.cake

Instalacja Cake jako narzędzia w systemie (globalnie lub lokalnie):

dotnet tool install --global Cake.Tool

Skrypt automatyzacji build.cake:

var target = Argument("target", "Build");
var configuration = Argument("configuration", "Release");
var solutionFile = "./MyCompanySolution.sln";

Task("Clean")
    .WithCriteria(c => HasArgument("rebuild"))
    .Does(() =>
    {
        Information("Czyszczenie katalogów wyjściowych dla konfiguracji: {0}", configuration);
        CleanDirectories($"./**/bin/{configuration}");
        CleanDirectories($"./**/obj/{configuration}");
    });

Task("Restore")
    .IsDependentOn("Clean")
    .Does(() =>
    {
        Information("Przywracanie pakietów NuGet...");
        DotNetRestore(solutionFile);
    });

Task("Build")
    .IsDependentOn("Restore")
    .Does(() =>
    {
        Information("Kompilacja rozwiązania...");
        DotNetBuild(solutionFile, new DotNetBuildSettings
        {
            Configuration = configuration,
            NoRestore = true,
        });
    });

Task("Test")
    .IsDependentOn("Build")
    .Does(() =>
    {
        Information("Uruchamianie testów jednostkowych...");
        DotNetTest(solutionFile, new DotNetTestSettings
        {
            Configuration = configuration,
            NoBuild = true,
            ArgumentCustomization = args => args.Append("--collect:\"XPlat Code Coverage\"")
        });
    });

RunTarget(target);

Uruchomienie procesu budowania:

# Uruchomienie domyślnego celu (Build wraz z zależnościami Clean i Restore):
dotnet cake

# Wymuszenie przebudowy z argumentem niestandardowym oraz wskazaniem innego celu:
dotnet cake --target=Test --configuration=Release --rebuild

2. Nowoczesny standard inżynieryjny: Cake Frosting

W środowiskach korporacyjnych tradycyjne pliki .cake zastępuje się Cake Frosting – potok budowania tworzy się jako standardowy, w pełni typowany projekt konsolowy C#.

Zalety Cake Frosting:

Plik projektu Build/Build.csproj
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <RunWorkingDirectory>$(MSBuildProjectDirectory)/..</RunWorkingDirectory>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Cake.Frosting" Version="4.0.0"/>
  </ItemGroup>
</Project>
Kod orkiestracji w C# (Build/Program.cs)
using Cake.Common.IO;
using Cake.Common.Tools.DotNet;
using Cake.Common.Tools.DotNet.Build;
using Cake.Common.Tools.DotNet.Pack;
using Cake.Common.Tools.DotNet.Test;
using Cake.Core;
using Cake.Core.Diagnostics;
using Cake.Frosting;

public static class Program
{
    public static int Main(string[] args)
    {
        return new CakeHost()
            .UseContext<BuildContext>()
            .Run(args);
    }
}

public class BuildContext : FrostingContext
{
    public string Configuration { get; set; }
    public string SolutionPath { get; set; }

    public BuildContext(ICakeContext context) : base(context)
    {
        Configuration = context.Arguments.GetArgument("configuration") ?? "Release";
        SolutionPath = "./MyCompanySolution.sln";
    }
}

[TaskName("Clean")]
public sealed class CleanTask : FrostingTask<BuildContext>
{
    public override void Run(BuildContext context)
    {
        context.CleanDirectories($"./**/bin/{context.Configuration}");
        context.CleanDirectories($"./**/obj/{context.Configuration}");
    }
}

[TaskName("Build")]
[IsDependentOn(typeof(CleanTask))]
public sealed class BuildTask : FrostingTask<BuildContext>
{
    public override void Run(BuildContext context)
    {
        context.DotNetBuild(context.SolutionPath, new DotNetBuildSettings
        {
            Configuration = context.Configuration
        });
    }
}

[TaskName("Test")]
[IsDependentOn(typeof(BuildTask))]
public sealed class TestTask : FrostingTask<BuildContext>
{
    public override void Run(BuildContext context)
    {
        context.DotNetTest(context.SolutionPath, new DotNetTestSettings
        {
            Configuration = context.Configuration,
            NoBuild = true
        });
    }
}

[TaskName("Default")]
[IsDependentOn(typeof(TestTask))]
public class DefaultTask : FrostingTask
{
}

Uruchomienie potoku Cake Frosting:

dotnet run --project ./Build -- --target=Test --configuration=Release

3. Zestawienie podejść do orkiestracji CI/CD

Cecha Skrypty Shell / PowerShell Tradycyjny Cake (.cake) Cake Frosting (C#)
Przenośność międzyplatformowa Niska (różnice bash / pwsh) Wysoka (działa na każdym środowisku z .NET) Wysoka (działa na każdym środowisku z .NET)
Wsparcie IDE / IntelliSense Zmienne Ograniczone (wtyczki Roslyn) Pełne (standardowy kod C#)
Debugowanie krokowe (F5) Złożone Trudne / ograniczone Pełne (jak w standardowej aplikacji C#)
Zarządzanie wtyczkami Zewnętrzne moduły powłoki Dyrektywy #addin Standardowy <PackageReference>
Bariera wejścia dla programisty .NET Wymaga nauki bash/pwsh Średnia (specyficzny DSL) Zerowa (czysty C# i klasy OOP)

Zadanie Laboratoryjne do wykonania (100 pkt)

Organizacja pracy: Każdy podpunkt można realizować w osobnym podkatalogu roboczym, kopiując wymagane pliki z poprzednich zadań. Wszelkie operacje kompilacji manualnej (csc, ildasm, ilasm, sn, gacutil, al) wykonujemy w oknie Developer Command Prompt for VS 2022.


Część 1: Kompilacja manualna, optymalizacje i boksowanie w CIL (15 pkt)

  1. Utwórz plik ClassA.cs o poniższej zawartości:
    using System;
    using System.Diagnostics;
    using System.Reflection;
    
    namespace JP_NET.Lab1
    {
        public partial class ClassA
        {
            private int _a;
            public int A { get => _a; set => _a = value; }
            public int b;
            public const char nameConst = 'A';
            public readonly char nameReadonly = 'A';
    
            public void MethodOfClassA()
            {
                Assembly asm = Assembly.GetExecutingAssembly();
                FileVersionInfo fvi = FileVersionInfo.GetVersionInfo(asm.Location);
                var x = String.Format("{0}.{1}", fvi.ProductMajorPart, fvi.ProductMinorPart);
                PartialMethodOfClassA(x);
            }
    
            partial void PartialMethodOfClassA(object x);
            partial void PartialMethodOfClassA(object x)
            {
                Console.WriteLine("Wydruk z" + " " + "klasy A. {0}", x);
            }
        }
    }
  2. Skompiluj plik ClassA.cs za pomocą kompilatora csc.exe do biblioteki .dll o nazwie JP_NET.Lab1a.dll (opcja -t:library).
  3. Skompiluj plik w trybie debugowania (-debug) do pliku JP_NET.Lab1a.DEB.dll. Zauważ, że powstał plik bazy symboli *.pdb.
  4. Skompiluj plik z włączoną optymalizacją (-o) do pliku JP_NET.Lab1a.O.dll.
  5. Dokonaj dezasemblacji obu bibliotek narzędziem ildasm.exe /out=<nazwa>.il. Porównaj oba pliki .il w edytorze tekstu:
    • Wskaż obecność nadmiarowych instrukcji nop w wersji Debug i ich brak w wersji zoptymalizowanej.
    • Zauważ, że kompilator już na etapie budowania połączył literały "Wydruk z" + " " + "klasy A. {0}" w jeden ciąg znaków.
    • Zlokalizuj w kodzie metody instrukcję box [mscorlib]System.Int32 (lub odpowiednik System.Runtime) – wyjaśnij prowadzącemu, dlaczego nastąpiła alokacja na stercie zarządzanej.
  6. Zlokalizuj w CIL 4 instrukcje wywołania metody PartialMethodOfClassA():
    ldarg.0
    ldc.i4.1
    box       [mscorlib]System.Int32
    call      instance void JP_NET.Lab1.ClassA::PartialMethodOfClassA(object)
  7. Zakomentuj w pliku ClassA.cs samą definicję metody częściowej (zostawiając jej deklarację nagłówkową partial void PartialMethodOfClassA(object x);). Skompiluj bibliotekę ponownie i sprawdź jej kod w ildasm. Zauważ, że kompilator całkowicie wyciął wywołanie tej metody z kodu binarnego.
  8. Odkomentuj metodę i skompiluj bibliotekę do stanu pierwotnego.

Część 2: Referencje, wiązanie stałych i wersjonowanie (15 pkt)

  1. Utwórz plik ProgA.cs:
    using System;
    using JP_NET.Lab1;
    
    public class Program
    {
       public static void Main(string[] args)
       {
          ClassA a = new ClassA();
          a.MethodOfClassA();
          a.A = 1;
          a.b = 2;
          Console.WriteLine("Const {0}", ClassA.nameConst);
          Console.WriteLine("Readonly {0}", a.nameReadonly);
       }
    }
  2. Skompiluj ProgA.cs z referencją do biblioteki (-r:JP_NET.Lab1a.dll) do pliku ProgA.exe. Uruchom program.
  3. Otwórz ProgA.exe w ildasm i porównaj sposób odwołania do właściwości A (call instance void set_A(int32)) oraz pola publicznego b (stfld int32 b).
  4. Utwórz plik AssemblyInfo.cs:
    using System.Reflection;
    [assembly: AssemblyVersion("1.0.0.0")]
  5. W pliku ClassA.cs zmień wartości pól na:
    public const char nameConst = 'B';
    public readonly char nameReadonly = 'B';
  6. Przekompiluj wyłącznie samą bibliotekę JP_NET.Lab1a.dll (dołączając AssemblyInfo.cs).
  7. Bez ponownej kompilacji uruchom stary plik ProgA.exe.
  8. Zaobserwuj wynik w konsoli: pole readonly przyjęło nową wartość 'B', natomiast const zachowało wartość 'A'.
    Wyjaśnij na obronie na podstawie kodu MSIL, dlaczego wartość stałej const została trwale wstrzyknięta (inlined) do wnętrza zestawu wywołującego.

Część 3: Inżynieria wsteczna i CIL Patching (10 pkt)

  1. Zdezasembluj bibliotekę JP_NET.Lab1a.dll do pliku CIL:
    ildasm JP_NET.Lab1a.dll /out=JP_NET.Lab1a.il
  2. Otwórz plik JP_NET.Lab1a.il w edytorze tekstowym.
  3. Zlokalizuj ciało metody PartialMethodOfClassA i zmień wyświetlany literał tekstowy z "Wydruk z klasy A. {0}" na:
    "Wydruk z klasy A po spatchowaniu CIL. {0}".
  4. Zrekompiluj zmodyfikowany plik .il narzędziem ilasm.exe:
    ilasm JP_NET.Lab1a.il /dll /out=JP_NET.Lab1a.dll
  5. Uruchom ProgA.exe i udowodnij, że aplikacja wykonała zmodyfikowany kod binarny. Zwróć uwagę, że brak podpisu cyfrowego umożliwia dokonywanie niewykrywalnych zmian w plikach binarnych.

Część 4: Moduły wieloplikowe i konsolidator al.exe (10 pkt)

(Część demonstrująca architekturę modułów wieloplikowych CLR)

  1. Utwórz plik ClassB.cs:
    namespace JP_NET.Lab1
    {
        public class ClassB
        {
            public void MethodOfClassB() => System.Console.WriteLine("Wydruk z klasy B.");
        }
    }
  2. Skompiluj ClassA.cs do modułu .NET (opcja -t:module) o nazwie ClassA.netmodule. To samo wykonaj dla ClassB.cs (ClassB.netmodule).
  3. Skompiluj ProgA.cs do aplikacji ProgABmod.exe, dołączając oba moduły opcją -addmodule:ClassA.netmodule,ClassB.netmodule.
  4. Zmień nazwę pliku ClassB.netmodule na dysku i uruchom program – program zadziała. Następnie zmień nazwę ClassA.netmodule – aplikacja rzuci wyjątek FileNotFoundException. Świadczy to o leniwym ładowaniu modułów do pamięci.
  5. Skompiluj ProgA.cs jako moduł ProgA.netmodule.
  6. Za pomocą konsolidatora al.exe połącz trzy moduły w jeden plik wykonywalny ze wskazanym punktem wejścia:
    al /out:ProgLinked.exe /target:exe /main:Program.Main ClassA.netmodule ClassB.netmodule ProgA.netmodule
  7. Porównaj w ildasm manifest pliku ProgLinked.exe i zaobserwuj deklaracje .file ClassA.netmodule oraz .class extern.

Część 5: Podpisywanie silną nazwą i integralność zestawów (10 pkt)

  1. Wygeneruj parę kluczy kryptograficznych:
    sn -k paraKluczy.snk
  2. Dodaj do pliku AssemblyInfo.cs atrybut:
    [assembly: AssemblyKeyFile("paraKluczy.snk")]
  3. Skompiluj bibliotekę JP_NET.Lab1a.dll z plikiem klucza. Sprawdź poprawność podpisu poleceniem:
    sn -v JP_NET.Lab1a.dll
  4. Uruchom aplikację ProgA.exe skompilowaną uprzednio z niepodpisaną biblioteką. Zaobserwuj błąd niezgodności manifestu: System.IO.FileLoadException / 0x80131040.
  5. Skompiluj aplikację konsolową ponownie z podpisaną biblioteką – zweryfikuj poprawne uruchomienie.

Część 6: Global Assembly Cache (GAC) i współdzielenie bibliotek (10 pkt)

(Uwaga: Wymaga uprawnień administratora w terminalu).

  1. Zainstaluj podpisaną bibliotekę w GAC:
    gacutil /i JP_NET.Lab1a.dll
  2. Zmień w AssemblyInfo.cs wersję na [assembly: AssemblyVersion("1.0.0.1")], a w ClassA.cs zmień komunikat na "Wydruk z nowej wersji 1.0.0.1".
  3. Skompiluj nową wersję biblioteki i zainstaluj ją w GAC obok poprzedniej.
  4. Skompiluj nową aplikację konsolową ProgA_v2.exe odwołującą się do nowej wersji biblioteki.
  5. Usuń lokalne pliki .dll z bieżącego folderu roboczego.
  6. Uruchom kolejno ProgA.exe oraz ProgA_v2.exe. Zaobserwuj, że każda aplikacja ładuje z GAC właściwą dla siebie wersję biblioteki. Sprawdź listę w GAC poleceniem gacutil /l JP_NET.Lab1a.
  7. Na koniec odinstaluj biblioteki z GAC (gacutil /u JP_NET.Lab1a).

Część 7: Wydajność: ReadyToRun (R2R) oraz Bezalokacyjny Parsing (Span<T>) (10 pkt)

Krok A: Publikacja ReadyToRun (5 pkt)

  1. Przygotuj aplikację konsolową zawierającą metodę intensywną obliczeniowo:
    static void HeavyMethod()
    {
        double result = 0;
        for (int i = 0; i < 50000; i++) result += Math.Sqrt(i) * Math.Sin(i);
        Console.WriteLine(result);
    }
  2. Opublikuj projekt w wariancie standardowym oraz ReadyToRun dla architektury 64-bitowej:
    dotnet publish -c Release -r win-x64 --no-self-contained
    dotnet publish -c Release -r win-x64 --no-self-contained -p:PublishReadyToRun=true -o ./pub-r2r
  3. Porównaj rozmiar pliku .dll/.exe w obu folderach i wyjaśnij na obronie obecność prekompilowanego kodu maszynowego.

Krok B: Bezalokacyjny parsing ze Span<T> (5 pkt)

  1. W aplikacji zaimplementuj metodę dekodującą ciąg tekstowy z parametrami:
    string sample = "STATUS=200;ID=49201;LINE=12;DELAY=00:04:15";
  2. Przygotuj dwie wersje metody wyciągającej wartość LINE oraz DELAY:
    • Wersja tradycyjna: używająca sample.Split(';') oraz .Substring() (generująca alokacje na stercie).
    • Wersja wydajna: operująca wyłącznie na ReadOnlySpan<char> i metodzie .Slice() (zero alokacji na stercie).
  3. Wykaż w kodzie brak alokacji nowych stringów podczas ekstrakcji wartości.

Część 8: Narzędzie .NET Global Tool (Otwarte Dane ZTM Gdańsk) (10 pkt)

  1. Utwórz aplikację konsolową .NET 8/9 pobierającą w czasie rzeczywistym odjazdy autobusów i tramwajów z API Otwartych Danych ZTM w Gdańsku (serwis TRISTAR/CKAN):
    • URL zapytania dla przystanku Miszewskiego 01 (ID: 2019):
      https://ckan2.multimediagdansk.pl/departures?stopId=2019
  2. Narzędzie powinno przyjmować parametr wejściowy z linii komend (np. identyfikator przystanku stopId lub numer linii) i wyświetlać sformatowaną listę odjazdów w oknie konsoli.
  3. Skonfiguruj plik .csproj jako instalowalne narzędzie (.NET Global Tool):
    <PropertyGroup>
      <PackAsTool>true</PackAsTool>
      <ToolCommandName>ztm-gdansk</ToolCommandName>
      <PackageOutputPath>./nupkg</PackageOutputPath>
    </PropertyGroup>
  4. Zbuduj pakiet poleceniem dotnet pack -c Release, a następnie zainstaluj narzędzie lokalnie w systemie:
    dotnet tool install --global --add-source ./nupkg <NazwaProjektu>
  5. Zademonstruj wywołanie narzędzia wprost z konsoli poleceniem ztm-gdansk 2019.

Część 9: Automatyzacja potoku CI/CD w Cake oraz Testy (10 pkt)

  1. Dodaj do solucji projekt z testami jednostkowymi (np. xUnit lub MSTest) weryfikujący poprawność działania metody filtrującej dane odjazdów.
  2. Przygotuj w głównym katalogu potok budowania wykorzystując do wyboru: tradycyjny skrypt build.cake LUB projekt konsolowy Cake Frosting (Build/Build.csproj). Potok musi realizować następujące zadania:
    • Clean – czyszczenie katalogów wyjściowych bin/ i obj/ oraz folderu artefaktów.
    • Restore – przywrócenie zależności NuGet (DotNetRestore).
    • Build – kompilacja całego rozwiązania w trybie Release (DotNetBuild).
    • Test – automatyczne wykonanie testów jednostkowych (DotNetTest).
    • Pack – wygenerowanie pakietu .nupkg narzędzia CLI do katalogu ./artifacts (DotNetPack).
  3. Uruchom proces automatyzacji odpowiednim poleceniem:
    • Dla skryptu build.cake:
      dotnet cake --target=Pack
    • Dla projektu Cake Frosting (rozwiązanie alternatywne):
      dotnet run --project ./Build -- --target=Pack
  4. Zaprezentuj działanie potoku prowadzącemu.

Zestawienie Oceny (Suma: 100 pkt)

Nr Zakres modułu Weryfikacja Punkty
1 Kompilacja, CIL i alokacja (nop/box) Flagi csc, porównanie w ildasm, usunięcie metody partial. 15 pkt
2 Referencje i stałe (const vs readonly) Różnica w kodzie MSIL, inlining stałych w kompilacji. 15 pkt
3 Inżynieria wsteczna (CIL Patching) Zmiana literału w .il, rekompilacja asemblerem ilasm. 10 pkt
4 Moduły wieloplikowe i al.exe Flaga -t:module, konsolidator al.exe, analiza manifestu. 10 pkt
5 Podpisywanie silną nazwą (sn.exe) Wygenerowanie pary kluczy, błąd manifestu 0x80131040. 10 pkt
6 Global Assembly Cache (GAC) Instalacja w GAC dwóch wersji biblioteki przez gacutil.exe. 10 pkt
7 Optymalizacja: R2R i Span<T> Narzut binarny R2R oraz bezalokacyjny parsing na ReadOnlySpan. 10 pkt
8 Narzędzie .NET Global Tool (ZTM) Pobieranie danych ZTM Gdańsk, instalacja narzędzia w systemie. 10 pkt
9 Potok automatyzacji w Cake & Testy Skrypt build.cake (lub projekt Cake Frosting) z zadaniami Clean → Restore → Build → Test → Pack. 10 pkt
SUMA 100 pkt

Przykładowe pytania na obronie ustnej:

  1. Dlaczego instrukcja box powoduje alokację na stercie i jak nowoczesny C# pozwala jej unikać (np. za pomocą typów generycznych lub Span<T>)?
  2. Dlaczego podmiana wartości stałej const w bibliotece DLL nie zadziała w programie klienckim bez jego rekompilacji, a podmiana wartości readonly zadziała?
  3. Czym różni się instrukcja maszynowa wygenerowana w technologii ReadyToRun od natywnego pliku wykonywalnego wygenerowanego przez Native AOT?
  4. Dlaczego struktura oznaczona jako ref struct (np. ReadOnlySpan<char>) nie może być polem wewnątrz zwykłej klasy class?