美文网首页
java实现对cron表达式解析,spring5.2.x的实现

java实现对cron表达式解析,spring5.2.x的实现

作者: 二哈_8fd0 | 来源:发表于2022-05-14 00:11 被阅读0次

cron表达式大家都知道,今天我们来看一下spring对cron具体是如何结合java中jdk时间的api实现时间计算的。先来分析一下旧版本的spring如何解析
实际也不是旧版本了,就是spring5.2.x(旧) 和 spring5.3.x(新)的实现对比
我们如何找到spring关于cron的实现呢。别忘了 @Scheduled注解可以实现单机版的定时任务,里面有cron属性可以配置


这里取cron使用了

然后很快就找到了这个类 org.springframework.scheduling.support。CronSequenceGenerator 通过观察,实际处理cron就这个一个类

在看这篇文章之前需要百度 cron在线表达式操作一遍不同逻辑cron是如何表达的

  1. 用 " " 空格来间隔不同的时间维度
  2. 用 * 代表所有,就是每小时,没分钟等,就是当前时间维度全部可选
  3. 用 - 代表范围,例如 小时 2-6 代表的意思是2时到6时的范围
  4. 用 / 代表间隔或者增量 例如 小时 2/7 代表的意思从2时开始,增量为6,会从日的2开始,然后9,然后16,然后23,然后到了下一个日当前时周期会继续从2开始,日的维度也是如此,到了下一个月维度会从/左边的值开始
  5. 用 , 标识固定值,例如 时 2,6,7,15 代表每个日的维度下,时只会取到2时,6时,7时,15时
  6. 用L 表示当前周期的最后一个单位,例如时,代表 23时,日则可能取28,29,30,31取决于当前月
  7. 还有工作日的逻辑这里不做详细讨论接下来看源码
  8. 还有 ? 表示 日 和 周互斥只能存在一个
先贴出来源码计算逻辑的大致流程图
计算逻辑

先来看spring5.2.x,一点点跟着源码学习

一切解析的源头CronSequenceGenerator#doParse

构造方法最后都会调用这个 parse

// 先会调用 parse,其实就是通过 " " 空格将表达式解析分割为数组。也就是空格是区分了不同的事件维度的逻辑
    private void parse(String expression) throws IllegalArgumentException {
        String[] fields = StringUtils.tokenizeToStringArray(expression, " ");
        if (!areValidCronFields(fields)) {
            throw new IllegalArgumentException(String.format(
                    "Cron expression must consist of 6 fields (found %d in \"%s\")", fields.length, expression));
        }
        doParse(fields);
    }
    private void doParse(String[] fields) {
// 第一个 表达式是代表秒
        setNumberHits(this.seconds, fields[0], 0, 60);
// 分
        setNumberHits(this.minutes, fields[1], 0, 60);
//时 可以看到 目前设定时的最大值 24
        setNumberHits(this.hours, fields[2], 0, 24);
//日,并且专指 dayOfMonth 就是 1 ~ 31
        setDaysOfMonth(this.daysOfMonth, fields[3]);
// 月
        setMonths(this.months, fields[4]);
// 最后是 周 并对英文月替换为阿拉伯数字,也就是cron理论上也支持使用英文月表达月的逻辑,这里的逻辑和日的逻辑相同,最后也是调用setNumberHits方法,只不过最大值是8,8其实就是bitSet的下表7,也就是周日可以是下标7,或者0
        setDays(this.daysOfWeek, replaceOrdinals(fields[5], "SUN,MON,TUE,WED,THU,FRI,SAT"), 8);
// 这里可以看到  7 和 0 都可以当作 周日,默认是 0
        if (this.daysOfWeek.get(7)) {
            // Sunday can be represented as 0 or 7
            this.daysOfWeek.set(0);
            this.daysOfWeek.clear(7);
        }
    }
