Skip to content

Commit

Permalink
Support multiple style of parsing/printing Durations
Browse files Browse the repository at this point in the history
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
  • Loading branch information
simonbasle authored and bclozel committed Jul 23, 2024
1 parent d219362 commit c92e043
Show file tree
Hide file tree
Showing 13 changed files with 875 additions and 42 deletions.
6 changes: 4 additions & 2 deletions framework-docs/modules/ROOT/pages/core/validation/format.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion framework-docs/modules/ROOT/pages/web/webflux/config.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
Original file line number Diff line number Diff line change
@@ -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");
}

}

}
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ public void registerFormatters(FormatterRegistry registry) {
registry.addFormatterForFieldType(MonthDay.class, new MonthDayFormatter());

registry.addFormatterForFieldAnnotation(new Jsr310DateTimeFormatAnnotationFormatterFactory());
registry.addFormatterForFieldAnnotation(new DurationFormatAnnotationFormatterFactory());
}

private DateTimeFormatter getFormatter(Type type) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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());
}
}
Loading

0 comments on commit c92e043

Please sign in to comment.