
大家好!
我们决定支持使用Windows Workflow Foundation到.Net Core的项目迁移主题,该主题是由DIRECTUM的同事发起的,因为几年前我们遇到了类似的问题,并且走了自己的路。
让我们从故事开始
我们的旗舰产品Avanpost IDM是帐户生命周期和员工访问管理系统。 他知道如何根据角色模型并根据请求自动管理访问。 在产品诞生之初,我们就拥有了一个相当简单的自助服务系统,该系统具有简单的分步工作流程,原则上不需要引擎。

但是,当面对大客户时,我们意识到需要一个更加灵活的工具,因为他们对访问权限协调过程的要求寻求了良好,重要的工作流程的规则。 在分析了需求之后,我们决定开发适合自己需求的BPMN格式的流程编辑器。 稍后我们将讨论使用React.js + SVG进行编辑器的开发,今天我们将讨论后端主题- 工作流引擎或业务流程引擎。
要求条件
在系统开发之初,我们对引擎有以下要求:
- 支持流程图,易于理解的格式,能够从我们的格式广播到引擎格式
- 流程状态存储
- 流程版本控制支持
- 支持流程的并行执行(分支)
- 在复制的商业产品中使用该解决方案的合适许可证
- 支持水平缩放
在分析市场(针对2014年)之后,我们确定了适用于.Net的几乎非替代解决方案:Windows Workflow Foundation。
Windows Workflow Foundation(WWF)
WWF是Microsoft用于定义,执行和管理工作流的技术。
其逻辑的基础是一组用于操作(活动)的容器,以及从这些容器构建顺序流程的能力。 容器可以是普通的-执行活动的过程中的某个步骤。 它可以是经理-包含分支逻辑。
您可以直接在Visual Studio中绘制过程。 编译后的业务流程图存储在Haml中,这非常方便-描述了格式,可以使自己编写流程设计师。 这是一方面。 另一方面,Xaml并不是用于存储描述的最方便的格式-或多或少的实际过程的编译方案非常庞大,尤其是因为冗余。 这很难理解,但您必须理解它。
但是,如果早晚有人可以理解各种方案并学会阅读它们,那么引擎本身缺乏透明性便会增加用户在系统运行过程中的麻烦。 当错误来自Wf的肠子时,并非总是能够100%找出故障的确切原因。 封闭的来源和相对的怪异无助于此。 错误修复通常是由于症状引起的。
公平地讲,这里值得澄清的是,由于Wf的强大定制功能,上述问题在很大程度上困扰着我们。 一位读者肯定会说我们自己创造了很多问题,然后英勇地解决了这些问题。 从一开始就必须制造出自制的发动机。 一般来说,他们是正确的。
最重要的是,该解决方案运行稳定并成功投入生产。 但是我们产品向.Net Core的过渡迫使我们放弃WWF并寻找另一个业务流程引擎,因为 截至2019年5月,Windows Workflow Foundation尚未迁移到.Net Core。 当我们在寻找一个新引擎时-在另一篇文章中作为主题,但是最后我们选择了Workflow Core。
工作流程核心
Workflow Core是一个免费的业务流程引擎。 它是根据MIT许可开发的,即可以安全地用于商业开发。
它是由一个人主动完成的,另外几个人会定期发出请求。 有用于其他语言(Java,Python等)的端口。
引擎定位为轻量化。 实际上,这只是按顺序执行按任何业务规则分组的操作的主机。
该项目具有Wiki文档 。 不幸的是,它没有描述引擎的所有功能。 但是,要求完整的文档是无礼的-开源项目得到一位发烧友的支持。 因此,维基足以入门。
开箱即用的支持将过程状态存储在外部存储(持久性存储)中。 提供者是以下方面的标准:
- Mongodb
- SQL服务器
- PostgreSQL的
- Sqlite
- 亚马逊DynamoDB
写您的提供程序不是问题。 我们以任何标准的来源为例。
支持水平缩放,也就是说,您可以一次在多个节点上运行引擎,同时具有一个存储过程状态的点(一个持久性存储)。 同时,引擎的内部任务队列应位于常规存储器中(可选,rabbitMQ)。 为了排除多个节点执行一项任务,同时提供了一个锁管理器。 与外部存储提供者类似,有一些标准实现:
- Azure存储租约
- 雷迪斯
- AWS DynamoDB
- SQLServer(在源代码中有,但是文档中没有任何内容)
从一个例子开始,最容易了解新事物。 因此,让我们开始吧。 我将从一开始就描述一个简单过程的构造,并进行解释。 一个例子似乎太简单了。 我同意-这很简单。 最开始的。
走吧
步骤
步骤是执行任何动作的过程中的步骤。 整个过程是由一系列步骤构成的。 一个步骤可以执行许多动作,例如对于外部某个事件可以重复执行。 有一系列步骤具有逻辑“开箱即用”:
当然,在某些内置基元上,您无法承受这个过程。 我们需要完成业务任务的步骤。 因此,暂时将它们放在一边,并按照我们自己的逻辑采取步骤。 为此,您需要继承StepBody抽象。
public abstract class StepBody : IStepBody { public abstract ExecutionResult Run(IStepExecutionContext context); }
当过程进入步骤时,将执行Run方法。 有必要在其中放置必要的逻辑。
public abstract class StepBody : IStepBody { public abstract ExecutionResult Run(IStepExecutionContext context); }
这些步骤支持依赖项注入。 为此,将它们作为必需的依赖项注册在同一容器中就足够了。
显然,该过程需要它自己的上下文-一个可以在其中添加中间执行结果的地方。 Wf核心具有自己的上下文,可以执行存储有关其当前状态的信息的进程。 您可以使用Run ()方法中的上下文变量来访问它。 除了内置的,我们还可以使用上下文。
我们将在下面更详细地分析描述和注册过程的方式,现在,我们仅定义一个特定的类—上下文。
public class ProcessContext { public int Number1 {get;set;} public int Number2 {get;set;} public string StepResult {get;set;} public ProcessContext() { Number1 = 1; Number2 = 2; } }
在变量Number中,我们写数字。 进入变量StepResult-步骤的结果。
我们决定了上下文。 您可以编写自己的步骤:
public class CustomStep : StepBody { private readonly Ilogger _log; public int Input1 { get; set; } public int Input2 { get; set; } public string Action { get; set; } public string Result { get; set; } public CustomStep(Ilogger log) { _log = log; } public override ExecutionResult Run(IStepExecutionContext context) { Result = ”none”; if (Action ==”sum”) { Result = Number1 + Number2; } if (Action ==”dif”){ Result = Number1 - Number2; } return ExecutionResult.Next(); } }
逻辑非常简单:两个数字和操作名称出现在输入中。 将运算结果写入输出变量Result 。 如果未定义操作,则结果为none 。
我们根据上下文决定,我们需要的逻辑也迈出了一步。 现在我们需要在引擎中注册我们的过程。
过程说明。 在引擎中注册。
有两种描述过程的方法。 第一个是代码中的描述-硬代码。
通过流畅的界面描述了该过程。 有必要从广义的IWorkflow <T>接口继承,其中T是模型上下文类。 在我们的例子中,这是一个ProcessContext 。
看起来像这样:
public class SimpleWorkflow : IWorkflow<ProcessContext> { public void Build(IWorkflowBuilder<ProcessContext> builder) {
描述本身将在Build方法内部。 编号和版本字段也是必填字段。 Wf核心支持流程版本控制-您可以使用相同的标识符注册n个流程版本。 当您需要更新现有流程并同时“激活”现有任务时,这很方便。
我们描述一个简单的过程:
public class SimpleWorkflow : IWorkflow<ProcessContext> { public void Build(IWorkflowBuilder<ProcessContext> builder) { builder.StartWith<CustomStep>() .Input(step => step.Input1, data => data.Number1) .Input(step => step.Input2, data => data.Number2) .Input(step => step.Action, data => “sum”) .Output(data => data.StepResult, step => step.Result) .EndWorkflow(); } public string Id => "SomeWorkflow"; public int Version => 1; }
如果将其翻译成“人类”语言,结果将是这样:该过程从CustomStep步骤开始。 步骤字段Input1的值从上下文字段Number1中获取 ,步骤字段Input2的值从上下文字段Number2中获取 , 操作字段被硬编码为值“ sum” 。 Result字段的输出将写入StepResult上下文字段 。 完成该过程。
同意,即使没有C#的专门知识,该代码也具有很高的可读性,很可能会弄清楚它。
在我们的过程中再增加一个步骤,该步骤会将上一步的结果输出到日志中:
public class CustomStep : StepBody { private readonly Ilogger _log; public string TextToOutput { get; set; } public CustomStep(Ilogger log) {
并更新过程:
public class SimpleWorkflow : IWorkflow<ProcessContext> { public void Build(IWorkflowBuilder<ProcessContext> builder) { builder.StartWith<CustomStep>() .Input(step => step.Input1, data => data.Number1) .Input(step => step.Input2, data => data.Number2) .Input(step => step.Action, data => “sum”) .Output(data => data.StepResult, step => step.Result) .Then<OutputStep>.Input(step => step.TextToOutput, data => data.StepResult) .EndWorkflow(); } public string Id => "SomeWorkflow"; public int Version => 2; }
现在,在执行加法运算的步骤之后,接下来是将结果输出到日志的步骤。 对于输入,我们传递Result和context变量,在最后一步中将执行结果写入该变量。 我将自由地断言,在实际系统中通过代码(硬代码)进行这种描述几乎没有用处。 除非用于某些办公流程。 能够分别存储电路更加有趣。 至少,我们不必在每次需要在过程中进行更改或添加新项目时都重新组合项目。 Wf核心通过存储json模式来提供此功能。 我们继续扩大我们的榜样。
Json流程说明
此外,我不会通过代码提供描述。 这并不是特别有趣,只会使文章膨胀。
Wf核心支持json中的架构描述。 在我看来,json比xaml更直观(在注释中,这是holivar的一个好话题:)。 文件结构非常简单:
{ "Id": "SomeWorkflow", "Version": 1, "DataType": "App.ProcessContext, App", "Steps": [ { /*step1*/ }, { /*step2*/ } ] }
DataType字段指示上下文类的完全限定名称以及在其中描述它的程序集的名称。 步骤存储过程中所有步骤的集合。 填写步骤元素:
{ "Id": "SomeWorkflow", "Version": 1, "DataType": "App.ProcessContext, App", "Steps": [ { "Id": "Eval", "StepType": "App.CustomStep, App", "NextStepId": "Output", "Inputs": { "Input1": "data.Number1", "Input2": "data.Number2" }, "Outputs": { "StepResult": "step.Result" } }, { "Id": "Output", "StepType": "App.OutputStep, App", "Inputs": { "TextToOutput": "data.StepResult" } } ] }
让我们仔细看看通过json进行的步骤描述的结构。
Id和NextStepId字段存储此步骤的标识符以及下一个步骤的指示符。 此外,集合元素的顺序并不重要。
StepType类似于DataType字段,它包含步骤类的全名(一种继承自StepBody并实现步骤逻辑的类型)和程序集的名称。 更有趣的是Inputs和Outputs对象。 它们以映射的形式设置。
对于Inputs, json元素名称是我们步骤的类字段的名称; 元素的值是类中字段的名称,即过程的上下文。
相反,对于Outputs, json元素名称是类中字段的名称,即进程的上下文; 元素值是我们步骤的类字段的名称。
为什么要通过数据{Field_name}指定上下文字段,而对于Output , 则要通过{Field_name} 步骤指定上下文字段? 由于wf core,元素值作为C#表达式执行(使用了Dynamic Expressions库 )。 这是相当有用的事情,如果架构师当然认可这样的耻辱,那么借助它的帮助,您可以在方案内部直接放置一些业务逻辑:)。
我们通过标准图元使方案多样化。 添加条件If步骤并处理外部事件。
如果
原始如果 。 困难从这里开始。 如果您习惯使用该符号进行bpmn和绘制过程,则将发现一个简单的设置。 根据文档,该步骤描述如下:
{ "Id": "IfStep", "StepType": "WorkflowCore.Primitives.If, WorkflowCore", "NextStepId": "nextStep", "Inputs": { "Condition": "<<expression to evaluate>>" }, "Do": [ [ { /*do1*/ }, { /*do2*/ } ] ] }
没有感觉到这里有什么问题吗? 我有一个 步骤输入设置为“ 条件 -表达式”。 接下来,我们设置Do数组(操作)中的步骤列表。 那么, 假分支在哪里? 为什么没有Do数组为False? 其实有。 可以理解, False分支只是沿过程的进一步传递,即紧跟NextStepId中的指针。 起初,我一直为此感到困惑。 好,整理一下。 虽然没有。 如果需要将在True情况下的流程动作放入Do内 ,则这将是“美丽的” json。 而如果有这些, 如果附上一打呢? 一切都会横着走。 他们还说,关于xaml的方案很难阅读。 有一个小技巧。 只是将显示器扩大。 上面稍微提到过,集合中步骤的顺序无关紧要,过渡遵循的迹象。 可以使用。 再增加一个步骤:
{ "Id": "Jump", "StepType": "App.JumpStep, App", "NextStepId": "" }
猜猜我要干什么? 的确,我们引入了服务步骤,该服务步骤将流程转移到NextStepId中的步骤。
更新我们的方案:
{ "Id": "SomeWorkflow", "Version": 1, "DataType": "App.ProcessContext, App", "Steps": [ { "Id": "Eval", "StepType": "App.CustomStep, App", "NextStepId": "MyIfStep", "Inputs": { "Input1": "data.Number1", "Input2": "data.Number2" }, "Outputs": { "StepResult": "step.Result" } }, { "Id": "MyIfStep", "StepType": "WorkflowCore.Primitives.If, WorkflowCore", "NextStepId": "OutputEmptyResult", "Inputs": { "Condition": "!String.IsNullOrEmpty(data.StepResult)" }, "Do": [ [ { "Id": "Jump", "StepType": "App.JumpStep, App", "NextStepId": "Output" } ] ] }, { "Id": "Output", "StepType": "App.OutputStep, App", "Inputs": { "TextToOutput": "data.StepResult" } }, { "Id": "OutputEmptyResult", "StepType": "App.OutputStep, App", "Inputs": { "TextToOutput": "\"Empty result\"" } } ] }
If步骤检查Eval步骤的结果是否为空。 如果不为空,则显示结果;如果为空,则显示消息“为空 ”。 “ 跳转”步骤将过程带到“ 输出”步骤,该步骤位于Do集合之外。 因此,我们保持了该方案的“垂直性”。 同样,以这种方式,人们可以向后跳n步,即 组织一个周期。 wf核心中有用于循环的内置原语,但它们并不总是很方便。 例如,在bpmn中,通过If来组织循环。
使用这种方法或标准,由您决定。 对于我们来说,这样的组织是更方便的步骤。
等待
WaitFor原语允许外界在已经运行的过程中对其进行影响。 例如,如果在过程阶段需要任何用户批准进一步的课程。 该过程将一直处于WaitFor步骤中,直到收到订阅的事件为止。
基本结构:
{ "Id": "Wait", "StepType": "WorkflowCore.Primitives.WaitFor, WorkflowCore", "NextStepId": "NextStep", "CancelCondition": "If(cancel==true)", "Inputs": { "EventName": "\"UserAction\"", "EventKey": "\"DoSum\"", "EffectiveDate": "DateTime.Now" } }
我会解释一些参数。
CancelCondition-中断等待的条件。 提供中断事件等待并继续进行的功能。 例如,如果一个进程同时等待n个不同的事件(wf内核支持并行执行步骤),则不必等待所有事件都到达 ,在这种情况下, CancelCondition将为我们提供帮助。 我们向上下文变量添加逻辑标志,并在接收到事件后将标志设置为true-所有WaitFor步骤均已完成。
EventName和EventKey-事件的名称和键。 为了区分事件,即在具有大量同时工作过程的真实系统中,需要字段。 以便引擎了解哪个事件是针对哪个过程和哪个步骤的。
有效日期 -一个可选的字段,用于添加事件时间戳。 如果您需要发布“未来”的事件,它可能会派上用场。 为了使其立即发布,可以将参数保留为空或设置当前时间。
并非在所有情况下都采取单独的步骤来从外部处理反应很方便;相反,即使通常情况下,这也是多余的。 通过将外部事件的期望及其处理逻辑添加到常规步骤中,可以避免额外的步骤。 我们通过订阅外部事件来补充CustomStep步骤:
public class CustomStep : StepBody { private readonly Ilogger _log; public string TextToOutput { get; set; } public CustomStep(Ilogger log) { _log = log; } public override ExecutionResult Run(IStepExecutionContext context) {
我们使用了标准的WaitForEvent()扩展方法。 它接受前面提到的参数EventName , EventKey和EffectiveDate 。 完成此步骤的逻辑后,该过程将等待所描述的事件,并在该事件在引擎总线中发布时再次调用Run()方法。 但是,以当前形式,我们无法区分最初进入步骤的时刻和事件之后的进入时刻。 但是我想以某种方式在步骤级别上前后分离逻辑。 而EventPublished标志将帮助我们实现这一点。 它位于流程的一般上下文中,您可以像这样获得它:
var ifEvent=context.ExecutionPointer.EventPublished;
基于此标志,您可以将逻辑安全地划分为外部事件之前和之后。
重要说明-根据引擎创建者的想法,一个步骤只能在一个事件上签名,并对其进行一次反应。 对于某些任务,这是一个非常不愉快的限制。 为了摆脱这种细微差别,我们甚至不得不“完成”引擎。 我们将在本文中跳过它们的描述,否则该文章将永远不会结束:)。 后续文章中将介绍更复杂的使用方法和改进示例。
引擎中的注册过程。 在公共汽车上发布事件。
因此,随着步骤的逻辑执行和对过程的描述弄清楚了。 剩下的是最重要的事情,没有它,该过程将无法进行-说明需要注册。
我们将使用标准的AddWorkflow()扩展方法,该方法会将其依赖项放在IoC容器中。
看起来像这样:
public static IServiceCollection AddWorkflow(this IServiceCollection services, Action<WorkflowOptions> setupAction = null)
IServiceCollection-接口-服务描述集合的合同。 他住在Microsoft的DI内部(有关更多信息, 请点击此处 )
WorkflowOptions-基本引擎设置。 不必自己设置它们;标准值对于初学者是完全可以接受的。 我们走得更远。
如果该过程是用代码描述的,则注册将如下所示:
var host = _serviceProvider.GetService<IWorkflowHost>(); host.RegisterWorkflow<SomeWorkflow, ProcessContext>();
如果该过程是通过json描述的,则必须按以下方式注册(当然,必须从存储位置预加载json描述):
var host = _serviceProvider.GetService<IWorkflowHost>(); var definitionLoader = _serviceProvider.GetService<IDefinitionLoader>(); var definition = loader.LoadDefinition({*json *});
此外,对于这两个选项,代码将相同:
host.Start();
definitionId参数是进程标识符。 在流程ID字段中写入的内容。 在这种情况下,id = SomeWorkflow 。
version参数指定要运行的进程的版本。 该引擎提供了使用一个标识符立即注册n个流程版本的功能。 当您需要在不破坏已经运行的任务的情况下更改流程说明时,这很方便-新任务将根据新版本创建,旧任务将安静地存在于旧任务中。
上下文参数是流程上下文的一个实例。
host.Start()和host.Stop()方法启动和停止进程托管。 如果在应用程序中启动进程是一项已应用的任务,并且是定期执行的,则应停止托管。 如果应用程序主要集中在各种过程的实现上,则无法停止托管。
有一种方法可以将消息从外界发送到引擎总线,然后将消息分发给订户:
Task PublishEvent(string eventName, string eventKey, object eventData, DateTime effectiveDate = null);
文章中对它的参数的描述更高( 请参见WaitFor基本部分 )。
结论
当我们决定采用Workflow Core-开源项目时,我们肯定会冒风险,该项目由一个人积极开发,即使文档非常差。 而且,您极有可能找不到在生产系统(我们公司除外)中使用wf core的实际做法。 当然,选择了单独的抽象层后,我们可以确保自己不会出现故障,也不需要为快速返回WWF(例如,一种自行编写的解决方案)而烦恼,但是一切进展顺利,并且故障并没有到来。
改用开源Workflow Core引擎解决了许多问题,这些问题使我们无法在WWF上安居乐业。 当然,其中最重要的是支持.Net Core,即使在WWF计划中也缺乏。
以下是开源。 与WWF一起工作并从肠中得到各种错误,至少能够阅读源代码的能力将非常有帮助。 更不用说改变他们的东西了。 这里有了Workflow Core的完全自由(包括许可-MIT)。 如果引擎的肠子突然出现错误,只需从github下载源代码,然后静静地寻找其发生原因。 是的,仅仅具有调试功能并具有断点的引擎功能已经极大地促进了该过程。
当然,为了解决一些问题,Workflow Core带来了自己的新问题。 我们必须对引擎核心进行大量更改。 但是 与从头开发自己的引擎相比,自行完成“完成”所花费的时间更少。 最终的解决方案在速度和稳定性方面是完全可以接受的,它使我们能够忘记引擎的问题,而专注于开发产品的商业价值。
PS:如果主题变得有趣,那么将在wf core上发布更多文章,并对引擎和复杂业务问题的解决方案进行更深入的分析。