Java 时间的重复事件(java.time 或任何时间库)

Recurring Events with Java Time (java.time or any time library)

我正在使用非常简单的重复模式(每 x 天或每 x 周)对 Java 中的重复事件进行建模。给定一个具有 startDateTimeendDateTime 和以天为单位的重复事件对象(具有 java.time 包中的 Period 类型),我想知道事件是否发生在给定日期(考虑夏令时)。

"taking DST into account" 的一些背景(在评论中请求后):

我目前正在以一种不太高效的方式执行此操作:

public class Event {
  ZonedDateTime start;
  ZonedDateTime end;
  Period recurrence;  // e.g. 7 days

  public boolean includes(ZonedDateTime dateTime) {
    ZonedDateTime tmpStart = startDate;
    ZoneDateTime tmpEnd = endDate;
    do {
        if (dateTime.isAfter(tmpStart) && dateTime.isBefore(tmpEnd))
            return true;
        tmpStart = tmpStart.plus(recurrence);
        tmpEnd = tmpEnd.plus(recurrence);
    } while (dateTime.isAfter(tmpStart));

    return false;
  }
}

例子 对于以下事件

start = 2015-09-07T00:00
end = 2015-09-07T08:00
recurrence = Period.ofDays(7)

调用 includes 产生以下结果:

