乐趣区

关于crontab:自己动手撸一个cron表达式解析器

背景

给公司某商城我的项目做了一套音讯平台,就是这货,此音讯不是短信邮件告诉之类的音讯,而是指音讯队列中的音讯,平台能够动态创建消费者和生产者,解决异步音讯,提供多种可视化伎俩对音讯处理过程进行全生命周期治理,有趣味的小伙伴能够理解下。广告工夫完结:),以下是注释

平台有一个小性能,能够配置定时工作,定时执行一些程序,一开始就简略用 ScheduledThreadPoolExecutor 实现了下,能够实现周期性执行工作,前面须要实现相似 一个月中某一天执行 这种非固定周期性的工作时就无奈实现,这就须要引入 cron 表达式,找一个反对 cron 表达式的框架并不难,spring boot 自身就反对,quartz 也反对,但思考到

  • 定时不是外围性能,不想为了一个非核心性能引入过多的依赖
  • cron 表达式只有 5 个变量,解析起来绝对简略
  • 本人造轮子,可控性比拟强

至于为什么不必 spring boot 自带的 cron 表达式性能(也没引入新的依赖),起因有两个

  • 零碎和 spring boot 在架构上就是解藕的,也就是系统核心并不依赖 spring boot,spring boot 只是实现了 web api 的性能,但定时属于零碎自身的性能,并不是 web api 的性能
  • spring boot 的 cron 不反对动态创建,须要在启动时确定

本文没有用到编译原理任何常识(实际上我也不会),齐全是硬解析,能够释怀食用,保障大家都看得懂:)

cron 表达式

cron 表达式是一个能够形容周期性工作的表达式语言,一个 cron 表达式蕴含 5 个局部,每个局部用空格隔开,比方上面这个表达式示意每天的 20:12 执行

12 20 * * *

cron 表达式每个局部含意如下

    1. 分钟
    1. 小时

每个局部容许以下几种操作符

  • * 取值范畴内的所有数字
  • / 每过多少个数字
  • 从 X 到 Z
  • 散列数字

实例

实例 1:每 1 分钟执行一次
* * * * *
实例 2:每小时的第 3 和第 15 分钟执行
3,15 * * * * 
实例 3:在上午 8 点到 11 点的第 3 和第 15 分钟执行
3,15 8-11 * * *
实例 4:每隔两天的上午 8 点到 11 点的第 3 和第 15 分钟执行
3,15 8-11 */2  *  *
实例 5:每周一上午 8 点到 11 点的第 3 和第 15 分钟执行
3,15 8-11 * * 1
实例 6:每晚的 21:30 重启 smb
30 21 * * *
实例 7:每月 1、10、22 日的 4 : 45 重启 smb
45 4 1,10,22 * *
实例 8:每周六、周日的 1 : 10 重启 smb
10 1 * * 6,0
实例 9:每天 18 : 00 至 23 : 00 之间每隔 30 分钟重启 smb
0,30 18-23 * * *
实例 10:每星期六的早晨 11 : 00 pm 重启 smb
0 23 * * 6
实例 11:每一小时重启 smb
0 */1 * * *

实现思路

要实现一个相似 quartz 的程序,须要两个外围组件配合能力实现,根本所有的定时类框架都是这个思路

  • 一个固定周期的线程(其实就是 Thread.sleep),周期取决于定时所反对的精度,该线程定时(比方 5s)查看是否有要执行的工作,那么就须要有一个程序通知它要不要执行
  • 依据 表达式 + 上一次执行工夫 判断本次执行周期是否要执行该工作,这就是解析器要干的事,也是咱们明天的工作

第一个组件比较简单,不在本次的探讨范畴,咱们这次次要探讨如何实现第二个组件,咱们把解析器分为两局部来讲

  • 数据结构
  • 算法

数据结构指的是,如何存储 cron 的数据(不是简略的字符串),筛选适合的数据结构能够事倍功半,算法指的是在解析完并且存储到指定的数据结构中后如何判断该周期是否命中,咱们离开讲。

数据结构

通过观察能够发现,每一部分能够分为两类

  • 周期执行类(比方每五分钟执行一次)
  • 固定工夫执行(比方 20:12 分执行)

不论哪一类咱们能够形象为 范畴(range), 比方分钟默认范畴事 1 -59,那么无论是周期性还是固定工夫,都逃不开该范畴,举几个例子

  • /5 *:范畴 1 -59,因为你不晓得上一次执行的分钟数,所以全范畴都有可能取值
  • 12 20 *:范畴[12],只能取 12
  • 12,13 :范畴[12-13]
  • 12-15 :范畴[12-15]

