.NET 纯原生实现 Cron 定时任务执行,未依赖第三方组件

前言

常用的定时任务组件有 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' && c <= '9')
                        {
                            ValueSet vs = GetValue(val, s, i);
                            val = vs.theValue;
                            i = vs.pos;
                        }
                        i = CheckNext(i, s, val, type);
                        return i;
                    }
                }
                else
                {
                    throw new FormatException($"意外字符:{c}");
                }

                return i;
            }



            // ReSharper disable once UnusedParameter.Local
            private static void CheckIncrementRange(int incr, int type)
            {
                if (incr > 59 && (type == Second || type == Minute))
                {
                    throw new FormatException($"增量 > 60 : {incr}");
                }
                if (incr > 23 && type == Hour)
                {
                    throw new FormatException($"增量 > 24 : {incr}");
                }
                if (incr > 31 && type == DayOfMonth)
                {
                    throw new FormatException($"增量 > 31 : {incr}");
                }
                if (incr > 7 && type == DayOfWeek)
                {
                    throw new FormatException($"增量 > 7 : {incr}");
                }
                if (incr > 12 && type == Month)
                {
                    throw new FormatException($"增量 > 12 : {incr}");
                }
            }

            /// <summary>
            /// Checks the next value.
            /// </summary>
            /// <param name="pos">The position.</param>
            /// <param name="s">The string to check.</param>
            /// <param name="val">The value.</param>
            /// <param name="type">The type to search.</param>
            /// <returns></returns>
            private int CheckNext(int pos, string s, int val, int type)
            {
                int end = -1;
                int i = pos;

                if (i >= s.Length)
                {
                    AddToSet(val, end, -1, type);
                    return i;
                }

                char c = s[pos];

                if (c == 'L')
                {
                    if (type == DayOfWeek)
                    {
                        if (val < 1 || val > 7)
                        {
                            throw new FormatException("星期日值必须介于1和7之间");
                        }
                        lastdayOfWeek = true;
                    }
                    else
                    {
                        throw new FormatException($"'L' 选项在这里无效。(位置={i})");
                    }
                    var data = GetSet(type);
                    data.Add(val);
                    i++;
                    return i;
                }

                if (c == 'W')
                {
                    if (type == DayOfMonth)
                    {
                        nearestWeekday = true;
                    }
                    else
                    {
                        throw new FormatException($"'W' 选项在这里无效。 (位置={i})");
                    }
                    if (val > 31)
                    {
                        throw new FormatException("'W' 选项对于大于 31 的值(一个月中的最大天数)没有意义");
                    }

                    var data = GetSet(type);
                    data.Add(val);
                    i++;
                    return i;
                }

                if (c == '#')
                {
                    if (type != DayOfWeek)
                    {
                        throw new FormatException($"'#' 选项在这里无效。 (位置={i})");
                    }
                    i++;
                    try
                    {
                        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 之间的数值必须跟在“#”选项后面");
                    }

                    var data = GetSet(type);
                    data.Add(val);
                    i++;
                    return i;
                }

                if (c == 'C')
                {
                    if (type == DayOfWeek)
                    {

                    }
                    else if (type == DayOfMonth)
                    {

                    }
                    else
                    {
                        throw new FormatException($"'C' 选项在这里无效。(位置={i})");
                    }
                    var data = GetSet(type);
                    data.Add(val);
                    i++;
                    return i;
                }

                if (c == '-')
                {
                    i++;
                    c = s[i];
                    int v = Convert.ToInt32(c.ToString(), CultureInfo.InvariantCulture);
                    end = v;
                    i++;
                    if (i >= s.Length)
                    {
                        AddToSet(val, end, 1, type);
                        return i;
                    }
                    c = s[i];
                    if (c >= '0' && c <= '9')
                    {
                        ValueSet vs = GetValue(v, s, i);
                        int v1 = vs.theValue;
                        end = v1;
                        i = vs.pos;
                    }
                    if (i < s.Length && s[i] == '/')
                    {
                        i++;
                        c = s[i];
                        int v2 = Convert.ToInt32(c.ToString(), CultureInfo.InvariantCulture);
                        i++;
                        if (i >= s.Length)
                        {
                            AddToSet(val, end, v2, type);
                            return i;
                        }
                        c = s[i];
                        if (c >= '0' && c <= '9')
                        {
                            ValueSet vs = GetValue(v2, s, i);
                            int v3 = vs.theValue;
                            AddToSet(val, end, v3, type);
                            i = vs.pos;
                            return i;
                        }
                        AddToSet(val, end, v2, type);
                        return i;
                    }
                    AddToSet(val, end, 1, type);
                    return i;
                }

                if (c == '/')
                {
                    if (i + 1 >= s.Length || s[i + 1] == ' ' || s[i + 1] == '\t')
                    {
                        throw new FormatException("\'/\' 后面必须跟一个整数。");
                    }

                    i++;
                    c = s[i];
                    int v2 = Convert.ToInt32(c.ToString(), CultureInfo.InvariantCulture);
                    i++;
                    if (i >= s.Length)
                    {
                        CheckIncrementRange(v2, type);
                        AddToSet(val, end, v2, type);
                        return i;
                    }
                    c = s[i];
                    if (c >= '0' && c <= '9')
                    {
                        ValueSet vs = GetValue(v2, s, i);
                        int v3 = vs.theValue;
                        CheckIncrementRange(v3, type);
                        AddToSet(val, end, v3, type);
                        i = vs.pos;
                        return i;
                    }
                    throw new FormatException($"意外的字符 '{c}' 后 '/'");
                }

                AddToSet(val, end, 0, type);
                i++;
                return i;
            }



            /// <summary>
            /// Gets the cron expression string.
            /// </summary>
            /// <value>The cron expression string.</value>
            private static string CronExpressionString;
            /// <summary>
            /// Skips the white space.
            /// </summary>
            /// <param name="i">The i.</param>
            /// <param name="s">The s.</param>
            /// <returns></returns>
            private static int SkipWhiteSpace(int i, string s)
            {
                for (; i < s.Length && (s[i] == ' ' || s[i] == '\t'); i++)
                {
                }

                return i;
            }

            /// <summary>
            /// Finds the next white space.
            /// </summary>
            /// <param name="i">The i.</param>
            /// <param name="s">The s.</param>
            /// <returns></returns>
            private static int FindNextWhiteSpace(int i, string s)
            {
                for (; i < s.Length && (s[i] != ' ' || s[i] != '\t'); i++)
                {
                }

                return i;
            }



            /// <summary>
            /// Adds to set.
            /// </summary>
            /// <param name="val">The val.</param>
            /// <param name="end">The end.</param>
            /// <param name="incr">The incr.</param>
            /// <param name="type">The type.</param>
            private void AddToSet(int val, int end, int incr, int type)
            {
                var data = GetSet(type);

                if (type == Second || type == Minute)
                {
                    if ((val < 0 || val > 59 || end > 59) && val != AllSpecInt)
                    {
                        throw new FormatException("分钟和秒值必须介于0和59之间");
                    }
                }
                else if (type == Hour)
                {
                    if ((val < 0 || val > 23 || end > 23) && val != AllSpecInt)
                    {
                        throw new FormatException("小时值必须介于0和23之间");
                    }
                }
                else if (type == DayOfMonth)
                {
                    if ((val < 1 || val > 31 || end > 31) && val != AllSpecInt
                        && val != NoSpecInt)
                    {
                        throw new FormatException("月日值必须介于1和31之间");
                    }
                }
                else if (type == Month)
                {
                    if ((val < 1 || val > 12 || end > 12) && val != AllSpecInt)
                    {
                        throw new FormatException("月份值必须介于1和12之间");
                    }
                }
                else if (type == DayOfWeek)
                {
                    if ((val == 0 || val > 7 || end > 7) && val != AllSpecInt
                        && val != NoSpecInt)
                    {
                        throw new FormatException("星期日值必须介于1和7之间");
                    }
                }

                if ((incr == 0 || incr == -1) && val != AllSpecInt)
                {
                    if (val != -1)
                    {
                        data.Add(val);
                    }
                    else
                    {
                        data.Add(NoSpec);
                    }
                    return;
                }

                int startAt = val;
                int stopAt = end;

                if (val == AllSpecInt && incr <= 0)
                {
                    incr = 1;
                    data.Add(AllSpec);
                }

                if (type == Second || type == Minute)
                {
                    if (stopAt == -1)
                    {
                        stopAt = 59;
                    }
                    if (startAt == -1 || startAt == AllSpecInt)
                    {
                        startAt = 0;
                    }
                }
                else if (type == Hour)
                {
                    if (stopAt == -1)
                    {
                        stopAt = 23;
                    }
                    if (startAt == -1 || startAt == AllSpecInt)
                    {
                        startAt = 0;
                    }
                }
                else if (type == DayOfMonth)
                {
                    if (stopAt == -1)
                    {
                        stopAt = 31;
                    }
                    if (startAt == -1 || startAt == AllSpecInt)
                    {
                        startAt = 1;
                    }
                }
                else if (type == Month)
                {
                    if (stopAt == -1)
                    {
                        stopAt = 12;
                    }
                    if (startAt == -1 || startAt == AllSpecInt)
                    {
                        startAt = 1;
                    }
                }
                else if (type == DayOfWeek)
                {
                    if (stopAt == -1)
                    {
                        stopAt = 7;
                    }
                    if (startAt == -1 || startAt == AllSpecInt)
                    {
                        startAt = 1;
                    }
                }
                else if (type == Year)
                {
                    if (stopAt == -1)
                    {
                        stopAt = MaxYear;
                    }
                    if (startAt == -1 || startAt == AllSpecInt)
                    {
                        startAt = 1970;
                    }
                }

                int max = -1;
                if (stopAt < startAt)
                {
                    switch (type)
                    {
                        case Second:
                            max = 60;
                            break;
                        case Minute:
                            max = 60;
                            break;
                        case Hour:
                            max = 24;
                            break;
                        case Month:
                            max = 12;
                            break;
                        case DayOfWeek:
                            max = 7;
                            break;
                        case DayOfMonth:
                            max = 31;
                            break;
                        case Year:
                            throw new ArgumentException("开始年份必须小于停止年份");
                        default:
                            throw new ArgumentException("遇到意外的类型");
                    }
                    stopAt += max;
                }

                for (int i = startAt; i <= stopAt; i += incr)
                {
                    if (max == -1)
                    {
                        data.Add(i);
                    }
                    else
                    {
                        int i2 = i % max;
                        if (i2 == 0 && (type == Month || type == DayOfWeek || type == DayOfMonth))
                        {
                            i2 = max;
                        }

                        data.Add(i2);
                    }
                }
            }
            /// <summary>
            /// Gets the set of given type.
            /// </summary>
            /// <param name="type">The type of set to get.</param>
            /// <returns></returns>
            private SortedSet<int> GetSet(int type)
            {
                switch (type)
                {
                    case Second:
                        return seconds;
                    case Minute:
                        return minutes;
                    case Hour:
                        return hours;
                    case DayOfMonth:
                        return daysOfMonth;
                    case Month:
                        return months;
                    case DayOfWeek:
                        return daysOfWeek;
                    case Year:
                        return years;
                    default:
                        throw new ArgumentOutOfRangeException();
                }
            }
            /// <summary>
            /// Gets the value.
            /// </summary>
            /// <param name="v">The v.</param>
            /// <param name="s">The s.</param>
            /// <param name="i">The i.</param>
            /// <returns></returns>
            private static ValueSet GetValue(int v, string s, int i)
            {
                char c = s[i];
                StringBuilder s1 = new StringBuilder(v.ToString(CultureInfo.InvariantCulture));
                while (c >= '0' && c <= '9')
                {
                    s1.Append(c);
                    i++;
                    if (i >= s.Length)
                    {
                        break;
                    }
                    c = s[i];
                }
                ValueSet val = new ValueSet();
                if (i < s.Length)
                {
                    val.pos = i;
                }
                else
                {
                    val.pos = i + 1;
                }
                val.theValue = Convert.ToInt32(s1.ToString(), CultureInfo.InvariantCulture);
                return val;
            }
            /// <summary>
            /// Gets the numeric value from string.
            /// </summary>
            /// <param name="s">The string to parse from.</param>
            /// <param name="i">The i.</param>
            /// <returns></returns>
            private static int GetNumericValue(string s, int i)
            {
                int endOfVal = FindNextWhiteSpace(i, s);
                string val = s.Substring(i, endOfVal - i);
                return Convert.ToInt32(val, CultureInfo.InvariantCulture);
            }



            /// <summary>
            /// Gets the month number.
            /// </summary>
            /// <param name="s">The string to map with.</param>
            /// <returns></returns>
            private static int GetMonthNumber(string s)
            {
                if (monthMap.ContainsKey(s))
                {
                    return monthMap[s];
                }

                return -1;
            }
            /// <summary>
            /// Gets the day of week number.
            /// </summary>
            /// <param name="s">The s.</param>
            /// <returns></returns>
            private static int GetDayOfWeekNumber(string s)
            {
                if (dayMap.ContainsKey(s))
                {
                    return dayMap[s];
                }

                return -1;
            }

            /// <summary>
            /// 在给定时间之后获取下一个触发时间。
            /// </summary>
            /// <param name="afterTimeUtc">开始搜索的 UTC 时间。</param>
            /// <returns></returns>
            public DateTimeOffset? GetTimeAfter(DateTimeOffset afterTimeUtc)
            {
                // 向前移动一秒钟,因为我们正在计算时间*之后*
                afterTimeUtc = afterTimeUtc.AddSeconds(1);

                // CronTrigger 不处理毫秒
                DateTimeOffset d = CreateDateTimeWithoutMillis(afterTimeUtc);

                // 更改为指定时区
                d = TimeZoneInfo.ConvertTime(d, timeZoneInfo);

                bool gotOne = false;
                //循环直到我们计算出下一次,或者我们已经过了 endTime
                while (!gotOne)
                {
                    SortedSet<int> st;
                    int t;
                    int sec = d.Second;

                    st = seconds.GetViewBetween(sec, 9999999);
                    if (st.Count > 0)
                    {
                        sec = st.First();
                    }
                    else
                    {
                        sec = seconds.First();
                        d = d.AddMinutes(1);
                    }
                    d = new DateTimeOffset(d.Year, d.Month, d.Day, d.Hour, d.Minute, sec, d.Millisecond, d.Offset);

                    int min = d.Minute;
                    int hr = d.Hour;
                    t = -1;

                    st = minutes.GetViewBetween(min, 9999999);
                    if (st.Count > 0)
                    {
                        t = min;
                        min = st.First();
                    }
                    else
                    {
                        min = minutes.First();
                        hr++;
                    }
                    if (min != t)
                    {
                        d = new DateTimeOffset(d.Year, d.Month, d.Day, d.Hour, min, 0, d.Millisecond, d.Offset);
                        d = SetCalendarHour(d, hr);
                        continue;
                    }
                    d = new DateTimeOffset(d.Year, d.Month, d.Day, d.Hour, min, d.Second, d.Millisecond, d.Offset);

                    hr = d.Hour;
                    int day = d.Day;
                    t = -1;

                    st = hours.GetViewBetween(hr, 9999999);
                    if (st.Count > 0)
                    {
                        t = hr;
                        hr = st.First();
                    }
                    else
                    {
                        hr = hours.First();
                        day++;
                    }
                    if (hr != t)
                    {
                        int daysInMonth = DateTime.DaysInMonth(d.Year, d.Month);
                        if (day > daysInMonth)
                        {
                            d = new DateTimeOffset(d.Year, d.Month, daysInMonth, d.Hour, 0, 0, d.Millisecond, d.Offset).AddDays(day - daysInMonth);
                        }
                        else
                        {
                            d = new DateTimeOffset(d.Year, d.Month, day, d.Hour, 0, 0, d.Millisecond, d.Offset);
                        }
                        d = SetCalendarHour(d, hr);
                        continue;
                    }
                    d = new DateTimeOffset(d.Year, d.Month, d.Day, hr, d.Minute, d.Second, d.Millisecond, d.Offset);

                    day = d.Day;
                    int mon = d.Month;
                    t = -1;
                    int tmon = mon;

                    bool dayOfMSpec = !daysOfMonth.Contains(NoSpec);
                    bool dayOfWSpec = !daysOfWeek.Contains(NoSpec);
                    if (dayOfMSpec && !dayOfWSpec)
                    {
                        // 逐月获取规则
                        st = daysOfMonth.GetViewBetween(day, 9999999);
                        bool found = st.Any();
                        if (lastdayOfMonth)
                        {
                            if (!nearestWeekday)
                            {
                                t = day;
                                day = GetLastDayOfMonth(mon, d.Year);
                                day -= lastdayOffset;

                                if (t > day)
                                {
                                    mon++;
                                    if (mon > 12)
                                    {
                                        mon = 1;
                                        tmon = 3333; // 确保下面的 mon != tmon 测试失败
                                        d = d.AddYears(1);
                                    }
                                    day = 1;
                                }
                            }
                            else
                            {
                                t = day;
                                day = GetLastDayOfMonth(mon, d.Year);
                                day -= lastdayOffset;

                                DateTimeOffset tcal = new DateTimeOffset(d.Year, mon, day, 0, 0, 0, d.Offset);

                                int ldom = GetLastDayOfMonth(mon, d.Year);
                                DayOfWeek dow = tcal.DayOfWeek;

                                if (dow == System.DayOfWeek.Saturday && day == 1)
                                {
                                    day += 2;
                                }
                                else if (dow == System.DayOfWeek.Saturday)
                                {
                                    day -= 1;
                                }
                                else if (dow == System.DayOfWeek.Sunday && day == ldom)
                                {
                                    day -= 2;
                                }
                                else if (dow == System.DayOfWeek.Sunday)
                                {
                                    day += 1;
                                }

                                DateTimeOffset nTime = new DateTimeOffset(tcal.Year, mon, day, hr, min, sec, d.Millisecond, d.Offset);
                                if (nTime.ToUniversalTime() < afterTimeUtc)
                                {
                                    day = 1;
                                    mon++;
                                }
                            }
                        }
                        else if (nearestWeekday)
                        {
                            t = day;
                            day = daysOfMonth.First();

                            DateTimeOffset tcal = new DateTimeOffset(d.Year, mon, day, 0, 0, 0, d.Offset);

                            int ldom = GetLastDayOfMonth(mon, d.Year);
                            DayOfWeek dow = tcal.DayOfWeek;

                            if (dow == System.DayOfWeek.Saturday && day == 1)
                            {
                                day += 2;
                            }
                            else if (dow == System.DayOfWeek.Saturday)
                            {
                                day -= 1;
                            }
                            else if (dow == System.DayOfWeek.Sunday && day == ldom)
                            {
                                day -= 2;
                            }
                            else if (dow == System.DayOfWeek.Sunday)
                            {
                                day += 1;
                            }

                            tcal = new DateTimeOffset(tcal.Year, mon, day, hr, min, sec, d.Offset);
                            if (tcal.ToUniversalTime() < afterTimeUtc)
                            {
                                day = daysOfMonth.First();
                                mon++;
                            }
                        }
                        else if (found)
                        {
                            t = day;
                            day = st.First();

                            //确保我们不会在短时间内跑得过快,比如二月
                            int lastDay = GetLastDayOfMonth(mon, d.Year);
                            if (day > lastDay)
                            {
                                day = daysOfMonth.First();
                                mon++;
                            }
                        }
                        else
                        {
                            day = daysOfMonth.First();
                            mon++;
                        }

                        if (day != t || mon != tmon)
                        {
                            if (mon > 12)
                            {
                                d = new DateTimeOffset(d.Year, 12, day, 0, 0, 0, d.Offset).AddMonths(mon - 12);
                            }
                            else
                            {
                                //这是为了避免从一个月移动时出现错误
                                //有 30 或 31 天到一个月更少。 导致实例化无效的日期时间。
                                int lDay = DateTime.DaysInMonth(d.Year, mon);
                                if (day <= lDay)
                                {
                                    d = new DateTimeOffset(d.Year, mon, day, 0, 0, 0, d.Offset);
                                }
                                else
                                {
                                    d = new DateTimeOffset(d.Year, mon, lDay, 0, 0, 0, d.Offset).AddDays(day - lDay);
                                }
                            }
                            continue;
                        }
                    }
                    else if (dayOfWSpec && !dayOfMSpec)
                    {
                        // 获取星期几规则
                        if (lastdayOfWeek)
                        {

                            int dow = daysOfWeek.First();

                            int cDow = (int)d.DayOfWeek + 1;
                            int daysToAdd = 0;
                            if (cDow < dow)
                            {
                                daysToAdd = dow - cDow;
                            }
                            if (cDow > dow)
                            {
                                daysToAdd = dow + (7 - cDow);
                            }

                            int lDay = GetLastDayOfMonth(mon, d.Year);

                            if (day + daysToAdd > lDay)
                            {

                                if (mon == 12)
                                {

                                    d = new DateTimeOffset(d.Year, mon - 11, 1, 0, 0, 0, d.Offset).AddYears(1);
                                }
                                else
                                {
                                    d = new DateTimeOffset(d.Year, mon + 1, 1, 0, 0, 0, d.Offset);
                                }

                                continue;
                            }

                            // 查找本月这一天最后一次出现的日期...
                            while (day + daysToAdd + 7 <= lDay)
                            {
                                daysToAdd += 7;
                            }

                            day += daysToAdd;

                            if (daysToAdd > 0)
                            {
                                d = new DateTimeOffset(d.Year, mon, day, 0, 0, 0, d.Offset);

                                continue;
                            }
                        }
                        else if (nthdayOfWeek != 0)
                        {

                            int dow = daysOfWeek.First();

                            int cDow = (int)d.DayOfWeek + 1;
                            int daysToAdd = 0;
                            if (cDow < dow)
                            {
                                daysToAdd = dow - cDow;
                            }
                            else if (cDow > dow)
                            {
                                daysToAdd = dow + (7 - cDow);
                            }

                            bool dayShifted = daysToAdd > 0;

                            day += daysToAdd;
                            int weekOfMonth = day / 7;
                            if (day % 7 > 0)
                            {
                                weekOfMonth++;
                            }

                            daysToAdd = (nthdayOfWeek - weekOfMonth) * 7;
                            day += daysToAdd;
                            if (daysToAdd < 0 || day > GetLastDayOfMonth(mon, d.Year))
                            {
                                if (mon == 12)
                                {
                                    d = new DateTimeOffset(d.Year, mon - 11, 1, 0, 0, 0, d.Offset).AddYears(1);
                                }
                                else
                                {
                                    d = new DateTimeOffset(d.Year, mon + 1, 1, 0, 0, 0, d.Offset);
                                }

                                continue;
                            }
                            if (daysToAdd > 0 || dayShifted)
                            {
                                d = new DateTimeOffset(d.Year, mon, day, 0, 0, 0, d.Offset);

                                continue;
                            }
                        }
                        else if (everyNthWeek != 0)
                        {
                            int cDow = (int)d.DayOfWeek + 1;
                            int dow = daysOfWeek.First();

                            st = daysOfWeek.GetViewBetween(cDow, 9999999);
                            if (st.Count > 0)
                            {
                                dow = st.First();
                            }

                            int daysToAdd = 0;
                            if (cDow < dow)
                            {
                                daysToAdd = (dow - cDow) + (7 * (everyNthWeek - 1));
                            }
                            if (cDow > dow)
                            {
                                daysToAdd = (dow + (7 - cDow)) + (7 * (everyNthWeek - 1));
                            }


                            if (daysToAdd > 0)
                            {
                                d = new DateTimeOffset(d.Year, mon, day, 0, 0, 0, d.Offset);
                                d = d.AddDays(daysToAdd);
                                continue;
                            }
                        }
                        else
                        {
                            int cDow = (int)d.DayOfWeek + 1;
                            int dow = daysOfWeek.First();

                            st = daysOfWeek.GetViewBetween(cDow, 9999999);
                            if (st.Count > 0)
                            {
                                dow = st.First();
                            }

                            int daysToAdd = 0;
                            if (cDow < dow)
                            {
                                daysToAdd = dow - cDow;
                            }
                            if (cDow > dow)
                            {
                                daysToAdd = dow + (7 - cDow);
                            }

                            int lDay = GetLastDayOfMonth(mon, d.Year);

                            if (day + daysToAdd > lDay)
                            {

                                if (mon == 12)
                                {
                                    d = new DateTimeOffset(d.Year, mon - 11, 1, 0, 0, 0, d.Offset).AddYears(1);
                                }
                                else
                                {
                                    d = new DateTimeOffset(d.Year, mon + 1, 1, 0, 0, 0, d.Offset);
                                }
                                continue;
                            }
                            if (daysToAdd > 0)
                            {
                                d = new DateTimeOffset(d.Year, mon, day + daysToAdd, 0, 0, 0, d.Offset);
                                continue;
                            }
                        }
                    }
                    else
                    {
                        throw new FormatException("不支持同时指定星期日和月日参数。");
                    }

                    d = new DateTimeOffset(d.Year, d.Month, day, d.Hour, d.Minute, d.Second, d.Offset);
                    mon = d.Month;
                    int year = d.Year;
                    t = -1;


                    if (year > MaxYear)
                    {
                        return null;
                    }

                    st = months.GetViewBetween(mon, 9999999);
                    if (st.Count > 0)
                    {
                        t = mon;
                        mon = st.First();
                    }
                    else
                    {
                        mon = months.First();
                        year++;
                    }
                    if (mon != t)
                    {
                        d = new DateTimeOffset(year, mon, 1, 0, 0, 0, d.Offset);
                        continue;
                    }
                    d = new DateTimeOffset(d.Year, mon, d.Day, d.Hour, d.Minute, d.Second, d.Offset);
                    year = d.Year;
                    t = -1;

                    st = years.GetViewBetween(year, 9999999);
                    if (st.Count > 0)
                    {
                        t = year;
                        year = st.First();
                    }
                    else
                    {
                        return null;
                    }

                    if (year != t)
                    {
                        d = new DateTimeOffset(year, 1, 1, 0, 0, 0, d.Offset);
                        continue;
                    }
                    d = new DateTimeOffset(year, d.Month, d.Day, d.Hour, d.Minute, d.Second, d.Offset);

                    //为此日期应用适当的偏移量
                    d = new DateTimeOffset(d.DateTime, timeZoneInfo.BaseUtcOffset);

                    gotOne = true;
                }

                return d.ToUniversalTime();
            }
            /// <summary>
            /// Creates the date time without milliseconds.
            /// </summary>
            /// <param name="time">The time.</param>
            /// <returns></returns>
            private static DateTimeOffset CreateDateTimeWithoutMillis(DateTimeOffset time)
            {
                return new DateTimeOffset(time.Year, time.Month, time.Day, time.Hour, time.Minute, time.Second, time.Offset);
            }



            /// <summary>
            /// Advance the calendar to the particular hour paying particular attention
            /// to daylight saving problems.
            /// </summary>
            /// <param name="date">The date.</param>
            /// <param name="hour">The hour.</param>
            /// <returns></returns>
            private static DateTimeOffset SetCalendarHour(DateTimeOffset date, int hour)
            {

                int hourToSet = hour;
                if (hourToSet == 24)
                {
                    hourToSet = 0;
                }
                DateTimeOffset d = new DateTimeOffset(date.Year, date.Month, date.Day, hourToSet, date.Minute, date.Second, date.Millisecond, date.Offset);
                if (hour == 24)
                {
                    d = d.AddDays(1);
                }
                return d;
            }
            /// <summary>
            /// Gets the last day of month.
            /// </summary>
            /// <param name="monthNum">The month num.</param>
            /// <param name="year">The year.</param>
            /// <returns></returns>
            private static int GetLastDayOfMonth(int monthNum, int year)
            {
                return DateTime.DaysInMonth(year, monthNum);
            }
            private class ValueSet
            {
                public int theValue;

                public int pos;
            }

        }

    }

}

