自己动手撸一个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 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 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 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

结束语

在实际项目中我们也可能会碰到类似这种稍微有点复杂的业务开发,面对这种开发时,一定一定不要马上编码,在没有把数据结构和算法理清楚的前提下贸然编码,肯定是有问题的,这个解析器说简单也不简单说复杂也不复杂,但是数据结构和算法也是花了我一天时间在笔记本上推敲,建议程序员都要有个笔记本,把思路在纸上面写清楚,写代码只是把纸上面的东西用代码实现而已(实际上编码+调试不到一小时),附上丑陋到只有我和上帝能看得懂的笔记
自己动手撸一个cron表达式解析器_第1张图片

你可能感兴趣的:(crontab)