因为范畴能够涵盖所有咱们反对的语法,这其中也有一个小问题,分,时,月是能够确定的,然而天是无奈确定范畴的,天是依据月来定的还受年(平年)影响,并且 cron 还反对周,周是没有对应具体概念的,如何解决周的问题?进一步形象,咱们把年月日合并定义范畴,那么该范畴内最多有 366 个选项,周的解决也很简略,比方 cron 外面指定周二,那么将年月日范畴中非周二日期去除即可,这样咱们就对立,综合以上,咱们定义了以下几个范畴

  • 年月日(年其实能够不必定义,因为年没有下限,能够始终往上加)

月和日合并要用什么数据结构存储呢,因为分和时都是 int 型,并且时递增的,最好是保持一致,思考到月 <=12,日 <=31,因为能够用 位操作 将两个数合并成一个数

 /**
     * 将月和日合并成一个 int 型整数
     * @param month
     * @param day
     * @return
     */
    public int encodeMonthday(int month, int day) {return (day & 0x000000FF) | (month << 8 & 0x0000FF00);
    }

    /**
     * 解码月
     * @param monthDay
     * @return
     */
    public int decodeMonth(int monthDay) {return (monthDay & 0x0000FF00) >> 8;
    }

    /**
     * 解码日
     * @param monthDay
     * @return
     */
    public int decodeDay(int monthDay) {return (monthDay & 0x000000FF);
    }

算法

这部分是最麻烦的,我试着尽量说分明,咱们把问题形象下可能更好了解,咱们把问题转换为以下形容

有 ABC 三个组合,A 取值[A1-AN],B 取值[B1-BN],C 取值[C1-CN],给定一个 DEF,求 DEF 在 ABC 中下一个最小值

是不是有点像大学外面做 ACM 题目的感觉,但形象下来就是这样子,我的思路是这样的(不肯定最优哈,大学时幻想着进校 ACM 队,后果连初选都没进,:),所以大家如果有更好的解法,欢送评论区留言

  • 从大往小判断
  • 先判断 F 在不在 C 中,如果在那么持续判断 E
  • 判断 E 在不在 B 中,如果在持续判断 D
  • 判断 D 在不在 A 中,如果在的话,那么只有算出 Min([A1-AN]>D)就行
  • 如果 D 不在 A 中,那么返回到 E 中,算出 Min([B1-BN]>E)
  • 以此类推

当然其中还有一些小问题须要解决,比方跨年问题等,具体的算法能够看代码,语言表达能力仅限于此

实现

整个解析器实现起来,代码局部不超过 200 行,所以浏览起来难度也不是很大,贴出残缺代码如下

package com.definesys.mc.core.cron;

import java.util.Calendar;
import java.util.Date;
import java.util.Set;

import static java.util.Calendar.DATE;
import static java.util.Calendar.DAY_OF_YEAR;

/**
 * @Description:
 * @author: jianfeng.zheng
 * @since: 2021/12/30 3:50 下午
 * @history: 1.2021/12/30 created by jianfeng.zheng
 */
public class CronParser {

    private String cronExp;

    public CronParser(String exp) {this.cronExp = exp;}

