【问题标题】:java.time DateTimeFormatter parsing with flexible fallback valuesjava.time DateTimeFormatter 使用灵活的后备值解析
【发布时间】:2018-09-23 12:27:03
【问题描述】:

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

JodaTime 可以像这样为年份指定一个备用值

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

无论解析器看起来如何(如果它包括一年),都会对其进行解析。

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

如果您对传入的日期格式没有任何控制权,因为它是用户提供的,这使得调用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"));
}

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

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

无法解析文本“2018/12/06”:发现冲突:1970 年 与 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

【问题讨论】:

  • 我也对你的问题感到困惑。你想提供默认值吗?如您所示,使用DateTimeFormatterBuilder。您想要覆盖日期时间值的某些部分吗?通过在DateTimeFormatter 实例化的日期时间对象上调用with… 方法进行调整。编辑您的问题以阐明默认值与覆盖。也许给出一些输入和期望输出的例子。
  • 抱歉不清楚。想象一下,我想解析日期格式dd/MM,然后我需要将parseDefaulting(ChronoField.YEAR, 1984) 添加到我的格式化程序中,知道这种格式没有年份。如果下一个解析(注意我不知道格式化程序,suer 提供)是YYYY/dd/MM,那么添加parseDefaulting(ChronoField.YEAR, 1984) 将返回一个异常,因为年份已经设置。所以我需要以某种方式区分这些日期格式,以便能够设置正确的默认值。希望这是有道理的。
  • 我已经从您的最后一个示例中复制了 java.time.format.DateTimeParseException: Text '2018/12/06' could not be parsed: Conflict found: Year 1970 differs from Year 2018。格式化程序有字段年份、时代和年份。我相信parseDefaulting 将填写一个字段,如果该特定字段尚未被解析,无论它可能是从其他字段推导出来的。我不知道解决方案。
  • 我在猜测是否可以使用DateTimeFormatter.parseUnresolved()DateTimeFormatter.withResolverFields() 找到解决方案。不明显。
  • 这里的斜杠只是一个例子,用户也可以使用像yyyy.MM.dd这样的点或破折号yyyy-MM-dd..我认为这将是一个正则表达式的超级复杂,因为你需要搜索字母(并计算它们),而不是中间的字符,基本上重建了java时间解析逻辑

标签: java datetime time java-time datetime-parsing


【解决方案1】:

您可以尝试我的库 Time4J 的解析引擎作为一种增强/改进,然后使用以下代码在解析期间生成 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”(公历)只要没有时代符号“G”各自的历史时代元素。

关于替代格式引擎的许多其他功能,请参阅documentation。还可以使用专用元素语法或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

【讨论】:

    【解决方案2】:

    我怀疑你是否应该想要这个,但我将它作为一个选项。

    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(据我所知)之间不存在直接转换。您需要自己权衡利弊。

    【讨论】:

    • 非常感谢您提供的代码示例和您为此付出的时间。保留一个未维护的库在 IMO 是不行的,即使这意味着我们必须投入更多的时间来将东西移植到 java.time。摆脱依赖总是好的。我现在通过将时间访问器合并到用户指定的日期来执行与上述类似的操作,并且到目前为止它可以工作。
    • 而不是迭代所有ChronoField.values(),您只需要LocalDate 使用对吗? IE。 ChronoField[] fieldsToOverride = { ChronoField.YEAR, ChronoField.MONTH_OF_YEAR, ChronoField.DAY_OF_MONTH };
    • @wilmol 一方面你是对的,例如,如果解析一天中的一个小时,我们已经知道它无助于定义日期,所以它可能感觉像是对它的浪费测试.另一方面,根据the docs LocalDate 支持来自ChronoField 的13 个字段,而不仅仅是您提到的3 个。在某些情况下它很重要,包括答案中的示例parseWithDefaults("uuuu/MM/dd", "2018/12/06"),以及许多其他情况。
    【解决方案3】:

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

    对我来说,最简单的解决方案是 cmets 中建议的:检查输入是否包含带有正则表达式的年份(或看起来像一年的东西,例如 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
    }
    

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

    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,又是queries the epoch-day field,但是当被解析的对象没有年份时,它无法计算epoch-day,所以我也在计算它

    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;
    }
    

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

    然后我检查返回的类型并采取相应措施。您可以扩展它以检查所需的多种类型,也可以编写自己的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
    

    【讨论】:

    • 非常感谢您的建议!我拿了这个和下面的评论,并创建了一个方法,我可以在其中传递另一个分区日期时间,然后将解析的 temporalaccessor 合并到那个方法中。到目前为止它有效。
    猜你喜欢
    • 2022-11-12
    • 1970-01-01
    • 1970-01-01
    • 2015-08-23
    • 2021-09-06
    • 2018-08-06
    • 1970-01-01
    • 2011-06-17
    • 2017-12-31
    相关资源
    最近更新 更多