java.util.Timer 调度程序每天出错,因为夏令时

java.util.Timer scheduler daily goes wrong because daylight savings

我下面的代码运行良好,除了夏令时更改外,作业每天都在正确的时间执行。它发生在十月底和三月。我的服务器有一个轻型硬件,它位于使用它的区域。 我怎样才能使它工作,使用最少的资源(没有石英或任何类似的)

// init daily scheduler 
final java.util.Timer timer = new java.util.Timer("MyTimer");
final String[] hourMinute = time.split(":");
final String hour = hourMinute[0];
final String minute = hourMinute[1];
String second = "0";
final Calendar calendar = Calendar.getInstance(TimeZone.getTimeZone("UTC"));
calendar.set(Calendar.HOUR_OF_DAY, Integer.parseInt(hour));
calendar.set(Calendar.MINUTE, Integer.parseInt(minute));
calendar.set(Calendar.SECOND, Integer.parseInt(second));
if(calendar.getTime().before(new Date())){
    calendar.add(Calendar.DATE, 1);
}

timer.schedule(job, calendar.getTime(), 
TimeUnit.MILLISECONDS.convert(1, TimeUnit.DAYS)); // period: 1 day

tl;博士

仅使用 java.time classes,从不使用 Date/Calendar.

天长短不一,并不总是 24 小时。所以,运行宁在某个time-of-day意味着每天确定一个特定的时刻。

public void scheduleNextRun ( LocalDate today )
{
    LocalDate dateOfNextRun = today;
    if ( LocalTime.now( this.zoneId ).isAfter( this.timeToExecute ) )
    {
        dateOfNextRun = dateOfNextRun.plusDays( 1 );
    }
    ZonedDateTime zdt = ZonedDateTime.of( dateOfNextRun , this.timeToExecute , this.zoneId );  // Determine a moment, a point on the timeline.
    Instant instantOfNextTaskExecution = zdt.toInstant(); // Adjust from time zone to offset of zero hours-minutes-seconds from UTC.
    Duration d = Duration.between( Instant.now() , instantOfNextTaskExecution );
    this.ses.schedule( this , d.toNanos() , TimeUnit.NANOSECONDS );
    System.out.println( "DEBUG - Will run this task after duration of " + d + " at " + zdt );
}

过时classes

你使用的是糟糕的 date-time classes 多年前被现代 java.time classes 所取代在 JSR 310 中。

并且您使用的 Timer class 多年前已被 Executors 框架取代,如 Java 文档中所述。

现代Java

预定的执行程序服务

在您的应用中的某处,初始化计划的执行程序服务。

ScheduledExecutorService see = Executors.newSingleThreadScheduledExecutor() ;

保留预定的执行程序服务。请务必在您的应用程序退出之前将其关闭,否则其后备线程池可能会像僵尸 ‍♂️ 一样继续 运行ning。

Runnable任务

将您的任务定义为 RunnableCallable

天长短不一

您希望您的任务 运行 每天在特定时区的指定 time-of-day 执行一次。

public class DailyTask implements Runnable { … } 

不是 意味着每 24 小时一次。在某些地区的某些日期,日子的长度会有所不同。它们可能是 23 小时、25 小时或 23.5 小时或其他一些小时数。

任务re-schedules本身

要确定下一个任务何时 运行,任务本身应该进行计算。然后该任务将 re-schedule 自身用于下一次执行。为此,我们的任务必须保留对其正在执行的计划执行程序服务的引用。我们的任务必须跟踪您想要 运行 的一天中的时间。并且该任务必须跟踪我们感知此 time-of-day 的时区。所以我们需要将此信息传递给任务的构造函数,并将这些值作为私有成员记住。

…
private final ScheduledExecutorService ses;
private final LocalTime timeToExecute;
private final ZoneId zoneId;

public DailyTask ( final ScheduledExecutorService ses , final LocalTime timeToExecute , final ZoneId zoneId )
{
    this.ses = ses;
    this.timeToExecute = timeToExecute;
    this.zoneId = zoneId;
}
…

为了模拟工作,在这个例子中我们简单地打印到控制台。我们在 Runnable 接口承诺的 run 方法中执行此操作。

@Override
public void run ( )
{
    // Perform the task’s work.
    System.out.println( Thread.currentThread().getId() + " is running at " + Instant.now() );
}

