补充

之前的文章简单介绍了工作流和Elsa工作流库,这里再补充说明两点

  1. 工作流的使用场景非常广泛,几乎涵盖了所有需要进行业务流程自动化管理的领域。

  2. 学习一个开源库,最简单的方法就是看源码,Elsa的工作流引擎源码非常简单易懂,并且提供了非常丰富的示例代码,举一个例子:审批工作流示例.\src\samples\aspnet\Elsa.Samples.AspNet.DocumentApproval



这个审批流是这样的:

作者发来一个文章,有两个审批人需要全部审批通过,文章才算通过,否则退回。

我们尝试阅读工作流源代码DocumentApprovalWorkflow.cs,并运行此项目,用postman发送请求

第一步:

假设这名叫Amanda的作者要发布文章,请求发送后,作者浏览器显示发送成功稍安勿躁之类的提示

同时后台打印作者信息和4个链接,分别是Jack和Lucy两位审批人“通过”和“退回”的url链接

Activities =
{
new HttpEndpoint
{
Path = new("/documents"),
SupportedMethods = new(new[] { HttpMethods.Post }),
ParsedContent = new(documentVariable),
CanStartWorkflow = true
},
new WriteLine(context => $"Document received from {documentVariable.Get<dynamic>(context)!.Author.Name}."),
new WriteHttpResponse
{
Content = new("<h1>Request for Approval Sent</h1><p>Your document has been received and will be reviewed shortly.</p>"),
ContentType = new(MediaTypeNames.Text.Html),
StatusCode = new(HttpStatusCode.OK),
ResponseHeaders = new(new HttpHeaders { ["X-Powered-By"] = new[] { "Elsa 3.0" } })
},

第二步:

Jack觉得文章不错,通过浏览器请求了“通过”链接,而Lucy觉得文章还不够好,需改进,她在浏览器中请求了“退回”链接。

两位审批人的审批结果存储于approvedVariable变量中

同时他们的浏览器返回的响应内容:Thanks for the approval 或 Sorry to hear that

    new Fork
{
JoinMode = ForkJoinMode.WaitAll,
Branches =
{
// Jack
new Sequence
{
Activities =
{
new WriteLine(context => $"Jack approve url: \n {GenerateSignalUrl(context, "Approve:Jack")}"),
new WriteLine(context => $"Jack reject url: \n {GenerateSignalUrl(context, "Reject:Jack")}"),
new Fork
{
JoinMode = ForkJoinMode.WaitAny,
Branches =
{
// Approve
new Sequence
{
Activities =
{
new Event("Approve:Jack"),
new SetVariable
{
Variable = approvedVariable,
Value = new(true)
},
new WriteHttpResponse
{
Content = new("Thanks for the approval, Jack!"),
}
}
}, // Reject
new Sequence
{
Activities =
{
new Event("Reject:Jack"),
new SetVariable
{
Variable = approvedVariable,
Value = new(false)
},
new WriteHttpResponse
{
Content = new("Sorry to hear that, Jack!"),
}
}
}
}
}
}
}, // Lucy
new Sequence
{
Activities =
{
new WriteLine(context => $"Lucy approve url: \n {GenerateSignalUrl(context, "Approve:Lucy")}"),
new WriteLine(context => $"Lucy reject url: \n {GenerateSignalUrl(context, "Reject:Lucy")}"),
new Fork
{
JoinMode = ForkJoinMode.WaitAny,
Branches =
{
// Approve
new Sequence
{
Activities =
{
new Event("Approve:Lucy"),
new SetVariable
{
Variable = approvedVariable,
Value = new(true)
},
new WriteHttpResponse
{
Content = new("Thanks for the approval, Lucy!"),
}
}
}, // Reject
new Sequence
{
Activities =
{
new Event("Reject:Lucy"),
new SetVariable
{
Variable = approvedVariable,
Value = new(false)
},
new WriteHttpResponse
{
Content = new("Sorry to hear that, Lucy!"),
}
}
}
}
}
}
}
}
},

第三步:

根据approvedVariable变量判定文章是否被审核通过。

如果通过则在控制台打印Document document-1 approved!, 否则打印Document document-1 not rejected!

    new WriteLine(context => $"Approved: {approvedVariable.Get<bool>(context)}"),
new If(context => approvedVariable.Get<bool>(context))
{
Then = new WriteLine(context => $"Document ${documentVariable.Get<dynamic>(context)!.Id} approved!"),
Else = new WriteLine(context => $"Document ${documentVariable.Get<dynamic>(context)!.Id} rejected!")
}
}

