3

我正在使用非常简单的重复模式(每 x 天或每周)对 Java 中的重复事件进行建模。给定一个带有 的事件对象startDateTimeendDateTime以及以天为单位的重复周期(其类型Period来自 java.time 包),我想找出事件是否在给定日期发生(考虑 DST)。

关于“考虑夏令时”的一些背景(在评论中提出要求后):

  • 这些事件用于安排员工的工作班次。
  • 员工 24/7 轮班工作(通常为 8 小时)
  • 夜班从 0:00 开始,到 8:00 结束。因此,在 DST 更改时,班次可以是 7 小时或 9 小时。在任何情况下都不应该有差距。

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

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)?我还想在日历上显示事件。

更新可能的重复使用我上面使用的完全相同的算法。有没有更快的方法可以在不遍历所有日期的情况下做到这一点?

4

2 回答 2

2

避免使用类型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;
  }

关于 DST,只要您对 JDK 的标准策略感到满意,即按差距的大小(从冬季时间更改为夏季时间)推动无效本地时间,该类就ZonedDateTime适合此方案。对于重叠,此类提供特殊方法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!!!)

ZonedDateTime由于 DST 效应,重复添加一个周期可以永远改变本地时间间隔。但是固定的工作时间表通常是根据本地时间戳定义的(在示例中为凌晨 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;
  }

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

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有很多缺点。一个例子是这里涉及的时间算术。LocalDateTime通过选择明确的时区参数的组合,可以更好地解决大多数与 DST 相关的问题。

关于解决无效当地时间:

好问题。JSR-310 只为间隙提供了一种内置转换策略,因此解决无效本地时间 02:30 的最终结果将是 3:30,而不是 3:00。这也是类型ZonedDateTime所做的。引文:

...本地日期时间根据间隙的长度调整为较晚。对于典型的一小时夏令时更改,本地日期时间将在一小时后移动到通常对应于“夏季”的偏移量中。

但是,可以应用以下解决方法来实现下一个有效时间(请注意,文档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中,我定义了PlainTimestamp对应的类型LocalDateTime并以这种方式解决了解决问题(没有 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)
于 2015-09-15T16:55:58.800 回答
1

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

您的问题的核心似乎是:

查明事件是否在给定日期发生(考虑 DST)

  • 假设您的意思是仅限日期。更具体地说,假设您的意思是给定日期与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
于 2015-09-11T23:47:00.633 回答