使用 JSR 310 的军事时区(DateTime API)

Military time zones using JSR 310 (DateTime API)

我在我的应用程序中使用 JSR 310 DateTime API*,我需要解析和格式化军事日期时间(称为 DTG 或 "date time group")。

我正在解析的格式如下所示(使用 DateTimeFormatter):

"ddHHmm'Z' MMM yy" // (ie. "312359Z DEC 14", for new years eve 2014)

如上所述,这种格式相当容易解析。当日期包含与 'Z'(祖鲁时区,与 UTC/GMT 相同)不同的时区时,就会出现问题,例如 'A'(Alpha,UTC+1:00)或 'B'(太棒了,UTC+2:00)。有关完整列表,请参阅 Military time zones

如何解析这些时区?或者换句话说,除了文字 'Z' 之外,我可以在上面的格式中输入什么来让它正确解析所有区域?我已经尝试使用 "ddHHmmX MMM yy""ddHHmmZ MMM yy""ddHHmmVV MMM yy",但是其中 none 有效(对于上面的示例,解析时都会抛出 DateTimeParseException: Text '312359A DEC 14' could not be parsed at index 6)。不允许在格式中使用单个 V(尝试实例化 DateTimeFormatterIllegalArgumentException)。

编辑:如果不是以下问题,符号 z 似乎可以工作。

我还应该提到我已经创建了一个 ZoneRulesProvider,其中包含所有命名区域和正确的偏移量。我已验证这些已使用 SPI 机制正确注册,并且我的 provideZoneIds() 方法已按预期调用。仍然不会解析。作为附带问题(编辑:这现在似乎是主要问题),API 不允许 'Z' 以外的单字符时区 ID(或 "regions")。

例如:

ZoneId alpha = ZoneId.of("A"); // boom

将抛出 DateTimeException: Invalid zone: A(甚至无需访问我的规则提供程序以查看它是否存在)。

这是 API 的疏忽吗?还是我做错了什么?


*) 实际上,我使用的是 Java 7 和 ThreeTen Backport,但我认为这对这个问题并不重要。

PS:我目前的解决方法是使用 25 个不同的 DateTimeFormatters 和文字区域 ID(即 "ddHHmm'A' MMM yy""ddHHmm'B' MMM yy" 等),使用 RegExp 用于提取区域 ID,并根据区域委托给正确的格式化程序。提供商中的区域 ID 被命名为 "Alpha"、"Bravo" 等,以允许 ZoneId.of(...) 找到区域。有用。但这不是很优雅,我希望有更好的解决方案。

java.time-package (JSR-310) 在支持区域 ID 方面的行为如指定的那样 - 参见javadoc。相关部分的显式引用(其他ID仅被视为格式为"Z"、“+hh:mm”、“-hh:mm”或"UTC+hh:mm"等的offset-ids):

A region-based ID must be of two or more characters

在开始加载时区数据之前,class ZoneRegion 的源代码中也实现了至少有两个字符的要求:

/**
 * Checks that the given string is a legal ZondId name.
 *
 * @param zoneId  the time-zone ID, not null
 * @throws DateTimeException if the ID format is invalid
 */
private static void checkName(String zoneId) {
    int n = zoneId.length();
    if (n < 2) {
       throw new DateTimeException("Invalid ID for region-based ZoneId, invalid format: " + zoneId);
    }
    for (int i = 0; i < n; i++) {
        char c = zoneId.charAt(i);
        if (c >= 'a' && c <= 'z') continue;
        if (c >= 'A' && c <= 'Z') continue;
        if (c == '/' && i != 0) continue;
        if (c >= '0' && c <= '9' && i != 0) continue;
        if (c == '~' && i != 0) continue;
        if (c == '.' && i != 0) continue;
        if (c == '_' && i != 0) continue;
        if (c == '+' && i != 0) continue;
        if (c == '-' && i != 0) continue;
        throw new DateTimeException("Invalid ID for region-based ZoneId, invalid format: " + zoneId);
    }
}

这解释了为什么 JSR-310/Threeten 不能写出像 ZoneId.of("A") 这样的表达式。字母 Z 之所以有效,是因为它在 ISO-8601 和 JSR-310 中都被指定为表示零偏移量。

您找到的解决方法在不支持军事时区的 JSR-310 范围内很好。因此您不会找到任何支持它的格式(只需研究class DateTimeFormatterBuilder - 格式模式符号的每次处理都被路由到构建器)。我得到的唯一模糊的想法是实现一个专门的 TemporalField 代表军事时区偏移。但是实施(如果可能的话)肯定比您的解决方法更复杂。

另一种更合适的解决方法是字符串预处理。由于您使用的是固定格式,期望军用字母始终位于输入中的相同位置,因此您可以简单地执行以下操作:

String input = "312359A Dec 14";
String offset = "";

switch (input.charAt(6)) {
  case 'A':
    offset = "+01:00";
    break;
  case 'B':
    offset = "+02:00";
    break;
  //...
  case 'Z':
    offset = "Z";
    break;
  default:
    throw new ParseException("Wrong military timezone: " + input, 6);
}
input = input.substring(0, 6) + offset + input.substring(7);
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("ddHHmmVV MMM yy", Locale.ENGLISH);
ZonedDateTime odt = ZonedDateTime.parse(input, formatter);
System.out.println(odt);
// output: 2014-12-31T23:59+01:00

备注:

  • 我用的是"Dec"而不是"DEC",否则解析器会报错。如果您的输入确实有大写字母,那么您可以使用构建器方法 parseCaseInsensitive().

  • 关于解析问题,使用字段 OffsetSeconds 的另一个答案是更好的答案 并且也得到了我的赞成(忽略了此功能)。这并不是更好,因为它给用户带来了定义从军区字母到偏移量的映射的负担——就像我对字符串预处理的建议一样。 但它更好,因为它允许使用构建器方法 optionalStart()optionalEnd(),因此可以处理可选的时区字母 A、B、...。 请参见还有 OP 关于可选区域 ID 的评论。

java.time中,ZoneId限制为2个字符或更多。具有讽刺意味的是,这是为了保留 space 以允许在未来的 JDK 版本中添加军事 ID,如果它被证明有大量需求的话。因此,遗憾的是您的提供者将无法工作,并且无法使用这些名称创建您想要的 ZoneId 实例。

一旦您考虑使用 ZoneOffset 而不是 ZoneId,解析问题就可以解决了(考虑到军事区域是固定偏移量,这是查看问题的好方法)。

关键是方法 DateTimeFormatterBuilder.appendText(TemporalField, Map),它允许使用您选择的文本将数字字段格式化并解析为文本。 ZoneOffset 是一个数字字段(值为偏移量中的总秒数)。

在这个例子中,我已经为 ZAB 设置了映射,但您需要将它们全部添加。否则,代码非常简单,设置一个可以打印和解析军事时间的格式化程序(使用 OffsetDateTime 作为日期和时间)。

Map<Long, String> map = ImmutableMap.of(0L, "Z", 3600L, "A", 7200L, "B");
DateTimeFormatter f = new DateTimeFormatterBuilder()
    .appendPattern("HH:mm")
    .appendText(ChronoField.OFFSET_SECONDS, map)
    .toFormatter();
System.out.println(OffsetTime.now().format(f));
System.out.println(OffsetTime.parse("11:30A", f));