Elsa工作流源码还提供了大量的Sample,这里就不一一列举了,

需求描述

根据不同的时间规则,发送下发问卷给客户填写。

下发问卷给用户填写,且填写有超时时间,期间要提醒用户答题,

如果问卷未在规定的时间内作答则,则作废,并提醒用户。

需求分析

我们将需求尽可能分解成为单一职责的功能单元,并定义这些功能单元的输入输出。

下发问卷任务 PublishQuestionnaireActivity

下发问卷是将问卷(Questionnaire)实例化成问卷实例(Survey),问卷实例绑定用户Id,用户在问卷实例上作答。明确输入和输出:

  • 输入:问卷ID
  • 输出:问卷实例对象SurveyDto

通知任务 NotificationActivity

通知在这个需求中需要发送问卷状态,时间等内容给对应的用户,同通至少包含标题和内容。

  • 输入:标题和内容
  • 输出:无

问卷状态跟踪任务 WaitFillInSurveyActivity

这个任务要追踪问卷实例的状态,当问卷实例状态为已完成时,可以继续执行后续任务。

  • 输入:问卷实例ID
  • 输出:无

定时和延时任务

用于延时执行每个下发问卷的时间,等待问卷超时,以及延时发送通知等。

  • 输入:开始日期,延时日期,间隔时间或cron表达式
  • 输出:无

根任务

根任务包含所有的子任务,完成这个任务后,整个流程结束。在这个需求中根任务只需要知道将什么问卷,发送给哪位用户,以及在何时发送这三个问题。

  • 输入:问卷ID,用户ID,发送时间
  • 输出:无

各子任务参数对于他们的根任务是透明的(Invisible),根任务只需要关心是否完成,而不需要知道任务参数。

代码实现

下发问卷活动 PublishQuestionnaireActivity

下发问卷任务可以抽象成为下发问卷活动 PublishQuestionnaireActivity

创建PublishQuestionnaireActivity类并设置输入QuestionnaireId,输出SurveyDto

public class PublishQuestionnaireActivity : Activity<SurveyDto>
{
public PublishQuestionnaireActivity()
{ }
public PublishQuestionnaireActivity(long questionnaireId)
{
QuestionnaireId = new Input<long>(questionnaireId);
} public Input<long> QuestionnaireId { get; set; } = default!;
}

重写ExecuteAsync方法,完成问卷下发逻辑

protected override async ValueTask ExecuteAsync(ActivityExecutionContext context)
{
var _surveyAppService = context.GetRequiredService<ISurveyAppService>();
if (_surveyAppService != null)
{
var currentUserId = await context.GetInputValueAsync<Guid>("UserId");
var survey = await _surveyAppService.PublishAsync(new PublishInput()
{
QuestionnaireId = this.QuestionnaireId.Get<long>(context),
UserId = currentUserId }) ?? throw new Exception("创建问卷失败");
context.SetResult(survey);
} await context.CompleteActivityAsync(); }

如此,其他的任务分别抽象成为相应的活动,这里展示完整代码

通知活动:NotificationActivity

public class NotificationActivity : Activity
{ public NotificationActivity()
{ }
public NotificationActivity(string title, string content)
{
Content = new Input<string>(content);
Title = new Input<string>(title);
} protected override async ValueTask ExecuteAsync(ActivityExecutionContext context)
{
var notificationManager = context.GetRequiredService<NotificationManager>(); if (notificationManager != null)
{
var title = this.Title.Get(context);
var content = this.Content.Get(context);
var currentUserId = await context.GetInputValueAsync<Guid>("UserId");
var data = new CreatePrivateMessageNotificationEto(currentUserId, title, content);
await notificationManager.Send(data);
} await context.CompleteActivityAsync(); } public Input<string> Title { get; set; } = default!;
public Input<string> Content { get; set; } = default!;
}

