java.time DateTimeFormatter 使用灵活的回退值进行解析

java.time DateTimeFormatter parsing with flexible fallback values

我正在尝试将一些代码从 joda 时间移植到 java 时间。

JodaTime 可以像这样为年份指定回退值

parser.withDefaultYear((new DateTime(DateTimeZone.UTC)).getYear()).parseDateTime(text);

无论解析器的外观如何(是否包含年份),都会对其进行解析。

java.time 那里变得更加严格。尽管有 DateTimeFormatterBuilder.parseDefaulting() 方法允许您指定回退,但只有当该特定字段在您要解析的日期中指定为 not 或标记为可选。

如果您对传入的日期格式没有任何控制,因为它是用户提供的,这使得调用 parseDefaulting.

变得非常困难

是否有任何解决方法,我可以在其中指定类似通用回退日期的内容,如果未指定,格式化程序将使用其值,或者我如何配置根本不使用的回退值,当它们被使用时在格式化程序中指定?

下面是最小的、完整的和可验证的示例。

public static DateTimeFormatter ofPattern(String pattern) {
    return new DateTimeFormatterBuilder()
        .appendPattern(pattern)
        .parseDefaulting(ChronoField.YEAR, 1970)
        .toFormatter(Locale.ROOT);
}

public void testPatterns() {
    // works
    assertThat(LocalDate.from(ofPattern("MM/dd").parse("12/06")).toString(), is("1970-12-06"));
    assertThat(LocalDate.from(ofPattern("uuuu/MM/dd").parse("2018/12/06")).toString(), is("2018-12-06"));
    // fails with exception, as it uses year of era
    assertThat(LocalDate.from(ofPattern("yyyy/MM/dd").parse("2018/12/06")).toString(), is("2018-12-06"));
}

期望的结果:测试应该解析字符串并通过(“绿色”)。

观察结果:测试的最后一行抛出异常,并显示以下消息和堆栈跟踪。

Text '2018/12/06' could not be parsed: Conflict found: Year 1970 differs from Year 2018

Exception in thread "main" java.time.format.DateTimeParseException: Text '2018/12/06' could not be parsed: Conflict found: Year 1970 differs from Year 2018
    at java.base/java.time.format.DateTimeFormatter.createError(DateTimeFormatter.java:1959)
    at java.base/java.time.format.DateTimeFormatter.parse(DateTimeFormatter.java:1820)
    at com.ajax.mypackage.MyTest.testPatterns(MyTest.java:33)
Caused by: java.time.DateTimeException: Conflict found: Year 1970 differs from Year 2018
    at java.base/java.time.chrono.AbstractChronology.addFieldValue(AbstractChronology.java:676)
    at java.base/java.time.chrono.IsoChronology.resolveYearOfEra(IsoChronology.java:620)
    at java.base/java.time.chrono.IsoChronology.resolveYearOfEra(IsoChronology.java:126)
    at java.base/java.time.chrono.AbstractChronology.resolveDate(AbstractChronology.java:463)
    at java.base/java.time.chrono.IsoChronology.resolveDate(IsoChronology.java:585)
    at java.base/java.time.chrono.IsoChronology.resolveDate(IsoChronology.java:126)
    at java.base/java.time.format.Parsed.resolveDateFields(Parsed.java:360)
    at java.base/java.time.format.Parsed.resolveFields(Parsed.java:266)
    at java.base/java.time.format.Parsed.resolve(Parsed.java:253)
    at java.base/java.time.format.DateTimeParseContext.toResolved(DateTimeParseContext.java:331)
    at java.base/java.time.format.DateTimeFormatter.parseResolved0(DateTimeFormatter.java:1994)
    at java.base/java.time.format.DateTimeFormatter.parse(DateTimeFormatter.java:1816)
    ... 1 more

我不确定你是否应该想要这个,但我将它作为一个选项提供。

private static LocalDate defaults = LocalDate.of(1970, Month.JANUARY, 1);

private static LocalDate parseWithDefaults(String pattern, String dateString) {
    TemporalAccessor parsed 
            = DateTimeFormatter.ofPattern(pattern, Locale.ROOT).parse(dateString);
    LocalDate result = defaults;
    for (TemporalField field : ChronoField.values()) {
        if (parsed.isSupported(field) && result.isSupported(field)) {
            result = result.with(field, parsed.getLong(field));
        }
    }
    return result;
}

