为什么 Spring 在绑定用 @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) 注释的 LocalDateTime 时忽略输入字符串的时间偏移?

Lex*_*int 2 spring datetime spring-mvc java-time

我有一个Activity带有该字段的对象:

@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
private LocalDateTime start;
Run Code Online (Sandbox Code Playgroud)

我通过发送表单将此对象绑定到控制器方法:

@RequestMapping(value = "update", method = RequestMethod.POST)
public String submitUpdateActivityForm(Activity activity) {
      activityRepository.save(activity);
      return "successPage;
}
Run Code Online (Sandbox Code Playgroud)

我正在使用 Spring Boot 1.5.1、Spring MVC 4.3.6,并且在我的 Web 应用程序中,我希望从任何时区的客户端接收时间,但LocalDateTime始终保持UTC。但是当 Spring 将对象从表单绑定到控制器中的请求参数时,它完全忽略了 type 属性的输入字符串的时间偏移LocalDateTime

我认为根据@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)Spring 的文档会找到字符串中的偏移量并将输入日期时间从给定的时区转换为我的服务器的时区(即 UTC)然后绑定。

例如:我希望将字符串2017-05-31T12:00-03:00转换为LocalDateTime '2017-05-31T15:00'2017-05-31T12:00Z在不更改的情况下解释为LocalDateTime '2017-05-31T12:00'. 不幸的是,LocalDateTime无论时间偏移如何,我总是得到。

这是正确的行为@DateTimeFormat还是我做错了什么?我应该实现 SpringConverter还是扩展PropertyEditorSupport

这是处理时间的正确方法吗?我想接受任何类型的日期,但将它们保留在 UTC 中,LocalDateTime因为这样我就可以轻松地将它们发送到客户端,在那里我可以将它们从 UTC 转换为客户端的本地时区并显示。

小智 5

LocalDateTime没有任何时区/偏移信息。所以我怀疑问题是(虽然我没有在 Spring 环境中测试过):Spring 正在将字符串解析为一个 OffsetDateTime(因为格式包含一个偏移量,比如-03:00)并获取它的本地日期时间部分(摆脱偏移量)。或者做一些其他类似的事情但忽略偏移量。

这样做时,不进行时间转换。所以我认为最好的解决方案是将字段更改为OffsetDateTimeInstant(请参阅下面的更多详细信息)。如果没有可能,你可以转换OffsetDateTimeLocalDateTime,使用下面的代码:

// convert OffsetDateTime to LocalDateTime (converting the time to UTC)
LocalDateTime localDateTime = OffsetDateTime.parse("2017-05-31T12:00-03:00")
                                  // change to UTC ("sameInstant" converts the time)
                                  .withOffsetSameInstant(ZoneOffset.UTC)
                                  // get only localdatetime part (without offset)
                                  .toLocalDateTime();
System.out.println(localDateTime);
Run Code Online (Sandbox Code Playgroud)

withOffsetSameInstant(ZoneOffset.UTC)将时间转换为 UTC。所以上面代码的输出是:

2017-05-31T15:00

您还可以使用相同的代码解析 UTC 字符串:

localDateTime = OffsetDateTime.parse("2017-05-31T12:00Z")
                    .withOffsetSameInstant(ZoneOffset.UTC)
                    .toLocalDateTime();
System.out.println(localDateTime);
Run Code Online (Sandbox Code Playgroud)

请注意,在这种情况下,withOffsetSameInstant(ZoneOffset.UTC)是多余的,因为字符串已经在 UTC 中(以 "Z" 结尾)。但是你可以毫无问题地离开它。输出将是:

2017-05-31T12:00


错误原因可能是什么

请注意,如果您不使用withOffsetSameInstant,则会得到不正确的结果:

localDateTime = OffsetDateTime.parse("2017-05-31T12:00-03:00").toLocalDateTime();
System.out.println(localDateTime); // 2017-05-31T12:00 (12h instead of 15h)
Run Code Online (Sandbox Code Playgroud)

这就是我怀疑 Spring 正在做的事情。或者它可能正在LocalDateTime使用忽略偏移量的解析器来解析- 与此非常相似:

System.out.println(LocalDateTime.parse("2017-05-31T12:00-03:00",
                                       DateTimeFormatter.ISO_DATE_TIME));
// output is 2017-05-31T12:00
Run Code Online (Sandbox Code Playgroud)

无论如何,Spring 忽略了偏移量。您可以尝试编写一个转换器(使用上面描述的代码)或使用下面描述的方法。


处理时区时的更好方法

IMO,LocalDateTime在处理可以处理多个时区和偏移量的日期/时间时,使用 a并不是最好的方法。那是因为 aLocalDateTime没有任何时区/偏移量信息并且无法正确处理它。

我认为在这种情况下最好的方法是使用OffsetDateTimeor Instant。我认为这Instant是最好的,原因如下。

如果您选择将字段类型更改为OffsetDateTime,则可以使用以下命令将其转换为 UTC withOffsetSameInstant(ZoneOffset.UTC)