等待问卷完成活动:WaitFillInSurveyActivity

public class WaitFillInSurveyActivity : Activity
{
public WaitFillInSurveyActivity()
{ }
public WaitFillInSurveyActivity(Func<ExpressionExecutionContext, long?> surveyId)
: this(Expression.DelegateExpression(surveyId))
{
} public WaitFillInSurveyActivity(long surveyId) => SurveyId = new Input<long>(surveyId); public WaitFillInSurveyActivity(Expression expression) => SurveyId = new Input<long>(expression, new MemoryBlockReference()); /// <inheritdoc />
protected override ValueTask ExecuteAsync(ActivityExecutionContext context)
{
var surveyId = SurveyId.Get(context);
if (surveyId == default)
{
var survey = context.ExpressionExecutionContext.GetLastResult<SurveyDto>();
surveyId = survey.Id;
}
var payload = new WaitFillInSurveyBookmarkPayload(surveyId);
context.CreateBookmark(new CreateBookmarkArgs
{
Payload = payload,
Callback = Resume,
BookmarkName = Type,
IncludeActivityInstanceId = false
});
return ValueTask.CompletedTask;
} private async ValueTask Resume(ActivityExecutionContext context)
{
await context.CompleteActivityAsync();
} public Input<long> SurveyId { get; set; } = default!;
}

此任务需要等待,我们创建一个Bookmark,注意创建Bookmark时,我们根据问卷实例SurveyId判断是否完成问卷的回答,因此指定IncludeActivityInstanceIdfalse,创建携带SurveyId的Payload类型:

public record WaitFillInSurveyBookmarkPayload(long SurveyId);

在回调OnResumeAsync中,我们使用context.CompleteActivityAsync来完成任务。

定时和延时活动:

Elsa.Scheduling库提供了用于定时和延时任务的触发器(触发器属于工作流的一种)

[.NET项目实战] Elsa开源工作流组件应用(二):内核解读 一文 "构建 - 构建活动 "章节 列出了Elsa所有内建的活动。

这里使用Elsa内建的三个触发器:

StartAt 在未来特定的时间戳触发工作流触发器

Delay 延迟执行工作流触发器。

Timer 定期触发工作流触发器。

问卷活动:QuestionnaireActivity

问卷活动是下发问卷,通知,等待填写问卷等活动的父级。

Elsa定义了容器类型的活动Container类型,其中的Activities可以包含其他活动。

Sequence和Parallel都是容器类型,是Activity的子类,它们分别表示并行和顺序执行。

除此之外我们还需要两个内建活动:

Fork:分支,用于分支并行执行,与Parallel类似,但比它多了一个等待完成功能。

通过ForkJoinMode属性,可以指定分支任务的执行方式,ForkJoinMode.WaitAny:等待任意一个任务完成,ForkJoinMode.WaitAll:等待所有任务完成。

Fault:故障,用于在工作流执行过程中,遇到异常时,触发故障。并结束工作流。

创建问卷活动类型QuestionnaireActivity,继承自Sequence类型,并设置一些属性,如问卷Id,问卷填写超时时间等。

[可选]Elsa在注册工作流时,Activity对象是会被序列化并存储到WorflowDefinition表中的, 因此这些属性可以被持久化到数据库中。

public class QuestionnaireActivity : Sequence
{
//可选,用于持久化一些属性
public TimeSpan Delay { get; set; }
public DateTime StartAt { get; set; }
public TimeSpan Interval { get; set; }
public string Cron { get; set; }
public TimeSpan Duration { get; set; }
public long QuestionnaireId { get; set; }
public TimeSpan FillInTimeout { get; set; } = TimeSpan.FromHours(2); public QuestionnaireActivity()
{ }
}

重写构造函数,并设置Activities属性

