[.NET專案實戰] Elsa開源工作流元件應用(三):實戰演練

林晓lx發表於2024-03-21

目錄
  • 補充
  • 需求描述
  • 需求分析
  • 程式碼實現
    • 下發問卷活動 PublishQuestionnaireActivity
    • 通知活動:NotificationActivity
    • 等待問卷完成活動:WaitFillInSurveyActivity
    • 定時和延時活動:
    • 問卷活動:QuestionnaireActivity
    • 建立工作流
    • 開始工作流
  • TroubleShooting

補充

之前的文章簡單介紹了工作流和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 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");

持續更新中...

--完結--

相關文章