    public Date nextDate(Date start) {Calendar lastCal = Calendar.getInstance();
        lastCal.setTime(start);

        // 上一次执行工夫字段
        int lastYear = lastCal.get(Calendar.YEAR);
        int lastMonth = lastCal.get(Calendar.MONTH) + 1;
        int lastDay = lastCal.get(Calendar.DAY_OF_MONTH);
        int lastMonthDay = this.encodeMonthday(lastMonth, lastDay);
        int lastHour = lastCal.get(Calendar.HOUR_OF_DAY);
        int lastMinute = lastCal.get(Calendar.MINUTE);
        int lastSecond = lastCal.get(Calendar.SECOND);

        // 下一次执行工夫字段
        Integer newMonthDay = null;
        Integer newHour = null;
        Integer newMinute = null;
        Integer newYear = lastYear;

        // 解析 cron 表达式
        String[] exps = cronExp.split("\\s+");
        CronRange minute = parseRange(exps[0], 0, 59);
        CronRange hour = parseRange(exps[1], 0, 23);
        CronRange day = parseRange(exps[2], 1, 31);
        CronRange month = parseRange(exps[3], 1, 12);
        CronRange week = parseRange(exps[4], 1, 7);
        CronRange monthDay = this.calMonthDay(month, day, week);
        if (monthDay.isEmpty()) {return null;}

        boolean isNotFound = false;
        if (monthDay.inRange(lastMonthDay)) {if (hour.inRange(lastHour)) {if (minute.inRange(lastMinute)) {newMinute = minute.getNextValue(lastMinute);
                }
                if (newMinute == null) {
                    // 如果分钟找不到,须要对小时进行递增
                    newHour = hour.getNextValue(lastHour);
                    isNotFound = newHour == null;
                    newMinute = minute.getMin();} else {newHour = lastHour;}
            }
            if (newHour == null) {if (isNotFound) {
                    // 如果小时找不到,须要对天数进行递增
                    if (monthDay.isAll()) {Calendar c = Calendar.getInstance();
                        c.setTime(start);
                        c.add(DATE, 1);
                        newMonthDay = this.encodeMonthday(c.get(Calendar.MONTH) + 1, c.get(Calendar.DAY_OF_MONTH));
                    } else {
                        // 如果跨年了就找不到
                        newMonthDay = monthDay.getNextValue(lastMonthDay);
                    }
                } else {newMonthDay = lastMonthDay;}
                newHour = hour.getMin();
                newMinute = minute.getMin();} else {newMonthDay = lastMonthDay;}
        } else {
            // 天如果不在范畴内,须要对天进行递增
            newMonthDay = monthDay.getNextValue(lastMonthDay);
            newHour = hour.getMin();
            newMinute = minute.getMin();}
        if (newMonthDay == null) {
            // 跨年
            newYear = newYear + 1;
            if (monthDay.isAll()) {
                // 1 月 1 日
                newMonthDay = 0x0101;
            } else {newMonthDay = monthDay.getMin();
            }
            newHour = hour.getMin();
            newMinute = minute.getMin();}
        Calendar newCal = Calendar.getInstance();
        newCal.set(Calendar.MONTH, this.decodeMonth(newMonthDay) - 1);
        newCal.set(Calendar.DAY_OF_MONTH, decodeDay(newMonthDay));
        newCal.set(Calendar.HOUR_OF_DAY, newHour);
        newCal.set(Calendar.MINUTE, newMinute);
        newCal.set(Calendar.SECOND, lastSecond);
        newCal.set(Calendar.YEAR, newYear);
        return newCal.getTime();}

    /**
     * 将月和日合并成一个 int 型整数
     * @param month
     * @param day
     * @return
     */
    public int encodeMonthday(int month, int day) {return (day & 0x000000FF) | (month << 8 & 0x0000FF00);
    }

    /**
     * 解码月
     * @param monthDay
     * @return
     */
    public int decodeMonth(int monthDay) {return (monthDay & 0x0000FF00) >> 8;
    }

    /**
     * 解码日
     * @param monthDay
     * @return
     */
    public int decodeDay(int monthDay) {return (monthDay & 0x000000FF);
    }

    private CronRange calMonthDay(CronRange month, CronRange day, CronRange week) {CronRange monthDay = new CronRange();
        if (month.isAll() && day.isAll() && week.isAll()) {
            // 如果都是全范畴的就不进行计算
            monthDay.setReturnAll(true);
            return monthDay;
        }
        int[] monthDays = {31, 0, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};
        // 如果是平年就是 29 天
        monthDays[1] = Calendar.getInstance().getActualMaximum(DAY_OF_YEAR) > 365 ? 29 : 28;
        Set<Integer> rangeMonth = month.getRange();
        for (Integer m : rangeMonth) {for (int d = 1; d <= monthDays[m - 1]; ++d) {if (day.inRange(d)) {
                    // 判断周的逻辑
                    if (!week.isAll()) {Calendar cal = Calendar.getInstance();
                        cal.set(Calendar.MONTH, m - 1);
                        cal.set(Calendar.DAY_OF_MONTH, d);
                        int w = cal.get(Calendar.DAY_OF_WEEK) - 1;
                        // 周日 - 周六 ==>1-7
                        w = w == 0 ? 7 : w;
                        if (!week.inRange(w)) {continue;}
                    }
                    monthDay.addRange(this.encodeMonthday(m, d));
                }
            }
        }
        return monthDay;
    }

