前情提要
排程神器-Hangfire
兩篇文章後,這篇來到重頭戲了,筆者主要參考TimerJob這幾篇gist文件,加以整理改造成符合筆者工作環境中的情境。原始需求是這樣,筆者所在環境,主管們因維運考量,一切以資料庫設定為準,爾後有臨時變更需要時,調動一下資料庫設定即可,一切設計以這個原則為最大考量達成目的,當然排程這件事也不例外了,主要考量有以下幾點
- 臨時調動執行時間
- 若排定時間執行時有出錯,是否可以手動立即執行
- 可能以某一個排程為基底,長出一個新的排程
等等,是個一大挑戰阿。
內容
筆者以前提情要提到的幾點去做一個設計過程的解說,以第一點「臨時調動執行時間」來說,應該要有一支API,執行後達到Job Initial
效果,因為需要臨時調動,須將排程設定放置於資料表中,經由Job Initial
這個作業,透過Hangfire
的AddOrUpdate
,因為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 { 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; }
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 { 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]); 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
來判斷是否要執行其排程主要邏輯。因此排程設定上面會單純一點,排程設定為每一天都會跑,經由PreCheck
的SP
做一個要不要跑排程的控制點。
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; } [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>(); 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種類得實作及註冊宣告使用方式,敬請期待。
參考