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).

将其解析回来