    /**
     * 解析表达式的取值范畴和循环周期
     *
     * @param exp
     * @param start
     * @param end
     * @return
     */
    public CronRange parseRange(String exp, int start, int end) {String[] exps = exp.trim().split("/");
        CronRange range = new CronRange();
        if (exps.length > 1) {range.setCycle(Integer.parseInt(exps[1]));
        }

        if (exps[0].trim().length() == 0) {range.range(start, end);
        } else if ("*".equals(exps[0])) {range.range(start, end);
            range.setReturnAll(exps.length == 1);
        } else if (exps[0].contains("-")) {String[] ss = exps[0].split("-");
            range.range(Integer.parseInt(ss[0]), Integer.parseInt(ss[1]));
        } else if (exps[0].contains(",")) {String[] ss = exps[0].split(",");
            for (String s : ss) {range.addRange(Integer.parseInt(s));
            }
        } else {range.addRange(Integer.parseInt(exps[0]));
        }
        return range;
    }
}

class CronRange {private Set<Integer> range = new TreeSet<>();
    private Integer cycle;
    private Integer max = null;
    private Integer min = null;
    private Boolean returnAll = false;

    public CronRange range(int start, int end) {for (int i = start; i <= end; ++i) {this.addRange(i);
        }
        return this;
    }

    public CronRange addRange(int value) {max = (max == null || value > max) ? value : max;
        min = (min == null || value < min) ? value : min;
        this.range.add(value);
        return this;
    }

    public Set<Integer> getRange() {return range;}


    public void setCycle(Integer cycle) {this.cycle = cycle;}


    public boolean inRange(int value) {return returnAll ? true : range.contains(value);
    }

    public boolean isEmpty() {return !returnAll && range.isEmpty();
    }


    public Integer getNextValue(int lastValue) {
        Integer value = null;
        if (this.cycle != null) {
            value = this.cycle + lastValue;
            while (!inRange(value)) {
                value = value + this.cycle;
                if (value > max) {
                    value = null;
                    break;
                }
            }
        } else {value = this.getNextMin(lastValue);
        }
        return value;
    }

    private Integer getNextMin(int value) {Integer[] integers = range.toArray(new Integer[range.size()]);
        Integer minValue = null;
        for (int i = 0; i < integers.length; ++i) {if (integers[i] > value) {minValue = integers[i];
                break;
            }
        }
        return minValue;
    }


    public Boolean isAll() {return returnAll;}

    public void setReturnAll(Boolean returnAll) {this.returnAll = returnAll;}

    public Integer getMin() {return min;}
}

测试

写了几个表达式测试了下,都合乎预期后果

public static void main(String[] cmd) throws ParseException {
        String cronExp = "* * * * *";
        CronParser parser = new CronParser(cronExp);
        String lastExecuteDateStr = "2022-1-3 22:23:22";
        SimpleDateFormat fmt = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        Date lastExecuteDate = fmt.parse(lastExecuteDateStr);
        for (int i = 0; i < 10; ++i) {lastExecuteDate = parser.nextDate(lastExecuteDate);
            if (lastExecuteDate == null) {return;}
            System.out.println(fmt.format(lastExecuteDate));
        }
    }

输入

2022-01-03 22:24:22
2022-01-03 22:25:22
2022-01-03 22:26:22
2022-01-03 22:27:22
2022-01-03 22:28:22

其余例子

# 每五分钟
*/5 * * * *
2022-01-03 22:28:22
2022-01-03 22:33:22
2022-01-03 22:38:22
2022-01-03 22:43:22
2022-01-03 22:48:22

#12 点的时候每五分钟
*/5 12 * * *
2022-01-03 12:00:22
2022-01-03 12:05:22
2022-01-03 12:10:22
2022-01-03 12:15:22
2022-01-03 12:20:22

#2 月 3 日 12 点的时候每五分钟
*/5 12 3 2 *
2022-02-03 12:00:22
2022-02-03 12:05:22
2022-02-03 12:10:22
2022-02-03 12:15:22
2022-02-03 12:20:22

结束语

在理论我的项目中咱们也可能会碰到相似这种略微有点简单的业务开发,面对这种开发时,肯定肯定不要马上编码,在没有把数据结构和算法理分明的前提下贸然编码,必定是有问题的,这个解析器说简略也不简略说简单也不简单,然而数据结构和算法也是花了我一天工夫在笔记本上斟酌,倡议程序员都要有个笔记本,把思路在纸下面写分明,写代码只是把纸下面的货色用代码实现而已(实际上编码 + 调试不到一小时),附上俊俏到只有我和上帝能看得懂的笔记

退出移动版