This is a viewer only at the moment see the article on how this works.
To update the preview hit Ctrl-Alt-R (or ⌘-Alt-R on Mac) or Enter to refresh. The Save icon lets you save the markdown file to disk
This is a preview from the server running through my markdig pipeline
Aufbau eines Workflow-Systems mit HTMX und ASP-.-NET Core Teil -, Hangfire Integration und Automatisierung
Wednesday, 15 January 2025
Einleitung
Im TeilWir haben einen schönen visuellen Editor gebaut, aber unsere Workflows laufen nur dann, wenn wir sie manuell auslösen. In diesem letzten Beitrag machen wir die Arbeitsabläufe mit Hangfire für ,:
- Geplante Ausführung Führe Workflows auf einem Zeitplan aus
- API-Abfrage Überwachen Sie externe APIs und triggern Sie Änderungen
- Staatliche Verwaltung Verfolgen Sie Triggerzustände über Exekutionen hinweg
- Dashboard Überwachen Sie alle Hintergrundarbeiten
Warum Hangfire?
Hangfire ist perfekt für unsere Bedürfnisse, weil es
- Speichert Jobs in unserer bestehenden PostgreSQL Datenbank
- Bietet ein integriertes Dashboard
- Unterstützt wiederkehrende Jobs
- Hat automatische Wiederholung Logik
- Waagen horizontal
Das Trigger State Model
Erste let-'s verstehen unsere Trigger Staat Entity -( , wir haben dies bereits in Teil erstellt.
[Table("workflow_trigger_states")]
public class WorkflowTriggerStateEntity
{
public int Id { get; set; }
public int WorkflowDefinitionId { get; set; }
// Type: "Schedule", "ApiPoll", "Webhook"
public string TriggerType { get; set; } = string.Empty;
// Configuration as JSON
public string ConfigJson { get; set; } = "{}";
// Current state as JSON (stores last poll time, content hash, etc.)
public string StateJson { get; set; } = "{}";
public bool IsEnabled { get; set; } = true;
public DateTime? LastCheckedAt { get; set; }
public DateTime? LastFiredAt { get; set; }
public int FireCount { get; set; } = 0;
public string? LastError { get; set; }
}
Diese Entity verfolgt alles über einen Workflow-Trigger.
- Als es zuletzt lief
- Was seine Konfiguration ist
- Welcher Zustand es ist, ' ist in .( für stattliche Trigger.
- Fehler, die aufgetreten sind
Geplante Workflows
Konfigurationsmodell
public class ScheduleTriggerConfig
{
public string IntervalType { get; set; } = "minutes"; // minutes, hours, days
public int IntervalValue { get; set; } = 60;
public Dictionary<string, object>? InputData { get; set; }
}
Der Planerjob
public class WorkflowSchedulerJob
{
private readonly MostlylucidDbContext _context;
private readonly WorkflowExecutionService _executionService;
private readonly ILogger<WorkflowSchedulerJob> _logger;
[AutomaticRetry(Attempts = 3)]
public async Task ExecuteScheduledWorkflowsAsync()
{
_logger.LogInformation("Checking for scheduled workflows");
// Get all enabled schedule triggers
var triggers = await _context.WorkflowTriggerStates
.Include(t => t.WorkflowDefinition)
.Where(t => t.IsEnabled && t.TriggerType == "Schedule")
.ToListAsync();
foreach (var trigger in triggers)
{
try
{
var config = JsonSerializer.Deserialize<ScheduleTriggerConfig>(
trigger.ConfigJson);
if (config == null) continue;
// Check if it's time to run
if (!ShouldRunScheduledWorkflow(trigger, config))
continue;
_logger.LogInformation(
"Executing scheduled workflow {WorkflowId}",
trigger.WorkflowDefinition.WorkflowId);
// Execute the workflow
await _executionService.ExecuteWorkflowAsync(
trigger.WorkflowDefinition.WorkflowId,
config.InputData,
"Scheduler");
// Update trigger state
trigger.LastCheckedAt = DateTime.UtcNow;
trigger.LastFiredAt = DateTime.UtcNow;
trigger.FireCount++;
var state = JsonSerializer.Deserialize<Dictionary<string, object>>(
trigger.StateJson) ?? new();
state["lastRun"] = DateTime.UtcNow.ToString("O");
trigger.StateJson = JsonSerializer.Serialize(state);
await _context.SaveChangesAsync();
}
catch (Exception ex)
{
_logger.LogError(ex,
"Error executing scheduled workflow {TriggerId}",
trigger.Id);
trigger.LastError = ex.Message;
await _context.SaveChangesAsync();
}
}
}
private bool ShouldRunScheduledWorkflow(
WorkflowTriggerStateEntity trigger,
ScheduleTriggerConfig config)
{
// First run?
if (!trigger.LastFiredAt.HasValue)
return true;
var timeSinceLastRun = DateTime.UtcNow - trigger.LastFiredAt.Value;
return config.IntervalType.ToLower() switch
{
"minutes" => timeSinceLastRun.TotalMinutes >= config.IntervalValue,
"hours" => timeSinceLastRun.TotalHours >= config.IntervalValue,
"days" => timeSinceLastRun.TotalDays >= config.IntervalValue,
_ => false
};
}
}
Wie es funktioniert
- Jede Minute Anrufe aus dem Hangfire
ExecuteScheduledWorkflowsAsync() - Wir suchen nach aktivierten Zeitplanauslösern
- Prüfen Sie für jeden Trigger, ob genügend Zeit vergangen ist
- Wenn ja, führen Sie den Workflow aus
- Aktualisieren des Triggerzustands mit der letzten Laufzeit
API-Abfrage
API-Abfrage ist interessanter.Wir überwachen externe APIs und lösen Workflows aus, wenn sich der Inhalt ändert.
Konfigurationsmodell
public class ApiPollTriggerConfig
{
public string Url { get; set; } = string.Empty;
public int IntervalSeconds { get; set; } = 300; // 5 minutes
public bool AlwaysTrigger { get; set; } = false;
public Dictionary<string, string>? Headers { get; set; }
}
Die Umfragearbeit
[AutomaticRetry(Attempts = 3)]
public async Task PollApiTriggersAsync()
{
_logger.LogInformation("Polling API triggers");
var triggers = await _context.WorkflowTriggerStates
.Include(t => t.WorkflowDefinition)
.Where(t => t.IsEnabled && t.TriggerType == "ApiPoll")
.ToListAsync();
foreach (var trigger in triggers)
{
try
{
var config = JsonSerializer.Deserialize<ApiPollTriggerConfig>(
trigger.ConfigJson);
if (config == null) continue;
// Check if it's time to poll
if (trigger.LastCheckedAt.HasValue)
{
var timeSinceLastCheck = DateTime.UtcNow - trigger.LastCheckedAt.Value;
if (timeSinceLastCheck.TotalSeconds < config.IntervalSeconds)
continue;
}
_logger.LogInformation("Polling API for workflow {WorkflowId}",
trigger.WorkflowDefinition.WorkflowId);
// Poll the API
using var httpClient = new HttpClient();
var response = await httpClient.GetAsync(config.Url);
var content = await response.Content.ReadAsStringAsync();
// Get previous state
var state = JsonSerializer.Deserialize<Dictionary<string, object>>(
trigger.StateJson) ?? new();
var previousHash = state.GetValueOrDefault("contentHash")?.ToString();
var currentHash = ComputeHash(content);
// Has content changed?
if (previousHash != currentHash || config.AlwaysTrigger)
{
_logger.LogInformation(
"API content changed, triggering workflow {WorkflowId}",
trigger.WorkflowDefinition.WorkflowId);
// Pass response as input to workflow
var inputData = new Dictionary<string, object>
{
["apiResponse"] = content,
["statusCode"] = (int)response.StatusCode,
["previousHash"] = previousHash ?? string.Empty,
["currentHash"] = currentHash
};
// Execute the workflow
await _executionService.ExecuteWorkflowAsync(
trigger.WorkflowDefinition.WorkflowId,
inputData,
$"ApiPoll:{config.Url}");
trigger.LastFiredAt = DateTime.UtcNow;
trigger.FireCount++;
// Update state
state["contentHash"] = currentHash;
state["lastContent"] = content.Length > 1000
? content.Substring(0, 1000)
: content;
state["lastPoll"] = DateTime.UtcNow.ToString("O");
}
trigger.LastCheckedAt = DateTime.UtcNow;
trigger.StateJson = JsonSerializer.Serialize(state);
trigger.LastError = null;
await _context.SaveChangesAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error polling API trigger {TriggerId}",
trigger.Id);
trigger.LastError = ex.Message;
trigger.LastCheckedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
}
}
}
private string ComputeHash(string content)
{
using var sha256 = System.Security.Cryptography.SHA256.Create();
var bytes = System.Text.Encoding.UTF8.GetBytes(content);
var hash = sha256.ComputeHash(bytes);
return Convert.ToBase64String(hash);
}
Wie es funktioniert
- Überprüfen Sie jede Minute alle API-Abfrageauslöser
- Prüfen Sie für jeden Trigger, ob seit der letzten Umfrage genügend Zeit vergangen ist.
- Umfrage der konfigurierten URL
- Berechnung eines Hashs des Antwortinhalts
- Vergleich mit dem im Zustand gespeicherten Hash
- Wenn geändert
AlwaysTriggerist true.),-Ausführung des Workflows - Übergeben Sie die API-Antwort als Eingabedaten an den Workflow
- Zustand mit neuem Hash aktualisieren
Fallbeispiel verwenden
Überwachen Sie GitHub-Veröffentlichungen
{
"triggerType": "ApiPoll",
"config": {
"url": "https://api.github.com/repos/dotnet/aspnetcore/releases/latest",
"intervalSeconds": 3600,
"alwaysTrigger": false
}
}
Diese Umfragen die GitHub API jede Stunde Wenn eine neue Veröffentlichung veröffentlicht wird, ändert sich der Inhalt Hash., und der Workflow führt mit den Release-Daten aus
Registrierung von Hangfire-Jobs
In Ihrem Program.cs oder Konfiguration des Starts
// Add Hangfire services
builder.Services.AddHangfire(config =>
{
config.UsePostgreSqlStorage(
builder.Configuration.GetConnectionString("DefaultConnection"));
});
builder.Services.AddHangfireServer();
// Register our job
builder.Services.AddScoped<WorkflowSchedulerJob>();
Dann, nachdem die App gestartet hat, registrieren Sie wiederkehrende Jobs.
app.UseHangfireDashboard("/hangfire");
// Register recurring jobs
RecurringJob.AddOrUpdate<WorkflowSchedulerJob>(
"scheduled-workflows",
job => job.ExecuteScheduledWorkflowsAsync(),
Cron.Minutely);
RecurringJob.AddOrUpdate<WorkflowSchedulerJob>(
"api-poll-triggers",
job => job.PollApiTriggersAsync(),
Cron.Minutely);
Das Hangfire-Dashboard
Hangfire enthält ein eingebautes --Dashboard, das unter /hangfire:
- ArbeitsplätzeAlle Warteschlangen ansehen und abgeschlossene Jobs anzeigen
- Wiederkehrende JobsVerwalten Sie unsere Workflow-Scheduler
- WiederholungenAuswählen und erneut versuchen fehlgeschlagene Jobs
- ServerÜberwachung von Hangfire-Servern
Sicherung des Dashboards
app.UseHangfireDashboard("/hangfire", new DashboardOptions
{
Authorization = new[]
{
new HangfireAuthorizationFilter()
}
});
public class HangfireAuthorizationFilter : IDashboardAuthorizationFilter
{
public bool Authorize(DashboardContext context)
{
var httpContext = context.GetHttpContext();
// Only allow authenticated users
return httpContext.User.Identity?.IsAuthenticated == true;
}
}
Steuerung von Triggern über UI
Lassen Sie 's hinzufügen Benutzeroberfläche für das Erstellen und Verwalten von Triggern
[HttpPost("workflow/{id}/triggers")]
public async Task<IActionResult> CreateTrigger(
string id,
[FromBody] TriggerCreateRequest request)
{
var workflow = await _context.WorkflowDefinitions
.FirstOrDefaultAsync(w => w.WorkflowId == id);
if (workflow == null)
return NotFound();
var trigger = new WorkflowTriggerStateEntity
{
WorkflowDefinitionId = workflow.Id,
TriggerType = request.Type,
ConfigJson = JsonSerializer.Serialize(request.Config),
StateJson = "{}",
IsEnabled = true
};
await _context.WorkflowTriggerStates.AddAsync(trigger);
await _context.SaveChangesAsync();
return Json(new { success = true, triggerId = trigger.Id });
}
public class TriggerCreateRequest
{
public string Type { get; set; } = string.Empty; // Schedule, ApiPoll
public object Config { get; set; } = new();
}
UI-Bauteil
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">⏰ Add Trigger</h2>
<div class="form-control">
<label class="label">Trigger Type</label>
<select class="select select-bordered" x-model="triggerType">
<option value="Schedule">Schedule</option>
<option value="ApiPoll">API Poll</option>
</select>
</div>
<!-- Schedule Config -->
<template x-if="triggerType === 'Schedule'">
<div class="space-y-4">
<div class="form-control">
<label class="label">Interval</label>
<div class="flex gap-2">
<input type="number"
x-model="scheduleConfig.intervalValue"
class="input input-bordered flex-1" />
<select x-model="scheduleConfig.intervalType"
class="select select-bordered">
<option value="minutes">Minutes</option>
<option value="hours">Hours</option>
<option value="days">Days</option>
</select>
</div>
</div>
</div>
</template>
<!-- API Poll Config -->
<template x-if="triggerType === 'ApiPoll'">
<div class="space-y-4">
<div class="form-control">
<label class="label">API URL</label>
<input type="url"
x-model="apiConfig.url"
class="input input-bordered"
placeholder="https://api.example.com/data" />
</div>
<div class="form-control">
<label class="label">Poll Interval (seconds)</label>
<input type="number"
x-model="apiConfig.intervalSeconds"
class="input input-bordered"
value="300" />
</div>
</div>
</template>
<button @click="createTrigger()" class="btn btn-primary mt-4">
Create Trigger
</button>
</div>
</div>
Real--.World Beispiel Workflow
Lassen Sie uns einen kompletten automatisierten Workflow erstellen, der
- Umfragen der GitHub API für neue Versionen
- Überprüft, ob die Version neuer ist als das was wir gesehen haben
- Protokolliert eine Nachricht
- Eine E-Mail senden könnte... an Slack, , usw.
Schritt Erstellen Sie den Workflow
{
"name": "GitHub Release Monitor",
"startNodeId": "parse-data",
"nodes": [
{
"id": "parse-data",
"type": "Transform",
"name": "Extract Version",
"inputs": {
"operation": "json_parse",
"data": "{{apiResponse}}"
}
},
{
"id": "log-release",
"type": "Log",
"name": "Log New Release",
"inputs": {
"message": "New release: {{tag_name}} - {{name}}",
"level": "info"
}
}
],
"connections": [
{
"sourceNodeId": "parse-data",
"targetNodeId": "log-release"
}
]
}
Schritt Erstellen Sie den API-Poll Trigger
{
"type": "ApiPoll",
"config": {
"url": "https://api.github.com/repos/dotnet/aspnetcore/releases/latest",
"intervalSeconds": 3600
}
}
Jetzt ,. jede Stunde ,. Hangfire wird :.
- Umfrage der GitHub API
- Vergleichen Sie den Inhalt Hash mit vorheriger Umfrage
- Wenn geändert, führen Sie den Workflow aus
- Der Workflow parsiert den JSON und protokolliert die Release-Info
Überwachung und Beobachtungsfähigkeit
Protokollierung
Alle Workflow-Ausführungen werden protokolliert
_logger.LogInformation(
"Workflow {WorkflowId} execution {ExecutionId} completed in {Duration}ms with status {Status}",
execution.WorkflowId,
execution.Id,
execution.DurationMs,
execution.Status);
Metrik
Wir können Prometheus Metriken hinzufügen
private static readonly Counter WorkflowExecutions = Metrics
.CreateCounter("workflow_executions_total",
"Total workflow executions",
new CounterConfiguration
{
LabelNames = new[] { "workflow_id", "status" }
});
// In execution service
WorkflowExecutions
.WithLabels(workflow.Id, execution.Status.ToString())
.Inc();
Ausschreibungen
Alarme einrichten für:
- fehlgeschlagene Arbeitsabläufe
- Workflows dauern zu lange
- API-Abfragefehler
- Auslöser, die im erwarteten Zeitrahmen abgefeuert haben
Leistungsbeurteilungen
Datenbank laden
Bei vielen Workflows kann häufig Umfragen die Datenbanklast signifikant sein.
Lösungsabfragen für Batch
// Instead of querying per trigger
var triggers = await _context.WorkflowTriggerStates
.Include(t => t.WorkflowDefinition)
.Where(t => t.IsEnabled && t.TriggerType == "ApiPoll")
.AsNoTracking() // Read-only
.ToListAsync();
API-Rate begrenzen
Wenn externe APIs abgefragt werden
Lösung: Exponielles Backoff
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.TooManyRequests)
{
// Back off
var retryAfter = response.Headers.RetryAfter?.Delta ?? TimeSpan.FromMinutes(5);
state["backoffUntil"] = DateTime.UtcNow.Add(retryAfter).ToString("O");
}
Erweiterte Funktionen
Bedingte Auslöser
Trigger nur, wenn bestimmte Bedingungen erfüllt sind
public class ConditionalTriggerConfig : ApiPollTriggerConfig
{
public string? Condition { get; set; } // e.g., "{{stars}} > 1000"
}
Abhängigkeiten auslösen
Kettenauslöser eines Arbeitsablaufs eines Abschlusses von ' löst ein anderes aus
// After workflow completes
if (execution.Status == WorkflowExecutionStatus.Completed)
{
var dependentTriggers = await _context.WorkflowTriggerStates
.Where(t => t.TriggerType == "WorkflowComplete" &&
t.ConfigJson.Contains(execution.WorkflowId))
.ToListAsync();
foreach (var trigger in dependentTriggers)
{
await _executionService.ExecuteWorkflowAsync(
trigger.WorkflowDefinition.WorkflowId,
execution.OutputData,
$"Triggered by {execution.WorkflowId}");
}
}
Prüfen von Hangfire-Jobs
Einheit testen Sie Ihre Jobs
[Fact]
public async Task ExecuteScheduledWorkflows_ShouldExecuteWhenIntervalPassed()
{
// Arrange
var mockContext = CreateMockContext();
var mockExecutionService = new Mock<IWorkflowExecutionService>();
var job = new WorkflowSchedulerJob(mockContext.Object,
mockExecutionService.Object, Mock.Of<ILogger>());
// Act
await job.ExecuteScheduledWorkflowsAsync();
// Assert
mockExecutionService.Verify(s => s.ExecuteWorkflowAsync(
It.IsAny<string>(),
It.IsAny<Dictionary<string, object>>(),
"Scheduler",
It.IsAny<CancellationToken>()), Times.Once);
}
Schlußfolgerung
Wir haben ein komplettes Automatisierungssystem aufgebaut. Unsere Workflows können jetzt.
✅ Auf Zeitpläne laufen Tägliche oder benutzerdefinierte Intervalle ✅ Umfrage-APIs Überwachen Sie externe Dienste auf Änderungen ✅ Gleiszustand Erinnere dich an das, was wir vorher gesehen haben ✅ Auto---Retry Behandeln Sie vorübergehende Ausfälle ✅ Überwachung Dashboard für alle Jobs ✅ Skala Hängfeuerhandgriffe Lastausgleich
Die vollständige Serie
Wir haben ein Enterprise-Workflow-System von scratch-:
- TeilEinführung und Architektur
- TeilKern-Workflow-Engine
- TeilVisualer Workflow-Editor
- TeilDie Integration der Hangfire-Einbindung von ,( , diesem Beitrag ,) ,
Du hast jetzt ,:'
- Eine leistungsstarke Workflow-Engine
- Ein schöner visueller Editor
- Automatisierte Ausführung
- API-Überwachung
- Vollständige Beobachtbarkeit
Was ist das nächste?
Mögliche Verbesserungen
- WebhooksAuslösung von Workflows über HTTP-Endpunkte
- E-Mail-NodesE-Mails von Workflows senden
- DatenbankknotenAbfrage-Datenbanken
- AI-KnotenMit LLMs integrieren
- Sub---WorkflowsZusammensetzen von Workflows
- Workflow-MarktplatzWorkflow-Vorlagen teilen
Quellencode
Alle Codes sind im Repository verfügbar.
- Hangfire-Jobs
Mostlylucid.SchedulerService/Jobs/ - Workflow-Modelle - :
Mostlylucid.Workflow.Shared/ - Workflow-Engine - :
Mostlylucid.Workflow.Engine/
Vielen Dank, dass Sie dieser Serie gefolgt sind.