本系列介绍了由 Elsa 工作流引擎驱动的用户界面的实现。
几个不同的步骤将导致全面有效的实施。 此实现可能无法为您提供满足您的特定需求的灵丹妙药。 尽管如此,它仍将提供对各种选项的见解。
在第 1 部分 — 由 Elsa 工作流驱动的 UI 中,讨论了基本解决方案设置。 在这一部分中,我们将创建本系列中使用的用户任务活动。
首先,我们向解决方案添加一个项目:UserTask.AddOns
。 它是一个.Net6类库。
该项目将添加用户任务活动所需的所有元素。 该应用程序将依赖于三个包。
- Elsa
- Elsa.Server.Api
- Microsoft.AspNetCore.Mvc.Core
将活动创建为名为 UserTaskSignal
的类。 该类将从 Activity
类继承。
using Elsa;
using Elsa.Activities.Signaling.Models;
using Elsa.ActivityResults;
using Elsa.Attributes;
using Elsa.Design;
using Elsa.Expressions;
using Elsa.Services;
using Elsa.Services.Models;
namespace UserTask.AddOns
{
/// <summary>
/// Suspends workflow execution until the specified signal is received.
/// </summary>
[Trigger(
Category = "Usertasks",
Description = "Suspend workflow execution until the specified signal is received.",
Outcomes = new[] { OutcomeNames.Done }
)]
public class UserTaskSignal : Activity
{
[ActivityInput(Hint = "The name of the signal to wait for.",
SupportedSyntaxes = new[] { SyntaxNames.JavaScript, SyntaxNames.Liquid })]
public string Signal { get; set; } = default!;
[ActivityOutput(Hint = "The input that was received with the signal.")]
public object SignalInput { get; set; }
[ActivityInput(
Hint = "The task name",
Category = "Task"
)]
public string TaskName { get; set; }
[ActivityInput(
Hint = "The title of the task that needs to be executed.",
Category = "Task"
)]
public string TaskTitle { get; set; }
[ActivityInput(
Hint = "The description of the task that needs to be executed.",
UIHint = ActivityInputUIHints.MultiLine,
Category = "Task"
)]
public string TaskDescription { get; set; }
[ActivityInput(
Hint = "The definition of the data expected to be returned",
UIHint = ActivityInputUIHints.MultiLine,
Category = "Task",
SupportedSyntaxes = new[] { SyntaxNames.JavaScript, SyntaxNames.Liquid })]
public string UIDefinition { get; set; }
[ActivityInput(
Hint = "Context data for the usertask",
UIHint = ActivityInputUIHints.MultiLine,
Category = "Task",
SupportedSyntaxes = new[] { SyntaxNames.JavaScript, SyntaxNames.Liquid })]
public object TaskData { get; set; }
[ActivityOutput] public object Output { get; set; }
protected override bool OnCanExecute(ActivityExecutionContext context)
{
if (context.Input is Signal triggeredSignal)
return string.Equals(triggeredSignal.SignalName, Signal, StringComparison.OrdinalIgnoreCase);
return false;
}
protected override IActivityExecutionResult OnExecute(ActivityExecutionContext context) =>
context.WorkflowExecutionContext.IsFirstPass ? OnResume(context) : Suspend();
protected override IActivityExecutionResult OnResume(ActivityExecutionContext context)
{
var triggeredSignal = context.GetInput<Signal>()!;
SignalInput = triggeredSignal.Input;
Output = triggeredSignal.Input;
context.LogOutputProperty(this, nameof(Output), Output);
return Done();
}
}
}
该活动是通用 UI 用户任务。 但是,任务的不同配置可能会导致任意数量的实现和行为。
例如,独特的信号使引擎能够区分哪个用户任务需要恢复。 该活动是一种阻塞活动,它将工作流的执行置于挂起模式,直到触发它继续。
请注意,当工作流中执行多个分支时,其他执行可能会继续。
那么,是什么让这个活动成为阻塞活动呢? 答案可以在 OnExecute
方法中找到,该活动告诉工作流引擎执行应该暂停。 您可以使用 Suspend()
来做到这一点;
protected override IActivityExecutionResult OnExecute()
{
return Suspend();
}
但是,由于阻塞活动可能位于工作流的开始处(如果是第一次执行),因此它不应暂停,而应假设初始触发器是已经处理了详细信息并希望根据以下情况启动工作流的 UI: 用户的输入。 (此场景将在第 6 部分中处理)
为了缓解此问题,使用以下代码:
context
.WorkflowExecutionContext
.IsFirstPass ?
OnResume(context):
Suspend();
为了恢复活动,代码非常简单。 输入被设置为输出,因此可以轻松地在下一个活动中使用。 并且输出被注册在上下文上。
Bookmarks
现在书签的处理已按顺序进行。
在此状态下用户任务的书签不使用附加信息来恢复工作流程。 它只是使用信号来区分用户任务的不同激活。 拥有其他信号至关重要,因为单个工作流程可以同时运行多个用户任务。 拥有不同的信号有助于确定要恢复工作流程中的哪个分支。 除此之外,UI 可以使用信号来确定显示哪种 UI 来处理用户任务。
书签实现由以下元素组成:
IUserTaskSignalInvoker
接口UserTaskSignalInvoker
UserTaskSignalBookmark
UserTaskSignalBookmarkProvider
IUserTaskSignalInvoker
接口是 UserTaskSignalInvoker
的接口。 此实施启动适当的工作流程。 根据所提供的信号,从注册的书签中选择工作流程并执行或分派。
UserTaskSignalBookmarkProvider
是当 Activity
进入挂起模式时注册书签的组件。 通过在基本实现中使用泛型,提供者报告书签。
UserTaskSignalBookmarkProvider
创建一个新的 UserTaskSignalBookmark
实例,该实例提供书签的详细信息。 在工作流执行期间,引擎激活提供者。
所有这些组件都需要在引擎中注册。 因此,创建了一个扩展方法来做到这一点。
using Elsa.Options;
using Microsoft.Extensions.DependencyInjection;
using UserTask.AddOns.Bookmarks;
using UserTask.AddOns.Endpoints.Models;
namespace UserTask.AddOns
{
public static class RegisterUserTaskSignal
{
public static ElsaOptionsBuilder AddUserTaskSignalActivities(this ElsaOptionsBuilder options, string engineId)
{
options.Services.AddScoped<IUserTaskSignalInvoker, UserTaskSignalInvoker>();
options.Services.AddBookmarkProvider<UserTaskSignalBookmarkProvider>();
options.Services.AddSingleton(opt => new ServerContext(engineId));
options.AddActivity<UserTaskSignal>();
return options;
}
}
}
该扩展允许在 Startup.cs
中注册组件。
EngineId
是一个附加选项,允许稍后扩展。 例如,拥有多个引擎来处理用户任务,但仅使用一个 UI 来激活正确引擎中的工作流程。
services.AddUserTaskSignalActivities(engineId)
Controller
Engine 需要有一个额外的控制器来实现 UI 应用程序和 Engine 之间的交互。
控制器有以下端点
/{signalName}
==> 获取等待返回特定信号的所有工作流程
{signalName}/dispatch
==> 向所有异步等待指定信号名称的工作流程发出信号。
{signalName}/execute
==> 向所有同步等待指定信号名称的工作流程发出信号。
为了让端点获取所有正在等待信号的工作流实例,将其设置为小写不变。 选择信号名称时需要记住的一些事情。
书签过滤器用于获取与信号关联的书签。 另一件要记住的事情是书签匹配是根据类型名称完成的。 当您派生用户任务时,此命名匹配可能会导致问题。
其他步骤非常简单。 获取工作流 ID,获取工作流实例并将其转换为视图模型。
using Elsa.Persistence;
using Elsa.Persistence.Specifications.WorkflowInstances;
using Elsa.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Http;
using UserTask.AddOns.Bookmarks;
using Elsa.Server.Api.ActionFilters;
using Swashbuckle.AspNetCore.Annotations;
using Elsa.Server.Api.Endpoints.Signals;
using UserTask.AddOns.Endpoints.Models;
using UserTask.AddOns.Extensions;
namespace UserTask.AddOns.Endpoints
{
[ApiController]
[Route("v{apiVersion:apiVersion}/usertask-signals")]
[Produces("application/json")]
public class UserTaskSignalController : Controller
{
private readonly IUserTaskSignalInvoker invoker;
private readonly IBookmarkFinder bookmarkFinder;
private readonly ServerContext serverContext;
private readonly IWorkflowInstanceStore workflowInstanceStore;
public UserTaskSignalController(IUserTaskSignalInvoker invoker, IBookmarkFinder bookmarkFinder, IWorkflowInstanceStore workflowInstanceStore, ServerContext serverContext)
{
this.workflowInstanceStore = workflowInstanceStore;
this.serverContext = serverContext;
this.invoker = invoker;
this.bookmarkFinder = bookmarkFinder;
}
[HttpPost("{signalName}/execute")]
[ElsaJsonFormatter]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ExecuteSignalResponse))]
[SwaggerOperation(
Summary = "Signals all workflows waiting on the specified signal name synchronously.",
Description = "Signals all workflows waiting on the specified signal name synchronously.",
OperationId = "UsertaskSignals.Execute",
Tags = new[] { "UsertaskSignals" })
]
public async Task<IActionResult> Handle(string signalName, ExecuteSignalRequest request,
CancellationToken cancellationToken = default)
{
var collectedWorkflows = await invoker.ExecuteWorkflowsAsync(signalName, request.Input,
request.WorkflowInstanceId, request.CorrelationId, cancellationToken);
return Ok(collectedWorkflows.ToList());
}
[HttpPost("{signalName}/dispatch")]
[ElsaJsonFormatter]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ExecuteSignalResponse))]
[SwaggerOperation(
Summary = "Signals all workflows waiting on the specified signal name asynchronously.",
Description = "Signals all workflows waiting on the specified signal name asynchronously.",
OperationId = "UsertaskSignals.Dispatch",
Tags = new[] { "UsertaskSignals" })
]
public async Task<IActionResult> HandleDispatch(string signalName, ExecuteSignalRequest request,
CancellationToken cancellationToken = default)
{
var collectedWorkflows = await invoker.DispatchWorkflowsAsync(signalName, request.Input,
request.WorkflowInstanceId, request.CorrelationId, cancellationToken);
return Ok(collectedWorkflows.ToList());
}
[HttpGet("{signalName}")]
// [ElsaJsonFormatter]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ExecuteSignalResponse))]
[SwaggerOperation(
Summary = "Gets all workflows waiting for a specific signal to be returned",
Description = "return a list of workflow instances",
OperationId = "Usertask" +
"Signals.Query",
Tags = new[] { "UsertaskSignals" })
]
public async Task<IActionResult> Collect(string signalName,
CancellationToken cancellationToken = default)
{
string normalizedSignal = signalName.ToLowerInvariant();
var bookmarkFilter = new UserTaskSignalBookmark { Signal = normalizedSignal };
var bookmarkResults = await bookmarkFinder.FindBookmarksAsync(nameof(UserTaskSignal), new[] { bookmarkFilter });
var workflowInstanceIds = new WorkflowInstanceIdsSpecification(bookmarkResults.Select(x => x.WorkflowInstanceId).ToList());
var workflowInstances = await workflowInstanceStore.FindManyAsync(workflowInstanceIds, null, null, cancellationToken);
var viewmodelResult = workflowInstances.ConvertToViewModels(serverContext);
normalizedSignal = null;
return Ok(viewmodelResult.ToList());
}
}
}
激活工作流(调度或执行)可以有额外的数据来过滤出正确的工作流实例。
请注意,您必须将数据包装在这个信封结构中。 因此,用户任务的实际数据放置在输入属性内。
{
"workflowInstanceId": "string",
"correlationId": "string",
"input": {}
}
Application
ProcessService
是启动新工作流实例的组件。
using ElsaDrivenWebApp.Services.Models;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Text;
namespace ElsaDrivenWebApp.Services
{
public class ProcessService
{
private readonly HttpClient httpClient;
public ProcessService(HttpClient httpClient)
{
this.httpClient = httpClient;
}
public async Task SendSignal(string signal)
{
await PostObjectJson(new JObject(), $"v1/signals/{signal}/execute");
}
private async Task PostObjectJson(object data, string url)
{
var content = new StringContent(JsonConvert.SerializeObject(data), Encoding.UTF8, "application/json");
await httpClient.PostAsync(url, content);
}
}
}
UsertaskService
是向引擎的Api发送信号的组件。 上一段中讨论的控制器正在处理该服务的请求。
using ElsaDrivenWebApp.Services.Models;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Text;
namespace ElsaDrivenWebApp.Services
{
public class UsertaskService
{
private readonly HttpClient httpClient;
public Dictionary<string, UsertaskViewModel> workflowInstancesCache = new Dictionary<string, UsertaskViewModel>();
public UsertaskService(HttpClient httpClient)
{
this.httpClient = httpClient;
}
public async Task<UsertaskViewModel[]> GetWorkflowsForSignal(string signal)
{
return await httpClient.GetFromJsonAsync<UsertaskViewModel[]>($"/v1/usertask-signals/{signal}");
}
public async Task<UsertaskViewModel[]> GetWorkflowsForSignals(List<string> signals)
{
var result = new List<UsertaskViewModel>();
await Task.WhenAll(signals.Select(async i => result.AddRange(await GetWorkflowsForSignal(i))));
return result.ToArray();
}
public async Task MarkAsCompleteAsync(string workflowInstanceId, string signal, JToken signalData)
{
var data = new MarkAsCompletedPostModel
{
WorkflowInstanceId = workflowInstanceId,
Input = signalData == null ? JValue.CreateNull() : signalData
};
var content = new StringContent(JsonConvert.SerializeObject(data), Encoding.UTF8, "application/json");
await httpClient.PostAsync($"/v1/usertask-signals/{signal}/execute", content);
}
}
}
UI 本身有一个用于创建工作流程的按钮。 用户任务服务获取等待用户任务信号恢复的工作流实例。 最后,用户界面有一个按钮,每个工作流程在单击时都会继续。
@page "/tasks/{signal}"
@inject UsertaskService userTaskService
@inject ProcessService processService
<PageTitle>Usertasks</PageTitle>
<h1>All usertasks</h1>
@if (tasks == null)
{
<p><em>Loading...</em></p>
}
else
{
<button @onclick="() => AddSample1Task()">Add Sample1</button>
<table class="table">
<thead>
<tr>
<th>Title</th>
<th>Engine</th>
<th>Description</th>
<th>Instance Id</th>
</tr>
</thead>
<tbody>
@foreach (var task in tasks)
{
<tr>
<td>
<button @onclick="() => UpdateTask(task)">@task.TaskTitle</button>
</td>
<td>@task.EngineId</td>
<td>@task.TaskDescription</td>
<td>@task.WorkflowInstanceId</td>
</tr>
}
</tbody>
</table>
}
@code {
[Parameter]
public string signal { get; set; } = string.Empty;
private List<UsertaskViewModel>? tasks;
protected override async Task OnInitializedAsync()
{
await LoadTasks();
}
private async Task LoadTasks()
{
var tasksArray = await userTaskService.GetWorkflowsForSignal(signal);
tasks = tasksArray.ToList();
}
private async Task AddSample1Task()
{
await processService.SendSignal("sample1");
await LoadTasks();
}
private async Task UpdateTask(UsertaskViewModel task)
{
await userTaskService.MarkAsCompleteAsync(task.WorkflowInstanceId, task.Signal, null);
tasks?.Remove(task);
}
}
这两个服务都在 Program.cs 中注册。
var baseAddress = builder.Configuration[“UsertaskService:BaseAddress”];
// Add services to the
container.builder.Services.AddRazorPages();
builder.Services.AddServerSideBlazor();
builder.Services.AddScoped(sp =>
new UsertaskService(
new HttpClient { BaseAddress = new Uri(baseAddress)}));
builder.Services.AddScoped(sp =>
new ProcessService(
new HttpClient { BaseAddress = zew Uri(baseAddress)}));
在接下来的部分中,将实现数据处理。