问题
I am trying to port some code from joda time to java time.
JodaTime had the possibility to specify a fallback value for the year like this
parser.withDefaultYear((new DateTime(DateTimeZone.UTC)).getYear()).parseDateTime(text);
Regardless how the parser looks (if it includes a year or not), this will be parsed.
java.time has become much more stricter there. Even though there is the DateTimeFormatterBuilder.parseDefaulting()
method, which allows you to specify fallbacks, this only works if that specific field is not specified in the date your want to parse or is marked as optional.
If you do not have any control about the incoming date format, as it is user supplied, this makes it super hard when to call parseDefaulting
.
Is there any workaround, where I can specify something like either a generic fallback date, whose values get used by the formatter, if they are not specified or how I configure fallback values that are simply not used, when they are specified in the formatter?
Minimal, complete and verifiable example follows.
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"));
}
Desired result: The test should parse the strings and pass (“be green”).
Observed result: The last line of the test throws an exception with the following message and stack trace.
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
回答1:
parseDefaulting
will set the value of the field if it's not found, even for fields that are not in the pattern, so you may end up with situations where both year and year-of-era are present in the parsed result.
To me, the easiest solution would be as suggested in the comments: check if the input contains a year (or something that looks like one, such as 4 digits) with a regex, or by checking the input's length, and then create the formatter accordingly (and without default values). Examples:
if (input_without_year) {
LocalDate d = MonthDay
.parse("12/06", DateTimeFormatter.ofPattern("MM/dd"))
.atYear(1970);
} else {
// use formatter with year, without default values
}
But if you want a generic solution, I'm afraid it's more complicated. One alternative is to parse the input and check if there are any year field in it. If there's none, then we change it to return a default value for the year:
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;
}
First we parse and get a TemporalAccessor
containing all the parsed fields. Then we check if it has year or year-of-era field. If it doesn't have any of those, we create another TemporalAccessor
with some default value for year.
In the code above, I'm using 1970, but you can change it to whatever you need. The withYear
method has some important details to notice:
- I'm assuming that the input always has month and day. If it's not the case, you can change the code below to use default values for them
- to check if a field is present, use the
isSupported
method
- to check if a field is present, use the
LocalDate.from
internally uses a TemporalQuery, which in turn queries the epoch-day field, but when the parsed object doesn't have the year, it can't calculate the epoch-day, so I'm calculating it as well
The withYear
method is as follows:
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);
}
}
};
}
Now this works:
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
But I still believe the first solution is simpler.
Alternative
Assuming that you're always creating a LocalDate
, another alternative is to use 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;
}
The method parseBest
receives a list of TemporalQuery instances (or equivalent method references, as the from methods above) and try to call them in order: in the code above, first it tries to create a LocalDate
, and if it's not possible, try a MonthDay
.
Then I check the type returned and act accordingly. You can expand this to check as many types you want, and you can also write your own TemporalQuery to handle specific cases.
With this, all cases also work:
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
回答2:
I’m in doubt whether you should want this, but I present it as an option.
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;
}
I am going the opposite way: instead of taking the missing fields and adjusting them into the parsed object, I am taking a default LocalDate
object and adjust the parsed fields into it. There are complicated rules for how this works, so I am afraid there may be a surprise or two in for us. Also, with a fully specified date like 2018/12/06, it uses 13 fields, so there is clearly some redundancy. However, I tried it with your three test examples:
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"));
It printed the expected
1970-12-06
2018-12-06
2018-12-06
A further thought
It sounds a bit like this piece of your software has been designed around this particular behaviour of Joda-Time. So even though you are migrating from Joda to java.time
— a migration with which you should be happy — if it were me, I’d consider keeping Joda-Time around for this particular corner. It’s not the most pleasant of options, particularly not since no direct conversions exist between Joda-time and java.time
(that I know of). You will need to weigh the pros and the cons yourself.
回答3:
You might try out the parse engine of my lib Time4J as a kind of enhancement/improvement and then use following code which produces instances of java.time.LocalDate
during parsing:
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
}
This works because - despite the strict parsing mode (where ambivalent element values are checked - the pattern symbol "y" will be mapped to "u" (proleptic gregorian year) as long as there is no era symbol "G" respective historic era element.
About the many other features of the alternative format engine, see the documentation. A builder for specialized element syntax or customized patterns is also available. Other variations of defining default values exist. Your Joda-default-code might be migrated this way (using the system timezone, but using UTC is easily possible, too):
parser.withDefaultSupplier( // also works if current year is changing
PlainDate.YEAR,
() -> SystemClock.inLocalView().today().getYear()
// or: () -> SystemClock.inZonalView(ZonalOffset.UTC).getYear()
)
Another important notice about patterns in use
The pattern syntax of Joda and java.time
are different. Are you aware of this fact? When migrating you have to convert the patterns anyway:
- y => u
- Y => y
- x => Y
来源:https://stackoverflow.com/questions/49812003/java-time-datetimeformatter-parsing-with-flexible-fallback-values