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
Building a Workflow System with HTMX and ASP.NET Core - Part 4: Hangfire Integration and Automation
Wednesday, 15 January 2025
Inleiding
In Deel 3, hebben we een mooi visuele redacteur gebouwd . Maar onze werkflows draaien alleen als we ze met de hand uitvoeren.
- Gescheduleerde uitvoering - Loop werkstromen op een schema
- API-enquête - Monitor externe API's en sneller op veranderingen
- State-management - Track-triggerstaaten over executies
- Tafelbord - Monitor alle achtergrondwerkjes
Waarom Hangfire?
Hangfire is perfect voor onze behoeften omdat het:
- Behoudt banen in onze bestaande PostgreSQL-database.
- Geïnstalleerd dashboard voor -.
- Ondersteunt herhalende banen
- Automatisch herproberen logica
- Skaleert horizontal.
Het Trigger State Model
Eerst: , laat ' ons onze spoorstaat-entiteit begrijpen. We hebben dit al gecreëerd in Part 2):
[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; }
}
Deze entiteit volgt alles over een workflow-trigger:
- Toen het de laatste keer liep.
- Wat zijn configuraties zijn?
- Wat staat het'in ( voor staatsmatige triggersM SK2
- Iedere fouten die voorkwamen
Gescheduleerde Werkstromen
Konfiguratiemodel
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; }
}
Het Skeduleerderwerk
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
};
}
}
Hoe het werkt:
- Elke minuut, Hangfire roept
ExecuteScheduledWorkflowsAsync() - We vragen naar geaktiveerde schema-triggers.
- Voor elke sneller, check of genoeg tijd voorbij is gekomen.
- Als ja, de werkstroom uitvoer
- Dateer de snellerstaat met de laatste uitvoertijd op.
API-enquête
API-enquête is interessanter - we monitoren externe API's en activeren werkflows wanneer inhoud verandert!
Konfiguratiemodel
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; }
}
Het stemwerk
[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);
}
Hoe het werkt:
- Elke minuut, check alle API poll-triggers
- Voor elke sneller, check of genoeg tijd voorbij is gekomen sinds de laatste enquête.
- Poll de geconfigureerde URL.
- Bereken een haas van de reactie inhoud.
- Vergelijk met vorige haas in staat opgeslagen
- Als veranderd (of
AlwaysTriggeris true), uitvoer de werkstroom - Pass de API-reactie als inputdata aan de workflow.
- Dateer de staat met een nieuw hash op.
Gebruik gevalvoorbeeld
Monitor GitHub Releases:
{
"triggerType": "ApiPoll",
"config": {
"url": "https://api.github.com/repos/dotnet/aspnetcore/releases/latest",
"intervalSeconds": 3600,
"alwaysTrigger": false
}
}
Dit enquêteert de GitHub API elke uur. Wanneer een nieuwe release is gepubliceerd, verandert de content hashM SK2 en het werkflow uitvoert met de release dataMSC3
Registreren van Hangfire banen
In je Program.cs of startopstelling:
// Add Hangfire services
builder.Services.AddHangfire(config =>
{
config.UsePostgreSqlStorage(
builder.Configuration.GetConnectionString("DefaultConnection"));
});
builder.Services.AddHangfireServer();
// Register our job
builder.Services.AddScoped<WorkflowSchedulerJob>();
Dan, na het starten van de appM SK1 herhalende banen registreren:
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);
De Hangfire-dashboard
Hangfire bevat een ingebouwde dashboard -, toegankelijk via /hangfire:
- Werken: Kijk naar alle in de rij staande, verwerkingsprocessenM SK2 en klaarstaande taken
- Herhalende Werken: Bestuur onze werkflowscheduleerders
- Retrees: Zie en herprobeer gevaalde banen
- Bedieners: Monitor Hangfire servers
Beveiliging van het dashboard
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;
}
}
Bedienen van Triggers via UI
Laat's UI toevoegen voor het creëren en beheren van snellers:
[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-component
<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
Laten we een volledig geautomatiseerde werkflow bouwen.
- Pollen de GitHub API voor nieuwe releases
- Kontroleert of de versie jonger is dan wat we gezien hebben.
- Logs een bericht
- (Kan een e-mail verzenden, post naar Slack , etcM SK3
Stap 1: Skep de 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"
}
]
}
Stap 2: Skep de API Poll Trigger
{
"type": "ApiPoll",
"config": {
"url": "https://api.github.com/repos/dotnet/aspnetcore/releases/latest",
"intervalSeconds": 3600
}
}
Nu, elke uur , Hangfire zal:
- Poll de GitHub API
- Vergelijk de inhoudshash met de vorige enquête.
- Als het veranderd is, voer de werkstroom uit.
- De workflow analyseert de JSON en registreert de release-informatie.
Monitoring en observabiliteit
Afteken
Alle werkflow-uitvoeren zijn gelogd:
_logger.LogInformation(
"Workflow {WorkflowId} execution {ExecutionId} completed in {Duration}ms with status {Status}",
execution.WorkflowId,
execution.Id,
execution.DurationMs,
execution.Status);
Metrik
We kunnen Prometheus metrics toevoegen:
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();
Alarms
Opstelling van alerten voor:
- Gevaalde werkstromen (Status == Gevaal
- Werkstromen nemen te lang.
- API-enquête falen
- Snellers die niet geactiveerd zijn in de verwachte tijdspanne
Prestatiebeoordelen
Databasis-laai
Met veel werkflows die vaak getekend worden, kan de database-laad significant zijn.
oplossing: Batch-vragen
// 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-tempobeperking
Als je externe API's vraagt:
oplossing: Exponentieel 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");
}
Gevorderde eigenschappen
Voorwaardelijke Snellers
Pas als bepaalde voorwaarden zijn bereikt:
public class ConditionalTriggerConfig : ApiPollTriggerConfig
{
public string? Condition { get; set; } // e.g., "{{stars}} > 1000"
}
Sneller Afhangingen
Chain-triggers - één werkstroom ' het voltooien van een ander triggert:
// 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}");
}
}
Hangfire Werken testen
Eenheid testen je werk:
[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);
}
Conclusie
We hebben een complete automatiseringssysteem gebouwd.
✅ Loop op schema's - HourlyM SK1 daily, of custom intervals ✅ Poll API's - Monitor externe diensten voor veranderingen ✅ Snitstaat - Onthoud wat we eerder gezien hebben ✅ Auto-herproberen - Handle transiente fouten ✅ Monitor - Tafelbord voor alle banen ✅ Skaal - Hangfire handles laad Balancing
De volledige serie
We hebben een bedrijf gebouwd, een werkflowsysteem van nul, een kwaliteitswerksysteem.
- Deel 1: Inleiding en architectuur
- Deel 2: Kernwerkflowmachine
- Deel 3: Visuele werkflow-editor
- Deel 4: Hangfire-integratie
Je hebt nu:
- Een krachtige werkstroommachine.
- Een mooie visuele redigeerder.
- Automatische uitvoering
- API-monitoring
- Volgrote observabiliteit
Wat's Volgende
Moontelijke verbeteringen:
- Webhooks: Sneller werkstromen via HTTP-endpoints
- E-mail-nodes: Stuur e-mails van werkstromen
- Databasisnodes: Navraagdatabases
- AI-nodes: Integratie met LLMs
- Sub-werkstromen: Samenwerken van werkstromen
- Workflowmarkt: Werkflow-templates delen
Broncode
Alle code is beschikbaar in het archive:
- Hangfire jobs:
Mostlylucid.SchedulerService/Jobs/ - Werkflowmodellen:
Mostlylucid.Workflow.Shared/ - Workflow-motor:
Mostlylucid.Workflow.Engine/
Dank je voor het volgen van deze serie! Geweldig werkflow-bouwen! M SK2