网页:https://elsa-workflows.github.io/elsa-core/docs/guides-document-approval
在本节中,我们将执行以下操作:
- 以编程方式定义长时间运行的工作流,在HTTP请求到达指定URL时执行,接受带有JSON负载的POST请求,该JSON负载表示要检查的文档。
- 相关活动组件:ReceiveHttpRequest,WriteHttpResponse,Fork,Join,SetVariable,Signaled,SendEmail和IfElse。
此工作流的目的是允许作者提交文档(建模为JSON对象),并允许审阅者批准或拒绝此文档。 此外,如果审阅者花费的时间太长而无法采取行动,则会定期提醒他。
我们将发布到工作流中的JSON有效负载如下所示:


{ "Id": "1", "Author": { "Name": "John", "Email": "john@gmail.com" }, "Body": "This is sample document." }
使用访问此模型的JavaScript表达式查看活动时,请牢记此结构。
创建ASP.NET Core项目
创建一个名为Elsa.Guides.ContentApproval.WebApp的新的空ASP.NET Core项目,并添加以下程序包:
- Elsa.Core
- Elsa.Activities.Email
- Elsa.Activities.Http
- Elsa.Activities.Timers
创建工作流类


using System; using System.Dynamic; using System.Net; using System.Net.Http; using Elsa.Activities.ControlFlow; using Elsa.Activities.Email.Activities; using Elsa.Activities.Http.Activities; using Elsa.Activities.Primitives; using Elsa.Activities.Timers.Activities; using Elsa.Activities.Workflows; using Elsa.Expressions; using Elsa.Services; using Elsa.Services.Models; namespace Elsa.Guides.DocumentApproval.WebApp { public class DocumentApprovalWorkflow : IWorkflow { public void Build(IWorkflowBuilder builder) { builder .StartWith<ReceiveHttpRequest>( x => { x.Method = HttpMethod.Post.Method; x.Path = new Uri("/documents", UriKind.Relative); x.ReadContent = true; } ) .Then<SetVariable>( x => { x.VariableName = "Document"; x.ValueExpression = new JavaScriptExpression<ExpandoObject>("lastResult().Body"); } ) .Then<SendEmail>( x => { x.From = new LiteralExpression("approval@acme.com"); x.To = new JavaScriptExpression<string>("Document.Author.Email"); x.Subject = new JavaScriptExpression<string>("`Document received from ${Document.Author.Name}`"); x.Body = new JavaScriptExpression<string>( "`Document from ${Document.Author.Name} received for review. " + "<a href=\"${signalUrl('Approve')}\">Approve</a> or <a href=\"${signalUrl('Reject')}\">Reject</a>`" ); } ) .Then<WriteHttpResponse>( x => { x.Content = new LiteralExpression( "<h1>Request for Approval Sent</h1><p>Your document has been received and will be reviewed shortly.</p>" ); x.ContentType = "text/html"; x.StatusCode = HttpStatusCode.OK; x.ResponseHeaders = new LiteralExpression("X-Powered-By=Elsa Workflows"); } ) .Then<SetVariable>( x => { x.VariableName = "Approved"; x.ValueExpression = new JavaScriptExpression<bool>("false"); } ) .Then<Fork>( x => { x.Branches = new[] { "Approve", "Reject", "Remind" }; }, fork => { fork .When("Approve") .Then<Signaled>(x => x.Signal = new LiteralExpression("Approve")) .Then("Join"); fork .When("Reject") .Then<Signaled>(x => x.Signal = new LiteralExpression("Reject")) .Then("Join"); fork .When("Remind") .Then<TimerEvent>( x => x.TimeoutExpression = new LiteralExpression<TimeSpan>("00:00:10"), name: "RemindTimer" ) .Then<IfElse>( x => x.ConditionExpression = new JavaScriptExpression<bool>("!!Approved"), ifElse => { ifElse .When(OutcomeNames.False) .Then<SendEmail>( x => { x.From = new LiteralExpression("reminder@acme.com"); x.To = new LiteralExpression("approval@acme.com"); x.Subject = new JavaScriptExpression<string>( "`${Document.Author.Name} is awaiting for your review!`" ); x.Body = new JavaScriptExpression<string>( "`Don't forget to review document ${Document.Id}.<br/>" + "<a href=\"${signalUrl('Approve')}\">Approve</a> or <a href=\"${signalUrl('Reject')}\">Reject</a>`" ); } ) .Then("RemindTimer"); } ); } ) .Then<Join>(x => x.Mode = Join.JoinMode.WaitAny, name: "Join") .Then<SetVariable>( x => { x.VariableName = "Approved"; x.ValueExpression = new JavaScriptExpression<object>("input('Signal') === 'Approve'"); } ) .Then<IfElse>( x => x.ConditionExpression = new JavaScriptExpression<bool>("!!Approved"), ifElse => { ifElse .When(OutcomeNames.True) .Then<SendEmail>( x => { x.From = new LiteralExpression("approval@acme.com"); x.To = new JavaScriptExpression<string>("Document.Author.Email"); x.Subject = new JavaScriptExpression<string>("`Document ${Document.Id} approved!`"); x.Body = new JavaScriptExpression<string>( "`Great job ${Document.Author.Name}, that document is perfect! Keep it up.`" ); } ); ifElse .When(OutcomeNames.False) .Then<SendEmail>( x => { x.From = new LiteralExpression("approval@acme.com"); x.To = new JavaScriptExpression<string>("Document.Author.Email"); x.Subject = new JavaScriptExpression<string>("`Document ${Document.Id} rejected`"); x.Body = new JavaScriptExpression<string>( "`Sorry ${Document.Author.Name}, that document isn't good enough. Please try again.`" ); } ); } ); } } }
代码较多! 让我们一步一步地从上到下查看。
.StartWith<ReceiveHttpRequest>( x => { x.Method = HttpMethod.Post.Method; x.Path = new Uri("/documents", UriKind.Relative); x.ReadContent = true; } )
由于存在 ReceiveHttpRequest 活动,每次接收到与路径/文档匹配的 HTTP POST 请求时,都将执行工作流。
我们将其 ReadContent 设置为 true,以便读取和解析请求主体。解析内容是通过适当的 IContentFormatter 完成的,该 IContentFormatter 是根据请求体的内容类型选择的。目前,只支持 application/json 和 text/json 内容类型,但也将添加对 application/x-www-form-urlencoded 和 multipart/form-data 的支持。它将把 JSON 内容解析为 ExpandoObject。
将 ReadContent 设置为 true 后,我们可以从工作流中的其他活动访问解析后的 JSON。活动将使用“ Content”键以及工作流执行上下文的 LastResult 属性将此值存储在其输出字典中。
.Then<SetVariable>( x => { x.VariableName = "Document"; x.ValueExpression = new JavaScriptExpression<ExpandoObject>("lastResult().Body"); } )
然后我们连接到 SetVariable 活动,该活动在我们称为 Document 的工作流上设置一个自定义变量。我们使用一个 JavaScript 表达式来分配作为 HTTP 请求的一部分收到的对象。
- 首先,我们调用一个名为 lastResult 的函数。此函数返回工作流执行上下文的 LastResult 值。因为这是由 ReceiveHttpRequest 设置的,所以它将包含一个对象,该对象保存有关接收到的 HTTP 请求的详细信息,包括一个 Body 属性,该属性包含已解析的 JSON 对象。


