- 補充
- 需求描述
- 需求分析
- 程式碼實現
- 下發問卷活動 PublishQuestionnaireActivity
- 通知活動:NotificationActivity
- 等待問卷完成活動:WaitFillInSurveyActivity
- 定時和延時活動:
- 問卷活動:QuestionnaireActivity
- 建立工作流
- 開始工作流
- TroubleShooting
補充
之前的文章簡單介紹了工作流和Elsa工作流庫,這裡再補充說明兩點
-
工作流的使用場景非常廣泛,幾乎涵蓋了所有需要進行業務流程自動化管理的領域。
-
學習一個開源庫,最簡單的方法就是看原始碼,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判斷是否完成問卷的回答,因此指定IncludeActivityInstanceId
為false
,建立攜帶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(),
};
}
建立工作流
現在我們來建立測試工作流,
- 新增一個工作流引數UserId,用於各活動中對使用者的查詢依賴。
- 分別實現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
-
在活動中執行非同步操作時,會導致報錯:
如下面的程式碼,執行Excute方法中的 context.CompleteActivityAsync()方法,時報錯
原因分析:scope資源被提前釋放
程式碼先執行到了112行,scope釋放
解決:帶有非同步的操作一定要使用ExecuteAsync方法
- 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");
持續更新中...
--完結--