以 HTMX 和 ASP.NET 核心 {-}*Part * 4:}建立工作流程系统
一. 导言导言 导言 导言 导言
内 部分#3@,_#我们建造了一个美丽的视觉编辑@.}但是我们的工作流程只有在我们手动触发它们的时候才能运行 在最后的一篇文章“,% we'}使用Hangfire 来“MS K5”使工作流程真正自主
- 计划执行 - 运行工作流程在日程上
- AIP 投票 - 监控外部API和触发更改
- 国家管理 跨处决的轨道触发状态
- 仪表板 - 监视所有背景工作
为何要上火?
着火是最适合我们需要的,因为它能满足我们的需要。
- 我们现有PostgreSQL数据库的仓库工作
- 提供已建的“-”仪表板
- 支持经常性工作
- 具有自动重试逻辑
- 水平水平缩平
触发国模式
第一次,#让我们来理解我们的触发国实体 @(}我们已经在Part Z2):BARBAR
[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; }
}
此实体跟踪所有关于工作流程触发@: @%
- 当它最后一次运行时
- 其配置是什么
- 在“(”中,用于指定触发器“MS K2”的状态如何?
- 发生的任何错误
工作流量
配置模型
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; }
}
调度工作
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
};
}
}
它如何运作 :
- 每分每秒 都打着点火电话
ExecuteScheduledWorkflowsAsync() - 我们查询已启用的时间表触发器
- 检查是否已经足够时间
- 如果是“% ,” , 请执行工作流程
- 以上次运行时间更新触发状态
APP投票
我们监控外部的API, 当内容改变时触发工作流程@!#
配置模型
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; }
}
投票工作
[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);
}
它如何运作 :
- 每分钟检查所有API投票的触发器
- 每个触发器,检查自上次投票以来是否已经过了足够时间
- 投放配置的 URL
- 计算回复内容的散列
- 与以前储存在州内的散列相比
- 如果更改了 @ (@ {% or
AlwaysTrigger执行工作流程 - 将API回复作为输入数据传送到工作流程
- 以新的散列更新状态
使用实例示例
监视 GitHub 发布@: @%
{
"triggerType": "ApiPoll",
"config": {
"url": "https://api.github.com/repos/dotnet/aspnetcore/releases/latest",
"intervalSeconds": 3600,
"alwaysTrigger": false
}
}
当新版本发布后, 内容会更改 hash changes @,和工作流程与发行数据执行@!
挂牌点火工作
在你的 Program.cs 或启动配置@: @% 1
// Add Hangfire services
builder.Services.AddHangfire(config =>
{
config.UsePostgreSqlStorage(
builder.Configuration.GetConnectionString("DefaultConnection"));
});
builder.Services.AddHangfireServer();
// Register our job
builder.Services.AddScoped<WorkflowSchedulerJob>();
程序启动后 @,#注册重复性工作@:}
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);
死火套板
校对:Portnoy /hangfire:
- 工作职位: 参见所有队列中的{,} process *\ MS K2 和已完成的工作
- 经常性工作: 管理工作流程调度器
- 重试: 视图和重试失败的工作
- 服务器服务器@:_Monitor 挂机服务器
保护仪表板的安全
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;
}
}
通过 UI 管理触发器
添加用于创建和管理触发器的UI@: @%
[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 构成部分
<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>
世界工作流量实例
建立完整的自动工作流程,
- 将GitHubAPI投放新释放
- 检查版本是否比 We'+ @ ve 所见更新
- 日志消息
- @(_@Could 发送电子邮件@,}寄至 Slack,}等等 @MS K3}
创建工作流程
{
"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"
}
]
}
步骤 @2: 创建 API Poll 触发器
{
"type": "ApiPoll",
"config": {
"url": "https://api.github.com/repos/dotnet/aspnetcore/releases/latest",
"intervalSeconds": 3600
}
}
{\fn黑体\fs22\bord1\shad0\3aHBE\4aH00\fscx67\fscy66\2cHFFFFFF\3cH808080}
- 将GitHub API扑灭
- 将内容( hash) 与前一次民调比较
- 如果更改了@ , 执行工作流程
- 工作流程解析 JSON 并记录发布信息
监测和观察
伐木
所有工作流程的处决都记录下来了 :
_logger.LogInformation(
"Workflow {WorkflowId} execution {ExecutionId} completed in {Duration}ms with status {Status}",
execution.WorkflowId,
execution.Id,
execution.DurationMs,
execution.Status);
计量数
我们可以加上普罗米修斯的衡量标准 :
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();
警报报警
为:设置警报
- 失败的工作流程 @ ( @ status @ MPK1 @ 失败@ MS K2#
- 工作流动时间太长
- AIP 投票失败
- 触发在预期时间框架内发射的触发器@' @t
业绩考量
数据库负载
由于许多工作流程的民意调查经常@, 数据库载荷可能相当大 @:
解决方案@:批量查询
// 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();
APIP率限制
当对外投票时 APIs:
解答@: @ 指数回落
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");
}
高级特征
有条件触发器
只有满足某些条件才能触发 :
public class ConditionalTriggerConfig : ApiPollTriggerConfig
{
public string? Condition { get; set; } // e.g., "{{stars}} > 1000"
}
触发
连锁触发器 @-一个工作流程@'完成触发另一个#:
// 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}");
}
}
夜间工作测试
单位测试您的工作@: @%
[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);
}
结论结论
我们的工作流程现在可以 :了
✅ 按时间表运行 @ - @ hourly @ , @ day=,}或自定义间隔 ✅ 票价价格指数 - 监测外部服务的变化 ✅ 音轨状态 记住我们所见过的 ✅ 自动\ - retry - 处理中转失败 ✅ 监测监视器 用于所有工作的-布板 ✅ 比例分摊比额表 上火控制负载平衡
完整系列
我们从头到尾建立了企业工作流程系统 -
- 部分#1介绍和建筑
- 部分#2:核心工作流程引擎
- 部分#3: 视觉工作流程编辑器
- 部分#4校对:Soup
你现在有:了
- 强大的工作流程引擎
- 美丽的视觉编辑
- 自动执行
- AIPI监测
- 完全可观测
什么是' 下一步 @?
可能的增强@: @%
- Webhoooks 网络图通过 HTTP 端点触发工作流程
- 电子邮件节点: 从工作流程发送电子邮件
- 数据库节点:查询数据库
- AI 节点: 与LLMS结合
- 工作流量: 合成工作流程
- 工作流程市场共享工作流程模板
源代码
所有代码都可在仓库中获取@: @%
- 挂机任务 @:
Mostlylucid.SchedulerService/Jobs/ - 工作流量模型
Mostlylucid.Workflow.Shared/ - 工作流程引擎+:
Mostlylucid.Workflow.Engine/
感谢您关注此系列 <!}快乐工作流程建设_!> _🎉