0%

[DotnetCore]排程神器-Hangfire:動態排程篇1

前情提要

排程神器-Hangfire兩篇文章後,這篇來到重頭戲了,筆者主要參考TimerJob這幾篇gist文件,加以整理改造成符合筆者工作環境中的情境。原始需求是這樣,筆者所在環境,主管們因維運考量,一切以資料庫設定為準,爾後有臨時變更需要時,調動一下資料庫設定即可,一切設計以這個原則為最大考量達成目的,當然排程這件事也不例外了,主要考量有以下幾點

  • 臨時調動執行時間
  • 若排定時間執行時有出錯,是否可以手動立即執行
  • 可能以某一個排程為基底,長出一個新的排程

等等,是個一大挑戰阿。

內容

筆者以前提情要提到的幾點去做一個設計過程的解說,以第一點「臨時調動執行時間」來說,應該要有一支API,執行後達到Job Initial效果,因為需要臨時調動,須將排程設定放置於資料表中,經由Job Initial這個作業,透過HangfireAddOrUpdate,因為JobId是一樣會Update掉,筆者這邊暫時不考慮刪除,刪除只能從Hangfire Dashboard做刪除動作。

第二點來說,Hangfire已經具備了,可以透過Hangfire Dashboard做到立即執行的效果。

第三點跟第一點也是有相關的,筆者情境是這樣,若全部的Job都是寫成一個個Job Class則,要長出新的Job則必須得改動程式,必須得編譯並上版,試想能不能夠把系統中的排程行為做一個整理,歸納出三到四種的Job類型,對應的就是固定的Job Class,一來大大降低重複開發,二來因為有歸納整理過,因此現在要長出一個類似的排程,透過設定加以調整,可以不用動到程式就能長出新的排程。

資料結構規劃

筆者這邊是沿用既有的Code Table,這純粹是已經有一個可以達到目的的資料表,就不另外設計了,這個資料結構完全沒有限制,只要能符合各位各自的排程需求即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public partial class CommCode
{
public string CodeType { get; set; }
public string CodeTypeName { get; set; }
public string Code { get; set; }
public string CodeName { get; set; }
public string CodeVal1 { get; set; }
public string CodeVal2 { get; set; }
public string CodeVal3 { get; set; }
public string CodeVal4 { get; set; }
public string CodeVal5 { get; set; }
public string CodeVal6 { get; set; }
public int? SeqNo { get; set; }
public string AuditUser { get; set; }
public DateTime? AuditTime { get; set; }
}

筆者會在Job種類規劃時會再說明,如何使用該資料結構,目前就先參考吧,這張資料表在筆者環境中是屬於開放式的欄位,可以將設定值使用不同的CodeType存放,也有一些是下拉式選單的選項等等,已經是開放式的設計了,不妨就拿來當作動態排程的Config設定吧。

Base Job規劃

這邊主要是仿照參考文章中的ITimerJob,調整為筆者環境適用的樣子

1
2
3
4
5
6
7
8
9
public interface ITimerJob<T> where T : CommCode
{
// 定義JobId
string JobId { get; }
// 排程執行
void Execute(IJobCancellationToken cancellationToken, PerformContext context, List<T> parameters);
// 進行排程
void Schedule(string cronExpression, List<T> parameters);
}