assertTrue(event.includes(2015-09-14T01:00)
assertTrue(event.includes(2015-09-21T01:00)
assertFalse(event.includes(2015-09-21T09:00)

执行此操作的高效方法是什么(如前所述,考虑 DST)?我也想在日历上显示事件。

更新possible duplicate 使用的算法与我在上面使用的算法完全相同。有没有更快的方法来执行此操作而无需遍历所有日期?

正如许多评论所显示的那样,您的问题没有准确定义。所以我会做一些假设。

你的问题的核心似乎是:

find out whether an event occurs at a given date (taking DST into account)

  • 假设您指的是仅限日期。更具体地说,假设您的意思是给定日期与 Event startDateTime 实例成员处于同一时区。
  • 让我们假设班次包含在日期内(与昨天或明天不重叠),如您所述。

在这种情况下,如果我们只关心日期,DST 是无关紧要的。事实上,时间是完全无关紧要的。我们只需要天数。模将告诉我们给定日期是否是重复次数的倍数。

ZoneId zoneId = ZoneId.of( "America/Montreal" );
// CRITICAL: Read the class doc to understand the policy used by java.time to handle DST cutovers when moving a LocalDateTime value into a zoned value.
// http://docs.oracle.com/javase/8/docs/api/java/time/ZonedDateTime.html#of-java.time.LocalDateTime-java.time.ZoneId-
ZonedDateTime start = ZonedDateTime.of( LocalDateTime.parse( "2015-09-07T00:00" ), zoneId );
ZonedDateTime stop = ZonedDateTime.of( LocalDateTime.parse( "2015-09-07T08:00" ), zoneId );
Integer recurrence = 2;

LocalDate givenDate = LocalDate.now( zoneId );  // The given date being in the same time zone as our shift start date-time is a *critical* assumption.
LocalDate startLocalDate = start.toLocalDate( );
Period period = Period.between( startLocalDate, givenDate );
int days = period.getDays( );
int mod = ( days % recurrence );
Boolean eventHappensOnThatDate = ( mod == 0 );

转储到控制台。

System.out.println("Does event: " + start   + "/" + stop + " recurring every " + recurrence + " days happen on " + givenDate + " ➙ " + eventHappensOnThatDate );

当运行.

Does event: 2015-09-07T00:00-04:00[America/Montreal]/2015-09-07T08:00-04:00[America/Montreal] recurring every 2 days happen on 2015-09-11 ➙ true

避免使用类型 ZonedDateTime - 请参阅下面的更新

假设您的循环周期始终只包含几天,我看到以下优化(特别是如果您的开始日期时间比您的日期时间参数早得多的情况):

  public boolean includes(ZonedDateTime dateTime) {
    ZonedDateTime tmpStart = start;
    ZonedDateTime tmpEnd = end;

    int distance = (int) ChronoUnit.DAYS.between(start, dateTime) - 1;
    if (distance > 0) {
        int factor = (int) (distance / recurrence.getDays());
        if (factor > 0) {
            Period quickAdvance = recurrence.multipliedBy(factor);
            tmpStart = start.plus(quickAdvance);
            tmpEnd = end.plus(quickAdvance);
        }
    }

    while (!tmpStart.isAfter(dateTime)) { // includes equality - okay for you?
        if (tmpEnd.isAfter(dateTime)) {
            return true;
        }
        tmpStart = tmpStart.plus(recurrence);
        tmpEnd = tmpEnd.plus(recurrence);
    }

    return false;
  }

关于夏令时,只要你能接受 JDK 的标准策略,将无效的本地时间按差距的大小(从冬令时改为夏令时)向前推进 class ZonedDateTime适合这个方案。对于重叠,此 class 提供特殊方法 withEarlierOffsetAtOverlap()withLaterOffsetAtOverlap()

但是,这些功能与如何找出给定的循环间隔是否包含给定的日期时间的问题并不真正相关,因为时区校正将以相同的方式应用于所有涉及的日期时间对象(startenddateTime)前提是所有对象都具有相同的时区。如果您想确定 startend(或 tmpStarttmpEnd)之间的持续时间,DST 则很重要。

更新:

我发现了一个与夏令时相关的隐藏错误。详细:

ZoneId zone = ZoneId.of("Europe/Berlin");
ZonedDateTime start = LocalDate.of(2015, 3, 25).atTime(2, 0).atZone(zone); // inclusive
ZonedDateTime end = LocalDate.of(2015, 3, 25).atTime(10, 0).atZone(zone); // exclusive
Period recurrence = Period.ofDays(2);

ZonedDateTime test = LocalDate.of(2015, 3, 31).atTime(2, 30).atZone(zone); // exclusive
System.out.println("test=" + test); // test=2015-03-31T02:30+02:00[Europe/Berlin]

start = start.plus(recurrence);
end = end.plus(recurrence);
System.out.println("start + 2 days = " + start); // 2015-03-27T02:00+01:00[Europe/Berlin]
System.out.println("end + 2 days =   " + end);   // 2015-03-27T10:00+01:00[Europe/Berlin]

start = start.plus(recurrence); // <- DST change to summer time!!!
end = end.plus(recurrence);
System.out.println("start + 4 days = " + start); // 2015-03-29T03:00+02:00[Europe/Berlin]
System.out.println("end + 4 days =   " + end);   // 2015-03-29T10:00+02:00[Europe/Berlin]

start = start.plus(recurrence);
end = end.plus(recurrence);
System.out.println("start + 6 days = " + start); // 2015-03-31T03:00+02:00[Europe/Berlin]
System.out.println("end + 6 days =   " + end);   // 2015-03-31T10:00+02:00[Europe/Berlin]

boolean includes = !start.isAfter(test) && end.isAfter(test);
System.out.println("includes=" + includes); // false (should be true!!!)

由于 DST 效应,向 ZonedDateTime 重复添加一个句点可能会永远改变本地时间间隔。但固定工作时间表通常根据本地时间戳定义(在示例中为凌晨 2 点至上午 10 点)。因此,在基本本地时间问题上应用一种通用时间戳本质上是错误的。

解决匹配工作时间间隔问题的正确数据类型是:LocalDateTime:

  public boolean includes(LocalDateTime dateTime) {
    LocalDateTime tmpStart = start;
    LocalDateTime tmpEnd = end;

    int distance = (int) ChronoUnit.DAYS.between(start, dateTime) - 1;
    if (distance > 0) {
        int factor = (int) (distance / recurrence.getDays());
        if (factor > 0) {
            Period quickAdvance = recurrence.multipliedBy(factor);
            tmpStart = start.plus(quickAdvance);
            tmpEnd = end.plus(quickAdvance);
        }
    }

    while (!tmpStart.isAfter(dateTime)) {
        if (tmpEnd.isAfter(dateTime)) {
            return true;
        }
        tmpStart = tmpStart.plus(recurrence);
        tmpEnd = tmpEnd.plus(recurrence);
    }

    return false;
  }

确定以小时为单位的物理实际工作时间是否也是正确的数据类型?是的,如果您将 LocalDateTime 与时区结合使用。

int minutes = (int) ChronoUnit.MINUTES.between(start.atZone(zone), end.atZone(zone));
System.out.println("hours=" + minutes / 60); // 7 (one hour less due to DST else 8)

结论:

类型ZonedDateTime有很多disadvantages。一个例子是这里涉及的时间算术。通过选择 LocalDateTime 和显式时区参数的组合,可以更好地解决大多数 DST 相关问题。

关于无效当地时间的解析:

好问题。 JSR-310 只提供了一个内置的 transition strategy for gaps,因此解析无效本地时间 02:30 的最终结果将是 3:30,而不是 3:00。这也是类型 ZonedDateTime 的作用。引用:

...the local date-time is adjusted to be later by the length of the gap. For a typical one hour daylight savings change, the local date-time will be moved one hour later into the offset typically corresponding to "summer".

但是,可以应用以下解决方法来实现下一个有效时间(注意,ZoneRules.getTransition(LocalDateTime) 的文档显示了错误的代码架构和示例):

LocalDateTime ldt = LocalDateTime.of(2015, 3, 29, 2, 30, 0, 0);
ZoneRules rules = ZoneId.of("Europe/Berlin").getRules();
ZoneOffsetTransition conflict = rules.getTransition(ldt);
if (conflict != null && conflict.isGap()) {
    ldt = conflict.getDateTimeAfter();
}
System.out.println(ldt); // 2015-03-29T03:00

对比一下(因为你原来也在其他库求过解决方法):Joda-Time只会在本地时间无效的情况下抛出异常,可能不是什么你要。在我的库 Time4J 中,我定义了对应于 LocalDateTime 的类型 PlainTimestamp 并以这种方式解决了解析问题(没有 if-else-constructions):

import static net.time4j.tz.GapResolver.NEXT_VALID_TIME;
import static net.time4j.tz.OverlapResolver.EARLIER_OFFSET;

PlainTimestamp tsp = PlainTimestamp.of(2015, 3, 29, 2, 30);
Moment nextValidTime = // equivalent of java.time.Instant
  tsp.in(Timezone.of(EUROPE.BERLIN).with(NEXT_VALID_TIME.and(EARLIER_OFFSET)));
tsp = nextValidTime.toZonalTimestamp(EUROPE.BERLIN);
System.out.println(tsp);
// 2015-03-29T03 (minute is zero and therefore left out here)