Att bygga ett arbetsruttsystem med HTMX och ASP.NET Core | - | Del |4: | Hangfire Integration och Automatisering
Inleiding
I Del 3, vi byggde en vacker visuell redigerare . Men våra arbetsflöden körs bara när vi manuell aktiverar dem . I den här sista punkten M SK3 kommer vi att göra arbetsflödena verkligt autonoma med hjälp av Hangfire för
- Avstängd exécution - Loopa arbetsflöden på ett plan
- API-röstningar - övervaka externa APIs och aktivera förändringar
- Stadsstyre - Stäng utlösande tillstånd över uitvoeringer
- Tavlansbord - övervaka alla bakgrundsjobb
Varför Hangfire?
Hangfire är perfekt för våra behov eftersom det
- Speicherar jobb i vår PostgreSQL-databas
- Geer en inbyggd dashboard
- Ondersteuner återhämtade jobb
- Har en automatisk återproberad logik
- Skalar horisontellt
Trigger-Étatmodellen
Först ",", låt "'" förstå vår utlösande statenitet , (", vi har redan skapat den i Part .
[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; }
}
Den här enheten följer allting om en arbetsströmbrytare:
- När den sist kördes
- Vad är dess konfiguration?
- Vad står det för? 's i ( för statfulla utlösande apparater
- Inga fel som inträffat
Timmattade arbetsflöden
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; }
}
Timmaverkets jobb
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
};
}
}
Hur det fungerar:
- Varje minut, Hangfire ringer
ExecuteScheduledWorkflowsAsync() - Vi frågar efter aktiverade schematrigger
- För varje aktivator checka om tillräckligt mycket tid har passerat
- Om ja, sätter igång arbetsflödet
- Dateer upp aktiveringslägen med den sista körningens tid
API-röstning
API-undersökningar är mer intressanta - vi övervakar externa APIs och aktiverar arbetsflöden när innehållet förändras!
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; }
}
Rörningens jobb
[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);
}
Hur det fungerar:
- Varje minut, kolla alla API-röstningstryglar
- För varje aktivator checka om tillräckligt mycket tid har passerat sedan förra undersökningen
- Poll den konfigurerade URL: n
- Beräkna en hash av svarets innehåll
- jämför med den tidigare haschen som lagrats i tillståndet
- Om den ändras ( eller
AlwaysTriggerär sant), sätter igång arbetsflödet - Släng API-responsen som inputdata till arbetsflödet
- uppdatera staten med en ny hash
Ta ett exempel
Monitora releases av GitHub:
{
"triggerType": "ApiPoll",
"config": {
"url": "https://api.github.com/repos/dotnet/aspnetcore/releases/latest",
"intervalSeconds": 3600,
"alwaysTrigger": false
}
}
Den här undersökningen mäter GitHubs API varje timme. När en ny Veröffentlichung är publicerad, ändras innehållshashet , och arbetsflödet utförs med VeröffentlichungsdataM SK3
Att registrera Hangfire jobb
I din Program.cs eller startkonfigurationen:
// Add Hangfire services
builder.Services.AddHangfire(config =>
{
config.UsePostgreSqlStorage(
builder.Configuration.GetConnectionString("DefaultConnection"));
});
builder.Services.AddHangfireServer();
// Register our job
builder.Services.AddScoped<WorkflowSchedulerJob>();
Sedan , efter appstarten, registrera återhämtade jobb
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);
Hangfire-brädan
Hangfire inkluderar en inbyggd dashboard som är tillgänglig på: /hangfire:
- jobb: Se alla uppreptade , bearbetande , och färdiga jobb
- Redaktiva jobb: Mana våra arbetsflödesplaner
- Förtjänningar: Se och prova igen misslyckade jobb
- Server: Monitor Hangfire-server
Att skydda tavlan
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;
}
}
Att hantera trigger via gränssnitt
Låt's lägga till gränssnittet för att skapa och sköta utlösande
[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-komponenter
<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 Example Workflow
Låt oss bygga en komplett automatiserad arbetsström som
- Pollar GitHubs API för nya versioner
- Kontrollerar om versionen är nyare än vad vi sett i '
- Protokollerar ett meddelande
- (Kan skicka ett e-mail , meddelande till Slack
steg 1: skapa arbetsprocessen
{
"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"
}
]
}
steg 2: skapa API Poll Trigger
{
"type": "ApiPoll",
"config": {
"url": "https://api.github.com/repos/dotnet/aspnetcore/releases/latest",
"intervalSeconds": 3600
}
}
Nu , varje timme, Hangfire kommer:
- Polla GitHubs API
- Vergleicha innehållshash med förra undersökningen
- Om den ändras, löser arbetsflödet
- arbetsflödet analyserar JSON och registrerar utsläppsinformationen
övervakning och observationalitet
Logging
Alla arbetsflöden är registrerade:
_logger.LogInformation(
"Workflow {WorkflowId} execution {ExecutionId} completed in {Duration}ms with status {Status}",
execution.WorkflowId,
execution.Id,
execution.DurationMs,
execution.Status);
Metrik
Vi kan lägga till Prometheus mätningar:
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();
Alerter
Inställ alarmer för:
- Misslyckade arbetsflöden (Status | == | Misslyckad
- Arbetsflöden tar för lång tid
- API-röstningsfel
- Trigger som inte har skjutit upp ' i den förväntade tidsramen
Tillvägagångspunkter
Databasislagning
Med många arbetsflöden som röstar ofta, kan datamängden vara signifikant.
Lösning: Batch-sökningar
// 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-hastighetsbegränsning
När man bedömer externa APIs:
Lösningen: Exponensella bakslag
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");
}
avancerade egenskaper
Bedingande Trigger
Bara utlösa om vissa krav är uppfyllda:
public class ConditionalTriggerConfig : ApiPollTriggerConfig
{
public string? Condition { get; set; } // e.g., "{{stars}} > 1000"
}
Trigger beroenden
Ketten utlöser - en arbetsström ' slutningen utlöses med en annan :
// 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}");
}
}
Att testa Hangfire-arbeten
Unit test your 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);
}
Slutsatsen
Vi har byggt ett komplett automatiserat system.
✅ Bättra på schema - timmer , dagligen M SK2 eller individuella intervall ✅ Poll APIs - övervaka externa tjänster efter förändringar ✅ Stängsläge - Kom ihåg vad vi ✅ Auto-retry - Handle transient fel ✅ Monitor - Dashboard för alla jobb ✅ Skala - Hangfire styr lastbalansering
Den kompletta serien
Vi har byggt ett företag
- Del 1: Inleiding och arkitektur
- Del 2: Core workflow-motor
- Del 3: Visuella arbetsflödesredigerare
- Del 4: Hangfire integration (this postM SK2
Du har nu:
- En kraftfull arbetsflödesmotor
- En vacker visuell redigerare
- Automatiserad utföring
- API-övervakning
- Full observability
Vad's Nästa?
Möjliga förbättringar:
- Webhooks: Triggerar arbetsflöden via HTTP-terminer
- E-postpunkter: Senda e-mail från arbetsflöden
- Databasisknoder: sökdatabaser
- AI-knoder: Integrera med LLMs
- Sub-flöden: Komponera arbetsflöden tillsammans
- Workflow-marknaden: dela templater för arbetsflödet
källkod
Alla koder är tillgängliga i arkiven:
- Hangfire jobb:
Mostlylucid.SchedulerService/Jobs/ - Workflow-modeller:
Mostlylucid.Workflow.Shared/ - Workflowmotorn:
Mostlylucid.Workflow.Engine/
Tack för att ni följde den här serien