在Java中使用Cron表达式查找上次执行时间

14

在Java中,有没有一种方法可以从Cron表达式中找到“最后触发时间”?

例如,如果now = 25-Apr-2010 10PM,并且cron表达式是0 15 10 ? * *(quartz),则应返回25-Apr-2010 10:15AM

注意:

  1. 我不在意使用标准的cron表达式(如Unix和Quartz)还是不太流行的表达式,只要它们能为我提取正确的“最后触发时间”。
  2. 同时,“最后触发时间”不是字面意义上的,因为触发器可能尚未触发,但在逻辑上应该有一种方式来告诉我们它(将会)上次触发的时间。
11个回答

10

cron-utils是一个开源的Java库,它可以解析、验证、迁移cron表达式,并支持您所需的操作。 要从给定时间之前的cron中获取上一个日期,只需执行以下操作:

//Get date for last execution
DateTime now = DateTime.now();
ExecutionTime executionTime = ExecutionTime.forCron(parser.parse("* * * * * * *"));
DateTime lastExecution = executionTime.lastExecution(now));

请记住,目前它还存在一些错误,并且对于更复杂的cron表达式可能无法正确计算。


你好Joao, 即使工作不运行(比如:服务器离线),这个也能工作吗? - Marin
1
@NunoMarinho 是的,它只是从 cron 表达式计算出前一个日期。该库本身并不知道您使用 cron 表达式做什么。该库不会安排要执行的操作或作业,它只是根据 cron 表达式计算日期。虽然我很久没有使用过这个库了,但请确保它符合您的需求,并在使用后彻底测试您的应用程序。 - João Neves

3

Quartz似乎有一些支持cron表达式的库。

请参见CronExpression类的Javadoc,该类有一个名为getTimeBefore的方法。即:

CronExpression cron = new CronExpression("0 15 10 ? * *");
Date today = new Date();
Date previousExecution = cron.getTimeBefore(today);

这可能取决于Quartz的版本,是否能够正常运行。

查看最新源代码(在撰写本文时为2.3.0版本),该方法尚未实现,始终返回null。