public QuestionnaireActivity(long questionnaireId, TimeSpan fillInTimeout)
{
this.QuestionnaireId = questionnaireId;
this.FillInTimeout = fillInTimeout;
var currentSurvey = new Variable<SurveyDto>();
Variables.Add(currentSurvey);
Activities = new List<IActivity>()
{
//流程开始打印
new WriteLine("问卷流程开始"), //下发问卷任务
new PublishQuestionnaireActivity(QuestionnaireId)
{
Name="PublishQuestionnaire",
Result=new Output<Questionnaire.Survey.Dto.SurveyDto> (currentSurvey)
},
//问卷到达提醒
new NotificationActivity("新问卷提醒", "您有新的问卷,请查收"), //问卷处理分支
new Fork
{
JoinMode = ForkJoinMode.WaitAny,
Branches =
{
//问卷即将过期提醒
new Sequence
{
Activities =
{
//等待
new Delay
{
Name = "RemindDelay",
TimeSpan = new(RemindDelay)
},
//通知
new NotificationActivity("问卷即将超时", "问卷即将超时,请尽快回答")
}
},
//问卷过期处理以及提醒
new Sequence
{
Activities =
{
//等待
new Delay
{
Name = "TimeoutDelay",
TimeSpan = new(FillInTimeout)
},
//通知
new NotificationActivity("问卷已过期", "问卷已过期,请等待工作人员处理"), //处理
new Fault()
{
Message=new ("问卷回答超时")
}
}
},
//问卷状态跟踪
new Sequence
{
Activities =
{
new WriteLine("开始等待问卷提交信号"),
new WaitFillInSurveyActivity(context => currentSurvey.Get<SurveyDto>(context)?.Id) }
}
}
},
//流程结束打印
new WriteLine("完成流程结束"),
new Finish(),
};
}

创建工作流

现在我们来创建测试工作流,

  1. 添加一个工作流参数UserId,用于各活动中对用户的查询依赖。
  2. 分别实现4个并行任务:延时发送问卷,定时发送问卷,定期间隔发送问卷,根据Cron表达式执行。和一个串行任务
public class Test1Workflow : WorkflowBase
{
public Guid UserId { get; set; }
protected override void Build(IWorkflowBuilder workflow)
{
var startTime = new Variable<DateTimeOffset>();
workflow.Inputs.Add(
new InputDefinition() { Name = "UserId", Type = typeof(Guid), StorageDriverType = typeof(WorkflowStorageDriver) }
);
workflow.WithVariable(startTime); workflow.Root = new Sequence
{
Activities =
{
new WriteLine("Start"),
new SetVariable<DateTimeOffset>
{
Variable = startTime,
Value = new (DateTime.Now )
}, new Parallel()
{
Activities =
{
//并行任务1:延时发送问卷
new Sequence()
{
Activities =
{
//问卷1 将在工作流启动后1小时执行 new Delay(TimeSpan.FromHours(1)),
new QuestionnaireActivity(1),
}
},
//并行任务2:定时发送问卷
new Sequence()
{
Activities =
{
//问卷2 将在 2024-4-1 08:30:00 执行
new StartAt(new DateTime(2024,4,1,8,30,0)),
new Delay(TimeSpan.FromHours(2)),
new QuestionnaireActivity(2),
}
},
//并行任务3:定期间隔发送问卷
new Sequence()
{
Activities =
{
//问卷3 每隔两个小时执行
new Timer(new TimeSpan(2,0,0)), new Delay(TimeSpan.FromHours(2)),
new QuestionnaireActivity(3),
}
},
//并行任务4:根据Cron表达式执行
new Sequence()
{
Activities =
{
//问卷4 每个月的最后一天上午10点执行任务
new Cron(cronExpression:"0 0 10 L * ?"), new Delay(TimeSpan.FromHours(2)),
new QuestionnaireActivity(4),
}
},
//并行任务5:根据某时间发送问卷
new Sequence()
{
Activities =
{
new StartAt(context=> startTime.Get(context).AddMinutes(90)), new Delay(TimeSpan.FromHours(2)),
new QuestionnaireActivity(5),
}
}, //串行任务
new Sequence()
{
Activities =
{
//问卷3 将在工作流启动后2小时执行 new Delay(TimeSpan.FromHours(2)),
new QuestionnaireActivity(3), //问卷4 将在问卷3完成1天后执行 new Delay(TimeSpan.FromDays(1)),
new QuestionnaireActivity(4), //问卷5 将在问卷4完成3天后执行 new Delay(TimeSpan.FromDays(3)),
new QuestionnaireActivity(5),
}
}
}
},
new Finish(),
},
};
}
}