CronHelper 中 CronExpression 的函数计算逻辑是从 Quart.NET 借鉴的,支持标准的 7位 cron 表达式,在需要生成Cron 表达式时可以直接使用网络上的各种 Cron 表达式在线生成

CronHelper 里面我们主要用到的功能就是 通过 Cron 表达式,解析下一次的执行时间。

服务运行这块我们采用微软的 BackgroundService 后台服务,这里还要用到一个后台服务批量注入的逻辑 关于后台逻辑批量注入可以看我之前写的一篇博客,这里就不展开介绍了《.NET 使用自带 DI 批量注入服务(Service)和 后台服务(BackgroundService)》

接下来看一下我这里写的一个DemoTask,代码如下:

using DistributedLock;
using Repository.Database;
using TaskService.Libraries;

namespace TaskService.Tasks
{
    public class DemoTask : BackgroundService
    {

        private readonly IServiceProvider serviceProvider;
        private readonly ILogger logger;



        public DemoTask(IServiceProvider serviceProvider, ILogger<DemoTask> logger)
        {
            this.serviceProvider = serviceProvider;
            this.logger = logger;
        }


        protected override async Task ExecuteAsync(CancellationToken stoppingToken)
        {
            CronSchedule.BatchBuilder(stoppingToken, this);

            await Task.Delay(-1, stoppingToken);
        }