计算下一步运行

对于那个 run 方法,我们必须添加下一个 运行 的计算。首先,我们捕获当前日期,以防在执行任务工作量时时钟 运行 转到下一个日期。

LocalDate today = LocalDate.now( this.zoneId );

接下来我们计算到下一个 运行.

的时间量
LocalDate tomorrow = today.plusDays( 1  );
ZonedDateTime zdt = ZonedDateTime.of( tomorrow, this.timeToExecute , this.zoneId );  // Determine a moment, a point on the timeline.
Instant instantOfNextTaskExecution = zdt.toInstant(); // Adjust from time zone to offset of zero hours-minutes-seconds from UTC.
Duration d = Duration.between( Instant.now() , instantOfNextTaskExecution );

正在执行程序服务运行上安排任务

最后我们安排了这项工作。

this.ses.schedule( this , d.toNanos() , TimeUnit.NANOSECONDS );

要第一次获得此任务 运行ning,调用应用程序需要在第一次安排此任务。让我们将此处显示的持续时间计算代码作为一种方法提供,而不是让调用应用程序进行持续时间计算。

public void scheduleNextRun( LocalDate today ) {
    LocalDate tomorrow = today.plusDays( 1 );
    ZonedDateTime zdt = ZonedDateTime.of( tomorrow , this.timeToExecute , this.zoneId );  // Determine a moment, a point on the timeline.
    Instant instantOfNextTaskExecution = zdt.toInstant(); // Adjust from time zone to offset of zero hours-minutes-seconds from UTC.
    Duration d = Duration.between( Instant.now() , instantOfNextTaskExecution );
    this.ses.schedule( this , d.toNanos() , TimeUnit.NANOSECONDS );
    System.out.println( "DEBUG - Will run this task after duration of " + d + " at " + zdt );
}

我们在该方法中有一个细微的错误。我们不应该假设下一个 运行 是明天的。如果在第一个 运行 上,当前时刻尚未超过目标 time-of-day,那么我们应该 在当前日期上添加一天。如果当前日期的 time-of-day 已经过去,我们应该只调用 .plusDays( 1 )

public void scheduleNextRun ( LocalDate today )
{
    LocalDate dateOfNextRun = today;
    if ( LocalTime.now( this.zoneId ).isAfter( this.timeToExecute ) )
    {
        dateOfNextRun = dateOfNextRun.plusDays( 1 );
    }
    ZonedDateTime zdt = ZonedDateTime.of( dateOfNextRun , this.timeToExecute , this.zoneId );  // Determine a moment, a point on the timeline.
    Instant instantOfNextTaskExecution = zdt.toInstant(); // Adjust from time zone to offset of zero hours-minutes-seconds from UTC.
    Duration d = Duration.between( Instant.now() , instantOfNextTaskExecution );
    this.ses.schedule( this , d.toNanos() , TimeUnit.NANOSECONDS );
    System.out.println( "DEBUG - Will run this task after duration of " + d + " at " + zdt );
}

最终代码

将所有代码放在一起看起来像这样。

package work.basil.timing;

import java.time.*;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;

public class DailyTask implements Runnable
{
    private final ScheduledExecutorService ses;
    private final LocalTime timeToExecute;
    private final ZoneId zoneId;

    public DailyTask ( final ScheduledExecutorService ses , final LocalTime timeToExecute , final ZoneId zoneId )
    {
        this.ses = ses;
        this.timeToExecute = timeToExecute;
        System.out.println( "timeToExecute = " + timeToExecute );
        this.zoneId = zoneId;
    }

    @Override
    public void run ( )
    {
        // Capture the current moment as seen your desired time zone.
        // Do this *before* your task’s work, in case during that task work the clock runs over to the next day.
        // Tip: Avoid scheduling tasks for midnight, the witching hour.
        LocalDate today = LocalDate.now( this.zoneId );
        // Perform the task’s work.
        System.out.println( "Thread ID: " + Thread.currentThread().getId() + " is running at " + Instant.now() );
        // Schedule the next task.
        this.scheduleNextRun( today );
    }

