Support multiple style of parsing/printing Durations

This commit introduces a notion of different styles for the formatting
of Duration.
The `@DurationFormat` annotation is added to ease selection of a style,
which are represented as DurationFormat.Style enum, as well as a
supported time unit represented as DurationFormat.Unit enum.

DurationFormatter has been retroffited to take such a Style,
optionally, at construction. The default is still the JDK style a.k.a.
ISO-8601.

This introduces the new SIMPLE style which uses a single number + a
short human-readable suffix. For instance "-3ms" or "2h".

This has the same semantics as the DurationStyle in Spring Boot and
is intended as a replacement for that feature, providing access to the
feature to projects that only depend on Spring Framework.

Finally, the `@Scheduled` annotation is improved by adding detection
of the style and parsing for the String versions of initial delay, fixed
delay and fixed rate.

See gh-22013
See gh-22474

Closes gh-30396
This commit is contained in:
Simon Baslé
2024-07-23 11:53:35 +02:00
committed by Brian Clozel
parent d219362eb1
commit c92e043bbc
13 changed files with 875 additions and 42 deletions

View File

@@ -74,7 +74,8 @@ The `format` subpackages provide several `Formatter` implementations as a conven
The `number` package provides `NumberStyleFormatter`, `CurrencyStyleFormatter`, and
`PercentStyleFormatter` to format `Number` objects that use a `java.text.NumberFormat`.
The `datetime` package provides a `DateFormatter` to format `java.util.Date` objects with
a `java.text.DateFormat`.
a `java.text.DateFormat`, as well as a `DurationFormatter` to format `Duration` objects
in different styles defined in the `@DurationFormat.Style` enum (see <<format-annotations-api>>).
The following `DateFormatter` is an example `Formatter` implementation:
@@ -280,7 +281,8 @@ Kotlin::
A portable format annotation API exists in the `org.springframework.format.annotation`
package. You can use `@NumberFormat` to format `Number` fields such as `Double` and
`Long`, and `@DateTimeFormat` to format `java.util.Date`, `java.util.Calendar`, `Long`
`Long`, `@DurationFormat` to format `Duration` fields in ISO8601 and simplified styles,
and `@DateTimeFormat` to format `java.util.Date`, `java.util.Calendar`, `Long`
(for millisecond timestamps) as well as JSR-310 `java.time`.
The following example uses `@DateTimeFormat` to format a `java.util.Date` as an ISO Date

View File

