WhoHolds

Egyetlen függőségmentes Native AOT Windows CLI .exe fájl, amely fájlokat zároló folyamatokat azonosít. Az alkalmazás és a CI pipeline C#-al készült. Igény szerint szkriptelhető JSON-kimenetet és kilépési kódokat biztosít.

Ez a dokumentum bemutatja, milyen kihívásokkal találkoztam a téma feldolgozása során, és hogyan oldottam meg őket.


A CLI futtatható állomány használatáról a GitHub repository README.md fájljában találsz információt.

Architektúra

Az alkalmazás négy rétegű architektúrát követ:

1. Native Method Signatures (WhoHolds.Core)

Itt nincs logika. Ez a réteg lényegében egyetlen osztályból, a NativeMethods-ból áll, amely a .dll függvényhívásokat definiálja:

[LibraryImport(_restartManager, StringMarshalling = StringMarshalling.Utf16)]
[DefaultDllImportSearchPaths(DllImportSearchPath.System32)]
internal static partial SystemErrorCode RmGetList(
    uint dwSessionHandle,
    out uint pnProcInfoNeeded,
    ref uint pnProcInfo,
    [In, Out] RmProcessInfo[]? rgAffectedApps,
    out RmRebootReason lpdwRebootReasons
);
DWORD RmGetList(
    [in]                DWORD              dwSessionHandle,
    [out]               UINT               *pnProcInfoNeeded,
    [in, out]           UINT               *pnProcInfo,
    [in, out, optional] RM_PROCESS_INFO [] rgAffectedApps,
    [out]               LPDWORD            lpdwRebootReasons
);

LibraryImportAttribute: source generation segítségével állítja elő a marshalling kódot. Native AOT alkalmazásoknál ezt érdemes előnyben részesíteni a futásidejű generálással szemben, mert:

  • A trimming és az AOT analízis képes ellenőrizni a generált kódot,
  • A generált kódot meg tudod vizsgálni és debuggolni,
  • Gyorsabb az indulás,
  • A marshalling döntések explicitek: a StringMarshalling megadása kötelező.

DefaultDllImportSearchPathsAttribute: a .dll keresését egy megbízható helyre korlátozza: a Windows rendszerkönyvtárra.

2. Native Method Handlers (WhoHolds.Core)

Ez a réteg olyan osztályokat definiál, amelyek a NativeMethods-ban található metódusdefiníciók felhasználásával vezénylik a logikát.

Erre tökéletes példa a RestartManagerSession - a NativeMethods-ban definiált Restart Manager függvényeket használja arra, hogy:

  1. Elindítson egy Restart Manager session-t.
  2. Regisztrálja a konstruktorban átadott fájlútvonalakat.
  3. Listázza a regisztrált fájlútvonalakhoz tartozó HolderProcess példányokat.
  4. Dispose-nál lezárja a Restart Manager session-t.

A hibákat a visszaadott SystemErrorCode és a függvény neve alapján az RmFunctionReference-en keresztül a megfelelő hibákat tartalmazó Result objektumokká alakítja. Azért a Result patternt választottam, mert:

  • Exception-t csak váratlan esetben (alkalmazáshibánál) illene dobni,
  • Egységes kimenetet biztosít a CLI-nek - ebben az esetben a Result egyben envelope is, ezért a kimenet JSON szerializációval scriptelhetővé válik.

3. WhoHolds.Core Public API

A CLI projekt publikus API-ja. A WhoHolds statikus osztály egységes Result kimenetet nyújt a CLI projektnek. Védelmet ad a következők ellen:

  • Könyvtárra mutató útvonalak,
  • Nem létező fájlok,
  • Rendszergazdai jogosultságot igénylő fájlok,
  • Érvénytelen fájlútvonalak.

4. CLI

Az alkalmazás belépési pontja, itt vannak definiálva a felhasználó számára elérhető parancsok és opciók.

A CliJsonContext (a JsonSerializerContext osztályból származik) source generated JSON szerializációt biztosít a Result envelope-hoz a ResultJsonConverter segítségével. Ez a converter a JSON dokumentum darabonkénti felépítését definiálja - szó szerint, olyan metódusokkal, mint a writer.WriteStartObject(), a writer.WritePropertyName(...) és a writer.WriteBoolean(...).
Az egységes JSON kulcsnevezést a JsonNamingPolicy.CamelCase.ConvertName(...) biztosítja - ezt arra használtam, hogy a mezőneveket nameof()-on keresztül adjam át. Így a szerializációban nincs primitive obsession.

Tesztelés

A tesztelést a kiváló TUnit frameworkkel végzem, amely a következőket nyújtja:

  • Gyors, source generated teszteket: a framework tesztmetódusonként egyetlen tesztosztályt generál,
  • Párhuzamosítást,
  • És egy olyan módot a tesztinfrastruktúra felállítására, ahol nem kell névterekkel küzdenem (korábban NUnit-ot használtam).

