常用的定時任務組件有 Quartz.Net 和 Hangfire 兩種,這兩種是使用人數比較多的定時任務組件,個人以前也是使用的 Hangfire ,慢慢的發現自己想要的其實只是一個能夠根據 Cron 表達式來定時執行函數的功能,Quartz.Net 和 Hangfire 雖然都能實現這個目的,但是 ...
常用的定時任務組件有 Quartz.Net 和 Hangfire 兩種,這兩種是使用人數比較多的定時任務組件,個人以前也是使用的 Hangfire ,慢慢的發現自己想要的其實只是一個能夠根據 Cron 表達式來定時執行函數的功能,Quartz.Net 和 Hangfire 雖然都能實現這個目的,但是他們都只用來實現 Cron表達式解析定時執行函數就顯得太笨重了,所以想著以 解析 Cron表達式定期執行函數為目的,編寫了下麵的一套邏輯。
首先為瞭解析 Cron表達式,我們需要一個CronHelper ,代碼如下
using System.Globalization; using System.Text; using System.Text.RegularExpressions; namespace Common { public class CronHelper { /// <summary> /// 獲取當前時間之後下一次觸發時間 /// </summary> /// <param name="cronExpression"></param> /// <returns></returns> public static DateTimeOffset GetNextOccurrence(string cronExpression) { return GetNextOccurrence(cronExpression, DateTimeOffset.UtcNow); } /// <summary> /// 獲取給定時間之後下一次觸發時間 /// </summary> /// <param name="cronExpression"></param> /// <param name="afterTimeUtc"></param> /// <returns></returns> public static DateTimeOffset GetNextOccurrence(string cronExpression, DateTimeOffset afterTimeUtc) { return new CronExpression(cronExpression).GetTimeAfter(afterTimeUtc)!.Value; } /// <summary> /// 獲取當前時間之後N次觸發時間 /// </summary> /// <param name="cronExpression"></param> /// <param name="count"></param> /// <returns></returns> public static List<DateTimeOffset> GetNextOccurrences(string cronExpression, int count) { return GetNextOccurrences(cronExpression, DateTimeOffset.UtcNow, count); } /// <summary> /// 獲取給定時間之後N次觸發時間 /// </summary> /// <param name="cronExpression"></param> /// <param name="afterTimeUtc"></param> /// <returns></returns> public static List<DateTimeOffset> GetNextOccurrences(string cronExpression, DateTimeOffset afterTimeUtc, int count) { CronExpression cron = new(cronExpression); List<DateTimeOffset> dateTimeOffsets = new(); for (int i = 0; i < count; i++) { afterTimeUtc = cron.GetTimeAfter(afterTimeUtc)!.Value; dateTimeOffsets.Add(afterTimeUtc); } return dateTimeOffsets; } private class CronExpression { private const int Second = 0; private const int Minute = 1; private const int Hour = 2; private const int DayOfMonth = 3; private const int Month = 4; private const int DayOfWeek = 5; private const int Year = 6; private const int AllSpecInt = 99; private const int NoSpecInt = 98; private const int AllSpec = AllSpecInt; private const int NoSpec = NoSpecInt; private SortedSet<int> seconds = null!; private SortedSet<int> minutes = null!; private SortedSet<int> hours = null!; private SortedSet<int> daysOfMonth = null!; private SortedSet<int> months = null!; private SortedSet<int> daysOfWeek = null!; private SortedSet<int> years = null!; private bool lastdayOfWeek; private int everyNthWeek; private int nthdayOfWeek; private bool lastdayOfMonth; private bool nearestWeekday; private int lastdayOffset; private static readonly Dictionary<string, int> monthMap = new Dictionary<string, int>(20); private static readonly Dictionary<string, int> dayMap = new Dictionary<string, int>(60); private static readonly int MaxYear = DateTime.Now.Year + 100; private static readonly char[] splitSeparators = { ' ', '\t', '\r', '\n' }; private static readonly char[] commaSeparator = { ',' }; private static readonly Regex regex = new Regex("^L-[0-9]*[W]?", RegexOptions.Compiled); private static readonly TimeZoneInfo timeZoneInfo = TimeZoneInfo.Local; public CronExpression(string cronExpression) { if (monthMap.Count == 0) { monthMap.Add("JAN", 0); monthMap.Add("FEB", 1); monthMap.Add("MAR", 2); monthMap.Add("APR", 3); monthMap.Add("MAY", 4); monthMap.Add("JUN", 5); monthMap.Add("JUL", 6); monthMap.Add("AUG", 7); monthMap.Add("SEP", 8); monthMap.Add("OCT", 9); monthMap.Add("NOV", 10); monthMap.Add("DEC", 11); dayMap.Add("SUN", 1); dayMap.Add("MON", 2); dayMap.Add("TUE", 3); dayMap.Add("WED", 4); dayMap.Add("THU", 5); dayMap.Add("FRI", 6); dayMap.Add("SAT", 7); } if (cronExpression == null) { throw new ArgumentException("cronExpression 不能為空"); } CronExpressionString = CultureInfo.InvariantCulture.TextInfo.ToUpper(cronExpression); BuildExpression(CronExpressionString); } /// <summary> /// 構建表達式 /// </summary> /// <param name="expression"></param> /// <exception cref="FormatException"></exception> private void BuildExpression(string expression) { try { seconds ??= new SortedSet<int>(); minutes ??= new SortedSet<int>(); hours ??= new SortedSet<int>(); daysOfMonth ??= new SortedSet<int>(); months ??= new SortedSet<int>(); daysOfWeek ??= new SortedSet<int>(); years ??= new SortedSet<int>(); int exprOn = Second; string[] exprsTok = expression.Split(splitSeparators, StringSplitOptions.RemoveEmptyEntries); foreach (string exprTok in exprsTok) { string expr = exprTok.Trim(); if (expr.Length == 0) { continue; } if (exprOn > Year) { break; } if (exprOn == DayOfMonth && expr.IndexOf('L') != -1 && expr.Length > 1 && expr.IndexOf(",", StringComparison.Ordinal) >= 0) { throw new FormatException("不支持在月份的其他日期指定“L”和“LW”"); } if (exprOn == DayOfWeek && expr.IndexOf('L') != -1 && expr.Length > 1 && expr.IndexOf(",", StringComparison.Ordinal) >= 0) { throw new FormatException("不支持在一周的其他日期指定“L”"); } if (exprOn == DayOfWeek && expr.IndexOf('#') != -1 && expr.IndexOf('#', expr.IndexOf('#') + 1) != -1) { throw new FormatException("不支持指定多個“第N”天。"); } string[] vTok = expr.Split(commaSeparator); foreach (string v in vTok) { StoreExpressionVals(0, v, exprOn); } exprOn++; } if (exprOn <= DayOfWeek) { throw new FormatException("表達式意料之外的結束。"); } if (exprOn <= Year) { StoreExpressionVals(0, "*", Year); } var dow = GetSet(DayOfWeek); var dom = GetSet(DayOfMonth); bool dayOfMSpec = !dom.Contains(NoSpec); bool dayOfWSpec = !dow.Contains(NoSpec); if (dayOfMSpec && !dayOfWSpec) { // skip } else if (dayOfWSpec && !dayOfMSpec) { // skip } else { throw new FormatException("不支持同時指定星期和日參數。"); } } catch (FormatException) { throw; } catch (Exception e) { throw new FormatException($"非法的 cron 表達式格式 ({e.Message})", e); } } /// <summary> /// Stores the expression values. /// </summary> /// <param name="pos">The position.</param> /// <param name="s">The string to traverse.</param> /// <param name="type">The type of value.</param> /// <returns></returns> private int StoreExpressionVals(int pos, string s, int type) { int incr = 0; int i = SkipWhiteSpace(pos, s); if (i >= s.Length) { return i; } char c = s[i]; if (c >= 'A' && c <= 'Z' && !s.Equals("L") && !s.Equals("LW") && !regex.IsMatch(s)) { string sub = s.Substring(i, 3); int sval; int eval = -1; if (type == Month) { sval = GetMonthNumber(sub) + 1; if (sval <= 0) { throw new FormatException($"無效的月份值:'{sub}'"); } if (s.Length > i + 3) { c = s[i + 3]; if (c == '-') { i += 4; sub = s.Substring(i, 3); eval = GetMonthNumber(sub) + 1; if (eval <= 0) { throw new FormatException( $"無效的月份值: '{sub}'"); } } } } else if (type == DayOfWeek) { sval = GetDayOfWeekNumber(sub); if (sval < 0) { throw new FormatException($"無效的星期幾值: '{sub}'"); } if (s.Length > i + 3) { c = s[i + 3]; if (c == '-') { i += 4; sub = s.Substring(i, 3); eval = GetDayOfWeekNumber(sub); if (eval < 0) { throw new FormatException( $"無效的星期幾值: '{sub}'"); } } else if (c == '#') { try { i += 4; nthdayOfWeek = Convert.ToInt32(s.Substring(i), CultureInfo.InvariantCulture); if (nthdayOfWeek is < 1 or > 5) { throw new FormatException("周的第n天小於1或大於5"); } } catch (Exception) { throw new FormatException("1 到 5 之間的數值必須跟在“#”選項後面"); } } else if (c == '/') { try { i += 4; everyNthWeek = Convert.ToInt32(s.Substring(i), CultureInfo.InvariantCulture); if (everyNthWeek is < 1 or > 5) { throw new FormatException("每個星期<1或>5"); } } catch (Exception) { throw new FormatException("1 到 5 之間的數值必須跟在 '/' 選項後面"); } } else if (c == 'L') { lastdayOfWeek = true; i++; } else { throw new FormatException($"此位置的非法字元:'{sub}'"); } } } else { throw new FormatException($"此位置的非法字元:'{sub}'"); } if (eval != -1) { incr = 1; } AddToSet(sval, eval, incr, type); return i + 3; } if (c == '?') { i++; if (i + 1 < s.Length && s[i] != ' ' && s[i + 1] != '\t') { throw new FormatException("'?' 後的非法字元: " + s[i]); } if (type != DayOfWeek && type != DayOfMonth) { throw new FormatException( "'?' 只能為月日或周日指定。"); } if (type == DayOfWeek && !lastdayOfMonth) { int val = daysOfMonth.LastOrDefault(); if (val == NoSpecInt) { throw new FormatException( "'?' 只能為月日或周日指定。"); } } AddToSet(NoSpecInt, -1, 0, type); return i; } var startsWithAsterisk = c == '*'; if (startsWithAsterisk || c == '/') { if (startsWithAsterisk && i + 1 >= s.Length) { AddToSet(AllSpecInt, -1, incr, type); return i + 1; } if (c == '/' && (i + 1 >= s.Length || s[i + 1] == ' ' || s[i + 1] == '\t')) { throw new FormatException("'/' 後面必須跟一個整數。"); } if (startsWithAsterisk) { i++; } c = s[i]; if (c == '/') { // is an increment specified? i++; if (i >= s.Length) { throw new FormatException("字元串意外結束。"); } incr = GetNumericValue(s, i); i++; if (incr > 10) { i++; } CheckIncrementRange(incr, type); } else { if (startsWithAsterisk) { throw new FormatException("星號後的非法字元:" + s); } incr = 1; } AddToSet(AllSpecInt, -1, incr, type); return i; } if (c == 'L') { i++; if (type == DayOfMonth) { lastdayOfMonth = true; } if (type == DayOfWeek) { AddToSet(7, 7, 0, type); } if (type == DayOfMonth && s.Length > i) { c = s[i]; if (c == '-') { ValueSet vs = GetValue(0, s, i + 1); lastdayOffset = vs.theValue; if (lastdayOffset > 30) { throw new FormatException("與最後一天的偏移量必須 <= 30"); } i = vs.pos; } if (s.Length > i) { c = s[i]; if (c == 'W') { nearestWeekday = true; i++; } } } return i; } if (c >= '0' && c <= '9') { int val = Convert.ToInt32(c.ToString(), CultureInfo.InvariantCulture); i++; if (i >= s.Length) { AddToSet(val, -1, -1, type); } else { c = s[i]; if (c >= '0