        [CronSchedule(Cron = "0/1 * * * * ?")]
        public void ClearLog()
        {
            try
            {
                using var scope = serviceProvider.CreateScope();
                var db = scope.ServiceProvider.GetRequiredService<DatabaseContext>();

                //省略业务代码
                Console.WriteLine("ClearLog:" + DateTime.Now);
            }
            catch (Exception ex)
            {
                logger.LogError(ex, "DemoTask.ClearLog");
            }
        }
        [CronSchedule(Cron = "0/5 * * * * ?")]
        public void ClearCache()
        {
            try
            {
                using var scope = serviceProvider.CreateScope();
                var db = scope.ServiceProvider.GetRequiredService<DatabaseContext>();
                var distLock = scope.ServiceProvider.GetRequiredService<IDistributedLock>();

                //省略业务代码
                Console.WriteLine("ClearCache:" + DateTime.Now);
            }
            catch (Exception ex)
            {
                logger.LogError(ex, "DemoTask.ClearCache");
            }
        }

    }
}

该Task中有两个方法 ClearLog 和 ClearCache 他们分别会每1秒和每5秒执行一次。需要注意在后台服务中对于 Scope 生命周期的服务在获取是需要手动 CreateScope();

实现的关键点在于 服务执行 ExecuteAsync 中的 CronSchedule.BatchBuilder(stoppingToken, this); 我们这里将代码有 CronSchedule 标记头的方法全部循环进行了启动,该方法的代码如下:

