如何正确使用java.time类?

How to use java.time classes correctly?

提问人:Paul Marcelin Bejan 提问时间:10/8/2023 最后编辑:Paul Marcelin Bejan 更新时间:10/10/2023 访问量:213

问:

我知道经过 3 年的软件开发,这听起来可能很尴尬,但我正在努力更好地理解 java.time 类以及如何使用它们来避免错误和不良做法。

在我参与的第一个项目中,使用的 java 版本是 8,但日期值存储在旧类 java.util.Date 上

两年后,新的公司,新的项目,java版本17,但仍然将日期值存储在旧类java.util.Date中

当我看到项目上有一个 DateUtils 时,混乱就开始了,其方法如下:

public static String toString_ddMMyyyy_dotted(Date date){
    LocalDateTime localDateTime = date.toInstant().atZone(ZoneId.systemDefualt()).toLocalDateTime();
    DateTimeFormatter.ofPattern("dd.MM.yyyy").format(localDateTime);
}

public static Date getBeginOfTheYear(Date date){
    LocalDate localDate = date.toInstant().atZone(ZoneId.systemDefualt()).toLocalDate();
    LocalDate beginOfTheYear = localDate.withMonth(1).withDayOfMonth(1);
    return Date.from(beginOfTheYear.atStartOfDay().atZone(ZoneId.systemDefault()).toInstant());
}

所以我的问题是,为什么不直接使用具有非常好的 API 的 java.time 类,而不是在需要对 Date 进行操作时将变量转换为 LocalDate/LocalDateTime?

这个想法是尽快重构项目,将 Date/DateTime/Time 值存储到 java.time 类中。 我从一个简单的模块开始,该模块将一些数据保存到 MongoDB 中。 我应用了以下字段类型转换:

  • 将日期值转换为 LocalDate
  • 将日期和时间值转换为 LocalDateTime

在测试过程中,我立即发现了一个问题,一个值为 2023-10-07 12:00:00 的 localDateTime 变量被保存在 MongoDB 上为 2023-10-07 10:00:00 只要将数据提取回 java 时,值回到 2023-10-07 12:00:00,这不是问题,但这并没有发生,所以这是一个大问题。

首先,这是否仅在使用 LocalDateTime 时发生,还是也发生在 LocalDate 中?

在实体/文档类上存储日期/日期时间值的最佳做法是什么?我应该使用 ZonedDateTime 而不是 LocalDateTime 吗?

java java-time localdate

评论

3赞 MadProgrammer 10/8/2023
“所以我的问题是,为什么不直接使用 java.time 类” - 遗留 - 更新和测试遗留代码所涉及的时间、精力和金钱,更不用说风险有时意味着这些方面在很多很多年里保持不变
3赞 MadProgrammer 10/8/2023
“在测试过程中,我立即发现了一个问题,一个localDateTime...” - 我的第一个想法是,了解MangoDB运行的时区,这可能会影响日期/时间值的处理方式。您应该尽可能多地保存时区信息或将日期/时间值从/转换为 UTC - 当向用户显示时,将它们转换为(除非您需要使用保存的时区)LocalDateTime
1赞 MadProgrammer 10/8/2023
关键是 - 数据库应该是不可知的 - 即它不应该依赖于定义值的时区,而是定义有关如何将日期/时间值存储到数据库中的规则(即始终是 UTC) - 这样,当从数据库读取值时,您将始终知道源时区。是的,我曾为一家全球安全公司工作,该公司拒绝提供事件的时区信息(因为更新所有软件需要花费很多钱)......因此,请大胆猜测该事件发生🙄的时间
3赞 Stephen C 10/8/2023
“最佳实践”将包括根本不使用......java.util.Date
3赞 Anonymous 10/8/2023
我知道经过 3 年的软件开发,这听起来可能很尴尬,......对我来说,这听起来一点也不尴尬。正确的日期和时间处理很复杂,需要时间来学习。

答:

2赞 Basil Bourque 10/8/2023 #1

转换而不是改造

这个想法是尽快重构项目,将 Date/DateTime/Time 值存储到 java.time 类中

根据代码库的大小和复杂性,这可能是相当危险的。

一种更保守的方法是在 java.time 中进行所有新的编程。要与尚未更新到 java.time 的旧代码进行互操作,请转换。您将在旧类上找到新的转换方法。查找 & 方法。这些转换方法提供完整的覆盖范围,让您可以来回切换。to…from…

修改代码时需要注意的另一句话:许多/大多数程序员对日期时间处理的理解很差。这个话题出奇地棘手和复杂,第一次遇到这些概念时很滑。在处理编程和数据库的严格性时,我们对日期时间的日常理解实际上对我们不利。因此,要小心糟糕的代码,错误的代码,它们会错误地处理日期时间。每次发现此类错误代码时,您都会遇到麻烦,因为可能生成了显示错误结果的报告,数据库可能存储无效数据等。

