LocalDateTime 转换为 ZonedDateTime

60

我有一个使用Java 8和Spring开发的Web应用程序,将支持多个地区。我需要为客户位置创建日历事件。假设我的Web和Postgres服务器托管在MST时区(但如果我们采用云服务,可能会是任何地方)。但是客户位于EST时区。根据我读到的一些最佳实践,我打算将所有日期时间都存储为UTC格式。数据库中的所有日期时间字段都声明为TIMESTAMP。

下面是我如何将LocalDateTime转换为UTC:

ZonedDateTime startZonedDT = ZonedDateTime.ofLocal(dto.getStartDateTime(), ZoneOffset.UTC, null);
//appointment startDateTime is a LocalDateTime
appointment.setStartDateTime( startZonedDT.toLocalDateTime() );

现在,举个例子,当一个日期的搜索请求进来时,我必须将请求的日期时间转换为UTC,获取结果,然后再转换为用户所在的时区(存储在数据库中)。
ZoneId  userTimeZone = ZoneId.of(timeZone.getID());
ZonedDateTime startZonedDT = ZonedDateTime.ofLocal(appointment.getStartDateTime(), userTimeZone, null);
dto.setStartDateTime( startZonedDT.toLocalDateTime() );

现在,我不确定这是否是正确的方法。我还想知道,因为我从LocalDateTime转换到ZonedDateTime,反之亦然,是否会丢失任何时区信息。
以下是我看到的情况,对我来说似乎不正确。当我从用户界面接收到LocalDateTime时,我得到的是:
2016-04-04T08:00

ZonedDateTime =
dateTime=2016-04-04T08:00
offset="Z"
zone="Z"

然后,当我将转换后的值分配给我的约会LocalDateTime时,我进行存储:
2016-04-04T08:00

我感觉因为我在存储中使用了 LocalDateTime,所以我失去了转换成 ZonedDateTime 的时区。

我应该让我的实体(约会)使用 ZonedDateTime 而不是 LocalDateTime,这样Postgres就不会丢失那些信息了吗?

---------------- 编辑 ----------------

在巴西尔给出的很好的答案之后,我意识到我有幸不需要关心用户的时区-所有的约会都针对特定位置,因此我可以将所有日期时间存储为UTC,然后在检索时将它们转换为该位置的时区。我进行了以下后续问题


你的应用程序需要时区信息吗? - Sanj
1
关于您的“编辑”部分,请注意政治家们喜欢重新定义时区、偏移量、夏令时等。因此,不要用UTC值过于超前。如果您指的是提前很久安排的牙科预约,例如“今年12月5日下午3点”,那么您确实需要使用LocalDateTime来存储这个事实。意图是下午3点,但政治家可能会在那时重新定义这一时刻。对于时间表,您可以暂时将时区应用于潜在的LocalDateTime,以生成一个实际的时刻作为ZonedDateTime/Instant - Basil Bourque
3个回答

130
Postgres没有TIMESTAMP数据类型。Postgres有两种日期和时间类型:TIMESTAMP WITH TIME ZONETIMESTAMP WITHOUT TIME ZONE。这些类型在时区信息方面有非常不同的行为。
  • WITH类型使用任何偏移量或时区信息来调整日期时间到UTC,然后丢弃该偏移量或时区;Postgres从不保存偏移/区域信息。
    • 此类型表示一个瞬间,时间线上的特定点。
  • WITHOUT类型忽略可能存在的任何偏移或区域信息。
    • 此类型不代表一个瞬间。它代表着大约26-27小时范围内(全球各地的时区范围)潜在时刻的模糊概念。
你几乎总是希望使用 WITH 类型,正如专家David E. Wheeler在这里所解释的那样。只有当你对时间有模糊的概念而不是时间轴上的固定点时,WITHOUT 才有意义。例如,“今年圣诞节从2016-12-25T00:00:00开始”将被存储在 WITHOUT 中,因为它适用于任何时区,还没有应用于任何一个单一的时区来得到时间轴上的实际时刻。如果圣诞老人的精灵们正在跟踪 Eugene Oregon US 的开始时间,那么他们将使用 WITH 类型和包含偏移量或时区的输入,例如 2016-12-25T00:00:00-08:00,该输入将保存为 Postgres 中的 2016-12-25T08:00.00Z(其中 Z 表示 Zulu 或 UTC)。
Postgres中的在java.time中的等价物是。由于您的意图是使用UTC(一件好事),因此您不应该使用(一件坏事)。这可能是您困惑和遇到麻烦的主要原因。您一直在考虑使用或,但您都不应该使用;相反,您应该使用(下面会讨论)。
“我还想知道,因为我从转换为,反之亦然,是否会丢失任何时区信息。”
Indeed you are. LocalDateTime的整个目的就是失去时区信息。因此,在大多数应用程序中我们很少使用这个类。再举一个例子,圣诞节或者“公司规定:我们世界各地的工厂在中午12:30休息。”这将是LocalTime,对于特定日期,则是LocalDateTime。但是,直到你应用时区来获取ZonedDateTime之前,它没有实际意义,不是时间轴上的实际点。这次午休将在德里工厂、杜塞尔多夫工厂和底特律工厂的时间轴上的不同点。

