DateTimeFormatter 解析 - 时区名称和夏令时重叠时间
DateTimeFormatter parsing - timezone names and daylight savings overlap times
为了提高一些遗留代码的性能,我正在考虑用 java.time.format.DateTimeFormatter 替换 java.text.SimpleDateFormat。
执行的任务包括解析使用 java.util.Date.toString 序列化的 date/time 值。使用 SimpleDateFormat,可以将它们转回原始时间戳(忽略小数秒),但是在尝试使用 DateTimeFormatter 执行相同操作时我遇到了问题。
当使用其中任何一种进行格式化时,我的本地时区被指示为 CET 或 CEST,具体取决于夏令时是否对要格式化的时间有效。然而,在解析时,CET 和 CEST 似乎被 DateTimeFormatter 视为相同。
这会在夏令时结束时产生重叠问题。格式化时,02:00:00 被创建两次,每次间隔一小时,但使用 CEST 和 CET 时区名称 - 这很好。但在解析时,无法回收该差异。
这是一个例子:
long msecPerHour = 3600000L;
long cet_dst_2016 = 1477778400000L;
DateTimeFormatter formatter =
DateTimeFormatter.ofPattern("EEE MMM dd HH:mm:ss zzz yyyy", Locale.ENGLISH);
ZoneId timezone = ZoneId.of("Europe/Berlin");
for (int hours = 0; hours < 6; ++hours) {
long time = cet_dst_2016 + msecPerHour * hours;
String formatted = formatter.format(Instant.ofEpochMilli(time).atZone(timezone));
long parsedTime = Instant.from(formatter.parse(formatted)).toEpochMilli();
System.out.println(formatted + ", diff: " + (parsedTime - time));
}
这导致
Sun Oct 30 00:00:00 CEST 2016, diff: 0
Sun Oct 30 01:00:00 CEST 2016, diff: 0
Sun Oct 30 02:00:00 CEST 2016, diff: 0
Sun Oct 30 02:00:00 CET 2016, diff: -3600000
Sun Oct 30 03:00:00 CET 2016, diff: 0
Sun Oct 30 04:00:00 CET 2016, diff: 0
它表明,尽管时区名称不同,但第二次出现的 02:00:00 与第一次出现的一样。所以结果实际上是一个小时。
显然,格式化字符串具有所有可用信息,SimpleDateFormat 解析实际上尊重它。是否可以使用 DateTimeFormatter 和给定的模式通过格式化和解析来回往返?
对于特定情况是可能的:
DateTimeFormatter formatter = new DateTimeFormatterBuilder()
.appendPattern("EEE MMM dd HH:mm:ss ")
.appendText(OFFSET_SECONDS, ImmutableMap.of(2L * 60 * 60, "CEST", 1L * 60 * 60, "CET"))
.appendPattern(" yyyy")
.toFormatter(Locale.ENGLISH);
这会将确切的偏移量映射到预期的文本。当您需要处理多个时区时,这种方法会失败。
要正确完成这项工作需要 JDK change。
它似乎 像一个错误。我在 Java 17 中进行了测试,它仍然是相同的行为。我深入研究了解析逻辑,我明白了为什么会这样。
首先发生的事情之一是 TimeZoneNameUtility.getZoneStrings(locale)
被调用。这给你一个 Strings
的二维数组
[
[
"Europe/Paris",
"Central European Standard Time", "CET",
"Central European Summer Time", "CEST",
"Central European Time", "CET"
],
// others
]
它构建了一个 prefix tree。此处的所有项目都映射到第 0 个项目 - "Europe/Paris"
。当它解析时,它会在前缀树中一次下降一个字符,例如C... E... T...
,然后 returns 一个匹配项(如果有的话)。由于 CEST 和 CET 映射到同一事物,因此它们实际上只是彼此的别名。
后面那个字符串是 passed to ZoneId.of()
,这意味着它是否是夏季的事实已被丢弃。
在 Java 18 中似乎确实对该代码进行了重大更改,所以他们可能正在解决这个问题。
一般解决方法
JodaStephen,java.time 的主要作者,在他的回答中展示了 CET 和 CEST(中欧时间和中欧夏令时)的解决方法。我提出了一种解决方法,我相信它适用于标准时间和夏令时 (DST) 具有不同缩写的所有时区。
public static ZonedDateTime parse(String text) {
ZonedDateTime result = ZonedDateTime.parse(text, FORMATTER);
if (result.format(FORMATTER).equals(text)) {
return result;
}
// Default we get the earlier offset at overlap,
// so if it didn’t work, try the later offset
result = result.withLaterOffsetAtOverlap();
if (result.format(FORMATTER).equals(text)) {
return result;
}
// As a last desperate attempt, try earlier offset explicitly
result = result.withEarlierOffsetAtOverlap();
if (result.format(FORMATTER).equals(text)) {
return result;
}
// Give up
throw new IllegalArgumentException();
}
该方法可以使用任何带有时区名称或缩写的格式化程序,只要它应该提供与它解析的输入相同的格式化输出(例如,可选部分是禁忌)。我假设了一个与你的相同的格式化程序:
private static final DateTimeFormatter FORMATTER
= DateTimeFormatter.ofPattern("EEE MMM dd HH:mm:ss zzz yyyy", Locale.ROOT);
您的问题在于毫秒值 1 477 789 200 000,它被格式化为 Sun Oct 30 02:00:00 CET 2016
,然后解析为 1 477 785 600 000,相差 -3 600 000 毫秒。那么让我们用那个来试试我的方法吧。
private static final ZoneId TIME_ZONE = ZoneId.of("Europe/Berlin");
long trouble = 1_477_789_200_000L;
String formatted = Instant.ofEpochMilli(trouble).atZone(TIME_ZONE).format(FORMATTER);
ZonedDateTime zdt = parse(formatted);
long parsedTime = zdt.toInstant().toEpochMilli();
System.out.println(formatted + ", diff: " + (parsedTime - trouble));
输出为:
Sun Oct 30 02:00:00 CET 2016, diff: 0
但不解析三个字母的时区缩写
综上所述,即使有针对秋季重叠情况的解决方法,您在尝试解析时区缩写时仍处于不稳定状态。大多数最常见的都是模棱两可的,你不知道你从解析中得到了什么。对于 CET 和 CEST,它们是许多欧洲时区的常用缩写,目前在标准时间共享偏移量 +01:00,在夏令时共享偏移量 +02:00,但历史上每个时区都有自己的偏移量并且很可能再次分道扬镳,因为欧盟决定完全放弃夏令时。明年一个时区可能全年使用 CET,而另一个时区全年使用 CEST。我上面的代码没有说明这一点。
相反,只需从 ZonedDateTime.toString
获取输出并使用单参数 ZonedDateTime.parse(CharSequence)
.
将其解析回来
为了提高一些遗留代码的性能,我正在考虑用 java.time.format.DateTimeFormatter 替换 java.text.SimpleDateFormat。
执行的任务包括解析使用 java.util.Date.toString 序列化的 date/time 值。使用 SimpleDateFormat,可以将它们转回原始时间戳(忽略小数秒),但是在尝试使用 DateTimeFormatter 执行相同操作时我遇到了问题。
当使用其中任何一种进行格式化时,我的本地时区被指示为 CET 或 CEST,具体取决于夏令时是否对要格式化的时间有效。然而,在解析时,CET 和 CEST 似乎被 DateTimeFormatter 视为相同。
这会在夏令时结束时产生重叠问题。格式化时,02:00:00 被创建两次,每次间隔一小时,但使用 CEST 和 CET 时区名称 - 这很好。但在解析时,无法回收该差异。
这是一个例子:
long msecPerHour = 3600000L;
long cet_dst_2016 = 1477778400000L;
DateTimeFormatter formatter =
DateTimeFormatter.ofPattern("EEE MMM dd HH:mm:ss zzz yyyy", Locale.ENGLISH);
ZoneId timezone = ZoneId.of("Europe/Berlin");
for (int hours = 0; hours < 6; ++hours) {
long time = cet_dst_2016 + msecPerHour * hours;
String formatted = formatter.format(Instant.ofEpochMilli(time).atZone(timezone));
long parsedTime = Instant.from(formatter.parse(formatted)).toEpochMilli();
System.out.println(formatted + ", diff: " + (parsedTime - time));
}
这导致
Sun Oct 30 00:00:00 CEST 2016, diff: 0
Sun Oct 30 01:00:00 CEST 2016, diff: 0
Sun Oct 30 02:00:00 CEST 2016, diff: 0
Sun Oct 30 02:00:00 CET 2016, diff: -3600000
Sun Oct 30 03:00:00 CET 2016, diff: 0
Sun Oct 30 04:00:00 CET 2016, diff: 0
它表明,尽管时区名称不同,但第二次出现的 02:00:00 与第一次出现的一样。所以结果实际上是一个小时。
显然,格式化字符串具有所有可用信息,SimpleDateFormat 解析实际上尊重它。是否可以使用 DateTimeFormatter 和给定的模式通过格式化和解析来回往返?
对于特定情况是可能的:
DateTimeFormatter formatter = new DateTimeFormatterBuilder()
.appendPattern("EEE MMM dd HH:mm:ss ")
.appendText(OFFSET_SECONDS, ImmutableMap.of(2L * 60 * 60, "CEST", 1L * 60 * 60, "CET"))
.appendPattern(" yyyy")
.toFormatter(Locale.ENGLISH);
这会将确切的偏移量映射到预期的文本。当您需要处理多个时区时,这种方法会失败。
要正确完成这项工作需要 JDK change。
它似乎 像一个错误。我在 Java 17 中进行了测试,它仍然是相同的行为。我深入研究了解析逻辑,我明白了为什么会这样。
首先发生的事情之一是 TimeZoneNameUtility.getZoneStrings(locale)
被调用。这给你一个 Strings
[
[
"Europe/Paris",
"Central European Standard Time", "CET",
"Central European Summer Time", "CEST",
"Central European Time", "CET"
],
// others
]
它构建了一个 prefix tree。此处的所有项目都映射到第 0 个项目 - "Europe/Paris"
。当它解析时,它会在前缀树中一次下降一个字符,例如C... E... T...
,然后 returns 一个匹配项(如果有的话)。由于 CEST 和 CET 映射到同一事物,因此它们实际上只是彼此的别名。
后面那个字符串是 passed to ZoneId.of()
,这意味着它是否是夏季的事实已被丢弃。
在 Java 18 中似乎确实对该代码进行了重大更改,所以他们可能正在解决这个问题。
一般解决方法
JodaStephen,java.time 的主要作者,在他的回答中展示了 CET 和 CEST(中欧时间和中欧夏令时)的解决方法。我提出了一种解决方法,我相信它适用于标准时间和夏令时 (DST) 具有不同缩写的所有时区。
public static ZonedDateTime parse(String text) {
ZonedDateTime result = ZonedDateTime.parse(text, FORMATTER);
if (result.format(FORMATTER).equals(text)) {
return result;
}
// Default we get the earlier offset at overlap,
// so if it didn’t work, try the later offset
result = result.withLaterOffsetAtOverlap();
if (result.format(FORMATTER).equals(text)) {
return result;
}
// As a last desperate attempt, try earlier offset explicitly
result = result.withEarlierOffsetAtOverlap();
if (result.format(FORMATTER).equals(text)) {
return result;
}
// Give up
throw new IllegalArgumentException();
}
该方法可以使用任何带有时区名称或缩写的格式化程序,只要它应该提供与它解析的输入相同的格式化输出(例如,可选部分是禁忌)。我假设了一个与你的相同的格式化程序:
private static final DateTimeFormatter FORMATTER
= DateTimeFormatter.ofPattern("EEE MMM dd HH:mm:ss zzz yyyy", Locale.ROOT);
您的问题在于毫秒值 1 477 789 200 000,它被格式化为 Sun Oct 30 02:00:00 CET 2016
,然后解析为 1 477 785 600 000,相差 -3 600 000 毫秒。那么让我们用那个来试试我的方法吧。
private static final ZoneId TIME_ZONE = ZoneId.of("Europe/Berlin");
long trouble = 1_477_789_200_000L;
String formatted = Instant.ofEpochMilli(trouble).atZone(TIME_ZONE).format(FORMATTER);
ZonedDateTime zdt = parse(formatted);
long parsedTime = zdt.toInstant().toEpochMilli();
System.out.println(formatted + ", diff: " + (parsedTime - trouble));
输出为:
Sun Oct 30 02:00:00 CET 2016, diff: 0
但不解析三个字母的时区缩写
综上所述,即使有针对秋季重叠情况的解决方法,您在尝试解析时区缩写时仍处于不稳定状态。大多数最常见的都是模棱两可的,你不知道你从解析中得到了什么。对于 CET 和 CEST,它们是许多欧洲时区的常用缩写,目前在标准时间共享偏移量 +01:00,在夏令时共享偏移量 +02:00,但历史上每个时区都有自己的偏移量并且很可能再次分道扬镳,因为欧盟决定完全放弃夏令时。明年一个时区可能全年使用 CET,而另一个时区全年使用 CEST。我上面的代码没有说明这一点。
相反,只需从 ZonedDateTime.toString
获取输出并使用单参数 ZonedDateTime.parse(CharSequence)
.