using Common;
using System.Reflection;

namespace TaskService.Libraries
{
    public class CronSchedule
    {
     public static void BatchBuilder(CancellationToken stoppingToken, object context)
        {
            var taskList = context.GetType().GetMethods().Where(t => t.GetCustomAttributes(typeof(CronScheduleAttribute), false).Length > 0).ToList();

            foreach (var t in taskList)
            {
                string cron = t.CustomAttributes.Where(t => t.AttributeType == typeof(CronScheduleAttribute)).FirstOrDefault()!.NamedArguments.Where(t => t.MemberName == "Cron" && t.TypedValue.Value != null).Select(t => t.TypedValue.Value!.ToString()).FirstOrDefault()!;

                Builder(stoppingToken, cron, t, context);
            }
        }



        private static async void Builder(CancellationToken stoppingToken, string cronExpression, MethodInfo action, object context)
        {
            var nextTime = DateTime.Parse(CronHelper.GetNextOccurrence(cronExpression).ToString("yyyy-MM-dd HH:mm:ss"));

            while (!stoppingToken.IsCancellationRequested)
            {
                var nowTime = DateTime.Parse(DateTimeOffset.UtcNow.ToString("yyyy-MM-dd HH:mm:ss"));

                if (nextTime == nowTime)
                {
                    _ = Task.Run(() =>
                    {
                        action.Invoke(context, null);

                    });

                    nextTime = DateTime.Parse(CronHelper.GetNextOccurrence(cronExpression).ToString("yyyy-MM-dd HH:mm:ss"));
                }
                else if (nextTime < nowTime)
                {
                    nextTime = DateTime.Parse(CronHelper.GetNextOccurrence(cronExpression).ToString("yyyy-MM-dd HH:mm:ss"));
                }

                await Task.Delay(1000, stoppingToken);
            }
        }

    }
    [AttributeUsage(AttributeTargets.Method)]
    public class CronScheduleAttribute : Attribute
    {
        public string Cron { get; set; }

    }
}

主要就是利用反射获取当前类中所有带有 CronSchedule 标记的方法,然后解析对应的 Cron 表达式获取下一次的执行时间,如果执行时间等于当前时间则执行一次方法,否则等待1秒钟循环重复这个逻辑。

然后启动我们的项目就可以看到如下的运行效果:

.NET 纯原生实现 Cron 定时任务执行,未依赖第三方组件

ClearLog 每1秒钟执行一次,ClearCache 每 5秒钟执行一次

总结

至此 .NET 纯原生实现 Cron 定时任务执行,未依赖第三方组件 就讲解完了,有任何不明白的,可以在文章下面评论或者私信我,欢迎大家积极的讨论交流。

有兴趣的朋友可以关注我目前在维护的一个 .NET 基础框架项目,项目地址如下:https://github.com/berkerdong/NetEngine.git https://gitee.com/berkerdong/NetEngine.git