Table of Contents

Services

Ein Service ist eine dauerhaft laufende Aufgabe, etwa Dateiüberwachung oder Nachrichtenempfang. Die Runtime startet ihn beim Laden des Moduls. Er läuft, bis das Modul entladen oder die Runtime beendet wird.

Service definieren

Leiten Sie von ModuleService ab, überschreiben Sie ExecuteAsync und registrieren Sie die Klasse in ConfigureServices:

public sealed class PreisimportWaechter(IBpSessions sessions) : ModuleService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            // Arbeiten
            await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken).ConfigureAwait(false);
        }
    }
}

public sealed class PreisimportModule : BpModule
{
    public override string ModuleName => "Preisimport";
    public override string ModuleVersion => "1.0.0";

    public static void ConfigureServices(IServiceCollection services) =>
        services.AddHostedService<PreisimportWaechter>();
}

Über das stoppingToken beenden Sie die Arbeit, wenn das Modul gestoppt wird. Geben Sie das Token an wartende und asynchrone Operationen weiter, damit auch diese den Abbruch berücksichtigen.

ServiceId verwendet standardmäßig den Klassennamen. Mit einem Override legen Sie eine eigene Kennung für Protokolle und Benachrichtigungen fest.

Abhängigkeiten anfordern

Services werden als Singleton registriert. Verwenden Sie für Datenzugriffe IBpSessions; weitere Abhängigkeiten können etwa ISecrets, INotifications, IWebhooks oder eigene Singleton-Dienste sein.

Für Protokollmeldungen steht die Property Logger bereit.

Kurzzeitige microtech-ERP-Sitzungen verwenden

Führen Sie Datenzugriffe über IBpSessions.RunAsync aus:

var preis = await sessions.RunAsync(
    session => session
        .QueryDataSet<Artikel>(includes: ["ArtNr", "Vk0Preis"])
        .FirstOrDefault(artikel => artikel.ArtNr == artikelNummer)?
        .Vk0Preis,
    stoppingToken).ConfigureAwait(false);

Beschränken Sie RunAsync auf den Datenzugriff. Warten Sie außerhalb davon auf neue Dateien oder Nachrichten.

Für GraphQL gibt es dieselbe Methode als RunGraphQlAsync; das angesprochene Verbindungsprofil muss dann eine GraphQL-Verbindung sein. Beide Varianten nehmen wahlweise eine Profil-ID entgegen, wenn nicht das Standardprofil gemeint ist.

Zustand über Ausführungen hinweg teilen

Soll eine Action auf den laufenden Service zugreifen, registrieren Sie dieselbe Instanz zusätzlich unter ihrem eigenen Typ:

public static void ConfigureServices(IServiceCollection services)
{
    services.AddSingleton<ImportWarteschlange>();
    services.AddHostedService(provider => provider.GetRequiredService<ImportWarteschlange>());
}

Unterschiede zu Actions

Service-Protokolle finden Sie in Studio unter Dienste, nicht in der Action-Historie.

Beispiel: Preisimport aus einem Verzeichnis

Der Service übernimmt Artikelpreise aus einem Austauschordner und archiviert verarbeitete Dateien. Das CSV-Format besteht aus einer Kopfzeile und Artikelnummer;Preis mit einem Punkt als Dezimaltrennzeichen.

Stellen Sie nur vollständig geschriebene Dateien als .csv bereit. Testen Sie den Import in einem Testmandanten; er ändert die Artikelpreise.

public sealed class PreisimportWaechter(IBpSessions sessions, INotifications notifications) : ModuleService
{
    private const string EingangsOrdner = @"C:\BpNexT.NET\preise\eingang";
    private const string ArchivOrdner = @"C:\BpNexT.NET\preise\archiv";

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        Directory.CreateDirectory(EingangsOrdner);
        Directory.CreateDirectory(ArchivOrdner);

        while (!stoppingToken.IsCancellationRequested)
        {
            foreach (var datei in Directory.EnumerateFiles(EingangsOrdner, "*.csv"))
            {
                await VerarbeiteDateiAsync(datei, stoppingToken).ConfigureAwait(false);
            }

            // Neue, vollständig bereitgestellte Dateien regelmäßig prüfen.
            await Task.Delay(TimeSpan.FromSeconds(15), stoppingToken).ConfigureAwait(false);
        }
    }

    private async Task VerarbeiteDateiAsync(string datei, CancellationToken cancellationToken)
    {
        var zeilen = await File.ReadAllLinesAsync(datei, cancellationToken).ConfigureAwait(false);
        var uebernommen = 0;

        foreach (var zeile in zeilen.Skip(1))
        {
            var felder = zeile.Split(';');
            if (felder.Length < 2 || !double.TryParse(
                    felder[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var neuerPreis))
                throw new FormatException($"Ungültige Preiszeile in {datei}: {zeile}");

            var artikelNummer = felder[0];

            // Eine geliehene Sitzung je Artikel: kurz halten, damit Ausführungen nicht warten.
            var geaendert = await sessions.RunAsync(
                session =>
                {
                    // Ohne includes, denn der Datensatz wird anschließend gespeichert.
                    var artikel = session
                        .QueryDataSet<Artikel>()
                        .FirstOrDefault(eintrag => eintrag.ArtNr == artikelNummer);

                    if (artikel is null)
                        return false;

                    artikel.Vk0Preis = neuerPreis;
                    artikel.Save();
                    return true;
                },
                cancellationToken).ConfigureAwait(false);

            if (geaendert)
                uebernommen++;
            else
                throw new InvalidOperationException($"Artikel {artikelNummer} aus {datei} nicht gefunden.");
        }

        File.Move(datei, Path.Combine(ArchivOrdner, Path.GetFileName(datei)), overwrite: false);
        Logger.LogInformation("{Count} Preise aus {Datei} übernommen", uebernommen, datei);

        if (uebernommen == 0)
        {
            await notifications.SendAsync(
                "Preisimport ohne Treffer",
                $"Aus '{Path.GetFileName(datei)}' konnte kein Preis übernommen werden.",
                BpNotificationSeverity.Warning,
                coalesceKey: "preisimport-leer",
                cancellationToken).ConfigureAwait(false);
        }
    }
}

Verwenden Sie eindeutige Dateinamen, damit vorhandene Archivdateien nicht überschrieben werden. Der Import ist nicht atomar: Bei einem Fehler können bereits Preise gespeichert worden sein. Die Eingabedatei bleibt dann liegen und wird beim nächsten Start erneut verarbeitet.

coalesceKey fasst wiederkehrende Benachrichtigungen zu einem Eintrag zusammen.

Fehler und Neustarts behandeln

Endet ExecuteAsync unerwartet, protokolliert die Runtime den Fehler, benachrichtigt die Administratoren und startet den Service nach einer Wartezeit erneut (standardmäßig zehn Sekunden).

Beim Wiederanlauf wird dieselbe Instanz verwendet; ihr Zustand bleibt erhalten. ModuleServiceMaxRestarts begrenzt die Versuche, -1 erlaubt unbegrenzt viele.

Gestalten Sie die Verarbeitung wiederanlauffähig und speichern Sie den Verarbeitungsstand bei Bedarf dauerhaft.

Service oder Trigger auswählen

Aufgabe Passend
Alle 15 Minuten Kurse abholen [Every] oder [Cron], kein Service
Auf Änderungen in microtech ERP reagieren [OnChanged], kein Service
Auf einer offenen Verbindung lauschen (Waage, Terminal, Warteschlange) Service
Einen Ordner überwachen, ohne dass ein fester Takt sinnvoll ist Service