    public void scheduleNextRun ( LocalDate today )
    {
        LocalDate dateOfNextRun = today;
        if ( LocalTime.now( this.zoneId ).isAfter( this.timeToExecute ) )
        {
            dateOfNextRun = dateOfNextRun.plusDays( 1 );
        }
        ZonedDateTime zdt = ZonedDateTime.of( dateOfNextRun , this.timeToExecute , this.zoneId );  // Determine a moment, a point on the timeline.
        Instant instantOfNextTaskExecution = zdt.toInstant(); // Adjust from time zone to offset of zero hours-minutes-seconds from UTC.
        Duration d = Duration.between( Instant.now() , instantOfNextTaskExecution );
        this.ses.schedule( this , d.toNanos() , TimeUnit.NANOSECONDS );
        System.out.println( "DEBUG - Will run this task after duration of " + d + " at " + zdt );
    }
}

演示应用程序

编写一个小演示应用程序来使用它。

为了让这个演示无需等待一整天就可以运行,让我们根据需要从当前时刻计算一分钟 time-of-day。然后我们只需要等一分钟就能看到这段代码的工作。

实际上,我们将等待 分钟,足够的时间确保我们的主要代码在我们关闭演示应用程序的计划执行程序服务之前完全完成。

package work.basil.timing;

import java.time.*;
import java.time.temporal.ChronoUnit;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;

public class App
{
    public static void main ( String[] args )
    {
        System.out.println( "INFO - Demo start. " + Instant.now() );

        ScheduledExecutorService ses = Executors.newSingleThreadScheduledExecutor();
        ZoneId z = ZoneId.of( "Europe/Bucharest" );
        System.out.println( "Now in " + z + " is " + ZonedDateTime.now( z ) );
        DailyTask task = new DailyTask( ses , LocalTime.now( z ).truncatedTo( ChronoUnit.SECONDS ).plusMinutes( 1 ) , z );
        task.scheduleNextRun( LocalDate.now( z ) );

        try { Thread.sleep( Duration.ofMinutes( 2 ).toMillis() ); } catch ( InterruptedException e ) { throw new RuntimeException( e ); }
        ses.shutdownNow();
        System.out.println( "INFO - Waiting for scheduled executor service to shutdown now. " + Instant.now() );
        try { ses.awaitTermination( 5 , TimeUnit.SECONDS ); } catch ( InterruptedException e ) { throw new RuntimeException( e ); }
        System.out.println( "INFO - Demo end. " + Instant.now() );
    }
}

当运行.

INFO - Demo start. 2022-03-08T22:03:14.837676Z
Now in Europe/Bucharest is 2022-03-09T00:03:14.848880+02:00[Europe/Bucharest]
timeToExecute = 00:04:14
DEBUG - Will run this task after duration of PT59.142979S at 2022-03-09T00:04:14+02:00[Europe/Bucharest]
Thread ID: 15 is running at 2022-03-08T22:04:14.007322Z
DEBUG - Will run this task after duration of PT23H59M59.979047S at 2022-03-10T00:04:14+02:00[Europe/Bucharest]
INFO - Waiting for scheduled executor service to shutdown now. 2022-03-08T22:05:14.868608Z
INFO - Demo end. 2022-03-08T22:05:14.871141Z

并非如此S.O.L.I.D。

上面看到的代码是可行的。我可以想象将其投入生产。然而,纵观全局,该代码存在设计缺陷。

SOLID principles are generally recognized as a way of designing better software. The S stands for Single-responsibility principle。这意味着 class 应该专注于做一件主要的事情,并且做好那一件事情。所以不同的工作应该由不同的classes来处理。

但在上面看到的代码中,我们混合了两个不同的作业。

我们有原来的工作,需要例行执行的任务。假设该任务是一些业务功能,例如编制销售报告,或计算会计 roll-up,或同步库存记录。这样的函数并不真正关心 when the 运行;他们关心销售数字、会计数字或库存水平。

决定何时 运行 这样的功能是一项不同的工作。捕捉当前时刻、应用时区、计算剩余时间等其他工作完全独立于销售、会计或库存。

甚至我们的Runnable class的名字也是违反Single-Responsibility原则的线索:DailyTask有两部分,Task指到要完成的业务功能,Daily 指的是日程安排杂务。

太理想了y,而不是让我们的任务重新安排自己,我们应该有两个不同的 classes。一个处理业务工作的原始工作,另一个处理执行的调度。

实现这个远远超出了最初的问题。所以我将把它留作 reader 的练习。 ;-)