.Then<SendEmail>( x => { x.From = new LiteralExpression("approval@acme.com"); x.To = new JavaScriptExpression<string>("Document.Author.Email"); x.Subject = new JavaScriptExpression<string>("`Document received from ${Document.Author.Name}`"); x.Body = new JavaScriptExpression<string>( "`Document from ${Document.Author.Name} received for review. " + "<a href=\"${signalUrl('Approve')}\">Approve</a> or <a href=\"${signalUrl('Reject')}\">Reject</a>`" ); } )
我们要做的第二件事是通知审阅者新的文件已提交。 为此,我们使用SendEmail活动发送电子邮件。 我们使用LiteralExpression和JavaScriptExpression对象的混合配置此活动。 LiteralExpression返回传递给其构造函数的文字字符串值,如果使用泛型类型重载,则可以选择将其转换为给定类型。 在这种情况下,我们只需要指定一个文字电子邮件地址:“ approval@acme.com。
To,Subject和Body的表达式更有趣,因为它们演示了如何使用JavaScript表达式访问我们之前定义的Document变量。
Body属性表达式使用一个名为signalUrl的JavaScript函数,该函数接受一个表示信号名称的参数。 这样做是生成一个包含安全令牌的绝对URL,该令牌包含以下信息:
- 工作流实例ID
- 信号名称
当对生成的URL发出HTTP请求时(例如,在收到电子邮件时单击该请求),Elsa将识别该URL并触发与安全令牌携带的工作流实例ID相匹配的工作流实例。 更具体地说,将通过信号事件触发工作流程,如果该事件被该类型的活动阻止,则导致工作流程恢复。 我们将在短期内通知您。
首先,我们要向客户端发送HTTP响应,并说文档已成功接收。
WriteHttpResponse