@@ -94,7 +94,7 @@ class WebConfig : WebFluxConfigurer {
[.small]#xref:web/webmvc/mvc-config/conversion.adoc[See equivalent in the Servlet stack]#
By default, formatters for various number and date types are installed, along with support
for customization via `@NumberFormat` and `@DateTimeFormat` on fields.
for customization via `@NumberFormat`, `@DurationFormat` and `@DateTimeFormat` on fields.
To register custom formatters and converters in Java config, use the following:

View File

@@ -4,7 +4,7 @@
[.small]#xref:web/webflux/config.adoc#webflux-config-conversion[See equivalent in the Reactive stack]#
By default, formatters for various number and date types are installed, along with support
for customization via `@NumberFormat` and `@DateTimeFormat` on fields.
for customization via `@NumberFormat`, `@DurationFormat` and `@DateTimeFormat` on fields.
To register custom formatters and converters, use the following:

View File

@@ -0,0 +1,213 @@
/*
* Copyright 2002-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.format.annotation;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.time.Duration;
import java.time.temporal.ChronoUnit;
import java.util.function.Function;
import org.springframework.lang.Nullable;
/**
* Declares that a field or method parameter should be formatted as a {@link java.time.Duration},
* according to the specified {@code style}.
*
* @author Simon Baslé
* @since 6.2
*/
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER, ElementType.ANNOTATION_TYPE})
public @interface DurationFormat {
/**
* Which {@code Style} to use for parsing and printing a {@code Duration}. Defaults to
* the JDK style ({@link Style#ISO8601}).
*/
Style style() default Style.ISO8601;
/**
* Define which {@link Unit} to fall back to in case the {@code style()}
* needs a unit for either parsing or printing, and none is explicitly provided in
* the input ({@code Unit.MILLIS} if unspecified).
*/
Unit defaultUnit() default Unit.MILLIS;
/**
* Duration format styles.
*/
enum Style {
/**
* Simple formatting based on a short suffix, for example '1s'.
* Supported unit suffixes are: {@code ns, us, ms, s, m, h, d}.
* This corresponds to nanoseconds, microseconds, milliseconds, seconds,
* minutes, hours and days respectively.
* <p>Note that when printing a {@code Duration}, this style can be lossy if the
* selected unit is bigger than the resolution of the duration. For example,
* {@code Duration.ofMillis(5).plusNanos(1234)} would get truncated to {@code "5ms"}
* when printing using {@code ChronoUnit.MILLIS}.
*/
SIMPLE,
/**
* ISO-8601 formatting.
* <p>This is what the JDK uses in {@link java.time.Duration#parse(CharSequence)}
* and {@link Duration#toString()}.
*/
ISO8601
}
/**
* Duration format unit, which mirrors a subset of {@link ChronoUnit} and allows conversion to and from
* supported {@code ChronoUnit} as well as converting durations to longs.
* The enum includes its corresponding suffix in the {@link Style#SIMPLE simple} Duration format style.
*/
enum Unit {
/**
* Nanoseconds ({@code "ns"}).
*/
NANOS(ChronoUnit.NANOS, "ns", Duration::toNanos),
/**
* Microseconds ({@code "us"}).
*/
MICROS(ChronoUnit.MICROS, "us", duration -> duration.toNanos() / 1000L),
/**
* Milliseconds ({@code "ms"}).
*/
MILLIS(ChronoUnit.MILLIS, "ms", Duration::toMillis),
/**
* Seconds ({@code "s"}).
*/
SECONDS(ChronoUnit.SECONDS, "s", Duration::getSeconds),
/**
* Minutes ({@code "m"}).
*/
MINUTES(ChronoUnit.MINUTES, "m", Duration::toMinutes),
/**
* Hours ({@code "h"}).
*/
HOURS(ChronoUnit.HOURS, "h", Duration::toHours),
/**
* Days ({@code "d"}).
*/
DAYS(ChronoUnit.DAYS, "d", Duration::toDays);
private final ChronoUnit chronoUnit;
private final String suffix;
private final Function<Duration, Long> longValue;
Unit(ChronoUnit chronoUnit, String suffix, Function<Duration, Long> toUnit) {
this.chronoUnit = chronoUnit;
this.suffix = suffix;
this.longValue = toUnit;
}
/**
* Convert this {@code DurationFormat.Unit} to its {@link ChronoUnit} equivalent.
*/
public ChronoUnit asChronoUnit() {
return this.chronoUnit;
}
/**
* Convert this {@code DurationFormat.Unit} to a simple {@code String} suffix,
* suitable for the {@link Style#SIMPLE} style.
*/
public String asSuffix() {
return this.suffix;
}
/**
* Parse a {@code long} from a {@code String} and interpret it to be a {@code Duration}
* in the current unit.
* @param value the String representation of the long
* @return the corresponding {@code Duration}
*/
public Duration parse(String value) {
return Duration.of(Long.parseLong(value), asChronoUnit());
}
/**
* Print a {@code Duration} as a {@code String}, converting it to a long value
* using this unit's precision via {@link #longValue(Duration)} and appending
* this unit's simple {@link #asSuffix() suffix}.
* @param value the {@code Duration} to convert to String
* @return the String representation of the {@code Duration} in the {@link Style#SIMPLE SIMPLE style}
*/
public String print(Duration value) {
return longValue(value) + asSuffix();
}
/**
* Convert the given {@code Duration} to a long value in the resolution of this
* unit. Note that this can be lossy if the current unit is bigger than the
* actual resolution of the duration.
* <p>For example, {@code Duration.ofMillis(5).plusNanos(1234)} would get truncated
* to {@code 5} for unit {@code MILLIS}.
* @param value the {@code Duration} to convert to long
* @return the long value for the Duration in this Unit
*/
public long longValue(Duration value) {
return this.longValue.apply(value);
}
/**
* Get the {@code Unit} corresponding to the given {@code ChronoUnit}.
* @throws IllegalArgumentException if that particular ChronoUnit isn't supported
*/
public static Unit fromChronoUnit(@Nullable ChronoUnit chronoUnit) {
if (chronoUnit == null) {
return Unit.MILLIS;
}
for (Unit candidate : values()) {
if (candidate.chronoUnit == chronoUnit) {
return candidate;
}
}
throw new IllegalArgumentException("No matching Unit for ChronoUnit." + chronoUnit.name());
}
/**
* Get the {@code Unit} corresponding to the given {@code String} suffix.
* @throws IllegalArgumentException if that particular suffix is unknown
*/
public static Unit fromSuffix(String suffix) {
for (Unit candidate : values()) {
if (candidate.suffix.equalsIgnoreCase(suffix)) {
return candidate;
}
}
throw new IllegalArgumentException("'" + suffix + "' is not a valid simple duration Unit");
}
}
}

View File

