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

@@ -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.