//-----------------再看每一个具体的解析----------------
// 解析 秒/分/时的方法 因为秒  分  时 永远不会有变化固定的范围用同一个方法
// 同时我们可以看到BitSet,这个是java的BitMap实现,可以最小化内存提供高速的true/false结果的判断,spring源码使用这个装载每个时间维度的可选选项,也就是允许的值。
private void setNumberHits(BitSet bits, String value, int min, int max) {
              // 直接用 , 进行分割,为什么这么做呢,如果分割了就是固定值,如果没有分割成功还是原来的值,继续往下判断
        String[] fields = StringUtils.delimitedListToStringArray(value, ",");
        for (String field : fields) {
// 如果还包含 / 代表就是间隔或者增量的逻辑
            if (!field.contains("/")) {
// 这个if 进来 其实就是把 * 所有和 - 或者已经被完的 , (被分割完只有一个数字)一起处理下面看这个  getRange方法
                // Not an incrementer so it must be a range (possibly empty)
// 这里如果是 range会返回一个范围,如果是固定值, 分割后会返回数组长度为2 两个相同的数字
                int[] range = getRange(field, min, max);
// 如果固定值bitSet设置 第一个值的位为true,就是可选值,如果范围则就是第一个值到第二个值区间就是可选范围,这里的设计是将, - 固定值和范围值一起计算
                bits.set(range[0], range[1] + 1);
            }
            else {
// 如果是增量逻辑 分割
                String[] split = StringUtils.delimitedListToStringArray(field, "/");
                if (split.length > 2) {
                    throw new IllegalArgumentException("Incrementer has more than two fields: '" +
                            field + "' in expression \"" + this.expression + "\"");
                }
// 第一个值 是增量的起始点,一定可选也是复用了这个方法,可以知道返回[x,x]相同的两个值的数组
                int[] range = getRange(split[0], min, max);
                if (!split[0].contains("-")) {
// 0 0 0-19/5 * * ?  这里的逻辑可以看0-19/5 这段,也就是/ 间隔的左侧代表的可以是从什么时间开始,右边是增量,也可以是左边是当前周期的范围下的增量,如果时 ,则代表0 到 19 时下增量,结果是 0时,5时,10时,15时,然后到下个周期。结合了范围和增量,其实就是如果带有- 在getRange方法内会将数组下标1设置为范围较大值,如果没有 - 直接将范围设置为 当前周期的最大值,为什么-1 因为bitSet的原因,从0开始计算
                    range[1] = max - 1;
                }
                int delta = Integer.parseInt(split[1]);
                if (delta <= 0) {
                    throw new IllegalArgumentException("Incrementer delta must be 1 or higher: '" +
                            field + "' in expression \"" + this.expression + "\"");
                }
// 增量结合范围的计算 放入bitSet作为可选值
                for (int i = range[0]; i <= range[1]; i += delta) {
                    bits.set(i);
                }
            }
        }
    }
//-----------------getRange()------------------------
// 这里处理  -  和 单独的数字 逻辑,如果有 - 返回数组2个下标分别为 - 的左右,如果只有一个数字返回两个相同的数字, 同时如果 * 直接取最大最小值
    private int[] getRange(String field, int min, int max) {
        int[] result = new int[2];
        if (field.contains("*")) {
            result[0] = min;
            result[1] = max - 1;
            return result;
        }
        if (!field.contains("-")) {
            result[0] = result[1] = Integer.parseInt(field);
        }
        else {
            String[] split = StringUtils.delimitedListToStringArray(field, "-");
            if (split.length > 2) {
                throw new IllegalArgumentException("Range has more than two fields: '" +
                        field + "' in expression \"" + this.expression + "\"");
            }
            result[0] = Integer.parseInt(split[0]);
            result[1] = Integer.parseInt(split[1]);
        }
        if (result[0] >= max || result[1] >= max) {
            throw new IllegalArgumentException("Range exceeds maximum (" + max + "): '" +
                    field + "' in expression \"" + this.expression + "\"");
        }
        if (result[0] < min || result[1] < min) {
            throw new IllegalArgumentException("Range less than minimum (" + min + "): '" +
                    field + "' in expression \"" + this.expression + "\"");
        }
        if (result[0] > result[1]) {
            throw new IllegalArgumentException("Invalid inverted range: '" + field +
                    "' in expression \"" + this.expression + "\"");
        }
        return result;
    }

上面看完了 时,分,秒的可选值解析,下面简单看下日,月,周

// 日的解析,日分为两周维度,dayOfMonth 和 dayOfWeek
    private void setDaysOfMonth(BitSet bits, String field) {
        int max = 31;
        // Days of month start with 1 (in Cron and Calendar) so add one
        setDays(bits, field, max + 1);
        //day of month 没有0
        bits.clear(0);
    }
    private void setDays(BitSet bits, String field, int max) {
// week  和 day互斥但是逻辑上? 和 * 一样的
        if (field.contains("?")) {
            field = "*";
        }
// 走了和 时分逻辑相同。。。参照上面的逻辑,这里最小值先设置0后又把0设置为false
        setNumberHits(bits, field, 0, max);
    }
// week逻辑和日相同只不过和 日互斥下面看月
    private void setMonths(BitSet bits, String value) {
        int max = 12;
// 也支持英文的月
        value = replaceOrdinals(value, "FOO,JAN,FEB,MAR,APR,MAY,JUN,JUL,AUG,SEP,OCT,NOV,DEC");
        BitSet months = new BitSet(13);
        // Months start with 1 in Cron and 0 in Calendar, so push the values first into a longer bit set
// 月比较特殊,当前旧版本使用Calendar计算,月是0 ~ 11 这里为了兼容推长一个单位
        setNumberHits(months, value, 1, max + 1);
        // ... and then rotate it to the front of the months
        for (int i = 1; i <= max; i++) {
            if (months.get(i)) {
// 然后进行一次转换,将可选值设置为 0 ~ 11 的范围
                bits.set(i - 1);
            }
        }
    }
