如何正确使用java.time类?

k7fdbhmy  于 2023-10-14  发布在  Java
关注(0)|答案(1)|浏览(80)

我知道这可能听起来很尴尬,经过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());
}

所以我的问题是,为什么不直接使用java.time类,这些类有很好的API,而不是在需要对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吗?

w6mmgewl

w6mmgewl1#

转换而非改造

我们的想法是尽快重构项目,将Date/DateTime/Time值存储到java.time类中
根据代码库的大小和复杂性,这可能相当危险。
一个更保守的方法是在 java.time 中进行所有新的编程。要与尚未更新为 java.time 的旧代码进行互操作,请转换。您将在旧类上找到新的转换方法。查找to…from…方法。这些转换方法提供了完整的覆盖范围,允许您来回转换。
修改代码时的另一个警告:许多/大多数程序员对日期-时间处理的理解很差。这个主题令人惊讶地棘手和复杂,第一次遇到时概念很滑。我们对日期-时间的传统理解在处理严格的编程和数据库时实际上对我们不利。所以要小心那些糟糕的代码,错误的代码,它们会错误地处理日期时间。每次你发现这样的错误代码,你将打开一桶的麻烦,因为报告可能已经产生显示不正确的结果,数据库可能存储无效的数据,等等。
您可以搜索现有的Stack Overflow问题和答案以了解更多信息。下面是一些简短的要点,以帮助指导你。

瞬间与瞬间

将日期值转换为LocalDate
将日期和时间值转换为LocalDateTime
不正确或者说不完整。
我建议你这样思考.有两种时间跟踪:

  • 矩(定)
  • 不一会儿(不定)

瞬间

时刻是时间轴上的一个特定点。在 java.time 中,时刻被跟踪为整秒计数,加上小数秒作为纳秒计数,自UTC中的1970年第一时刻的历元引用以来。“in UTC”是“从UTC的时间子午线偏移0小时-分钟-秒”的缩写。
表示矩的基本类是java.time.Instant。这个类的对象表示在UTC中看到的时刻,总是在UTC中。这个类是 jav.time 框架的基本构建块。这门课应该是你处理时刻的第一个和最后一个想法。使用Instant跟踪某个时刻,除非您的业务规则特别涉及特定区域或偏移量。而java.util.Datejava.time.Instant专门取代,两者都表示UTC中的一个时刻,但Instant的纳秒分辨率比java.util.Date的毫秒分辨率更高。
另外两个类跟踪 java.time 中的时刻:OffsetDateTimeZonedDateTime
OffsetDateTime表示与UTC的偏移量不为零的时刻。一个偏移量仅仅是UTC前后的几个小时-分钟-秒。因此,巴黎的人把墙上的钟调得比协调世界时早一两个小时,而东京的人把钟调得比协调世界时早九个小时,而加拿大新斯科舍省的人把钟调得比协调世界时晚三四个小时。
时区不仅仅是一个偏移。时区是一个命名的历史,过去,现在和未来的变化,由特定地区的人民所使用的偏移,由他们的政治家决定。时区的名称格式为Continent/Region,例如Europe/ParisAsia/Tokyo
要通过特定区域的挂钟时间查看日期和时间,请使用ZonedDateTime
请注意,您可以通过to…at…with…方法在这三个类InstantOffsetDateTimeZonedDateTime之间轻松、干净地来回移动。它们提供了三种看待同一时刻的方式。通常,您在业务逻辑和数据存储以及数据交换中使用Instant,而在向用户展示时使用ZonedDateTime

不一会儿

