【问题标题】:Joda Time LocalTime of 24:00 end-of-dayJoda Time LocalTime of 24:00 end-of-day
【发布时间】:2011-03-21 23:24:03
【问题描述】:

我们正在创建一个日程安排应用程序,我们需要表示某人在白天的可用日程安排,无论他们在哪个时区。从 Joda Time 的 Interval 中得到启发,它表示两个实例之间的绝对时间间隔 (开始包含,结束排他),我们创建了一个 LocalInterval。 LocalInterval 由两个 LocalTime 组成(开始包含,结束排除),我们甚至制作了一个方便的类来在 Hibernate 中持久化它。

例如,如果某人从下午 1:00 到下午 5:00 有空,我们将创建:

new LocalInterval(new LocalTime(13, 0), new LocalTime(17, 0));

到目前为止一切顺利——直到有人想在某天晚上 11:00 到午夜有空。由于间隔的结束是排他性的,因此应该可以很容易地表示为:

new LocalInterval(new LocalTime(23, 0), new LocalTime(24, 0));

确认!不去。这会引发异常,因为 LocalTime 不能容纳任何大于 23 的小时。

这对我来说似乎是一个设计缺陷 --- Joda 没有考虑到有人可能想要一个表示非包容性端点的 LocalTime。

这真的令人沮丧,因为它在我们创建的非常优雅的模型中炸开了一个洞。

除了分叉 Joda 并取出第 24 小时的支票之外,我还有哪些选择? (不,我不喜欢使用虚拟值——比如 23:59:59——来表示 24:00。)

更新:对于那些一直说没有 24:00 这样的事情的人,这里引用 ISO 8601-2004 4.2.3 注释 2,3:“一个日历日的结束 [24:00] 恰逢[00:00] 在下一个日历日的开始......”和“[hh] 具有值 [24] 的表示仅优选表示时间间隔的结束......”

【问题讨论】:

  • 从您的问题的上下文来看,我猜您的意思是说“LocalInterval 由两个 LocalTimes(开始包含,结束排除)组成,......”
  • @MusiGenesis:是的,这是一个错字。我已经更新了问题以指示结束排他性。谢谢。
  • 非常有趣的问题。前段时间它启发了我将 24:00 的功能引入我自己的日期/时间库 Time4J。它与LocalTime类似的挂件,即PlainTime支持稍宽的00:00/24:00范围。而且我发现这个功能不会造成任何问题(时间顺序仍然很清楚),但可以帮助以更优雅的方式解决一些其他问题(例如 IANA-TZDB 也使用值 24:00)。

标签: java jodatime intervals localtime


【解决方案1】:

这不是设计缺陷。 LocalDate 不处理 (24,0) 因为没有 24:00 这样的事情。

另外,如果您想表示晚上 9 点到凌晨 3 点之间的时间间隔,会发生什么情况?

这是怎么回事:

new LocalInterval(new LocalTime(23, 0), new LocalTime(0, 0));

您只需要处理结束时间可能“早于”开始时间的可能性,并在必要时添加一天,并希望没有人希望表示超过 24 小时的时间间隔。

或者,将区间表示为LocalDateDurationPeriod 的组合。这消除了“超过 24 小时”的问题。

【讨论】:

  • “[T]这里没有 24:00 这样的事情”。确实有。参见 ISO 8601-2004 第 4.2.3 节“午夜”。因为我的目的是排他性的,这正是我想要的。
  • 您给出的示例的问题在于它代表了一个负时间间隔,即从晚上 11:00 到凌晨 00:00,发生在 11 小时前。考虑从 00:00 到 00:00 的时间间隔——这是一个没有长度的时间间隔,还是一整天的时间间隔(忽略 DST 变化)?应该是前者---一整天的时间间隔,不包括结束,将是 00:00 到 24:00。如果不是因为某处不喜欢值 24 而引发异常的单行代码,这一切都会很好地工作并且符合 ISO 8601。
  • @GarretWilson 这是一个好方法。只需考虑该区间是半开放的,包含独占。如果 begin
【解决方案2】:

为了完整起见,此测试失败:

