From 3805d2b6451c91a70ed54031c969a069b815b32d Mon Sep 17 00:00:00 2001 From: Falcon <12919280+falconfly@user.noreply.gitee.com> Date: Wed, 19 Feb 2025 15:57:11 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E6=88=90CronExpression=E5=B9=B6?= =?UTF-8?q?=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Falcon.SugarApi.Test/CronExpressionTests.cs | 90 +++++++ .../TimedBackgroundTask/CronExpression.cs | 235 ++++++++++++++++++ .../TimedBackgroundTask/CronResult.cs | 111 +++++++++ .../TimedBackgroundTask/TimePartEnum.cs | 56 +++++ 4 files changed, 492 insertions(+) create mode 100644 Falcon.SugarApi.Test/CronExpressionTests.cs create mode 100644 Falcon.SugarApi/TimedBackgroundTask/CronExpression.cs create mode 100644 Falcon.SugarApi/TimedBackgroundTask/CronResult.cs create mode 100644 Falcon.SugarApi/TimedBackgroundTask/TimePartEnum.cs diff --git a/Falcon.SugarApi.Test/CronExpressionTests.cs b/Falcon.SugarApi.Test/CronExpressionTests.cs new file mode 100644 index 0000000..068ff5f --- /dev/null +++ b/Falcon.SugarApi.Test/CronExpressionTests.cs @@ -0,0 +1,90 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; + +namespace Falcon.SugarApi.Test +{ + [TestClass()] + public class CronExpressionTests + { + [TestMethod] + public void CronExpressionTest() { + var now20250213102512 = new DateTime(2025,2,13,10,25,12); + + var cron = new CronExpression("0 30 3 * * 1-5"); // 工作日凌晨3:30:00 + var nextTime = cron.GetNextOccurrence(now20250213102512); + if(nextTime == null) { + Assert.Fail("nextTime is null!"); + return; + } + Assert.IsTrue(DateToString(nextTime) == "20250214033000"); + cron = new CronExpression("");//下一秒触发 + nextTime = cron.GetNextOccurrence(now20250213102512); + Assert.IsTrue(DateToString(nextTime) == "20250213102513"); + nextTime = cron.GetNextOccurrence(nextTime); + Assert.IsTrue(DateToString(nextTime) == "20250213102514",nextTime.ToString()); + + nextTime = cron.GetNextOccurrence(nextTime); + Assert.IsTrue(DateToString(nextTime) == "20250213102515",nextTime.ToString()); + + nextTime = cron.GetNextOccurrence(nextTime); + Assert.IsTrue(DateToString(nextTime) == "20250213102516",nextTime.ToString()); + + cron = new CronExpression("10");//每分钟10秒时触发 + nextTime = cron.GetNextOccurrence(now20250213102512); + Assert.IsTrue(DateToString(nextTime) == "20250213102610"); + + cron = new CronExpression("10 25");//每个小时的25分10秒触发 + nextTime = cron.GetNextOccurrence(now20250213102512); + Assert.IsTrue(DateToString(nextTime) == "20250213112510"); + + cron = new CronExpression("10 25");//每个小时的25分10秒触发 + nextTime = cron.GetNextOccurrence(now20250213102512); + Assert.IsTrue(DateToString(nextTime) == "20250213112510"); + + cron = new CronExpression("10/10 25");//每个小时的25分10秒触发,只有每10秒触发一次 + nextTime = cron.GetNextOccurrence(now20250213102512); + Assert.IsTrue(DateToString(nextTime) == "20250213102520"); + nextTime = cron.GetNextOccurrence(nextTime); + Assert.IsTrue(DateToString(nextTime) == "20250213102530"); + + cron = new CronExpression("0 0 3-5");//每天凌晨3点到5点每小时触发一次 + nextTime = cron.GetNextOccurrence(now20250213102512); + Assert.IsTrue(DateToString(nextTime) == "20250214030000"); + nextTime = cron.GetNextOccurrence(nextTime); + Assert.IsTrue(DateToString(nextTime) == "20250214040000"); + nextTime = cron.GetNextOccurrence(nextTime); + Assert.IsTrue(DateToString(nextTime) == "20250214050000"); + nextTime = cron.GetNextOccurrence(nextTime); + Assert.IsTrue(DateToString(nextTime) == "20250215030000"); + + cron = new CronExpression("0 0 3-5 * * 1-5");//每个工作日凌晨3点到5点每小时触发一次 + nextTime = cron.GetNextOccurrence(now20250213102512); + Assert.IsTrue(DateToString(nextTime) == "20250214030000"); + nextTime = cron.GetNextOccurrence(nextTime); + Assert.IsTrue(DateToString(nextTime) == "20250214040000"); + nextTime = cron.GetNextOccurrence(nextTime); + Assert.IsTrue(DateToString(nextTime) == "20250214050000"); + nextTime = cron.GetNextOccurrence(nextTime); + Assert.IsTrue(DateToString(nextTime) == "20250217030000",DateToString(nextTime)); + + cron = new CronExpression("0 0/30 3-5 * * 1-5");//每个工作日凌晨3点到5点每小时触发两次,平均每半小时一次 + nextTime = cron.GetNextOccurrence(now20250213102512); + Assert.IsTrue(DateToString(nextTime) == "20250214030000",nextTime.ToString()); + nextTime = cron.GetNextOccurrence(nextTime); + Assert.IsTrue(DateToString(nextTime) == "20250214033000"); + nextTime = cron.GetNextOccurrence(nextTime); + Assert.IsTrue(DateToString(nextTime) == "20250214040000"); + nextTime = cron.GetNextOccurrence(nextTime); + Assert.IsTrue(DateToString(nextTime) == "20250214043000"); + nextTime = cron.GetNextOccurrence(nextTime); + Assert.IsTrue(DateToString(nextTime) == "20250214050000"); + nextTime = cron.GetNextOccurrence(nextTime); + Assert.IsTrue(DateToString(nextTime) == "20250214053000"); + nextTime = cron.GetNextOccurrence(nextTime); + Assert.IsTrue(DateToString(nextTime) == "20250217030000"); + + } + + private string? DateToString(DateTime? date) => date?.ToString("yyyyMMddHHmmss"); + } +} \ No newline at end of file diff --git a/Falcon.SugarApi/TimedBackgroundTask/CronExpression.cs b/Falcon.SugarApi/TimedBackgroundTask/CronExpression.cs new file mode 100644 index 0000000..aa0f2e1 --- /dev/null +++ b/Falcon.SugarApi/TimedBackgroundTask/CronExpression.cs @@ -0,0 +1,235 @@ +using Falcon.SugarApi; +using Falcon.SugarApi.TimedBackgroundTask; +using System; +using System.Collections.Generic; +using System.Linq; + +/// +/// Cron表达式 +/// +public class CronExpression +{ + /// + /// 可选的秒范围枚举 + /// + public List Seconds { get; } + /// + /// 可选的分范围枚举 + /// + public List Minutes { get; } + /// + /// 可选的时范围枚举 + /// + public List Hours { get; } + /// + /// 可选的天范围枚举 + /// + public List DaysOfMonth { get; } + /// + /// 可选的月范围枚举 + /// + public List Months { get; } + /// + /// 可选的星期范围枚举 + /// + public List DaysOfWeek { get; } + /// + /// 可选的年范围枚举 + /// + public List Years { get; set; } + + /// + /// 通过提供cron表达式构造对象 + /// 表达式以空格分隔,按顺序每一段分别为“秒,分,时,日,月,星期,年” + /// 取值范围:秒分时取值范围0-59,日取值范围1-31,月取值范围1-12,星期取值范围0-6,年取值范围两年内 + /// 取值*表示可以匹配任意值,比如*表示每一秒,* *表示所有分秒。 + /// 取值-表示取值范围,比如1-5 3匹配3分1秒到3分5秒所有时间。 + /// 取值/表示周期取值,比如0/10表示0秒开始每10秒匹配一次。 + /// 取值可以用,分割,表示取值枚举,比如0 10,20,30 5 表示匹配每天早上5点10分、5点20分和5点30分。 + /// 表达式右侧的*可以省略。比如0 15 4表示每天上午4点15分,和0 15 4 * * * *相同 + /// 星期取值范围0-6,0表示星期天,1表示星期一,以此类推。 + /// 日取值范围1-31,不用考虑大月小月和二月的特殊情况,方法会自动过滤这些特殊日期 + /// + /// cron表达式 + /// + public CronExpression(string cronExpression) { + var fields = cronExpression.Split(new[] { ' ' },StringSplitOptions.RemoveEmptyEntries); + + Seconds = fields.Length > 0 ? GetRange(fields[0],0,59) : Enumerable.Range(0,60).ToList(); + Minutes = fields.Length > 1 ? GetRange(fields[1],0,59) : Enumerable.Range(0,60).ToList(); + Hours = fields.Length > 2 ? GetRange(fields[2],0,23) : Enumerable.Range(0,23).ToList(); + DaysOfMonth = fields.Length > 3 ? GetRange(fields[3],1,31) : Enumerable.Range(1,31).ToList(); + Months = fields.Length > 4 ? GetRange(fields[4],1,12) : Enumerable.Range(1,12).ToList(); + DaysOfWeek = fields.Length > 5 ? GetRange(fields[5],0,6) : Enumerable.Range(0,7).ToList(); + var nowYear = DateTime.Now.Year; + Years = fields.Length > 6 ? GetRange(fields[6],nowYear,nowYear + 2) : Enumerable.Range(nowYear,2).ToList(); + } + + /// + /// 通过提供的时间获取下一次时间 + /// + /// 上一次的时间 + /// 下一次到达时间 + public DateTime GetNextOccurrence(DateTime afterTime) { + var ct = new CronResult(afterTime.AddSeconds(1)); + while(!ct.IsAllAdjust) { + if(!ct.IsYearAdjust) { + AdjustYear(ct); + } + if(!ct.IsMonthAdjust) { + AdjustMonth(ct); + if(!ct.IsYearAdjust) { + continue; + } + } + if(!ct.IsDayAdjust) { + AdjustDay(ct); + if(!ct.IsYearAdjust || !ct.IsMonthAdjust || !ct.IsDayAdjust) { + continue; + } + } + if(!ct.IsHourAdjust) { + AdjustHour(ct); + if(!ct.IsYearAdjust || !ct.IsMonthAdjust || !ct.IsDayAdjust) { + continue; + } + } + if(!ct.IsMinuteAdjust) { + AdjustMinute(ct); + if(!ct.IsYearAdjust || !ct.IsMonthAdjust || !ct.IsDayAdjust || !ct.IsHourAdjust) { + continue; + } + } + if(!ct.IsSecondAdjust) { + AdjustSecond(ct); + } + } + return ct.AdjustTime; + } + + private void AdjustYear(CronResult date) { + var next = Years.Where(a => a >= date.AdjustTime.Year); + if(next.Any()) { + date.SetAdjustTime(date.AdjustTime.AddYears(next.First() - date.AdjustTime.Year),TimePartEnum.Year); + date.IsYearAdjust = true; + } + else { + date.IsNullVal = true; + date.IsYearAdjust = true; + date.IsMonthAdjust = true; + date.IsDayAdjust = true; + date.IsHourAdjust = true; + date.IsMinuteAdjust = true; + date.IsSecondAdjust = true; + date.AdjustTime = DateTime.MaxValue; + } + } + + private void AdjustMonth(CronResult date) { + var next = Months.Where(m => m >= date.AdjustTime.Month); + if(next.Any()) { + date.SetAdjustTime(date.AdjustTime.AddMonths(next.First() - date.AdjustTime.Month),TimePartEnum.YearMonth); + date.IsMonthAdjust = true; + } + else { + date.SetAdjustTime(date.AdjustTime.AddYears(1),TimePartEnum.YearMonth); + } + } + + private void AdjustDay(CronResult date) { + var dt = date.AdjustTime; + int year = dt.Year; + int month = dt.Month; + int day = dt.Day; + int maxDayInMonth = DateTime.DaysInMonth(year,month); + var next = DaysOfMonth.Where(a => a >= day && a <= maxDayInMonth && DaysOfWeek.Contains((int)dt.DayOfWeek)); + if(next.Any()) { + date.SetAdjustTime(dt.AddDays(next.First() - day),TimePartEnum.YearDay); + date.IsDayAdjust = true; + return; + } + if(!DaysOfWeek.Contains((int)dt.DayOfWeek)) { + date.SetAdjustTime(dt.AddDays(1),TimePartEnum.YearDay); + return; + } + date.SetAdjustTime(dt.AddMonths(1),TimePartEnum.YearMonth); + } + + private void AdjustHour(CronResult date) { + var next = Hours.Where(m => m >= date.AdjustTime.Hour); + if(next.Any()) { + date.SetAdjustTime(date.AdjustTime.AddHours(next.First() - date.AdjustTime.Hour),TimePartEnum.YearHour); + date.IsHourAdjust = true; + return; + } + date.SetAdjustTime(date.AdjustTime.AddDays(1),TimePartEnum.YearHour); + } + + private void AdjustMinute(CronResult date) { + var next = Minutes.Where(m => m >= date.AdjustTime.Minute); + if(next.Any()) { + date.SetAdjustTime(date.AdjustTime.AddMinutes(next.First() - date.AdjustTime.Minute),TimePartEnum.YearMinute); + date.IsMinuteAdjust = true; + return; + } + date.SetAdjustTime(date.AdjustTime.AddHours(1),TimePartEnum.YearMinute); + } + + private void AdjustSecond(CronResult date) { + var next = Seconds.Where(m => m >= date.AdjustTime.Second); + if(next.Any()) { + date.SetAdjustTime(date.AdjustTime.AddSeconds(next.First() - date.AdjustTime.Second),TimePartEnum.YearSecond); + date.IsSecondAdjust = true; + return; + } + date.SetAdjustTime(date.AdjustTime.AddMinutes(1),TimePartEnum.YearSecond); + } + + private static List GetRange(string exp,int min,int max) { + var charList = "0123456789*-/,".ToArray(); + if(exp.ToCharArray().Any(a => a.NotIn(charList))) { + throw new ArgumentOutOfRangeException(nameof(exp)); + } + var list = new List(); + if(exp == "*") { + return Enumerable.Range(min,max - min + 1).ToList(); + } + if(int.TryParse(exp,out int iexp) && iexp.Between(min,max)) { + list.Add(iexp); + return list; + } + if(exp.Contains('-')) { + var g = exp.Split('-',StringSplitOptions.RemoveEmptyEntries); + var i = int.Parse(g[0]); + var x = int.Parse(g[1]); + i = Math.Max(i,min); + x = Math.Min(x,max); + while(i <= x) { + list.Add(i++); + } + return list; + } + if(exp.Contains('/')) { + var g = exp.Split('/',StringSplitOptions.RemoveEmptyEntries); + var f = Math.Max(min,int.Parse(g[0])); + var s = int.Parse(g[1]); + while(f < max) { + list.Add(f); + f += s; + } + return list; + } + if(exp.Contains(',')) { + foreach(var ie in exp.Split(',')) { + if(int.TryParse(ie,out int iei) && iei.Between(min,max)) { + list.Add(iei); + } + else { + throw new ArgumentException("给定的exp表达式错误,逗号分割的每一项都必须是数字",nameof(exp)); + } + } + return list; + } + throw new ArgumentException("提供的cron表达式错误",nameof(exp)); + } +} \ No newline at end of file diff --git a/Falcon.SugarApi/TimedBackgroundTask/CronResult.cs b/Falcon.SugarApi/TimedBackgroundTask/CronResult.cs new file mode 100644 index 0000000..ff7e4fb --- /dev/null +++ b/Falcon.SugarApi/TimedBackgroundTask/CronResult.cs @@ -0,0 +1,111 @@ +using System; + +namespace Falcon.SugarApi.TimedBackgroundTask +{ + public class CronResult + { + public DateTime AdjustTime { get; set; } + public bool IsYearAdjust { get; set; } = false; + public bool IsMonthAdjust { get; set; } = false; + public bool IsDayAdjust { get; set; } = false; + public bool IsHourAdjust { get; set; } = false; + public bool IsMinuteAdjust { get; set; } = false; + public bool IsSecondAdjust { get; set; } = false; + + public bool IsAllAdjust + => (IsYearAdjust && IsMonthAdjust && IsYearAdjust + && IsDayAdjust && IsHourAdjust && IsMinuteAdjust && IsSecondAdjust) || IsNullVal; + + /// + /// 没有匹配到值 + /// + public bool IsNullVal { get; set; } = false; + + public CronResult(DateTime last) => this.AdjustTime = last; + + public void SetAdjustTime(DateTime dt,TimePartEnum part) { + if(!part.HasFlag(TimePartEnum.Year)) { + return; + } + if(dt.Year != this.AdjustTime.Year) { + this.AdjustTime = new DateTime(dt.Year,1,1,0,0,0); + IsYearAdjust = false; + IsMonthAdjust = false; + IsDayAdjust = false; + IsHourAdjust = false; + IsMinuteAdjust = false; + IsSecondAdjust = false; + return; + } + //else { + // IsYearAdjust = true; + //} + if(!part.HasFlag(TimePartEnum.Month)) { + return; + } + if(dt.Month != this.AdjustTime.Month) { + this.AdjustTime = new DateTime(dt.Year,dt.Month,1,0,0,0); + IsMonthAdjust = false; + IsDayAdjust = false; + IsHourAdjust = false; + IsMinuteAdjust = false; + IsSecondAdjust = false; + return; + } + //else { + // IsMonthAdjust = true; + //} + if(!part.HasFlag(TimePartEnum.Day)) { + return; + } + if(dt.Day != this.AdjustTime.Day) { + this.AdjustTime = new DateTime(dt.Year,dt.Month,dt.Day,0,0,0); + IsDayAdjust = false; + IsHourAdjust = false; + IsMinuteAdjust = false; + IsSecondAdjust = false; + return; + } + //else { + // IsDayAdjust = true; + //} + if(!part.HasFlag(TimePartEnum.Hour)) { + return; + } + if(dt.Hour != this.AdjustTime.Hour) { + this.AdjustTime = new DateTime(dt.Year,dt.Month,dt.Day,dt.Hour,0,0); + IsHourAdjust = false; + IsMinuteAdjust = false; + IsSecondAdjust = false; + return; + } + //else { + // IsHourAdjust = true; + //} + if(!part.HasFlag(TimePartEnum.Minute)) { + return; + } + if(dt.Minute != this.AdjustTime.Minute) { + this.AdjustTime = new DateTime(dt.Year,dt.Month,dt.Day,dt.Hour,dt.Minute,0); + IsMinuteAdjust = false; + IsSecondAdjust = false; + return; + } + //else { + // IsMinuteAdjust = true; + //} + if(!part.HasFlag(TimePartEnum.Second)) { + return; + } + if(dt.Second != this.AdjustTime.Second) { + this.AdjustTime = dt; + IsSecondAdjust = false; + return; + } + //else { + // IsSecondAdjust = true; + //} + } + + } +} diff --git a/Falcon.SugarApi/TimedBackgroundTask/TimePartEnum.cs b/Falcon.SugarApi/TimedBackgroundTask/TimePartEnum.cs new file mode 100644 index 0000000..0f2602d --- /dev/null +++ b/Falcon.SugarApi/TimedBackgroundTask/TimePartEnum.cs @@ -0,0 +1,56 @@ +using System; + +namespace Falcon.SugarApi.TimedBackgroundTask +{ + /// + /// 时间结构枚举 + /// + [Flags] + public enum TimePartEnum + { + /// + /// 年 + /// + Year = 1, + /// + /// 月 + /// + Month = 2, + /// + /// 日 + /// + Day = 4, + /// + /// 小时 + /// + Hour = 8, + /// + /// 分 + /// + Minute = 16, + /// + /// 秒 + /// + Second = 32, + /// + /// 年和月 + /// + YearMonth = Year + Month, + /// + /// 年月日 + /// + YearDay = YearMonth + Day, + /// + /// 年月日时 + /// + YearHour = YearDay + Hour, + /// + /// 年月日时分 + /// + YearMinute = YearHour + Minute, + /// + /// 年月入时分秒 + /// + YearSecond = YearMinute + Second, + } +}