另一方面,我们有“一刻也没有”的跟踪。这些是不确定的类型。
引起最多混淆的非时刻类是LocalDateTime。这个类表示一个带有时间的日期,但没有任何偏移量或时区的概念。因此,如果我说2024年1月23日中午,LocalDateTime.of( 2024 , Month.JANUARY , 23 , 12 , 0 , 0 , 0 ),你无法知道我是指东京的中午,图卢兹的中午,还是美国俄亥俄州托莱多的中午--三个相隔几个小时的非常不同的时刻。
如果有疑问,请不要使用LocalDateTime。一般来说,在编写商业应用程序时,我们关心的是瞬间,所以很少使用LocalDateTime
最大的例外是,当我们在商业应用程序中最经常需要LocalDateTime时,预约是预约,比如牙医。在这里,我们需要单独保存LocalDateTime和时区(ZoneId,而不是将它们合并为ZonedDateTime。原因很关键: 政客们经常改变时区规则 *,他们这样做是不可预测的,而且往往没有什么预警。因此,下午3点牙医预约可能会提前一个小时,或者晚一个小时,如果那里的政治家决定:

  • 采用夏令时。

  • 放弃DST。

  • 一年四季都在忙着(最新的时尚)。

  • 不同的抵消会带来商业机会。例如:冰岛采用零抵消。

  • 另一种补偿会产生政治影响。例如:印度在广阔的次大陆建立了一个单一的时区。

  • 作为对邻国的外交行动来调整他们的时钟。例如:朝鲜改变了他们的抵消,以配合韩国。

  • 采取抵消入侵/占领部队的措施。

所有这些发生的频率比大多数人意识到的要高得多。这样的变化打破了天真的应用程序编写不必要的。
其他不定类型包括:

  • LocalDate表示仅日期值,没有时间,也没有任何区域/偏移量。
  • LocalTime表示没有任何日期和任何区域/偏移的纯时间值。

LocalDate可能很棘手,因为许多人没有意识到,在任何给定的时刻,日期在地球仪的时区不同。现在在日本东京是“明天”,而在加拿大埃德蒙顿则是“昨天”。

您的代码

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

这个代码有两个问题。

  • 一是LocalDateTime的使用不当。date var后面的java.util.Date类表示偏移量为零的矩。在转换为LocalDateTime时,您将丢弃关键信息(偏移量)。
  • 另一个问题是ZoneId.systemDefualt()的使用。这意味着结果会根据JVM当前默认时区的不同而有所不同。如上所述,这意味着日期可能会有所不同,而不仅仅是一天中的时间。在两台不同的机器上运行的相同代码可能会产生两个不同的日期。这可能是你想在你的应用程序,或者这可能是一个粗鲁的觉醒,一个不知情的程序员。

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

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());
}

首先,要注意的是,在遗留的日期-时间类中有 * 两个 * Date类:java.util.Date表示一个时刻,java.sql.Date表示一个日期,但实际上是一个时刻(在一个糟糕的类设计决策中)。我会假设你的意思java.util.Date
其次,避免使用这种代码的调用链。可读性、调试和日志记录都变得更加困难。在进行一系列转换时,请编写简单的短行。在每一行上使用注解来证明操作的合理性,并解释您的动机。并且,出于同样的原因避免使用var;在进行此类转换时使用显式返回类型。
date.toInstant()很不错。当遇到java.util.Date时,立即转换为Instant
同样,使用ZoneId.systemDefualt()意味着代码的结果会因任何系统管理员或用户更改默认时区而有所不同。这可能是这段代码的作者的意图,但我对此表示怀疑。
当你不传递参数时,部分beginOfTheYear.atStartOfDay()产生LocalDateTime。所以你再次丢弃有价值的信息(偏移),而没有获得任何回报。
另一个问题:你的代码甚至无法编译。java.util.Date.from方法接受Instant,而不是调用beginOfTheYear.atStartOfDay()返回的LocalDateTime
为了正确地获取某个特定时刻的年初的第一个时刻,您几乎肯定会希望决定业务规则的人指定特定的时区。

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

您确实应该将传入的java.util.Date转换为Instant

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。
请注意,在确定一年中的第一个时刻时,我们将ZoneId的参数传递给atStartOfDay,这与您的代码相反。这里的结果是ZonedDateTime,而不是代码中的LocalDateTime

ZonedDateTime zdtFirstMomentOfTheYearInTokyo = firstOfYear.atStartOfDay( zTokyo ) ;

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

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,但这并没有发生,所以这是一个大问题。
太多的东西要解开,你的细节太少。
我建议你在这个问题上再写一个问题。提供足够的细节,沿着示例代码,以进行诊断。

相关问题