已在2010年回答,仍然返回空值 :( - Scott
今天,它仍然返回null :( - Domenico Campagnolo

3
首先,我不知道是否存在支持此功能的现有库。Quartz可能支持,但标准Java类库肯定不支持。
其次,严格来说,您最初要求的是不可能实现的。库能告诉您的最好结果是cron表达式“应该已经”或“应该”触发。理论上唯一能告诉您cron表达式实际上最后一次触发时间的只有调度程序实例本身。

嗨,史蒂芬,感谢您的反馈。我已经在我的问题注释中添加了更多信息以澄清我的问题。 - a-sak

2
import org.springframework.scheduling.support.CronSequenceGenerator;

import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.Calendar;
import java.util.Date;
import java.util.TimeZone;
import java.util.concurrent.TimeUnit;

public class DateCalendarUtil {

    public static Date lastRunOn(String cronExpression) {
        final Date nextExecution = fromLocalDateTime(toLocalDate(nextCronDate(cronExpression)).atTime(23, 59, 59));
        return subtract(nextExecution, numberOfDays(nextExecution, nextCronDate(cronExpression, nextExecution)).intValue());
    }

    /**
     * Converts {@link Date} to {@link LocalDate} with default system {@link ZoneId}
     *
     * @param date to be converted to {@link LocalDate}
     * @return converted {@link Date}
     */
    public static LocalDate toLocalDate(Date date) {
        return toLocalDate(date, ZoneId.systemDefault());
    }

    /**
     * Converts {@link Date} to {@link LocalDate} with provided {@link ZoneId}
     * @param date to be converted to {@link LocalDate}
     * @param zoneId with which {@link Date} will be converted
     * @return converted {@link Date}
     */
    public static LocalDate toLocalDate(Date date, ZoneId zoneId) {
        return date.toInstant().atZone(zoneId).toLocalDate();
    }

    /**
     * Converts {@link Date} to {@link LocalDateTime} with provided {@link ZoneId}
     * @param date to be converted to {@link LocalDateTime} with provided {@link ZoneId}
     * @param zoneId with which {@link Date} will be converted
     * @return converted {@link Date}
     */
    public static LocalDateTime toLocalDateTime(Date date, ZoneId zoneId) {
        return date.toInstant().atZone(zoneId).toLocalDateTime();
    }

    /**
     * Converts {@link Date} to {@link LocalDateTime} with system default {@link ZoneId}
     *
     * @param date to be converted to {@link LocalDateTime}
     * @return converted {@link Date}
     */
    public static LocalDateTime toLocalDateTime(Date date) {
        return toLocalDateTime(date, ZoneId.systemDefault());
    }

    /**
     * Converts {@link LocalDate} to {@link Date} with default system {@link ZoneId}
     * @param localDate to be converted to {@link Date}
     * @return converted {@link LocalDate}
     */
    public static Date fromLocalDate(LocalDate localDate) {
        return fromLocalDate(localDate, ZoneId.systemDefault());
    }

    /**
     * Converts {@link LocalDate} to {@link Date} with provided {@link ZoneId}
     * @param localDate to be converted to {@link Date}
     * @param zoneId with which {@link LocalDate} converted
     * @return converted {@link LocalDate}
     */
    public static Date fromLocalDate(LocalDate localDate, ZoneId zoneId) {
        return Date.from(localDate.atStartOfDay(zoneId).toInstant());
    }

    /**
     * Converts {@link LocalDateTime} to {@link Date} with default system {@link ZoneId}
     *
     * @param localDateTime to be converted to {@link Date}
     * @return converted {@link LocalDateTime}
     */
    public static Date fromLocalDateTime(LocalDateTime localDateTime) {
        return fromLocalDateTime(localDateTime, ZoneId.systemDefault());
    }

    /**
     * Converts {@link LocalDateTime} to {@link Date} with provided {@link ZoneId}
     *
     * @param localDateTime to be converted to {@link Date}
     * @param zoneId        with which localDateTime converted to {@link Date}
     * @return converted {@link Date}
     */
    private static Date fromLocalDateTime(LocalDateTime localDateTime, ZoneId zoneId) {
        return Date.from(localDateTime.atZone(zoneId).toInstant());
    }

    public static Date yesterday() {
        return yesterday(TimeZone.getDefault());
    }

    public static Date yesterday(TimeZone timezone) {
        return subtract(new Date(), 1, timezone);
    }

    /**
     * Generates start time of give date with system default {@link TimeZone}
     * @param date Date of which start time to be generated
     * @return Date with start time as 00:00:00
     */
    public static Date startTime(Date date) {
        return startTime(date, TimeZone.getDefault());
    }

    /**
     * Generates start time of give date with provided {@link TimeZone}
     * @param date Date of which start time to be generated
     * @param timeZone with which {@link Calendar} created
     * @return Date with start time as 00:00:00
     */
    public static Date startTime(Date date, TimeZone timeZone) {
        Calendar calendar = Calendar.getInstance(timeZone);
        calendar.setTime(date);
        calendar.set(Calendar.HOUR_OF_DAY, 0);
        calendar.set(Calendar.MINUTE, 0);
        calendar.set(Calendar.SECOND, 0);
        return calendar.getTime();
    }

    /**
     * Generates end time of give date with system default {@link TimeZone}
     * @param date Date of which end time to be generated
     * @return Date with end time as 23:59:59
     */
    public static Date endTime(Date date) {
        return endTime(date, TimeZone.getDefault());
    }

    /**
     * Generates end time of give date with provided {@link TimeZone}
     * @param date Date of which end time to be generated
     * @param timeZone with which {@link Calendar} created
     * @return Date with end time as 23:59:59
     */
    public static Date endTime(Date date, TimeZone timeZone) {
        Calendar calendar = Calendar.getInstance(timeZone);
        calendar.setTime(date);
        calendar.set(Calendar.HOUR_OF_DAY, 23);
        calendar.set(Calendar.MINUTE, 59);
        calendar.set(Calendar.SECOND, 59);
        return calendar.getTime();
    }

    /**
     * Calculates number of days between from and to
     * @param from start Date
     * @param to end date
     * @return number of days including last date
     */
    public static Long numberOfDays(Date from, Date to) {
        return TimeUnit.DAYS.convert(to.getTime() - from.getTime(), TimeUnit.MILLISECONDS) + 1;
    }

    /**
     * Gives next {@link Date} from given cron expression
     * @param cronExpression cron expression
     * @return next {@link Date}
     */
    public static Date nextCronDate(String cronExpression) {
        return nextCronDate(cronExpression, new Date());
    }

    public static Date nextCronDate(String cronExpression, Date date) {
        CronSequenceGenerator generator = new CronSequenceGenerator(cronExpression, TimeZone.getTimeZone("IST"));
        return DateCalendarUtil.fromLocalDate(DateCalendarUtil.toLocalDate(generator.next(date)));
    }
}

代码看起来很好,但你使用的减法函数没有定义,请添加。 - Baldeep Singh Kwatra

1
如果你正在使用org.quartz,就可以使用context.getPreviousFireTime(); 例子:
public class TestJob implements Job {

    @Override
    public void execute(JobExecutionContext context) throws JobExecutionException {

        context.getPreviousFireTime(); 
    }
}

如果您使用 context.getTrigger().getPreviousFireTime(),那么您将获得当前正在运行的 Job 的触发时间。

1
我在Quartz中找到的一种解决方案是对触发器进行向后一次时间间隔的操作,然后计算下一个触发时间。通过遍历所有的触发器,可以确定最近一次应该触发的时间。
计算每次触发之间的时间间隔:
Date nextFireTime = trigger.getNextFireTime();
Date subsequentFireTime = trigger.getFireTimeAfter(nextFireTime);
long interval = subsequentFireTime.getTime() - nextFireTime.getTime();

找到过去一个时间间隔内下次触发的时间:

Date previousPeriodTime = new Date(System.currentTimeMillis() - interval);
Date previousFireTime = trigger.getFireTimeAfter(previousPeriodTime);

我发现如果你正在使用 CronTrigger,这会阻止你请求过去的触发时间。为了解决这个问题,我修改了开始时间,因此上面的代码片段变成了:

Date originalStartTime = trigger.getStartTime(); // save the start time
Date previousPeriodTime = new Date(originalStartTime.getTime() - interval);
trigger.setStartTime(previousPeriodTime);
Date previousFireTime = trigger.getFireTimeAfter(previousPeriodTime);
trigger.setStartTime(originalStartTime); // reset the start time to be nice

遍历所有触发器,找到最近过去的那一个:

for (String groupName : scheduler.getTriggerGroupNames()) {
    for (String triggerName : scheduler.getTriggerNames(groupName)) {
        Trigger trigger = scheduler.getTrigger(triggerName, groupName);
        // code as detailed above...
        interval = ...
        previousFireTime = ...
    }
}

我将把这个重构成帮助方法或类的练习留给读者。实际上,我在一个子类委托触发器中使用上述算法,然后将其放置在按先前触发时间排序的集合中。


3
后续火灾之间的差异并不总是相同。 - Ahamed
Ahamed是正确的。这段代码通常是错误的,只在一小部分情况下才是正确的。 - Husain

0

我正在使用 cron-utils 9.1.6 来基于“现在”和“cron表达式”获取最后执行时间,并添加预期持续时间(以秒为单位)来计算预期结束时间。

代码语言是Scala。

import java.time.ZonedDateTime
import com.cronutils.model.definition.CronDefinitionBuilder
import com.cronutils.model.time.ExecutionTime
import com.cronutils.parser.CronParser
import com.cronutils.model.CronType.QUARTZ
import java.time.ZoneOffset.UTC

val parser = new CronParser(CronDefinitionBuilder.instanceDefinitionFor(QUARTZ))
val cronExpression = "0 0 23 ? * *"
val now = ZonedDateTime.now(UTC)
val expectedDurationSec = 1500

println(s"now: $now")
val executionTime = ExecutionTime.forCron(parser.parse(cronExpression))
val lastExecution = executionTime.lastExecution(now)
println(s"lastExecution: ${lastExecution.get()}")
val expectedEnd = lastExecution.get().plusSeconds(expectedDurationSec)
println(s"expectedEnd: $expectedEnd")

输出结果为:

now: 2022-04-21T01:08:27.499Z
lastExecution: 2022-04-20T23:00Z
expectedEnd: 2022-04-20T23:25Z

0

令人惊奇的是,仍然没有基于CronExpression获取上一次触发时间的石英方法...

如何获取上一次触发时间?

如果您正在操作基本的CRON,例如0 0 0 * * ?(每天凌晨00:00:00),则可以使用João Neves的解决方案(使用com.cronutils.model.time.ExecutionTime)。

否则,如果您正在操作复杂的CRON,例如0 30 11 ? * MON,THU *,它将无法正常工作。您将获得随机结果(我得到了星期三...)。

编辑:我进行了其他测试,最新版本效果更好(之前的测试是使用版本<3.1.6进行的)。注意:如果您要使用版本>3.1.6,则需要Java 8。

您可以使用的解决方案是在触发作业时存储它。


如何验证作业已被触发?

我找到的解决方案是使用Quartz(CronExpression)中的getNextValidTimeAfter。这个方法很好用。 你会问我为什么要寻找上一个有效时间!你是对的,请给我一秒钟!

假设我们有一个每月一次的CRON(0 0 16 1 * ? = 每月1日下午4:00:00),我们想每天检查前一次执行是否成功。 您需要在每次执行时存储getNextValidTime,并将此日期与今天的日期进行比较。例如(格式DD/MM/YYYY):

• 01/01/2019 → 作业已触发,我们存储下一个触发时间(称其为nextFireTime):

CronExpression trigger = new CronExpression("0 0 16 1 * ?");
Date nextFireTime = trigger.getNextValidTimeAfter(new Date());
// = 01/02/2019

• 02/01/2019 → 验证日期:02/01/2019 < 01/02/2019 通过

...

• 01/02/2019 → 假设服务器宕机,任务未被触发。

• 02/02/2019 → 服务器开启,日期验证:02/02/2019 > 01/02/2019 不行!

→ 我们知道之前的触发时间没有起作用。现在你可以按照自己的意愿去做(触发任务并存储新的下一次触发时间)。


另一个可能会引起您兴趣的选项,请参见MISFIRE_INSTRUCTION_FIRE_NOW

调度程序发现触发器错过时,作业将立即执行。这是明智的策略。例如:您已经在凌晨2点安排了一些系统清理工作。不幸的是,应用程序由于维护而在那个时间段内关闭,并在凌晨3点重新启动。因此,触发器失效,调度程序试图通过在尽可能快的时间(即凌晨3点)运行它来挽救局面。

来源(https://dzone.com/articles/quartz-scheduler-misfire

e.g.:

Trigger trigger = newTrigger().
 withSchedule(
  cronSchedule("0 0 9-17 ? * MON-FRI").
   withMisfireHandlingInstructionFireAndProceed()
 ).
 build();

官方文档:https://www.quartz-scheduler.org/api/2.1.7/org/quartz/CronTrigger.html

0
有Spring的org.springframework.scheduling.support.CronSequenceGenerator,它根据任意时间计算cron执行的next()时间。假设"上次触发时间"是指最早的时间t,满足next(t) ≥ now,可以通过简单的迭代算法找到它。
从给定的时间开始,向后走,找到一些时刻 t,其中 next(t) < now(意味着 t 属于某个先前的时间间隔)。可以通过步长加倍的方式来实现这一点,采用一些任意的初始步长大小。假设许多现实生活中的 cron 表达式产生大致相等的时间间隔,对于初始步长大小的合理猜测是 next(now) - now + ε,或者当 now 预计在执行之间时,是 next(next(now)) - next(now) + ε,根据合同,此值应大于 0;
从 t 开始,向前迭代 t := next(t),直到 next(t) ≥ now。
static long previous(String cron, TimeZone timezone, long ms) {
    var generator = new CronSequenceGenerator(cron, timezone);

    // 1. Walk back to find some moment where t.next() < ms
    long t1 = generator.next(new Date(ms)).getTime() - ms;  // initial step
    long t;
    do {
        if (t1 <= 0) {      // overflow or negative initial step
            throw new IllegalArgumentException("Failed to find previous time");
        }
        t = ms - t1 - 1;    // next try for t
        t1 *= 2;            // double step size
    } while (generator.next(new Date(t)).getTime() >= ms);

    // 2. Walk forward through next().next()... chain to find
    // the earliest moment where t1.next() >= ms
    while (t < ms) {
        t1 = t;
        t = generator.next(new Date(t)).getTime();
    }
    return t1;
}
CronSequenceGenerator 可以被 CronExpression 或者其他类似具有 next() 函数的替代,这段代码仅仅是演示了如何利用 "next" 来找到 "previous" 的方法。

-1
public class CronMircoUtils {

    /**
     * Use this method to calculate previous valid date for cron
     * @param ce CronExpression object with cron expression
     * @param date Date for find previous valid date
     * @return
     */
    public static Date getPreviousValidDate(CronExpression ce, Date date) {
        try {
            Date nextValidTime = ce.getNextValidTimeAfter(date);
            Date subsequentNextValidTime = ce.getNextValidTimeAfter(nextValidTime);
            long interval = subsequentNextValidTime.getTime() - nextValidTime.getTime();
            return new Date(nextValidTime.getTime() - interval);
        } catch (Exception e) {
            throw new IllegalArgumentException("Unsupported cron or date", e);
        }
    }
}

源代码在https://github.com/devbhuwan/cron-micro-utils中。


1
这仅适用于定期触发。当没有“subsequentNextValidTime”或者触发不规则(例如在10、12、11、17、37分钟时触发)时会发生什么? - João Neves

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接