开始工作流

工作流启动参数需设置Input对象


var input = new Dictionary<string, object>
{ {"UserId", "D1522DBC-5BFC-6173-EB60-3A114454350C"}, }; var startWorkflowOptions = new StartWorkflowRuntimeOptions
{
Input = input,
VersionOptions = versionOptions,
InstanceId = instanceId,
}; // Start the workflow.
var result = await _workflowRuntime.StartWorkflowAsync(workflowDefinition.DefinitionId, startWorkflowOptions);

下面进入喜闻乐见的踩坑填坑环节

TroubleShooting

  1. 在活动中执行异步操作时,会导致报错:

    如下面的代码,执行Excute方法中的 context.CompleteActivityAsync()方法,时报错

原因分析:scope资源被提前释放

代码先执行到了112行,scope释放

解决:带有异步的操作一定要使用ExecuteAsync方法

  1. delay之后,Workflow的Input无法访问

原因分析:

Delay或其他Schedule类型的Activity,通过创建Bookmark挂起任务,当任务被唤醒时,input被workflowState.Output替换掉,和原先的input不一样了。

解决:

虽然input被替换了,但数据库的input还在,可以通过workflowInstanceId先取回workflowInstance对象,再通过instance.WorkflowState.Input.TryGetValue方法获取原始input值。

可以创建一个一个扩展方法GetInputValueAsync,Delay之后的活动中调用即可。

public static async Task<TValue> GetInputValueAsync<TValue>(this ActivityExecutionContext context, string name)
{
TValue value;
if (!context.TryGetWorkflowInput(name, out value))
{
var workflowInstanceStore = context.GetRequiredService<IWorkflowInstanceStore>(); var instance = await workflowInstanceStore.FindAsync(new WorkflowInstanceFilter()
{
Id = context.WorkflowExecutionContext.Id });
if (instance != null)
{
instance.WorkflowState.Input.TryGetValue(name, out value);
}
} return value; }

在Activity中调用:

await context.GetInputValueAsync<Guid>("UserId");

持续更新中...

--完结--

[.NET项目实战] Elsa开源工作流组件应用(三):实战演练的更多相关文章

  1. 【.NET Core项目实战-统一认证平台】第三章 网关篇-数据库存储配置(1)

    [.NET Core项目实战-统一认证平台]开篇及目录索引 本篇将介绍如何扩展Ocelot中间件实现自定义网关,并使用2种不同数据库来演示Ocelot配置信息存储和动态更新功能,内容也是从实际设计出发 ...

  2. .NET Core/.NET5/.NET6 开源项目汇总3:工作流组件

    系列目录     [已更新最新开发文章,点击查看详细] 开源项目是众多组织与个人分享的组件或项目,作者付出的心血我们是无法体会的,所以首先大家要心存感激.尊重.请严格遵守每个项目的开源协议后再使用.尊 ...

  3. react 项目实战(四)组件化表单/表单控件 高阶组件

    高阶组件:formProvider 高阶组件就是返回组件的组件(函数) 为什么要通过一个组件去返回另一个组件? 使用高阶组件可以在不修改原组件代码的情况下,修改原组件的行为或增强功能. 我们现在已经有 ...

  4. 小D课堂-SpringBoot 2.x微信支付在线教育网站项目实战_2-5.开源工具的优缺点选择和抽象方法的建议

    笔记 5.开源工具的优缺点选择和抽象方法的建议     简介:讲解开源工具的好处和弊端,如pageHeper分页拦截器,tk自动生成工具,抽象方法的利弊等 1.开源工具             好处: ...

  5. 简单的物流项目实战,WPF的MVVM设计模式(三)

    往Services文件里面添加接口以及实现接口 IUserService接口 List<User> GetAllUser(); GetUserService类 ConnectToDatab ...

  6. 【.NET Core项目实战-统一认证平台】第四章 网关篇-数据库存储配置(2)

    [.NET Core项目实战-统一认证平台]开篇及目录索引 上篇文章我们介绍了如何扩展Ocelot网关,并实现数据库存储,然后测试了网关的路由功能,一切都是那么顺利,但是有一个问题未解决,就是如果网关 ...

  7. 【Slickflow学习】.NET开源工作流环境搭建(三)

    第一次自己写博客文章,大家多多指教.写博客主要记录一下学习的过程,给初学者提供下参考,也留给自己做备忘. Slickflow .NET开源工作流-环境搭建 在VS2010中使用附加进程的方式调试IIS ...

  8. 小D课堂-SpringBoot 2.x微信支付在线教育网站项目实战_汇总

    2018年Spring Boot 2.x整合微信支付在线教育网站高级项目实战视频课程 小D课堂-SpringBoot 2.x微信支付在线教育网站项目实战_1-1.SpringBoot整合微信支付开发在 ...

  9. .NET 5 开源工作流框架elsa技术研究

    今天假期第一天,研究了.NET 5开源工作流框架elsa,现在分享给大家. 一.框架简介 elsa是一个开源的.NET Standard 工作流框架,官方网站:https://elsa-workflo ...

  10. Asp.Net Core 2.0 项目实战(1) NCMVC开源下载了

    Asp.Net Core 2.0 项目实战(1) NCMVC开源下载了 Asp.Net Core 2.0 项目实战(2)NCMVC一个基于Net Core2.0搭建的角色权限管理开发框架 Asp.Ne ...