來宣告一個Base Timer Job

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
public abstract class TimerJob<T, T2> : ITimerJob<T2>
where T : ITimerJob<T2>
where T2 : CommCode
{
private readonly object schedulerLogListLock = new object();
private readonly ILoggerManager _logger;
public abstract string JobId { get; }
protected IJobCancellationToken _cancellationToken;
protected PerformContext _context;
private List<SchedulerLog> _logModels;
private SchedulerLog _logModel;

protected abstract void PerformJobTasks(List<T2> parameters);
private string _connStr;

public TimerJob(IConfiguration config
, List<SchedulerLog> logModels
, SchedulerLog schedulerLog, ILoggerManager logger)
{
_connStr = Encoding.UTF8.GetString(Convert.FromBase64String(config.GetConnectionString("FMTR")));
_logModels = logModels;
_logModel = schedulerLog;
this._logModel.AuditUser = "SYS";
_logger = logger;
}

//[DisableConcurrentExecution(0)]
public void Execute(IJobCancellationToken cancellationToken
, PerformContext context
, List<T2> parameters)
{
_cancellationToken = cancellationToken;
_context = context;

try
{
var jobSetting = parameters.FirstOrDefault();
this._logModel.JobId = jobSetting.CodeName.Split(';')[0];

_logger.LogInfo($"{this._logModel.JobId} Start!");

if (string.IsNullOrEmpty(jobSetting.CodeVal5) == false)
{
using (var _conn = new SqlConnection(_connStr))
{
var preCheckResult = _conn.Query<PreCheckResponseModel>(jobSetting.CodeVal5).FirstOrDefault();
_logger.LogInfo($"[{this._logModel.JobId} PreCheck] {preCheckResult.CanRun}");
if (preCheckResult.CanRun == "Y")
{
lock(schedulerLogListLock)
{
PerformJobTasks(parameters);
}
}
}
}
else
{
lock(schedulerLogListLock)
{
PerformJobTasks(parameters);
}
}

_logger.LogInfo($"{this._logModel.JobId} End!");
}
catch (Exception ex)
{
_logger.LogError($"[{this._logModel.JobId} Error] {ex}");
this._logModel.ErrMessage = $"{ex}";
this._logModels.AddSchedulerLog(this._logModel);
}
finally
{
// TODO: Add SchedulerLogs to table
if (this._logModels.Count > 0)
{
lock (schedulerLogListLock)
{
using (var _conn = new SqlConnection(_connStr))
{
_conn.Execute(@"INSERT INTO [dbo].[SchedulerLog]
([JobID], [StepDesc], [Result], [ErrMessage], [AuditUser], [AuditTime])
VALUES(@JobID, @StepDesc, @Result, @ErrMessage, @AuditUser, @AuditTime)"
, this._logModels);
}
}
}
}
}

public void Schedule(string cronExpression, List<T2> parameters)
{
var jobSetting = parameters.FirstOrDefault();
var jobInfos = jobSetting.CodeName.Split(";");
(string jobId, string jobClassName) jobInfo = ValueTuple.Create(jobInfos[0], jobInfos[1]);
//jobSetting.CodeTypeName == "Normal" ? JobId : jobSetting.CodeName;
var jobName = jobInfo.jobId;
if (string.IsNullOrWhiteSpace(cronExpression))
{
RecurringJob.RemoveIfExists(jobName);
}
RecurringJob.AddOrUpdate<T>(jobName, x => x.Execute(JobCancellationToken.Null, null, parameters)
, cronExpression, TimeZoneInfo.Local);
}
}

public class PreCheckResponseModel
{
public string CanRun { get; set; }
}

筆者參考的文章中的設計,加上筆者情境所需融合過,會比較複雜一點,讓筆者娓娓道來。其中

  • PerformJobTasks: 宣告成abstract,用意就是真正的上層Job會實作的Method
  • List<SchedulerLog>:主要開放上層Job在執行上若想記錄執行過程則新增到該變數中,由Base Timer Job於Finally時統一Insert到資料庫去
  • schedulerLogListLock :因為會有一些非同步作業,使得Insert時有可能被變動,因此使用lock參數lock作業
  • Schedule Method:拆解設定欄位,最後使用AddOrUpdate做新增、更新排程作業
  • PreCheckResponseModel:筆者這邊環境的情境,很多都是依照BusinessDate做事情,無法使用排程於星期一到五才執行這種設定方式,因為台灣環境比較特殊,有補班、臨休、颱風假等,由一個欄位去定義其PreCheck的需求,筆者這邊就制定回傳結構為PreCheckResponseModel,利用屬性CanRun來判斷是否要執行其排程主要邏輯。因此排程設定上面會單純一點,排程設定為每一天都會跑,經由PreCheckSP做一個要不要跑排程的控制點。