真正的计算逻辑 CronSequenceGenerator#next
    public Date next(Date date) {
// 省略一堆注释,意思是cron表达式是从最小的维度 秒开始计算,如果有当前时间维度下的移动则不会计算下一个周期,如果秒计算完了进入下一个周期,再计算下一个周期的通过在线cron测试可见
        Calendar calendar = new GregorianCalendar();
        calendar.setTimeZone(this.timeZone);
        calendar.setTime(date);
        calendar.set(Calendar.MILLISECOND, 0);
// 原始的 时间戳
        long originalTimestamp = calendar.getTimeInMillis();
        doNext(calendar, calendar.get(Calendar.YEAR));
// 如果时间计算后 和之前的时间戳相同,则向后偏移1秒继续计算
        if (calendar.getTimeInMillis() == originalTimestamp) {
            // We arrived at the original timestamp - round up to the next whole second and try again...
            calendar.add(Calendar.SECOND, 1);
            doNext(calendar, calendar.get(Calendar.YEAR));
        }

        return calendar.getTime();
    }
// -------具体计算逻辑--------
    private void doNext(Calendar calendar, int dot) {
// 目前不知道这个list干啥用的
        List<Integer> resets = new ArrayList<>();
// 先计算秒,获取当前秒数
        int second = calendar.get(Calendar.SECOND);
        List<Integer> emptyList = Collections.emptyList();
// 传入秒的可选项,当前秒绝对值,当前时间,当前计算的时间维度,下一个时间维度,一个list,返回一个更新后的值,如果当前时间绝对值没有变化,说明当前值是命中到可选值的,但是需要加入到可能重置的列表中,因为可能 25分15秒,当前15秒是命中到可选值,但是25分不是,或者时不是,那么需要将15秒重置为0重新计算
        int updateSecond = findNext(this.seconds, second, calendar, Calendar.SECOND, Calendar.MINUTE, emptyList);
        if (second == updateSecond) {
// 需要下一个时间维度先变化,那么resets就需要之后再重新计算一次,现在可以理解resets 就是在产生时间维度跨越时,需要回溯计算,也就是回来重新计算的列表,目前这些只是猜测继续往下看
            resets.add(Calendar.SECOND);
        }
// 分逻辑
        int minute = calendar.get(Calendar.MINUTE);
        int updateMinute = findNext(this.minutes, minute, calendar, Calendar.MINUTE, Calendar.HOUR_OF_DAY, resets);
        if (minute == updateMinute) {
            resets.add(Calendar.MINUTE);
        }
        else {
// 这里如果是分已经产生了变化,那么在findNext方法内会对秒进行重置到0秒,这里直接递归调用再从秒开始计算,那么秒会立即被修正
            doNext(calendar, dot);
        }
// 时逻辑
        int hour = calendar.get(Calendar.HOUR_OF_DAY);
        int updateHour = findNext(this.hours, hour, calendar, Calendar.HOUR_OF_DAY, Calendar.DAY_OF_WEEK, resets);
        if (hour == updateHour) {
//需要可能被重置
            resets.add(Calendar.HOUR_OF_DAY);
        }
        else {
//如果时变了,立即再回溯计算 时分,如果本次调用已经是分的偏移导致递归调用看是否命中,那么前两个时间会重新再从0 计算到 第一个可选值,所以递归计算没有什么影响,但是这样是必要的吗,答:是必要的,如果是dayOfMonth/dayOfWeek,那么day的命中受下一个时间维度变化而变化,例如我设置了固定值31,或者周2,那么因为计算从小的时间维度到大的时间维度计算,在5月有31号,或者5月2号是周2,那么月却偏移了,到了6月,那么2号不一定是周2了,31号也不一定有了,一定要重新计算
            doNext(calendar, dot);
        }
// day逻辑需要 week 和 dayOfMonth同时计算
        int dayOfWeek = calendar.get(Calendar.DAY_OF_WEEK);
        int dayOfMonth = calendar.get(Calendar.DAY_OF_MONTH);
// day非常特殊 着重看一下
        int updateDayOfMonth = findNextDay(calendar, this.daysOfMonth, dayOfMonth, this.daysOfWeek, dayOfWeek, resets);
// 回溯计算逻辑还是一样的
        if (dayOfMonth == updateDayOfMonth) {
            resets.add(Calendar.DAY_OF_MONTH);
        }
        else {
            doNext(calendar, dot);
        }

        int month = calendar.get(Calendar.MONTH);
        int updateMonth = findNext(this.months, month, calendar, Calendar.MONTH, Calendar.YEAR, resets);
        if (month != updateMonth) {
// 这里是一个死循环检测,也就是完全计算不出来日期抛出异常,因为4年闰年周期都没有计算出一个时间,那么永远也不会有 了。
            if (calendar.get(Calendar.YEAR) - dot > 4) {
                throw new IllegalArgumentException("Invalid cron expression \"" + this.expression +
                        "\" led to runaway search for next trigger");
            }
// 月变了要回溯计算
            doNext(calendar, dot);
        }

    }