我采取相反的方式:我没有采用缺失的字段并将它们调整到已解析的对象中,而是采用默认的 LocalDate 对象并将已解析的字段调整到其中。这是如何运作的有复杂的规则,所以恐怕我们可能会有一两个惊喜。此外,对于像 2018/12/06 这样的完全指定的日期,它使用 13 个字段,因此显然存在一些冗余。但是,我用你的三个测试例子试过了:

    System.out.println(parseWithDefaults("MM/dd", "12/06"));
    System.out.println(parseWithDefaults("uuuu/MM/dd", "2018/12/06"));
    System.out.println(parseWithDefaults("yyyy/MM/dd", "2018/12/06"));

它打印了预期的

1970-12-06
2018-12-06
2018-12-06

进一步思考

这听起来有点像您的软件是围绕 Joda-Time 的这一特定行为设计的。因此,即使你正在从 Joda 迁移到 java.time——一个你应该感到高兴的迁移——如果是我,我会考虑在这个特定的角落保留 Joda-Time。这不是最令人愉快的选择,尤其是因为 Joda-time 和 java.time(据我所知)之间不存在直接转换。你需要自己权衡利弊。

parseDefaulting 将在未找到字段时设置该字段的值,即使对于不在模式中的字段也是如此,因此您最终可能会遇到同时存在年份和年份的情况在解析结果中。

对我来说,最简单的解决方案是评论中建议的那样:使用正则表达式检查输入是否包含年份(或看起来像一年的东西,例如 4 位数字),或者检查输入的长度,然后相应地创建格式化程序(并且没有默认值)。示例:

if (input_without_year) {
    LocalDate d = MonthDay
                      .parse("12/06", DateTimeFormatter.ofPattern("MM/dd"))
                      .atYear(1970);
} else {
    // use formatter with year, without default values
}

但如果你想要一个通用的解决方案,恐怕会更复杂。一种替代方法是解析输入并检查其中是否有任何年份字段。如果有 none,那么我们将其更改为 return 年份的默认值:

public static TemporalAccessor parse(String pattern, String input) {
    DateTimeFormatter fmt = DateTimeFormatter.ofPattern(pattern, Locale.ROOT);
    final TemporalAccessor parsed = fmt.parse(input);
    // check year and year of era
    boolean hasYear = parsed.isSupported(ChronoField.YEAR);
    boolean hasYearEra = parsed.isSupported(ChronoField.YEAR_OF_ERA);
    if (!hasYear && !hasYearEra) {
        // parsed value doesn't have any year field
        // return another TemporalAccessor with default value for year
        // using year 1970 - change it to Year.now().getValue() for current year
        return withYear(parsed, 1970); // see this method's code below
    }
    return parsed;
}

首先我们解析并得到一个包含所有解析字段的TemporalAccessor。然后我们检查它是否有 year 或 year-of-era 字段。如果它没有任何这些,我们将创建另一个 TemporalAccessor 并使用一些年份的默认值。

在上面的代码中,我使用的是 1970,但您可以将其更改为您需要的任何值。 withYear 方法有一些重要的细节需要注意:

  • 我假设输入总是有月和日。如果不是这种情况,您可以更改下面的代码以使用它们的默认值
    • 要检查字段是否存在,请使用 isSupported 方法
  • LocalDate.frominternally uses a TemporalQuery, which in turn queries the epoch-day field,但是解析出来的对象没有年份的时候,无法计算纪元日,所以我也在计算

withYear方法如下:

public static TemporalAccessor withYear(TemporalAccessor t, long year) {
    return new TemporalAccessor() {

        @Override
        public boolean isSupported(TemporalField field) {
            // epoch day is used by LocalDate.from
            if (field == ChronoField.YEAR_OF_ERA || field == ChronoField.EPOCH_DAY) {
                return true;
            } else {
                return t.isSupported(field);
            }
        }

        @Override
        public long getLong(TemporalField field) {
            if (field == ChronoField.YEAR_OF_ERA) {
                return year;
                // epoch day is used by LocalDate.from
            } else if (field == ChronoField.EPOCH_DAY) {
                // Assuming the input always have month and day
                // If that's not the case, you can change the code to use default values as well,
                // and use MonthDay.of(month, day)
                return MonthDay.from(t).atYear((int) year).toEpochDay();
            } else {
                return t.getLong(field);
            }
        }
    };
}

