自己動手擼一個cron表示式解析器

wls1036發表於2022-01-03

背景

給公司某商城專案做了一套訊息平臺,就是這貨,此訊息不是簡訊郵件通知之類的訊息,而是指訊息佇列中的訊息,平臺可以動態建立消費者和生產者,處理非同步訊息,提供多種視覺化手段對訊息處理過程進行全生命週期管理,有興趣的小夥伴可以瞭解下。廣告時間結束:),以下是正文

平臺有一個小功能,可以配置定時任務,定時執行一些程式,一開始就簡單用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

結束語

在實際專案中我們也可能會碰到類似這種稍微有點複雜的業務開發,面對這種開發時,一定一定不要馬上編碼,在沒有把資料結構和演算法理清楚的前提下貿然編碼,肯定是有問題的,這個解析器說簡單也不簡單說複雜也不復雜,但是資料結構和演算法也是花了我一天時間在筆記本上推敲,建議程式設計師都要有個筆記本,把思路在紙上面寫清楚,寫程式碼只是把紙上面的東西用程式碼實現而已(實際上編碼+除錯不到一小時),附上醜陋到只有我和上帝能看得懂的筆記

相關文章