Job種類規劃

筆者這邊事前統計過各式各樣的排程執行邏輯,可以整理為以下類別

  • SP執行類: 可以進階設定是否要將SP執行結果打包成檔案上傳至指定路徑
  • Bat檔案執行類: 主要是因為與其他系統串接,主要的前台部分,需要由執行期bat檔案做驅動
  • 下載類: 主要與其他系統交換檔案,由檔案內容塞到固定的資料表中,透過一支SP做後置處理
  • Normal類: 有一些排程無法歸納為上述三種,即便前面執行其中一項,但後續有額外的處理,筆者這邊就不另外規劃做處理,保留其彈性

當然要看各位的排程執行情境去做一個歸納,上述只是依照筆者這邊遇到的情境做一個統整規劃設計,筆者這邊就點到這邊為止,寫下一篇來做一個進一步介紹其運作內容,不然這篇要爆了。

Job Initial流程設計

這篇的主要關鍵在這個部份,分為兩個部份,筆者習慣拆為API端及Service端

API端設計

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class JobInitialController : SchedulerBaseController
{
private readonly IJobInitialService _jobInitialService;
public JobInitialController(IJobInitialService jobInitialService)
{
_jobInitialService = jobInitialService;
}
/// <summary>
/// 批次初始化
/// </summary>
/// <returns></returns>
[ProducesResponseType(typeof(Result), StatusCodes.Status200OK)]
[HttpGet("")]
public IActionResult Get()
{
_jobInitialService.InitialProcess();
return SKBResponse(new Result().Success());
}
}

Service端設計

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
public class JobInitialService : IJobInitialService
{
private readonly ILoggerManager _logger;
public static readonly string _assemblyName = "{Assembly Namespace}";
private readonly string _connStr;
private readonly IServiceProvider _serviceProvider;
public JobInitialService(IConfiguration config
, IServiceProvider serviceProvider, ILoggerManager logger)
{
_connStr = Encoding.UTF8.GetString(Convert.FromBase64String(config.GetConnectionString("FMTR")));
_serviceProvider = serviceProvider;
_logger = logger;
}
public void InitialProcess()
{
var batchJobs = new List<CommCode>();
// 先撈出設定檔案,筆者這邊CodeType定義為Schdeuler
using (var _conn = new SqlConnection(_connStr))
{
batchJobs = _conn.Query<CommCode>(@"SELECT * FROM CommCode WHERE CodeType = @CodeType AND ISNULL(CodeVal1, '') <> ''"
, new { CodeType = "Scheduler" }).ToList();
}

foreach (var job in batchJobs)
{
var className = "";
var jobType = default(Type);
var jobInfos = job.CodeName.Split(";");
(string jobId, string jobClassName) jobInfo = ValueTuple.Create(jobInfos[0], jobInfos[1]);
switch (job.CodeTypeName)
{
case "Normal":
className = $"{_assemblyName}.{jobInfo.jobClassName}";
jobType = Type.GetType(className);
break;
case "NXBat":
className = $"{_assemblyName}.NXBatJob";
jobType = Type.GetType(className);
break;
case "SP":
className = $"{_assemblyName}.SPExecuteJob";
jobType = Type.GetType(className);
break;
case "Download":
className = $"{_assemblyName}.DownloadJob";
jobType = Type.GetType(className);
break;
default:
break;
}

_logger.LogInfo($"[Current Job] {JsonConvert.SerializeObject(job)}");
var jobInstance = _serviceProvider.GetService(jobType) as ITimerJob<CommCode>;
jobInstance.Schedule(job.CodeVal1, new List<CommCode>() { job });
}
}
}

最主要為最後兩行,透過ServiceProvider取得的實體做一個轉型為ITimerJob,藉此可以點出Schdeule Method,就會執行Hangfire RecurringJob的AddOrUpdate,就會排進Hangfire的排程宣告中。

結論

這篇就以動態排程設計概念為主做一個闡述,下篇會著重在筆者設計的Job種類得實作及註冊宣告使用方式,敬請期待。

參考