@Test()
public void testJoda() throws DGConstraintViolatedException {
    DateTimeFormatter simpleTimeFormatter = DateTimeFormat.forPattern("HHmm");
    LocalTime t1 = LocalTime.parse("0000", simpleTimeFormatter);
    LocalTime t2 = LocalTime.MIDNIGHT;
    Assert.assertTrue(t1.isBefore(t2));
}  

这意味着 MIDNIGHT 常量对于问题不是很有用,正如有人建议的那样。

【讨论】:

    【解决方案3】:

    23:59:59 之后就是第二天的 00:00:00。所以也许在下一个日历日使用LocalTime0, 0

    尽管您的开始时间和结束时间都包含在内,但无论如何,23:59:59 确实是您想要的。这包括第 23 小时第 59 分钟的第 59 秒,并且正好在 00:00:00 结束范围。

    没有 24:00(使用LocalTime 时)。

    【讨论】:

    • 抱歉,我原来的问题有错字。我的意思是“结束独家”,所以我想要的是 24:00。确实有24:00这样的事情。参见 ISO 8601-2004 第 4.2.3 节“午夜”。因为我的目的是排他性的,这正是我想要的。
    • @garrett-wilson - 在阅读了一些 Joda 文档后,我有一些建议。首先,您是否尝试过使用LocalTime.MIDNIGHT 常量?其次,规范所说的和实现的内容不一定相同。你能试试System.out.println(new LocalTime(12,0).getChronology().hourOfDay().getMaximumValue())之类的吗?如果该值小于 24,则执行中不存在 24:00。这可能是您可以在 Joda 中提出的错误。
    • "23:59:59 之后就是第二天的 00:00:00。"不必要。有时在 23:59:59 之后是 23:59:60 - hpiers.obspm.fr/iers/bul/bulc/bulletinc.dat
    • "没有 24:00 这样的事情。"不准确。 LocalTime 不支持 days > 23:59 但这并不是说它们不存在。 GTFS 每天 24 小时以上运行。 developers.google.com/transit/gtfs/reference/#stop_timestxt
    【解决方案4】:

    我们最终采用的解决方案是使用 00:00 作为 24:00 的替代,在整个班级和应用程序的其余部分使用逻辑来解释这个本地值。这是一个真正的组合,但它是我能想到的最不打扰和最优雅的东西。

    首先,LocalTimeInterval 类保留一个内部标志,表明间隔端点是否是一天结束的午夜 (24:00)。此标志仅在结束时间为 00:00(等于 LocalTime.MIDNIGHT)时为真。

    /**
     * @return Whether the end of the day is {@link LocalTime#MIDNIGHT} and this should be considered midnight of the
     *         following day.
     */
    public boolean isEndOfDay()
    {
        return isEndOfDay;
    }
    

    默认情况下,构造函数将 00:00 视为一天的开始,但还有一个替代构造函数用于手动创建一整天的间隔:

    public LocalTimeInterval(final LocalTime start, final LocalTime end, final boolean considerMidnightEndOfDay)
    {
        ...
        this.isEndOfDay = considerMidnightEndOfDay && LocalTime.MIDNIGHT.equals(end);
    }
    

    这个构造函数不只是有一个开始时间和一个“是一天结束”标志是有原因的:当与带有时间下拉列表的 UI 一起使用时,我们不知道是否用户将选择 00:00(呈现为 24:00),但我们知道,由于下拉列表是范围的末尾,在我们的用例中,它表示 24:00。 (虽然 LocalTimeInterval 允许空间隔,但我们不允许在我们的应用程序中使用它们。)

    重叠检查需要特殊的逻辑来处理 24:00:

    public boolean overlaps(final LocalTimeInterval localInterval)
    {
        if (localInterval.isEndOfDay())
        {
            if (isEndOfDay())
            {
                return true;
            }
            return getEnd().isAfter(localInterval.getStart());
        }
        if (isEndOfDay())
        {
            return localInterval.getEnd().isAfter(getStart());
        }
        return localInterval.getEnd().isAfter(getStart()) && localInterval.getStart().isBefore(getEnd());
    }
    

    同样,如果 isEndOfDay() 返回 true,则转换为绝对间隔需要在结果中再增加一天。重要的是,应用程序代码永远不要从 LocalTimeInterval 的开始和结束值手动构造间隔,因为结束时间可能表示一天结束:

    public Interval toInterval(final ReadableInstant baseInstant)
    {
        final DateTime start = getStart().toDateTime(baseInstant);
        DateTime end = getEnd().toDateTime(baseInstant);
        if (isEndOfDay())
        {
            end = end.plusDays(1);
        }
        return new Interval(start, end);
    }
    

    当在数据库中持久化 LocalTimeInterval 时,我们能够使组合完全透明,因为 Hibernate 和 SQL 没有 24:00 限制(而且确实没有 LocalTime 的概念)。如果 isEndOfDay() 返回 true,我们的 PersistentLocalTimeIntervalAsTime 实现将存储并检索 24:00 的真实时间值:

        ...
        final Time startTime = (Time) Hibernate.TIME.nullSafeGet(resultSet, names[0]);
        final Time endTime = (Time) Hibernate.TIME.nullSafeGet(resultSet, names[1]);
        ...
        final LocalTime start = new LocalTime(startTime, DateTimeZone.UTC);
        if (endTime.equals(TIME_2400))
        {
            return new LocalTimeInterval(start, LocalTime.MIDNIGHT, true);
        }
        return new LocalTimeInterval(start, new LocalTime(endTime, DateTimeZone.UTC));
    

        final Time startTime = asTime(localTimeInterval.getStart());
        final Time endTime = localTimeInterval.isEndOfDay() ? TIME_2400 : asTime(localTimeInterval.getEnd());
        Hibernate.TIME.nullSafeSet(statement, startTime, index);
        Hibernate.TIME.nullSafeSet(statement, endTime, index + 1);
    

    很遗憾,我们不得不首先编写一个解决方法;这是我能做的最好的了。

    【讨论】:

    • 对于调度应用,我认为最优雅的解决方案。有多少次,Outlook 中的某个人试图通过匹配确切的时间来代表他们的“全天事件”:将其转换为我们的时区差异,我们的整个日历一团糟。这正是为什么在 Outlook 中你有一个单独的切换来代表一整天的原因。
    【解决方案5】:

    您的问题可以定义为在环绕的域上定义一个间隔。您的最小值为 00:00,最大值为 24:00(不含)。

    假设您的区间定义为(下限,上限)。如果您要求 lower

    我不知道您的日程安排应用程序是否会涉及环绕间隔,但如果您要在不涉及天数的情况下前往 (21:00, 24:00),我看不出有什么会阻止您需要 (21:00, 02:00) 而不涉及天数(从而导致环绕维度)。

    如果您的设计适用于环绕式实现,则区间运算符非常简单。

    例如(在伪代码中):

    is x in (lower, upper)? :=
    if (lower <= upper) return (lower <= x && x <= upper)
    else return (lower <= x || x <= upper)
    

    在这种情况下,我发现围绕 Joda-Time 编写一个实现运算符的包装器非常简单,并且减少了思想/数学和 API 之间的阻抗。即使只是为了将 24:00 包含为 00:00。

    我同意 24:00 的排除在一开始让我很恼火,如果有人提供解决方案会很好。对我来说幸运的是,考虑到我对时间间隔的使用主要由环绕语义支配,我总是最终得到一个包装器,它顺便解决了 24:00 排除问题。

    【讨论】:

      【解决方案6】:

      24:00 是一个艰难的时间。虽然我们人类可以理解这意味着什么,但编写一个 API 来表示它而不会对其他一切产生负面影响在我看来几乎是不可能的。

      无效的值 24 在 Joda-Time 中被深度编码 - 试图删除它会在很多地方产生负面影响。我不建议尝试这样做。

      对于您的问题,本地间隔应包含(LocalTime, LocalTime, Days)(LocalTime, Period)。后者稍微灵活一些。这是正确支持从 23:00 到 03:00 的时间间隔所必需的。

      【讨论】:

      • 因此请考虑一下您的 (LocalTime, Period) 提案在 2011 年 3 月 13 日在美国是如何运作的。如果我在周日 00:00-12:00 标记我的可用性,则使用您的方法将使用 (00:00, 12hours)。因为我们在 3 月 13 日损失了一个小时,所以您的表示将产生当天的 00:00-13:00,因为在午夜的 DateTime 上增加 12 小时将得到下午 1:00。如果我将结束时间保留为 LocalTime,则将 12:00 解析为午夜的 DateTime 将为我提供正确的时间 --- 由于 DST,2011-03-13 的间隔会更短。
      【解决方案7】:

      我觉得JodaStephen(LocalTime, LocalTime, Days)的提议可以接受。

      考虑到 2011 年 3 月 13 日和周日 00:00-12:00 有空,(00:00, 12:00, 0) 实际上是 11 小时,因为 DST。

      例如 15:00-24:00 的可用性,然后您可以将其编码为 (15:00, 00:00, 1),这将扩展为 2011-03-13T15:00 - 2011-03-14T00:00,而结束则需要 2011-03 -13T24:00。这意味着您将在下一个日历日使用 00:00 的 LocalTime,就像已经建议的 aroth 一样。

      当然,直接使用 24:00 LocalTime 并符合 ISO 8601 会很好,但如果不在 JodaTime 内部进行大量更改,这似乎是不可能的,因此这种方法似乎不太危险。

      最后但并非最不重要的一点是,您甚至可以使用 (16:00, 05:00, 1) 之类的东西来延长一天的障碍...

      【讨论】:

        【解决方案8】:

        这是我们对 TimeInterval 的实现,使用 null 作为结束日期的结束日期。它支持overlaps() 和contains() 方法,也基于joda-time。它支持跨越多天的时间间隔。

        /**
         * Description: Immutable time interval<br>
         * The start instant is inclusive but the end instant is exclusive.
         * The end is always greater than or equal to the start.
         * The interval is also restricted to just one chronology and time zone.
         * Start can be null (infinite).
         * End can be null and will stay null to let the interval last until end-of-day.
         * It supports intervals spanning multiple days.
         */
        public class TimeInterval {
        
            public static final ReadableInstant INSTANT = null; // null means today
        //    public static final ReadableInstant INSTANT = new Instant(0); // this means 1st jan 1970
        
            private final DateTime start;
            private final DateTime end;
        
            public TimeInterval() {
                this((LocalTime) null, null);
            }
        
            /**
             * @param from - null or a time  (null = left unbounded == LocalTime.MIDNIGHT)
             * @param to   - null or a time  (null = right unbounded)
             * @throws IllegalArgumentException if invalid (to is before from)
             */
            public TimeInterval(LocalTime from, LocalTime to) throws IllegalArgumentException {
                this(from == null ? null : from.toDateTime(INSTANT),
                        to == null ? null : to.toDateTime(INSTANT));
            }
        
            /**
             * create interval spanning multiple days possibly.
             *
             * @param start - start distinct time
             * @param end   - end distinct time
             * @throws IllegalArgumentException - if start > end. start must be <= end
             */
            public TimeInterval(DateTime start, DateTime end) throws IllegalArgumentException {
                this.start = start;
                this.end = end;
                if (start != null && end != null && start.isAfter(end))
                    throw new IllegalArgumentException("start must be less or equal to end");
            }
        
            public DateTime getStart() {
                return start;
            }
        
            public DateTime getEnd() {
                return end;
            }
        
            public boolean isEndUndefined() {
                return end == null;
            }
        
            public boolean isStartUndefined() {
                return start == null;
            }
        
            public boolean isUndefined() {
                return isEndUndefined() && isStartUndefined();
            }
        
            public boolean overlaps(TimeInterval other) {
                return (start == null || (other.end == null || start.isBefore(other.end))) &&
                        (end == null || (other.start == null || other.start.isBefore(end)));
            }
        
            public boolean contains(TimeInterval other) {
                return ((start != null && other.start != null && !start.isAfter(other.start)) || (start == null)) &&
                        ((end != null && other.end != null && !other.end.isAfter(end)) || (end == null));
            }
        
            public boolean contains(LocalTime other) {
                return contains(other == null ? null : other.toDateTime(INSTANT));
            }
        
            public boolean containsEnd(DateTime other) {
                if (other == null) {
                    return end == null;
                } else {
                    return (start == null || !other.isBefore(start)) &&
                            (end == null || !other.isAfter(end));
                }
            }
        
            public boolean contains(DateTime other) {
                if (other == null) {
                    return start == null;
                } else {
                    return (start == null || !other.isBefore(start)) &&
                            (end == null || other.isBefore(end));
                }
            }
        
            @Override
            public String toString() {
                final StringBuilder sb = new StringBuilder();
                sb.append("TimeInterval");
                sb.append("{start=").append(start);
                sb.append(", end=").append(end);
                sb.append('}');
                return sb.toString();
            }
        }
        

        【讨论】:

          【解决方案9】:

          这个问题很老,但其中许多答案都集中在 Joda Time 上,并且只部分解决了真正的潜在问题:

          OP 代码中的模型与其建模的现实不符。

          不幸的是,由于您似乎确实关心日期之间的边界条件,因此您的“否则优雅的模型”与您正在建模的问题不匹配。您已使用一对时间值来表示间隔。尝试将模型简化到两次是在低于现实世界问题的复杂性。现实中确实存在日界限,并且有一对时间会丢失这种类型的信息。与往常一样,过度简化会导致后续恢复或补偿丢失信息的复杂性。真正的复杂性只能从代码的一部分推到另一部分。

          只有借助“不受支持的用例”的魔力,才能消除现实的复杂性。

          您的模型仅在问题空间中才有意义,即人们并不关心开始时间和结束时间之间可能存在多少天。该问题空间与大多数现实世界的问题不匹配。因此,Joda Time 不能很好地支持也就不足为奇了。小时位置 (0-24) 使用 25 个值是code smell,通常表明设计存在缺陷。一天只有 24 小时,所以不需要 25 个值!

          请注意,由于您没有捕获LocalInterval 两端的日期,因此您的班级也没有捕获足够的信息来解释夏令时。 [00:30:00 TO 04:00:00) 通常为 3.5 小时,但也可能为 2.5 或 4.5 小时。

          您应该使用开始日期/时间和持续时间,或者使用开始日期/时间和结束日期/时间(包括开始,不包括结束is a good default choice)。由于夏令时、闰年和闰秒等原因,如果您打算显示结束时间,则使用持续时间会变得很棘手。另一方面,如果您希望显示持续时间,则使用结束日期变得同样棘手。存储两者当然是危险的,因为它违反了DRY principle。如果我正在编写这样一个类,我将存储结束日期/时间并封装通过对象上的方法获取持续时间的逻辑。这样类类的客户就不会都想出自己的代码来计算持续时间。

          我会编写一个示例,但还有更好的选择。使用来自 Joda 时间的标准 Interval 类,它已经接受了开始时刻以及持续时间或结束时刻。它还将愉快地为您计算持续时间或结束时间。遗憾的是,JSR-310 没有间隔或类似的类。 (虽然可以使用ThreeTenExtra 来弥补)

          Joda Time 和 Sun/Oracle (JSR-310) 相对聪明的人都非常仔细地考虑过这些问题。你可能比他们聪明。这是可能的。然而,即使你是一个更亮的灯泡,你的 1 小时也可能无法完成他们花费数年的时间。除非您处于深奥的边缘案例中,否则花费精力对它们进行第二次猜测通常是浪费时间和金钱。 (当然在 OP JSR-310 还没有完成的时候......)

          希望以上内容对在设计或解决类似问题时发现此问题的人们有所帮助。

          【讨论】:

            猜你喜欢
            • 1970-01-01
            • 2019-09-25
            • 1970-01-01
            • 2018-10-14
            • 2022-12-27
            • 1970-01-01
            • 1970-01-01
            • 1970-01-01
            • 1970-01-01
            相关资源
            最近更新 更多