现在可以了:

System.out.println(LocalDate.from(parse("MM/dd", "12/06"))); // 1970-12-06
System.out.println(LocalDate.from(parse("uuuu/MM/dd", "2018/12/06"))); // 2018-12-06
System.out.println(LocalDate.from(parse("yyyy/MM/dd", "2018/12/06"))); // 2018-12-06

但我仍然认为第一个解决方案更简单。

备选

假设您总是创建 LocalDate,另一种选择是使用 parseBest:

public static LocalDate parseLocalDate(String pattern, String input) {
    DateTimeFormatter fmt = DateTimeFormatter.ofPattern(pattern, Locale.ROOT);

    // try to create a LocalDate first
    // if not possible, try to create a MonthDay
    TemporalAccessor parsed = fmt.parseBest(input, LocalDate::from, MonthDay::from);

    LocalDate dt = null;

    // check which type was created by the parser
    if (parsed instanceof LocalDate) {
        dt = (LocalDate) parsed;
    } else if (parsed instanceof MonthDay) {
        // using year 1970 - change it to Year.now().getValue() for current year
        dt = ((MonthDay) parsed).atYear(1970);
    } // else etc... - do as many checkings you need to handle all possible cases

    return dt;
}

方法 parseBest receives a list of TemporalQuery instances (or equivalent method references, as the from methods above) 并尝试按顺序调用它们:在上面的代码中,首先它尝试创建一个 LocalDate,如果不可能,请尝试MonthDay.

然后我检查类型 returned 并采取相应的行动。您可以扩展它以检查任意数量的类型,您也可以编写自己的 TemporalQuery 来处理特定情况。

有了这个,所有情况也都有效:

System.out.println(parseLocalDate("MM/dd", "12/06")); // 1970-12-06
System.out.println(parseLocalDate("uuuu/MM/dd", "2018/12/06")); // 2018-12-06
System.out.println(parseLocalDate("yyyy/MM/dd", "2018/12/06")); // 2018-12-06

您可以尝试将我的库 Time4J 的解析引擎作为一种 enhancement/improvement,然后使用以下代码在解析期间生成 java.time.LocalDate 的实例:

static ChronoFormatter<LocalDate> createParser(String pattern) {
    return ChronoFormatter // maybe consider caching the immutable formatter per pattern
        .ofPattern(
            pattern,
            PatternType.CLDR,
            Locale.ROOT, // locale-sensitive patterns require another locale
            PlainDate.axis(TemporalType.LOCAL_DATE) // converts to java.time.LocalDate
        )
        .withDefault(PlainDate.YEAR, 1970)
        .with(Leniency.STRICT);
}

public static void main(String[] args) throws Exception {
    System.out.println(createParser("uuuu/MM/dd").parse("2018/12/06")); // 2018-12-06
    System.out.println(createParser("yyyy/MM/dd").parse("2018/12/06")); // 2018-12-06
    System.out.println(createParser("MM/dd").parse("12/06")); // 1970-12-06
}

这是可行的,因为 - 尽管有严格的解析模式(其中检查了矛盾的元素值 - 模式符号 "y" 将映射到 "u"(proleptic gregorian year)as只要没有纪元符号 "G" 相应的历史纪元元素。

关于替代格式引擎的许多其他功能,请参阅 documentation. A builder for specialized element syntax or customized patterns 也可用。存在定义默认值的其他变体。您的 Joda-default-code 可能会以这种方式迁移(使用系统时区,但也很容易使用 UTC):

parser.withDefaultSupplier( // also works if current year is changing
  PlainDate.YEAR,
  () -> SystemClock.inLocalView().today().getYear()
  // or: () -> SystemClock.inZonalView(ZonalOffset.UTC).getYear()
)

另一个关于使用模式的重要通知

Joda 和 java.time 的模式语法不同。你知道这个事实吗?迁移时无论如何都必须转换模式:

  • y => 你
  • Y => 是
  • x => Y