A szükséges tesztinfrastruktúra többféleképpen példányosítható:

  • Tesztmetódusonként, osztálypéldány-mezőn keresztül,
  • Tesztosztályonként, statikus osztálymezőn keresztül,
  • A framework által injektálva és kezelve (aszinkron inicializálással és felszabadítással).

Ez a tesztelő framework a legjobb teljesítményű és legrugalmasabb, amit eddig használtam, adj neki egy csillagot a GitHubon!

.NET-ben általánosságban nem ajánlott egy tesztprojektre egy másik tesztprojektből hivatkozni, ha a Microsoft Testing Platform alatt tesztelünk. A teszt függőségek és konfigurációk átszivárognak: csomagok, module initializerek vagy assembly szintű konfiguráció. Én viszont még azelőtt így csináltam, hogy erről tudtam volna, ami nemcsak a fenti problémákat okozta, hanem a tesztek duplikálódását is. Emiatt át kellett alakítanom a teszteket, és a közös kódot egy külön projektbe kellett tennem, amely csak a TUnit.Core-ra hivatkozik, és nem készít belőle futtatható állományt.

CI Pipeline

GitHub Actions-t használtam a CI pipeline futtatására. Egy .yml workflow fájlt olvas be, amely a pipeline lépéseit írja le, az utolsó lépés pedig a Pipeline projektet futtatja, amely egy szokatlan C# kódbázis, és a CI logika nagy részét tartalmazza. Ezt a ModularPipelines NuGet csomag tette lehetővé - amelyet ugyanaz a fejlesztő készített, mint a TUnit frameworköt.

A csomag egy buildert biztosít requirementek regisztrálásához, például a WindowsRequirement-et - ami a CI hostot Windowsra korlátozza -, vagy egyedi requirementekhez, mint a CppBuildToolRequirement, amely ellenőrzi, hogy a gépen megvannak-e a Native AOT fordításhoz szükséges C++ build tools. A requirementek a pipeline tényleges moduljai követik: RestoreModule, BuildModule, TestModule, PublishModule, SmokeTestModule és DraftReleaseModule. Az utolsó három modul skip conditiont definiál: csak tag push esetén futnak.

A pipeline tesztelése

Mivel a pipeline C#-ban íródott, ugyanúgy tesztelhető, mint bármely más kód. De hogyan teszteljünk egy CI pipeline-t, és hol húzzuk meg a határt? A teljes pipeline futtatása a tesztekből nem opció: a pipeline a lépései között maga is futtatja a teszteket, így a tesztek újra elindítanák a pipeline-t, ami megint lefuttatná a teszteket, és így tovább.

A teljesen mockolt pipeline-hoz tartozó tesztek karbantartása sem jó megoldás: bármilyen függőségváltozás, például egy új service, egy új konfigurációs érték vagy akár egy settings osztály apró módosítása is eltörheti a teszteket.

Ehelyett az egyes requirementeket és modulokat unit tesztelem mockolt konfigurációval és service-ekkel. Ez elég ahhoz, hogy még az éles környezetbe kerülés előtt elkapjam a súlyos hibákat, anélkül, hogy a pipeline-nak saját magát kellene futtatnia.

További nehézség a ModularPipelines architektúrája: egy Module végrehajtásához a könyvtár belső összekötő logikájára van szükség.

Ezért úgy döntöttem, hogy a moduljaim vékony ragasztórétegek maradnak. A statikus metódusok tartalmazzák a logika különálló építőköveit, a modul pedig csak meghívja és összekapcsolja őket:


[DependsOn<PublishModule>]
public sealed class SmokeTestModule(IOptions<PipelineSettings> options) : Module
{
    protected override ModuleConfiguration Configure()
    {
        return ModuleConfiguration.Create().WithTagPushSkip(options.Value.IsTagPush).Build();
    }

    protected override async Task ExecuteModuleAsync(
        IModuleContext context,
        CancellationToken cancellationToken
    )
    {
        var publishedBuild = (await context.GetModule<PublishModule>()).EnsurePublished();

        var result = await ExecuteVersionCommandAsync(
            publishedBuild,
            context.Shell.Command,
            cancellationToken
        );

        ThrowIfVersionOutputInvalid(result.StandardOutput, publishedBuild.Version);
    }

    public static async Task<CommandResult> ExecuteVersionCommandAsync(
        PublishedBuild publishedBuild,
        ICommandContext context,
        CancellationToken cancellationToken
    )
    {
        return await context.ExecuteCommandLineTool(
            new GenericCommandLineToolOptions(publishedBuild.FilePath)
            {
                Arguments = ["--version"],
            },
            cancellationToken: cancellationToken
        );
    }

    ...
}

A kompromisszum az, hogy a modulokon publikus statikus metódusok vannak, de ezzel sokkal könnyebb őket tesztelni. A csomag a v3.2.8-as verzióban még nem rendelkezik jó tesztelési támogatással, de ez a v4-ben változni fog.