.Then<WriteHttpResponse>( x => { x.Content = new LiteralExpression( "<h1>Request for Approval Sent</h1><p>Your document has been received and will be reviewed shortly.</p>" ); x.ContentType = "text/html"; x.StatusCode = HttpStatusCode.OK; x.ResponseHeaders = new LiteralExpression("X-Powered-By=Elsa Workflows"); } )
WriteHttpResponse活动只是将响应写回到客户端。 通过该活动,我们可以配置状态码,内容类型,内容主体和响应头以发送回去。
SetVariable
.Then<SetVariable>( x => { x.VariableName = "Approved"; x.ValueExpression = new LiteralExpression<bool>("false"); } )
这次,我们使用SetVariable活动来初始化另一个名为Approved的变量。 稍后将使用此变量来检查审阅者是否单击了“批准”或“拒绝”链接(触发了适当的信号)。 我们需要预先初始化此变量,因为在下一个活动中,我们会将执行分叉到3个分支中,其中一个会启动一个定期检查此变量的计时器。 如果未定义变量,则工作流程将出错。
Fork, IfElse


.Then<Fork>( x => { x.Branches = new[] { "Approve", "Reject", "Remind" }; }, fork => { fork .When("Approve") .Then<Signaled>(x => x.Signal = new LiteralExpression("Approve")) .Then("Join"); fork .When("Reject") .Then<Signaled>(x => x.Signal = new LiteralExpression("Reject")) .Then("Join"); fork .When("Remind") .Then<TimerEvent>( x => x.TimeoutExpression = new LiteralExpression<TimeSpan>("00:00:10"), name: "RemindTimer" ) .Then<IfElse>( ... ); } )
Fork活动使我们可以将工作流执行分为多个分支。 在这种情况下,我们将分支到以下分支:
- Approve
- Reject
- Remind
对于Approve和Reject分支,我们都连接到前面提到的Signaled活动。 因为我们分叉执行,所以这两个活动的工作流程都将被阻塞。 当审阅者单击电子邮件中的任一链接时,将触发这些活动之一并恢复工作流程的执行。 发生这种情况时,工作流程将继续执行“加入”活动,稍后将进行介绍。
连接活动
请注意,我们可以连接到作为泛型类型参数指定的活动以外的其他活动。我们可以改为指定活动的 ID,而不是使用类型参数指定接下来要执行的活动。实际上,Next < t > 方法只是简单地定义一个活动,然后自动地在当前活动和正在定义的活动之间创建一个连接。Next 方法没有类型参数,只有一个名称: string 参数,它只是在当前活动和 ID 指定的活动之间创建一个连接。


.Then<IfElse>( x => x.ConditionExpression = new JavaScriptExpression<bool>("!!Approved"), ifElse => { ifElse .When(OutcomeNames.False) .Then<SendEmail>( x => { x.From = new LiteralExpression("reminder@acme.com"); x.To = new LiteralExpression("approval@acme.com"); x.Subject = new JavaScriptExpression<string>( "`${Document.Author.Name} is awaiting for your review!`" ); x.Body = new JavaScriptExpression<string>( "`Don't forget to review document ${Document.Id}.<br/>" + "<a href=\"${signalUrl('Approve')}\">Approve</a> or <a href=\"${signalUrl('Reject')}\">Reject</a>`" ); } ) .Then("RemindTimer"); } );
IfElse 活动将执行分成两个分支。根据其 ConditionExpression 计算的布尔值,将继续在 True 分支或 False 分支上执行。在我们的示例中,条件检查 Approved 工作流变量的值是否等于 true。


.Then<Join>(x => x.Mode = Join.JoinMode.WaitAny, name: "Join") .Then<SetVariable>( x => { x.VariableName = "Approved"; x.ValueExpression = new JavaScriptExpression<object>("input('Signal') === 'Approve'"); } )
Join 变量将工作流执行合并回单个分支。指定 WaitAny 的 JoinMode 将导致该活动在任何传入的活动执行之后立即继续工作流。换句话说,一旦“批准”或“拒绝”信号被触发,工作流将立即恢复。当这样做时,我们将 Approved 工作流变量分别设置为 true 或 false。