随机推荐

  1. NOI 2021 补全记录

    来补题了昂. D1T1 轻重边 对于原树进行重链剖分,使用一颗线段树维护每一条重边是否时"重边",然后对于轻边,在父亲出维护最后一次通过 \(1\) 操作清空"重边&qu ...

  2. NC235247 Sramoc问题

    题目链接 题目 题目描述 \(Sramoc(K ,M)\) 表示用数字 \(0,1,2,3,4,...,k-1\) 组成的自然数中能被M整除的最小数.给定 \(K,M\) \(2\leq K\leq ...

  3. Python异步编程原理篇之IO多路复用模块selector

    selector 简介 selector 是一个实现了IO复用模型的python包,实现了IO多路复用模型的 select.poll 和 epoll 等函数. 它允许程序同时监听多个文件描述符(例如套 ...

  4. 从零开始手写 mybatis (三)jdbc pool 从零实现数据库连接池

    前景回顾 第一节 从零开始手写 mybatis(一)MVP 版本 中我们实现了一个最基本的可以运行的 mybatis. 第二节 从零开始手写 mybatis(二)mybatis interceptor ...

  5. USB至串口TTL转接设备及Console线

    USB转串口常见芯片方案 FT232, FTDI(英国) 公认稳定可靠, 传输速率3Mbps, 功能最强, 单芯片内置SPI,TWI,JTAG,GPIO等功能. FT232BM为较早型号, FT232 ...

  6. 【Unity3D】点选物体、框选物体、绘制外边框

    1 需求描述 ​ 绘制物体外框线条盒子 中介绍了绘制物体外框长方体的方法,本文将介绍物体投影到屏幕上的二维外框绘制方法. 点选物体:点击物体,可以选中物体,按住 Ctrl 追加选中,选中的物体设置为红 ...

  7. Python之初级RPG小游戏

    在国外网站上找到一个练习Python的小游戏感觉不错,自己实现了一下. 通过该练习你能学到: 元组 字典 简单定义函数和封装 条件控制语句 游戏说明 以下是3个房间和1个花园: Hall 客厅 有一把 ...

  8. 文心一言 VS 讯飞星火 VS chatgpt (201)-- 算法导论15.2 6题

    六.用go语言,证明:对 n 个元素的表达式进行完全括号化,恰好需要 n-1 对括号. 文心一言,代码正常运行: 在Go语言中,证明对n个元素的表达式进行完全括号化恰好需要n-1对括号,可以通过数学归 ...

  9. ThreadLocal的应用场景和注意事项有哪些?

    https://cloud.tencent.com/developer/article/1618405

  10. 【八股cover#4】OS Q&A与知识点

    OS Q&A与知识点 重点知识 进程 概念 ​ 我们编译的代码可执行文件只是储存在硬盘的静态文件,运行时被加载到内存,CPU执行内存中指令,这个运行的程序被称为进程. 进程是对运行时程序的封装 ...