// ---------findNext----------
    private int findNext(BitSet bits, int value, Calendar calendar, int field, int nextField, List<Integer> lowerOrders) {
// 计算当前时间维度的下一个值,这里不单单利用了bitSet的内存低,计算快的效果,还利用了api的特性,用当前时间取下一个可选值时,会直接返回下一个可选的位下标,如果超出范围(跨越周期的计算了)返回-1
// 如果当前的时间周期已经在可选值会直接返回 ,方法外部进行了判断,放入可能会重置的集合中,也就是当前时间周期没有变,但是如果下一个时间周期的值变了则会重置当前时间到最小值,但不一定是可选值
        int nextValue = bits.nextSetBit(value);
        // roll over if needed
        if (nextValue == -1) {
// 下一个时间维度 +1  到这里并不一定计算完成,因为下一个周期的设置还没有计算
            calendar.add(nextField, 1);
// 然后将当前周期设置为当前时间维度的最小值,后续重新计算
            reset(calendar, Collections.singletonList(field));
// 将值设置为可选值的第一个值
            nextValue = bits.nextSetBit(0);
        }
        if (nextValue != value) {
// 这里是一套组合拳,首先方法外部也会判断这个nextValue != value逻辑,如果相等则会让下一个周期在这里调用reset时取重新使上一个时间维度重置为最小值,但不一定是可选值
// 这里设置好当前时间维度
            calendar.set(field, nextValue);
// 如果有,需要重置之前小的时间维度为最小值但不一定是可选值
            reset(calendar, lowerOrders);
        }
        return nextValue;
    }

// ----------reset----------
    private void reset(Calendar calendar, List<Integer> fields) {
        for (int field : fields) {
            calendar.set(field, field == Calendar.DAY_OF_MONTH ? 1 : 0);
        }
    }
下面看看day的特殊逻辑
    private int findNextDay(Calendar calendar, BitSet daysOfMonth, int dayOfMonth, BitSet daysOfWeek, int dayOfWeek,
            List<Integer> resets) {
    // dayOfYear的绝对范围
        int count = 0;
        int max = 366;
        // the DAY_OF_WEEK values in java.util.Calendar start with 1 (Sunday),
        // but in the cron pattern, they start with 0, so we subtract 1 here
// 如果 day of month 和 day of week 当前值不在可选值内并且加的天数没有超过1年
//因为互斥一起执行即可,实际只有一个在生效
        while ((!daysOfMonth.get(dayOfMonth) || !daysOfWeek.get(dayOfWeek - 1)) && count++ < max) {
// 循环一天一天往上加
            calendar.add(Calendar.DAY_OF_MONTH, 1);
// 加完1 后 获取相对值
            dayOfMonth = calendar.get(Calendar.DAY_OF_MONTH);
            dayOfWeek = calendar.get(Calendar.DAY_OF_WEEK);
// 没加一天重置 时,分,秒可能的重置,后续回溯计算
            reset(calendar, resets);
        }
        if (count >= max) {
            throw new IllegalArgumentException("Overflow in day for expression \"" + this.expression + "\"");
        }
// 返回变化后的值
        return dayOfMonth;
    }
到这里就结束了所有旧版本的cron计算逻辑
  1. 通过解析cron表达式到bitSet作为可选值
  2. 时分秒月 通过bitSet的nextSetBit 计算,天通过+1循环计算判断
  3. 通过当前时间维度如果没变则加入可能重置列表,大的时间维度变了,立即重置小维度的时间 + 回溯计算,一直计算到所有时间维度都命中到可选值为止
那么旧版本的spring对cron表达式的计算逻辑分析完毕,下一篇文章会对spring5.3.x进行分析,全面支持jdk8的LocalDateTime,其实是所有Temporal的所有子类

相关文章

网友评论

      本文标题:java实现对cron表达式解析,spring5.2.x的实现

      本文链接:https://www.haomeiwen.com/subject/afpuurtx.html