@@ -198,6 +198,7 @@ public class DateTimeFormatterRegistrar implements FormatterRegistrar {
registry.addFormatterForFieldType(MonthDay.class, new MonthDayFormatter());
registry.addFormatterForFieldAnnotation(new Jsr310DateTimeFormatAnnotationFormatterFactory());
registry.addFormatterForFieldAnnotation(new DurationFormatAnnotationFormatterFactory());
}
private DateTimeFormatter getFormatter(Type type) {

View File

@@ -0,0 +1,57 @@
/*
* Copyright 2002-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.format.datetime.standard;
import java.time.Duration;
import java.util.Set;
import org.springframework.context.support.EmbeddedValueResolutionSupport;
import org.springframework.format.AnnotationFormatterFactory;
import org.springframework.format.Parser;
import org.springframework.format.Printer;
import org.springframework.format.annotation.DurationFormat;
/**
* Formats fields annotated with the {@link DurationFormat} annotation using the
* selected style for parsing and printing JSR-310 {@code Duration}.
*
* @author Simon Baslé
* @since 6.2
* @see DurationFormat
* @see DurationFormatter
*/
public class DurationFormatAnnotationFormatterFactory extends EmbeddedValueResolutionSupport
implements AnnotationFormatterFactory<DurationFormat> {
// Create the set of field types that may be annotated with @DurationFormat.
private static final Set<Class<?>> FIELD_TYPES = Set.of(Duration.class);
@Override
public final Set<Class<?>> getFieldTypes() {
return FIELD_TYPES;
}
@Override
public Printer<?> getPrinter(DurationFormat annotation, Class<?> fieldType) {
return new DurationFormatter(annotation.style(), annotation.defaultUnit());
}
@Override
public Parser<?> getParser(DurationFormat annotation, Class<?> fieldType) {
return new DurationFormatter(annotation.style(), annotation.defaultUnit());
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2002-2016 the original author or authors.
* Copyright 2002-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -21,25 +21,75 @@ import java.time.Duration;
import java.util.Locale;
import org.springframework.format.Formatter;
import org.springframework.format.annotation.DurationFormat;
import org.springframework.lang.Nullable;
/**
* {@link Formatter} implementation for a JSR-310 {@link Duration},
* following JSR-310's parsing rules for a Duration.
* following JSR-310's parsing rules for a Duration by default and
* supporting additional {@code DurationFormat.Style} styles.
*
* @author Juergen Hoeller
* @since 4.2.4
* @see Duration#parse
* @since 6.2
* @see DurationFormatterUtils
* @see DurationFormat.Style
*/
class DurationFormatter implements Formatter<Duration> {
public class DurationFormatter implements Formatter<Duration> {
private final DurationFormat.Style style;
@Nullable
private final DurationFormat.Unit defaultUnit;
/**
* Create a {@code DurationFormatter} following JSR-310's parsing rules for a Duration
* (the {@link DurationFormat.Style#ISO8601 ISO-8601} style).
*/
DurationFormatter() {
this(DurationFormat.Style.ISO8601);
}
/**
* Create a {@code DurationFormatter} in a specific {@link DurationFormat.Style}.
* <p>When a unit is needed but cannot be determined (e.g. printing a Duration in the
* {@code SIMPLE} style), {@code DurationFormat.Unit#MILLIS} is used.
*/
public DurationFormatter(DurationFormat.Style style) {
this(style, null);
}
/**
* Create a {@code DurationFormatter} in a specific {@link DurationFormat.Style} with an
* optional {@code DurationFormat.Unit}.
* <p>If a {@code defaultUnit} is specified, it may be used in parsing cases when no
* unit is present in the string (provided the style allows for such a case). It will
* also be used as the representation's resolution when printing in the
* {@link DurationFormat.Style#SIMPLE} style. Otherwise, the style defines its default
* unit.
*
* @param style the {@code DurationStyle} to use
* @param defaultUnit the {@code DurationFormat.Unit} to fall back to when parsing and printing
*/
public DurationFormatter(DurationFormat.Style style, @Nullable DurationFormat.Unit defaultUnit) {
this.style = style;
this.defaultUnit = defaultUnit;
}
@Override
public Duration parse(String text, Locale locale) throws ParseException {
return Duration.parse(text);
if (this.defaultUnit == null) {
//delegate to the style
return DurationFormatterUtils.parse(text, this.style);
}
return DurationFormatterUtils.parse(text, this.style, this.defaultUnit);
}
@Override
public String print(Duration object, Locale locale) {
return object.toString();
if (this.defaultUnit == null) {
//delegate the ultimate of the default unit to the style
return DurationFormatterUtils.print(object, this.style);
}
return DurationFormatterUtils.print(object, this.style, this.defaultUnit);
}
}

View File

@@ -0,0 +1,171 @@
/*
* Copyright 2002-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.format.datetime.standard;
import java.time.Duration;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.springframework.format.annotation.DurationFormat;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
/**
* Support {@code Duration} parsing and printing in several styles, as listed in
* {@link DurationFormat.Style}.
* <p>Some styles may not enforce any unit to be present, defaulting to {@code DurationFormat.Unit#MILLIS}
* in that case. Methods in this class offer overloads that take a {@link DurationFormat.Unit} to
* be used as a fall-back instead of the ultimate MILLIS default.
*
* @author Phillip Webb
* @author Valentine Wu
* @author Simon Baslé
* @since 6.2
*/
public abstract class DurationFormatterUtils {
private DurationFormatterUtils() {
// singleton
}
/**
* Parse the given value to a duration.
* @param value the value to parse
* @param style the style in which to parse
* @return a duration
*/
public static Duration parse(String value, DurationFormat.Style style) {
return parse(value, style, null);
}
/**
* Parse the given value to a duration.
* @param value the value to parse
* @param style the style in which to parse
* @param unit the duration unit to use if the value doesn't specify one ({@code null}
* will default to ms)
* @return a duration
*/
public static Duration parse(String value, DurationFormat.Style style, @Nullable DurationFormat.Unit unit) {
return switch (style) {
case ISO8601 -> parseIso8601(value);
case SIMPLE -> parseSimple(value, unit);
};
}
/**
* Print the specified duration in the specified style.
* @param value the value to print
* @param style the style to print in
* @return the printed result
*/
public static String print(Duration value, DurationFormat.Style style) {
return print(value, style, null);
}
/**
* Print the specified duration in the specified style using the given unit.
* @param value the value to print
* @param style the style to print in
* @param unit the unit to use for printing, if relevant ({@code null} will default
* to ms)
* @return the printed result
*/
public static String print(Duration value, DurationFormat.Style style, @Nullable DurationFormat.Unit unit) {
return switch (style) {
case ISO8601 -> value.toString();
case SIMPLE -> printSimple(value, unit);
};
}
/**
* Detect the style then parse the value to return a duration.
* @param value the value to parse
* @return the parsed duration
* @throws IllegalArgumentException if the value is not a known style or cannot be
* parsed
*/
public static Duration detectAndParse(String value) {
return detectAndParse(value, null);
}
/**
* Detect the style then parse the value to return a duration.
* @param value the value to parse
* @param unit the duration unit to use if the value doesn't specify one ({@code null}
* will default to ms)
* @return the parsed duration
* @throws IllegalArgumentException if the value is not a known style or cannot be
* parsed
*/
public static Duration detectAndParse(String value, @Nullable DurationFormat.Unit unit) {
return parse(value, detect(value), unit);
}
/**
* Detect the style from the given source value.
* @param value the source value
* @return the duration style
* @throws IllegalArgumentException if the value is not a known style
*/
public static DurationFormat.Style detect(String value) {
Assert.notNull(value, "Value must not be null");
// warning: the order of parsing starts to matter if multiple patterns accept a plain integer (no unit suffix)
if (ISO_8601_PATTERN.matcher(value).matches()) {
return DurationFormat.Style.ISO8601;
}
if (SIMPLE_PATTERN.matcher(value).matches()) {
return DurationFormat.Style.SIMPLE;
}
throw new IllegalArgumentException("'" + value + "' is not a valid duration, cannot detect any known style");
}
private static final Pattern ISO_8601_PATTERN = Pattern.compile("^[+-]?[pP].*$");
private static final Pattern SIMPLE_PATTERN = Pattern.compile("^([+-]?\\d+)([a-zA-Z]{0,2})$");
private static Duration parseIso8601(String value) {
try {
return Duration.parse(value);
}
catch (Throwable ex) {
throw new IllegalArgumentException("'" + value + "' is not a valid ISO-8601 duration", ex);
}
}
private static Duration parseSimple(String text, @Nullable DurationFormat.Unit fallbackUnit) {
try {
Matcher matcher = SIMPLE_PATTERN.matcher(text);
Assert.state(matcher.matches(), "Does not match simple duration pattern");
String suffix = matcher.group(2);
DurationFormat.Unit parsingUnit = (fallbackUnit == null ? DurationFormat.Unit.MILLIS : fallbackUnit);
if (StringUtils.hasLength(suffix)) {
parsingUnit = DurationFormat.Unit.fromSuffix(suffix);
}
return parsingUnit.parse(matcher.group(1));
}
catch (Exception ex) {
throw new IllegalArgumentException("'" + text + "' is not a valid simple duration", ex);
}
}
private static String printSimple(Duration duration, @Nullable DurationFormat.Unit unit) {
unit = (unit == null ? DurationFormat.Unit.MILLIS : unit);
return unit.print(duration);
}
}

View File

@@ -141,15 +141,22 @@ public @interface Scheduled {
/**
* Execute the annotated method with a fixed period between the end of the
* last invocation and the start of the next.
* <p>The time unit is milliseconds by default but can be overridden via
* {@link #timeUnit}.
* <p>This attribute variant supports Spring-style "${...}" placeholders
* as well as SpEL expressions.
* <p>The duration String can be in several formats:
* <ul>
* <li>a plain integer &mdash; which is interpreted to represent a duration in
* milliseconds by default unless overridden via {@link #timeUnit()} (prefer
* using {@link #fixedDelay()} in that case)</li>
* <li>any of the known {@link org.springframework.format.annotation.DurationFormat.Style
* DurationFormat.Style}: the {@link org.springframework.format.annotation.DurationFormat.Style#ISO8601 ISO8601}
* style or the {@link org.springframework.format.annotation.DurationFormat.Style#SIMPLE SIMPLE} style
* &mdash; using the {@link #timeUnit()} as fallback if the string doesn't contain an explicit unit</li>
* </ul>
* <p><b>NOTE: With virtual threads, fixed rates and cron triggers are recommended
* over fixed delays.</b> Fixed-delay tasks operate on a single scheduler thread
* with {@link org.springframework.scheduling.concurrent.SimpleAsyncTaskScheduler}.
* @return the delay as a String value &mdash; for example, a placeholder
* or a {@link java.time.Duration#parse java.time.Duration} compliant value
* @return the delay as a String value &mdash; for example a placeholder,
* or a {@link org.springframework.format.annotation.DurationFormat.Style#ISO8601 java.time.Duration} compliant value
* or a {@link org.springframework.format.annotation.DurationFormat.Style#SIMPLE simple format} compliant value
* @since 3.2.2
* @see #fixedDelay()
*/
@@ -165,12 +172,20 @@ public @interface Scheduled {
/**
* Execute the annotated method with a fixed period between invocations.
* <p>The time unit is milliseconds by default but can be overridden via
* {@link #timeUnit}.
* <p>This attribute variant supports Spring-style "${...}" placeholders
* as well as SpEL expressions.
* @return the period as a String value &mdash; for example, a placeholder
* or a {@link java.time.Duration#parse java.time.Duration} compliant value
* <p>The duration String can be in several formats:
* <ul>
* <li>a plain integer &mdash; which is interpreted to represent a duration in
* milliseconds by default unless overridden via {@link #timeUnit()} (prefer
* using {@link #fixedDelay()} in that case)</li>
* <li>any of the known {@link org.springframework.format.annotation.DurationFormat.Style
* DurationFormat.Style}: the {@link org.springframework.format.annotation.DurationFormat.Style#ISO8601 ISO8601}
* style or the {@link org.springframework.format.annotation.DurationFormat.Style#SIMPLE SIMPLE} style
* &mdash; using the {@link #timeUnit()} as fallback if the string doesn't contain an explicit unit</li>
* <li>one of the above, with Spring-style "${...}" placeholders as well as SpEL expressions</li>
* </ul>
* @return the period as a String value &mdash; for example a placeholder,
* or a {@link org.springframework.format.annotation.DurationFormat.Style#ISO8601 java.time.Duration} compliant value
* or a {@link org.springframework.format.annotation.DurationFormat.Style#SIMPLE simple format} compliant value
* @since 3.2.2
* @see #fixedRate()
*/
@@ -189,12 +204,20 @@ public @interface Scheduled {
/**
* Number of units of time to delay before the first execution of a
* {@link #fixedRate} or {@link #fixedDelay} task.
* <p>The time unit is milliseconds by default but can be overridden via
* {@link #timeUnit}.
* <p>This attribute variant supports Spring-style "${...}" placeholders
* as well as SpEL expressions.
* @return the initial delay as a String value &mdash; for example, a placeholder
* or a {@link java.time.Duration#parse java.time.Duration} compliant value
* <p>The duration String can be in several formats:
* <ul>
* <li>a plain integer &mdash; which is interpreted to represent a duration in
* milliseconds by default unless overridden via {@link #timeUnit()} (prefer
* using {@link #fixedDelay()} in that case)</li>
* <li>any of the known {@link org.springframework.format.annotation.DurationFormat.Style
* DurationFormat.Style}: the {@link org.springframework.format.annotation.DurationFormat.Style#ISO8601 ISO8601}
* style or the {@link org.springframework.format.annotation.DurationFormat.Style#SIMPLE SIMPLE} style
* &mdash; using the {@link #timeUnit()} as fallback if the string doesn't contain an explicit unit</li>
* <li>one of the above, with Spring-style "${...}" placeholders as well as SpEL expressions</li>
* </ul>
* @return the initial delay as a String value &mdash; for example a placeholder,
* or a {@link org.springframework.format.annotation.DurationFormat.Style#ISO8601 java.time.Duration} compliant value
* or a {@link org.springframework.format.annotation.DurationFormat.Style#SIMPLE simple format} compliant value
* @since 3.2.2
* @see #initialDelay()
*/

View File

@@ -59,6 +59,8 @@ import org.springframework.core.Ordered;
import org.springframework.core.annotation.AnnotatedElementUtils;
import org.springframework.core.annotation.AnnotationAwareOrderComparator;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.format.annotation.DurationFormat;
import org.springframework.format.datetime.standard.DurationFormatterUtils;
import org.springframework.lang.Nullable;
import org.springframework.scheduling.TaskScheduler;
import org.springframework.scheduling.Trigger;
@@ -567,21 +569,10 @@ public class ScheduledAnnotationBeanPostProcessor
}
private static Duration toDuration(String value, TimeUnit timeUnit) {
if (isDurationString(value)) {
return Duration.parse(value);
}
return toDuration(Long.parseLong(value), timeUnit);
DurationFormat.Unit unit = DurationFormat.Unit.fromChronoUnit(timeUnit.toChronoUnit());
return DurationFormatterUtils.detectAndParse(value, unit); // interpreting as long as fallback already
}
private static boolean isDurationString(String value) {
return (value.length() > 1 && (isP(value.charAt(0)) || isP(value.charAt(1))));
}
private static boolean isP(char ch) {
return (ch == 'P' || ch == 'p');
}
/**
* Return all currently scheduled tasks, from {@link Scheduled} methods
* as well as from programmatic {@link SchedulingConfigurer} interaction.

View File

@@ -53,6 +53,8 @@ import org.springframework.core.convert.ConversionFailedException;
import org.springframework.core.convert.support.DefaultConversionService;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.format.annotation.DateTimeFormat.ISO;
import org.springframework.format.annotation.DurationFormat;
import org.springframework.format.annotation.DurationFormat.Style;
import org.springframework.format.support.FormattingConversionService;
import org.springframework.validation.BindingResult;
import org.springframework.validation.DataBinder;
@@ -484,6 +486,17 @@ class DateTimeFormattingTests {
assertThat(binder.getBindingResult().getFieldValue("duration").toString()).isEqualTo("PT8H6M12.345S");
}
@Test
void testBindDurationAnnotated() {
MutablePropertyValues propertyValues = new MutablePropertyValues();
propertyValues.add("styleDuration", "2ms");
binder.bind(propertyValues);
assertThat(binder.getBindingResult().getFieldValue("styleDuration"))
.isNotNull()
.isEqualTo("2000us");
assertThat(binder.getBindingResult().getAllErrors()).isEmpty();
}
@Test
void testBindYear() {
MutablePropertyValues propertyValues = new MutablePropertyValues();
@@ -711,6 +724,9 @@ class DateTimeFormattingTests {
private Duration duration;
@DurationFormat(style = Style.SIMPLE, defaultUnit = DurationFormat.Unit.MICROS)
private Duration styleDuration;
private Year year;
private Month month;
@@ -870,6 +886,14 @@ class DateTimeFormattingTests {
this.duration = duration;
}
public Duration getStyleDuration() {
return this.styleDuration;
}
public void setStyleDuration(Duration styleDuration) {
this.styleDuration = styleDuration;
}
public Year getYear() {
return this.year;
}

View File

@@ -0,0 +1,297 @@
/*
* Copyright 2002-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.format.datetime.standard;
import java.time.Duration;
import java.time.format.DateTimeParseException;
import java.time.temporal.ChronoUnit;
import java.util.Arrays;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.format.annotation.DurationFormat.Unit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
import static org.springframework.format.annotation.DurationFormat.Style.ISO8601;
import static org.springframework.format.annotation.DurationFormat.Style.SIMPLE;
/**
* Tests for {@link DurationFormatterUtils}.
*/
class DurationFormatterUtilsTests {
@Test
void parseSimpleWithUnits() {
Duration nanos = DurationFormatterUtils.parse("1ns", SIMPLE, Unit.SECONDS);
Duration micros = DurationFormatterUtils.parse("-2us", SIMPLE, Unit.SECONDS);
Duration millis = DurationFormatterUtils.parse("+3ms", SIMPLE, Unit.SECONDS);
Duration seconds = DurationFormatterUtils.parse("4s", SIMPLE, Unit.SECONDS);
Duration minutes = DurationFormatterUtils.parse("5m", SIMPLE, Unit.SECONDS);
Duration hours = DurationFormatterUtils.parse("6h", SIMPLE, Unit.SECONDS);
Duration days = DurationFormatterUtils.parse("-10d", SIMPLE, Unit.SECONDS);
assertThat(nanos).hasNanos(1);
assertThat(micros).hasNanos(-2 * 1000);
assertThat(millis).hasMillis(3);
assertThat(seconds).hasSeconds(4);
assertThat(minutes).hasMinutes(5);
assertThat(hours).hasHours(6);
assertThat(days).hasDays(-10);
}
@Test
void parseSimpleWithoutUnits() {
assertThat(DurationFormatterUtils.parse("-123", SIMPLE, Unit.SECONDS))
.hasSeconds(-123);
assertThat(DurationFormatterUtils.parse("456", SIMPLE, Unit.SECONDS))
.hasSeconds(456);
}
@Test
void parseNoChronoUnitSimpleWithoutUnitsDefaultsToMillis() {
assertThat(DurationFormatterUtils.parse("-123", SIMPLE))
.hasMillis(-123);
assertThat(DurationFormatterUtils.parse("456", SIMPLE))
.hasMillis(456);
}
@Test
void parseNullChronoUnitSimpleWithoutUnitsDefaultsToMillis() {
assertThat(DurationFormatterUtils.parse("-123", SIMPLE, null))
.hasMillis(-123);
assertThat(DurationFormatterUtils.parse("456", SIMPLE, null))
.hasMillis(456);
}
@Test
void parseSimpleThrows() {
assertThatIllegalArgumentException().isThrownBy(() -> DurationFormatterUtils.parse(";23s", SIMPLE))
.withMessage("';23s' is not a valid simple duration")
.withCause(new IllegalStateException("Does not match simple duration pattern"));
assertThatIllegalArgumentException().isThrownBy(() -> DurationFormatterUtils.parse("+23y", SIMPLE))
.withMessage("'+23y' is not a valid simple duration")
.withCause(new IllegalArgumentException("'y' is not a valid simple duration Unit"));
}
@Test
void parseIsoNoChronoUnit() {
//these are based on the examples given in Duration.parse
// "PT20.345S" -- parses as "20.345 seconds"
assertThat(DurationFormatterUtils.parse("PT20.345S", ISO8601))
.hasMillis(20345);
// "PT15M" -- parses as "15 minutes" (where a minute is 60 seconds)
assertThat(DurationFormatterUtils.parse("PT15M", ISO8601))
.hasSeconds(15*60);
// "PT10H" -- parses as "10 hours" (where an hour is 3600 seconds)
assertThat(DurationFormatterUtils.parse("PT10H", ISO8601))
.hasHours(10);
// "P2D" -- parses as "2 days" (where a day is 24 hours or 86400 seconds)
assertThat(DurationFormatterUtils.parse("P2D", ISO8601))
.hasDays(2);
// "P2DT3H4M" -- parses as "2 days, 3 hours and 4 minutes"
assertThat(DurationFormatterUtils.parse("P2DT3H4M", ISO8601))
.isEqualTo(Duration.ofDays(2).plusHours(3).plusMinutes(4));
// "PT-6H3M" -- parses as "-6 hours and +3 minutes"
assertThat(DurationFormatterUtils.parse("PT-6H3M", ISO8601))
.isEqualTo(Duration.ofHours(-6).plusMinutes(3));
// "-PT6H3M" -- parses as "-6 hours and -3 minutes"
assertThat(DurationFormatterUtils.parse("-PT6H3M", ISO8601))
.isEqualTo(Duration.ofHours(-6).plusMinutes(-3));
// "-PT-6H+3M" -- parses as "+6 hours and -3 minutes"
assertThat(DurationFormatterUtils.parse("-PT-6H+3M", ISO8601))
.isEqualTo(Duration.ofHours(6).plusMinutes(-3));
}
@Test
void parseIsoIgnoresFallbackChronoUnit() {
assertThat(DurationFormatterUtils.parse("P2DT3H4M", ISO8601, Unit.NANOS))
.isEqualTo(Duration.ofDays(2).plusHours(3).plusMinutes(4));
}
@Test
void parseIsoThrows() {
assertThatIllegalArgumentException().isThrownBy(() -> DurationFormatterUtils.parse("P2DWV3H-4M", ISO8601))
.withMessage("'P2DWV3H-4M' is not a valid ISO-8601 duration")
.withCause(new DateTimeParseException("Text cannot be parsed to a Duration", "", 0));
}
@Test
void printSimple() {
assertThat(DurationFormatterUtils.print(Duration.ofNanos(12345), SIMPLE, Unit.NANOS))
.isEqualTo("12345ns");
assertThat(DurationFormatterUtils.print(Duration.ofNanos(-12345), SIMPLE, Unit.MICROS))
.isEqualTo("-12us");
}
@Test
void printSimpleNoChronoUnit() {
assertThat(DurationFormatterUtils.print(Duration.ofNanos(12345), SIMPLE))
.isEqualTo("0ms");
assertThat(DurationFormatterUtils.print(Duration.ofSeconds(-3), SIMPLE))
.isEqualTo("-3000ms");
}
@Test
void printIsoNoChronoUnit() {
assertThat(DurationFormatterUtils.print(Duration.ofNanos(12345), ISO8601))
.isEqualTo("PT0.000012345S");
assertThat(DurationFormatterUtils.print(Duration.ofSeconds(-3), ISO8601))
.isEqualTo("PT-3S");
}
@Test
void printIsoIgnoresChronoUnit() {
assertThat(DurationFormatterUtils.print(Duration.ofNanos(12345), ISO8601, Unit.HOURS))
.isEqualTo("PT0.000012345S");
assertThat(DurationFormatterUtils.print(Duration.ofSeconds(-3), ISO8601, Unit.HOURS))
.isEqualTo("PT-3S");
}
@Test
void detectAndParse() {
assertThat(DurationFormatterUtils.detectAndParse("PT1.234S", Unit.NANOS))
.as("iso")
.isEqualTo(Duration.ofMillis(1234));
assertThat(DurationFormatterUtils.detectAndParse("1234ms", Unit.NANOS))
.as("simple with explicit unit")
.isEqualTo(Duration.ofMillis(1234));
assertThat(DurationFormatterUtils.detectAndParse("1234", Unit.NANOS))
.as("simple without suffix")
.isEqualTo(Duration.ofNanos(1234));
}
@Test
void detectAndParseNoChronoUnit() {
assertThat(DurationFormatterUtils.detectAndParse("PT1.234S"))
.as("iso")
.isEqualTo(Duration.ofMillis(1234));
assertThat(DurationFormatterUtils.detectAndParse("1234ms"))
.as("simple with explicit unit")
.isEqualTo(Duration.ofMillis(1234));
assertThat(DurationFormatterUtils.detectAndParse("1234"))
.as("simple without suffix")
.isEqualTo(Duration.ofMillis(1234));
}
@Test
void detect() {
assertThat(DurationFormatterUtils.detect("+3ms"))
.as("SIMPLE")
.isEqualTo(SIMPLE);
assertThat(DurationFormatterUtils.detect("-10y"))
.as("invalid yet matching SIMPLE pattern")
.isEqualTo(SIMPLE);
assertThat(DurationFormatterUtils.detect("P2DT3H-4M"))
.as("ISO8601")
.isEqualTo(ISO8601);
assertThat(DurationFormatterUtils.detect("P2DWV3H-4M"))
.as("invalid yet matching ISO8601 pattern")
.isEqualTo(ISO8601);
assertThatIllegalArgumentException().isThrownBy(() -> DurationFormatterUtils.detect("WPT2H-4M"))
.withMessage("'WPT2H-4M' is not a valid duration, cannot detect any known style")
.withNoCause();
}
@Nested
class DurationFormatUnit {
@Test
void longValueFromUnit() {
Duration nanos = Duration.ofSeconds(3).plusMillis(22).plusNanos(1111);
assertThat(Unit.NANOS.longValue(nanos))
.as("NANOS")
.isEqualTo(3022001111L);
assertThat(Unit.MICROS.longValue(nanos))
.as("MICROS")
.isEqualTo(3022001);
assertThat(Unit.MILLIS.longValue(nanos))
.as("MILLIS")
.isEqualTo(3022);
assertThat(Unit.SECONDS.longValue(nanos))
.as("SECONDS")
.isEqualTo(3);
Duration minutes = Duration.ofHours(1).plusMinutes(23);
assertThat(Unit.MINUTES.longValue(minutes))
.as("MINUTES")
.isEqualTo(83);
assertThat(Unit.HOURS.longValue(minutes))
.as("HOURS")
.isEqualTo(1);
Duration days = Duration.ofHours(48 + 5);
assertThat(Unit.HOURS.longValue(days))
.as("HOURS in days")
.isEqualTo(53);
assertThat(Unit.DAYS.longValue(days))
.as("DAYS")
.isEqualTo(2);
}
@Test
void unitFromSuffix() {
assertThat(Unit.fromSuffix("ns")).as("ns").isEqualTo(Unit.NANOS);
assertThat(Unit.fromSuffix("us")).as("us").isEqualTo(Unit.MICROS);
assertThat(Unit.fromSuffix("ms")).as("ms").isEqualTo(Unit.MILLIS);
assertThat(Unit.fromSuffix("s")).as("s").isEqualTo(Unit.SECONDS);
assertThat(Unit.fromSuffix("m")).as("m").isEqualTo(Unit.MINUTES);
assertThat(Unit.fromSuffix("h")).as("h").isEqualTo(Unit.HOURS);
assertThat(Unit.fromSuffix("d")).as("d").isEqualTo(Unit.DAYS);
assertThatIllegalArgumentException().isThrownBy(() -> Unit.fromSuffix("ws"))
.withMessage("'ws' is not a valid simple duration Unit");
}
@Test
void unitFromChronoUnit() {
assertThat(Unit.fromChronoUnit(ChronoUnit.NANOS)).as("ns").isEqualTo(Unit.NANOS);
assertThat(Unit.fromChronoUnit(ChronoUnit.MICROS)).as("us").isEqualTo(Unit.MICROS);
assertThat(Unit.fromChronoUnit(ChronoUnit.MILLIS)).as("ms").isEqualTo(Unit.MILLIS);
assertThat(Unit.fromChronoUnit(ChronoUnit.SECONDS)).as("s").isEqualTo(Unit.SECONDS);
assertThat(Unit.fromChronoUnit(ChronoUnit.MINUTES)).as("m").isEqualTo(Unit.MINUTES);
assertThat(Unit.fromChronoUnit(ChronoUnit.HOURS)).as("h").isEqualTo(Unit.HOURS);
assertThat(Unit.fromChronoUnit(ChronoUnit.DAYS)).as("d").isEqualTo(Unit.DAYS);
assertThatIllegalArgumentException().isThrownBy(() -> Unit.fromChronoUnit(ChronoUnit.CENTURIES))
.withMessage("No matching Unit for ChronoUnit.CENTURIES");
}
@Test
void unitSuffixSmokeTest() {
assertThat(Arrays.stream(Unit.values()).map(u -> u.name() + "->" + u.asSuffix()))
.containsExactly("NANOS->ns", "MICROS->us", "MILLIS->ms", "SECONDS->s",
"MINUTES->m", "HOURS->h", "DAYS->d");
}
@Test
void chronoUnitSmokeTest() {
assertThat(Arrays.stream(Unit.values()).map(Unit::asChronoUnit))
.containsExactly(ChronoUnit.NANOS, ChronoUnit.MICROS, ChronoUnit.MILLIS,
ChronoUnit.SECONDS, ChronoUnit.MINUTES, ChronoUnit.HOURS, ChronoUnit.DAYS);
}
}
}

View File

@@ -496,8 +496,10 @@ class ScheduledAnnotationBeanPostProcessorTests {
@CsvSource(textBlock = """
PropertyPlaceholderWithFixedDelay, 5000, 1000, 5_000, 1_000
PropertyPlaceholderWithFixedDelay, PT5S, PT1S, 5_000, 1_000
PropertyPlaceholderWithFixedDelay, 5400ms, 1s, 5_400, 1_000
PropertyPlaceholderWithFixedDelayInSeconds, 5000, 1000, 5_000_000, 1_000_000
PropertyPlaceholderWithFixedDelayInSeconds, PT5S, PT1S, 5_000, 1_000
PropertyPlaceholderWithFixedDelayInSeconds, 5400ms, 500ms, 5_400, 500
""")
void propertyPlaceholderWithFixedDelay(@NameToClass Class<?> beanClass, String fixedDelay, String initialDelay,
long expectedInterval, long expectedInitialDelay) {
@@ -536,8 +538,10 @@ class ScheduledAnnotationBeanPostProcessorTests {
@CsvSource(textBlock = """
PropertyPlaceholderWithFixedRate, 3000, 1000, 3_000, 1_000
PropertyPlaceholderWithFixedRate, PT3S, PT1S, 3_000, 1_000
PropertyPlaceholderWithFixedRate, 3200ms, 1s, 3_200, 1_000
PropertyPlaceholderWithFixedRateInSeconds, 3000, 1000, 3_000_000, 1_000_000
PropertyPlaceholderWithFixedRateInSeconds, PT3S, PT1S, 3_000, 1_000
PropertyPlaceholderWithFixedRateInSeconds, 3200ms, 500ms, 3_200, 500
""")
void propertyPlaceholderWithFixedRate(@NameToClass Class<?> beanClass, String fixedRate, String initialDelay,
long expectedInterval, long expectedInitialDelay) {