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

Why does Spring ignore time offset of the input string when binding LocalDateTime annotated with @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)?

我有一个对象 Activity,字段为:

@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
private LocalDateTime start;

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

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

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

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

例如:我希望将字符串 2017-05-31T12:00-03:00 转换为 LocalDateTime '2017-05-31T15:00' 并将 2017-05-31T12:00Z 解释为 [=21] =].不幸的是,无论时间偏移如何,我总是得到 LocalDateTime

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

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

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

执行此操作时,不会进行时间转换。所以我认为最好的解决方案是将字段更改为 OffsetDateTimeInstant(请参阅下面的更多详细信息)。如果不可能,您可以使用以下代码OffsetDateTime转换为LocalDateTime

// 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);

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

2017-05-31T15:00

你也可以用同样的代码解析UTC字符串:

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

请注意,在这种情况下,withOffsetSameInstant(ZoneOffset.UTC) 是多余的,因为字符串已经是 UTC (it ends with "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)

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

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

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


处理时区的更好方法

IMO,在处理可以处理多个时区和偏移量的 date/times 时,使用 LocalDateTime 并不是最佳方法。那是因为LocalDateTime没有任何timezone/offset信息,无法正确处理。

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

如果您选择将字段类型更改为 OffsetDateTime,您可以使用 withOffsetSameInstant(ZoneOffset.UTC):

将其转换为 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));

要将此 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

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


如果您选择使用 Instant(这是一个 UTC 时刻,独立于 timezone/offset),您可以这样解析它:

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

如果要明确表示date/time是UTC,我认为Instant是最好的选择。如果你使用LocalDateTime,不清楚它在什么时区(实际上,因为这个class没有这样的信息,技术上它不在任何时区),你必须记住(或将这个信息存储在任何地方别的)。对于 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

请注意,伦敦夏令时也适用。

转换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

但是如果你使用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]

PS: 当我说 "wrong way" 时,我的意思是 "wrong for your case"。如果我的本地时间是 10 小时,并且我想创建一个表示“伦敦时区 10 小时”的对象,则 localDateTime.atZone() 可以完成这项工作。但就您而言,10h 是 UTC 时间。但由于对象是本地对象,您需要先将其转换为 UTC,然后再转换为另一个时区。这就是为什么 LocalDateTime 不适合您的情况。

并且在转换回 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());

因此,IMO,使用 Instant 更直接,因为 UTC 的使用是明确的并且转换 to/from 另一个时区很容易。但是如果你不能改变你的字段类型,你可以将 LocalDateTime to/from 转换为另一种类型(OffsetDateTime 似乎是最好的选择),照顾 timezone/offset 详情如上所述。