您可以搜索现有的 Stack Overflow 问题和答案以了解更多信息。以下是一些简短的要点,可帮助您指导您。

时刻与非时刻

将日期值转换为 LocalDate

将日期和时间值转换为 LocalDateTime

不對。或者不完整,我应该说。

我建议你这样构建你的思维......有两种时间跟踪:

  • 力矩(确定)
  • 不是一刻(无限期)

时刻

时刻是时间轴上的一个特定点。自 UTC 中 1970 年第一个时刻的纪元引用以来,时刻在 java.time 中以整秒计数,加上小数秒以纳秒计数进行跟踪。“in UTC”是“与UTC时间子午线的偏移量为零小时-分钟-秒”的缩写。

表示时刻的基本类是 。此类的对象表示在 UTC 中看到的时刻,始终以 UTC 表示。这个类是 jav.time 框架的基本构建块。这门课应该是你在处理时刻时的第一个也是最后一个想法。用于跟踪某个时刻,除非您的业务规则专门涉及特定区域或偏移量。并特别替换为 ,两者都表示 UTC 中的某个时刻,但比毫秒的分辨率更精细,达到纳秒。java.time.InstantInstantjava.util.Datejava.time.InstantInstantjava.util.Date

另外两个类在 java.time 中跟踪时刻:& .OffsetDateTimeZonedDateTime

OffsetDateTime表示与 UTC 偏移量不为零时所看到的时刻。偏移量仅比 UTC 提前或晚几小时/分钟-秒。因此,巴黎的人们将时钟设置在UTC前一两个小时,而东京的人们将时钟设置在九小时前,而加拿大新斯科舍省的人们则将时钟设置在UTC前三到四个小时。

时区不仅仅是一个偏移量。时区是特定地区人民根据其政治家决定的偏移量的过去、现在和未来变化的命名历史。时区的名称格式为 & 。Continent/RegionEurope/ParisAsia/Tokyo

要通过特定区域的挂钟时间查看日期和时间,请使用 。ZonedDateTime

请注意,您可以轻松干净地在这三个类之间来回移动,以及通过它们的 、 和 方法。它们提供了三种看待同一时刻的方式。通常,您在业务逻辑和数据存储和数据交换中使用,同时用于向用户演示。InstantOffsetDateTimeZonedDateTimeto…at…with…InstantZonedDateTime

不是片刻

另一方面,我们有“没有片刻”的跟踪。这些是不确定类型。

最引起混淆的 not-a-moment 类是 。此类表示具有时间的日期,但缺少任何偏移量或时区的概念。因此,如果我说 2024 年 1 月 23 日中午,你无法知道我指的是东京的中午、图卢兹的中午还是美国俄亥俄州托莱多的中午——三个截然不同的时刻相隔几个小时。LocalDateTimeLocalDateTime.of( 2024 , Month.JANUARY , 23 , 12 , 0 , 0 , 0 )

如有疑问,请勿使用 .通常,在编写业务应用程序时,我们关心时刻,因此很少使用 .LocalDateTimeLocalDateTime

最大的例外是,当我们最常需要在商业应用程序中时,是预约,例如牙医。在那里,我们需要单独保存 和 时区 (),而不是将它们组合成一个 .原因至关重要:政客们经常改变时区规则,而且他们这样做是不可预测的,而且往往几乎没有预警。因此,如果那里的政客决定,下午3点的牙医任命可能会提前一小时或晚一个小时:LocalDateTimeLocalDateTimeZoneIdZonedDateTime

  • 采用夏令时 (DST)。
  • 放弃 DST。
  • 全年保持夏令时(最新的时尚)。
  • 不同的抵消将带来商机。示例:冰岛采用偏移量为零。
  • 不同的抵消将产生政治影响。例如:印度在广阔的次大陆上建立了一个单一的时区。
  • 调整他们的时钟作为相对于邻国的外交举动。示例:朝鲜更改其偏移量以匹配韩国。
  • 采用入侵/占领军的偏移。

所有这些发生的频率都比大多数人意识到的要高得多。这样的更改会不必要地破坏幼稚的应用程序。

其他不确定类型包括:

  • LocalDate对于没有时间且没有任何区域/偏移量的仅日期值。
  • LocalTime对于没有任何日期和任何区域/偏移量的仅时间值。

这可能很棘手,因为许多人不知道,在任何给定的时刻,日期在全球范围内因时区而异。现在是日本东京的“明天”,同时也是加拿大埃德蒙顿的“昨天”。LocalDate

您的代码

LocalDateTime localDateTime = date.toInstant().atZone(ZoneId.systemDefualt()).toLocalDateTime();

