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 |