LocalDateTime中的“Local”可能反直觉,因为它表示没有特定的位置。当你在类名中读到“Local”时,请认为“不是某一时刻……没有在时间轴上……只是关于日期时间的模糊想法”。

您的服务器操作系统时区应该几乎总是设置为UTC。但是,您的编程不应依赖于此外部性,因为系统管理员很容易更改它或任何其他Java应用程序可以在JVM中更改当前默认时区。因此,请始终指定所需/预期的时区(顺便说一下,Locale也是如此)。
要点:
  • 您正在过度劳累。
  • 程序员/系统管理员必须学会“全球思考,本地呈现”。
在工作日戴上您的极客硬帽时,请以UTC为基础进行思考。只有在一天结束时,当切换到您的普通人模式时,才需要考虑您所在城镇的本地时间。
您的业务逻辑应专注于UTC。您的数据库存储、业务逻辑、数据交换、序列化、日志记录和自己的思考都应在UTC时区(而且是24小时制)完成。在向用户呈现数据时,然后才应用特定的时区。将分区日期时间视为外部事物,而不是您应用程序内部的工作部分。
在Java端,您需要在大部分业务逻辑中使用java.time.Instant(UTC时间轴上的一个时刻)。请注意保留HTML标签。
Instant now = Instant.now();

希望JDBC驱动程序最终能够直接处理像Instant这样的java.time类型。在那之前,我们必须使用java.sql类型。旧的java.sql类有新的方法用于转换到/从java.time。
java.sql.TimeStamp ts = java.sql.TimeStamp.valueOf( instant );

现在,通过在Postgres中定义为TIMESTAMP WITH TIME ZONE的列上使用setTimestampjava.sql.TimeStamp对象传递到PreparedStatement以保存。
要执行相反的操作:
Instant instant = ts.toInstant();

这很简单,从 Instant 转换为 java.sql.Timestamp,再转换为 TIMESTAMP WITH TIME ZONE,全部使用UTC时间,没有涉及到时区。 服务器操作系统、JVM和客户端的当前默认时区都不相关。
为了向用户呈现,应用一个时区。使用 正确的时区名称,而不是如 ESTIST 等三至四个字母的代码。
ZoneId zoneId = ZoneId.of( "America/Montreal" );
ZonedDateTime zdt = ZonedDateTime.ofInstant( instant , zoneId );

您可以根据需要调整为不同的时区。

ZonedDateTime zdtKolkata = zdt.withZoneSameInstant( ZoneId.of( "Asia/Kolkata" ) );

要回到UTC时间线上的瞬间(即Instant),您可以从ZonedDateTime中提取。
Instant instant = zdt.toInstant();

这里没有使用LocalDateTime

如果你得到的数据没有任何与UTC偏移或时区相关的信息,例如2016-04-04T08:00,那么这个数据对你来说是完全无用的(假设我们不是在讨论圣诞节或公司午餐等场景)。没有偏移/时区信息的日期时间就像没有指定货币的货币金额:142.70甚至是$142.70 - 无用的。但是USD 142.70CAD 142.70MNX 142.70……这些才是有用的。

如果你确信了预期的偏移/时区上下文,并且得到了2016-04-04T08:00值,则:

  1. 将该字符串解析为LocalDateTime
  2. 应用一个UTC偏移量来获取OffsetDateTime,或者(更好的方法)应用时区来获取ZonedDateTime

像这样的代码。

LocalDateTime ldt = LocalDateTime.parse( "2016-04-04T08:00" );
ZoneId zoneId = ZoneId.of( "Asia/Kolkata" ); // Or "America/Montreal" etc.
ZonedDateTime zdt = ldt.atZone( zoneId ); // Or atOffset( myZoneOffset ) if only an offset is known rather than a full time zone.

您的问题实际上是其他许多问题的重复。这些问题在其他问题和答案中已经被讨论了很多次。我建议您搜索并学习Stack Overflow以更深入地了解此主题。

JDBC 4.2