// 2017-05-31T15:00Z
System.out.println(OffsetDateTime.parse("2017-05-31T12:00-03:00").withOffsetSameInstant(ZoneOffset.UTC));

// 2017-05-31T12:00Z
System.out.println(OffsetDateTime.parse("2017-05-31T12:00Z").withOffsetSameInstant(ZoneOffset.UTC));
Run Code Online (Sandbox Code Playgroud)

要将其转换OffsetDateTime为另一个时区并返回到 UTC,您可以执行以下操作:

OffsetDateTime utcOffset = OffsetDateTime.parse("2017-05-31T12:00-03:00").withOffsetSameInstant(ZoneOffset.UTC);
System.out.println(utcOffset); // 2017-05-31T15:00Z

// convert to London timezone
ZonedDateTime z = utcOffset.atZoneSameInstant(ZoneId.of("Europe/London"));
System.out.println(z); // 2017-05-31T16:00+01:00[Europe/London]

// convert back to UTC
System.out.println(z.withZoneSameInstant(ZoneOffset.UTC)); // 2017-05-31T15:00Z
Run Code Online (Sandbox Code Playgroud)

请注意,UTC 中的 15h 是伦敦的 16h,因为 5 月是英国的夏令时,API 会自动处理它。


如果您选择使用 an Instant(这是一个 UTC 时间,独立于时区/偏移量),您可以这样解析它:

// 2017-05-31T15:00:00Z
System.out.println(OffsetDateTime.parse("2017-05-31T12:00-03:00").toInstant());

// 2017-05-31T12:00:00Z
System.out.println(OffsetDateTime.parse("2017-05-31T12:00Z").toInstant());
Run Code Online (Sandbox Code Playgroud)

如果您想明确表示日期/时间是 UTC,我相信这Instant是最好的选择。如果使用LocalDateTime,则不清楚它位于哪个时区(实际上,因为此类没有此类信息,从技术上讲它不在任何时区中),并且您必须记住(或将此信息存储在其他任何地方)。随着Instant,UTC的使用是明确的。

从和到另一个时区的转换非常容易Instant

// UTC instant (2017-05-31T15:00:00Z)
Instant instant = OffsetDateTime.parse("2017-05-31T12:00-03:00").toInstant();

// converts to London timezone
ZonedDateTime london = instant.atZone(ZoneId.of("Europe/London"));
System.out.println(london); // 2017-05-31T16:00+01:00[Europe/London] 
// ** note that 15h in UTC is 16h in London, because in May it's British's Summer Time

// converts back to UTC instant
System.out.println(london.toInstant()); // 2017-05-31T15:00:00Z
Run Code Online (Sandbox Code Playgroud)

请注意,还应用了伦敦的夏令时。

转换Instantto/fromOffsetDateTime也很简单:

// converts to offset +05:00
OffsetDateTime odt = instant.atOffset(ZoneOffset.ofHours(5));
System.out.println(odt); // 2017-05-31T20:00+05:00

// converts back to UTC instant
System.out.println(odt.toInstant()); // 2017-05-31T15:00:00Z
Run Code Online (Sandbox Code Playgroud)

但是如果使用LocalDateTime, 处理一些情况就不是那么明显了:

// LocalDateTime (2017-05-31T15:00)
LocalDateTime dt = OffsetDateTime.parse("2017-05-31T12:00-03:00")
                      .withOffsetSameInstant(ZoneOffset.UTC)
                      .toLocalDateTime();

// converts to London timezone (wrong way)
ZonedDateTime wrongLondon = dt.atZone(ZoneId.of("Europe/London"));
System.out.println(wrongLondon); // 2017-05-31T15:00+01:00[Europe/London] (hour is 15 instead of 16)

// converts to London timezone (right way: first convert to UTC, then to London)
ZonedDateTime correctLondon = dt.atZone(ZoneOffset.UTC).withZoneSameInstant(ZoneId.of("Europe/London"));
System.out.println(correctLondon); // 2017-05-31T16:00+01:00[Europe/London]
Run Code Online (Sandbox Code Playgroud)

PS:当我说“错误的方式”时,我的意思是“你的情况错了”。如果我的本地时间是 10 小时,并且我想创建一个表示“伦敦时区 10 小时”的对象,那么localDateTime.atZone()就可以了。但在您的情况下,10h 是 UTC 时间。但由于对象是本地的,您需要先将其转换为 UTC,然后再转换为其他时区。这就是为什么 aLocalDateTime不适合您的情况。

并且在转换回 时LocalDateTime,您必须注意在获取本地部分之前转换为 UTC:

// wrong: 2017-05-31T16:00
System.out.println(correctLondon.toLocalDateTime());

// correct: 2017-05-31T15:00
System.out.println(correctLondon.withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime());
Run Code Online (Sandbox Code Playgroud)

因此,IMO 的使用Instant更加直接,因为 UTC 的使用是明确的,并且与其他时区之间的转换很容易。但是,如果您无法更改字段的类型,则可以将其转换LocalDateTime为/从另一种类型(OffsetDateTime似乎是最佳选择),如上所述处理时区/偏移量详细信息。