.Then<IfElse>( x => x.ConditionExpression = new JavaScriptExpression<bool>("!!Approved"), ifElse => { ifElse .When(OutcomeNames.True) .Then<SendEmail>( x => { x.From = new LiteralExpression("approval@acme.com"); x.To = new JavaScriptExpression<string>("Document.Author.Email"); x.Subject = new JavaScriptExpression<string>("`Document ${Document.Id} approved!`"); x.Body = new JavaScriptExpression<string>( "`Great job ${Document.Author.Name}, that document is perfect! Keep it up.`" ); } ); ifElse .When(OutcomeNames.False) .Then<SendEmail>( x => { x.From = new LiteralExpression("approval@acme.com"); x.To = new JavaScriptExpression<string>("Document.Author.Email"); x.Subject = new JavaScriptExpression<string>("`Document ${Document.Id} rejected`"); x.Body = new JavaScriptExpression<string>( "`Sorry ${Document.Author.Name}, that document isn't good enough. Please try again.`" ); } ); } );
最后,我们只需检查 Approved 的值,然后向文档的作者发送适当的电子邮件,最后完成工作流。
现在已经定义了工作流,我们应该更新 Startup 类如下:


using Elsa.Activities.Email.Extensions; using Elsa.Activities.Http.Extensions; using Elsa.Activities.Timers.Extensions; using Elsa.Extensions; using Elsa.Services; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; namespace Elsa.Guides.DocumentApproval.WebApp { public class Startup { public Startup(IConfiguration configuration) { Configuration = configuration; } public IConfiguration Configuration { get; } public void ConfigureServices(IServiceCollection services) { services .AddElsa() .AddHttpActivities(options => options.Bind(Configuration.GetSection("Http"))) .AddEmailActivities(options => options.Bind(Configuration.GetSection("Smtp"))) .AddTimerActivities(options => options.Bind(Configuration.GetSection("BackgroundRunner"))) .AddWorkflow<DocumentApprovalWorkflow>; } public void Configure(IApplicationBuilder app) { app.UseHttpActivities(); } } }
更新Appsettings.json
正如您所看到的,我们正在通过将 HTTP、 Email 和 Timer 活动的选项绑定到 Configuration 来配置它们。虽然您可以手动进行配置,但让我们按照以下方式更新 appsettings.json:


{ "Logging": { "LogLevel": { "Default": "Warning" } }, "AllowedHosts": "*", "Http": { "BaseUrl": "http://localhost:5000" }, "Smtp": { "Host": "localhost", "Port": "2525" }, "BackgroundRunner": { "SweepInterval": "PT01S" } }
运行
为了尝试这个工作流程,需要使用以下两个工具:
Postman 使我们能够轻松地将 JSON 内容发布到我们的工作流中。Smtp4Dev 使我们能够在本地启动 SMTP 服务,拦截所有即将发出的电子邮件,而不需要实际将它们发送给收件人。我将我的设置为侦听端口2525。
首先,启动应用程序。 如果一切顺利,则Web主机将准备在http:// localhost:5000处接收传入的HTTP请求:
控制台窗口显示:
Hosting environment: Production Content root path: C:\Projects\Elsa\elsa-guides\src\Elsa.Guides.DocumentApproval.WebApp Now listening on: http://localhost:5000 Now listening on: https://localhost:5001 Application started. Press Ctrl+C to shut down.
接下来,发送以下 HTTP 请求:


POST /documents HTTP/1.1 Host: localhost:5000 Content-Type: application/json { "Id": "3", "Author": { "Name": "John", "Email": "john@gmail.com" }, "Body": "This is sample document." }
或者使用 cUrl 格式:


curl --location --request POST "http://localhost:5000/documents" \ --header "Content-Type: application/json" \ --data "{ \"Id\": \"3\", \"Author\": { \"Name\": \"John\", \"Email\": \"john@gmail.com\" }, \"Body\": \"This is sample document.\" }"
响应应该是这样的:
<h1>Request for Approval Sent</h1> <p>Your document has been received and will be reviewed shortly.</p>
当你启动 Smtp4Dev Web UI 时,你应该看到这个:

每隔10秒左右,提醒电子邮件信息就应该出现:

这将一直继续,直到您单击“批准”或“拒绝”链接为止。
在这个演练中,我们已经看到了如何在 ReceiveHttpRequest、 signalUrl JavaScript 函数的帮助下实现长时间运行的工作流,我们还看到了如何使用各种其他活动来实现提醒循环。