从JDBC 4.2开始,我们可以直接使用java.time对象与数据库交换。不再需要使用java.sql.Timestamp或其相关类。

存储时,请使用JDBC规范中定义的OffsetDateTime

myPreparedStatement.setObject( … , instant.atOffset( ZoneOffset.UTC ) ) ;  // The JDBC spec requires support for `OffsetDateTime`. 

...或者直接使用Instant,如果你的JDBC驱动支持的话。

myPreparedStatement.setObject( … , instant ) ;  // Your JDBC driver may or may not support `Instant` directly, as it is not required by the JDBC spec. 

检索中。

OffsetDateTime odt = myResultSet.getObject( … , OffsetDateTime.class ) ;

Table of date-time types in Java (both legacy and modern) and in standard SQL


关于 java.time

java.time 框架内置于 Java 8 及更高版本中。这些类替代了老旧的、问题多多的遗留日期时间类,例如 java.util.DateCalendarSimpleDateFormat

要了解更多信息,请参阅Oracle教程。并在Stack Overflow上搜索许多示例和解释。规范是JSR 310

Joda-Time项目现在处于维护模式,建议迁移到java.time类。

您可以直接使用数据库交换 java.time 对象。使用符合 JDBC 4.2 或更高版本的 JDBC 驱动程序。无需字符串,无需 java.sql.* 类。Hibernate 5 和 JPA 2.2 支持 java.time

如何获取 java.time 类?

Table of which java.time library to use with which version of Java or Android


非常感谢您的出色回答。我已经阅读了许多关于在Web应用程序中处理时区的最佳方法的帖子和文章。我很幸运地知道所有使用的时间都是针对特定位置的,而不管用户系统时钟显示他们在哪里。如果您不介意,我有一个跟进问题要问我的问题。再次感谢您如此详细和有用的答案。 - sonoerin
1
@funder7(A)是的,LocalTime表示一天中的时间,没有更多,也没有更少。(B)预订未来的事件或约会有点复杂,需要考虑可能的时区规则变化。如果您的意思是“明年1月21日下午3点牙医预约”,则将日期和时间存储在Postgres类型TIMESTAMP WITHOUT TIME ZONE中。在第二个文本类型的单独列中,存储所需时区的ID,例如America/Chicago。在运行时构建要呈现给用户的日历时,提取第一个作为LocalDateTime,第二个作为ZoneId,组合成ZonedDateTime - Basil Bourque
@funder7 告诉你要将未来的约会时间保存为UTC时间的人是错误的,这是最糟糕的做法,而不是最佳实践。在数据库中使用类似于SQL标准的TIMESTAMP WITHOUT TIME ZONE类型来存储,而不是TIMESTAMP WITH TIME ZONE。使用第二列记录所需的时区标识符。如果在事件之后,您想要记录活动实际开始、结束或付款的时间点,那么您就有了一个已知的具体时间点,因此您可以使用单个类型为TIMESTAMP WITH TIME ZONE的列来记录UTC时间,而不是TIMESTAMP WITHOUT TIME ZONE - Basil Bourque
1
让我们在聊天中继续这个讨论 - Basil Bourque
1
假设您预约了明年10月15日的牙科预约。当您预约时,您所在的国家观察到夏令时(DST)从9月的最后一天开始。然后您所在的国家决定更改规则,使DST从10月的最后一天开始。您应该在哪个时间出现在牙医诊所?以UTC记录的预约将是错误的,会受到DST偏移量的影响。 - Basil Bourque
显示剩余6条评论

13

TLDR:

要将LocalDateTime转换为ZonedDateTime,请使用以下代码。您可以使用.atZone(zoneId),时区ID的全面列表在列TZ数据库名称中找到维基百科。

LocalDateTime localDateTime = LocalDateTime.parse( "2016-04-04T08:00" );
ZoneId zoneId = ZoneId.of( "UTC" ); // Or "Asia/Kolkata" etc.
ZonedDateTime zdt = localDateTime.atZone( zoneId );

0
public ZonedDateTime transformToZonedDateTime(String date, String timeZone) {
    DateTimeFormatter formatter = DateTimeFormatter.ofPattern("dd-MM-yyyy");
    LocalDate localDate = LocalDate.parse(Date, formatter);
    LocalDateTime localDateTime = localDate.atTime(LocalTime.now());
    ZonedDateTime zonedDateTime = localDateTime.atZone(ZoneOffset.UTC);
    ZoneId zoneId = ZoneId.of(timeZone);
    return zonedDateTime.withZoneSameInstant(zoneId);
}

输入:date = "18-05-2023" and timeZone = "Asia/Hong_Kong"
返回:"2023-05-19T20:43Z"

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接