该代码有两个问题。

  • 一是使用不当。var 后面的类表示偏移量为零的时刻。在转换为 时,您丢弃了关键信息,即偏移量。LocalDateTimejava.util.DatedateLocalDateTime
  • 另一个问题是使用 .这意味着结果会因 JVM 的当前默认时区而异。如上所述,这意味着日期可能会有所不同,而不仅仅是一天中的时间。在两台不同计算机上运行的相同代码可能会生成两个不同的日期。这可能是你想要的应用程序,或者这可能是对一个不知情的程序员的粗鲁唤醒。ZoneId.systemDefualt()

您的另一段代码也有问题。

public static Date getBeginOfTheYear(Date date){
    LocalDate localDate = date.toInstant().atZone(ZoneId.systemDefualt()).toLocalDate();
    LocalDate beginOfTheYear = localDate.withMonth(1).withDayOfMonth(1);
    return Date.from(beginOfTheYear.atStartOfDay().atZone(ZoneId.systemDefault()).toInstant());
}

首先,请注意,不幸的是,在旧版日期时间类中有两个类:一会儿,它假装代表一个仅日期,但实际上是一个时刻(在一个非常糟糕的类设计决策中)。我会承担你的手段.Datejava.util.Datejava.sql.Datejava.util.Date

其次,避免使用此类代码的此类调用链。可读性、调试和日志记录都变得更加困难。在进行一系列转换时,请改为编写简单的短行。在每一行上使用注释来证明操作的合理性,并解释您的动机。并且,出于同样的原因避免使用;执行此类转换时,请使用显式返回类型。var

这很好。当遇到 时,立即转换为 .date.toInstant()java.util.DateInstant

同样,使用意味着代码的结果会因任何更改默认时区的系统管理员或用户的心血来潮而有所不同。这可能是此代码作者的意图,但我对此表示怀疑。ZoneId.systemDefualt()

当您不传递任何参数时,部件 beginOfTheYear.atStartOfDay() 会生成 LocalDateTime。因此,您再次丢弃了有价值的信息(偏移量),而没有获得任何回报。

另一个问题:你的代码甚至无法编译。该方法采用 Instant,而不是调用 beginOfTheYear.atStartOfDay() 返回的 Instantjava.util.Date.fromLocalDateTime

为了正确地获得特定时刻的一年中第一个时刻的第一个时刻,您几乎肯定希望决定业务规则的人决定特定的时区。

ZoneId zTokyo = ZoneId.of( "Asia/Tokyo" ) ;

您确实应该将传入的 .java.util.DateInstant

Instant instant = myJavaUtilDate.toInstant() ;

然后应用该区域。

ZonedDateTime zdt = instant.atZone( zTokyo ) ;

提取年份部分。如上所述,日期可能因时区而异。因此,年份可能会有所不同,具体取决于您在上面的上一行代码中提供的区域!

Year year = Year.from( zdt ) ;

获得那一年的第一天。

LocalDate firstOfYear = year.atDay( 1 ) ;

java.time 确定该区域中当天的第一个时刻。不要假设开始时间是 00:00。某些区域中的某些日期从其他时间开始,例如 01:00。

请注意,在确定一年中的第一个时刻时,我们将参数传递给 ,这与代码相反。此处的结果是代码中的 而不是 。ZoneIdatStartOfDayZonedDateTimeLocalDateTime

ZonedDateTime zdtFirstMomentOfTheYearInTokyo = firstOfYear.atStartOfDay( zTokyo ) ; 

最后,我们转换为 .在绿地代码中,我们会像瘟疫一样避免这个类。但是在您现有的代码库中,我们必须转换以与旧代码中尚未更新到 java.time 的部分进行互操作。java.util.Date

Instant instantFirstMomentOfTheYearInTokyo = zdtFirstMomentOfTheYearInTokyo.toInstant(); 
java.util.Date d = java.util.Date.from( instantFirstMomentOfTheYearInTokyo ) ;

MongoDB数据库

你说:

在测试过程中,我立即发现了一个问题,一个值为 2023-10-07 12:00:00 的 localDateTime 变量被保存在 MongoDB 上为 2023-10-07 10:00:00 只要将数据提取回 java 时,值回到 2023-10-07 12:00:00,这不是问题,但这并没有发生,所以这是一个大问题。

太多了,无法解开,你的细节太少了。

我建议你专门针对这个问题发布另一个问题。提供足够的详细信息以及示例代码以进行诊断。

评论

0赞 Paul Marcelin Bejan 10/10/2023
你有从 1:00 开始的区域示例吗?
2赞 Basil Bourque 10/10/2023
@PaulMarcelinBejan 今年,古巴、埃及、黎巴嫩、巴拉圭的时区。请参阅维基百科