Skip to content

Commit c92e043

Browse files
simonbaslebclozel
authored andcommitted
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
1 parent d219362 commit c92e043

File tree

13 files changed

+875
-42
lines changed

13 files changed

+875
-42
lines changed

Diff for: framework-docs/modules/ROOT/pages/core/validation/format.adoc

+4-2
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,8 @@ The `format` subpackages provide several `Formatter` implementations as a conven
7474
The `number` package provides `NumberStyleFormatter`, `CurrencyStyleFormatter`, and
7575
`PercentStyleFormatter` to format `Number` objects that use a `java.text.NumberFormat`.
7676
The `datetime` package provides a `DateFormatter` to format `java.util.Date` objects with
77-
a `java.text.DateFormat`.
77+
a `java.text.DateFormat`, as well as a `DurationFormatter` to format `Duration` objects
78+
in different styles defined in the `@DurationFormat.Style` enum (see <<format-annotations-api>>).
7879

7980
The following `DateFormatter` is an example `Formatter` implementation:
8081

@@ -280,7 +281,8 @@ Kotlin::
280281

281282
A portable format annotation API exists in the `org.springframework.format.annotation`
282283
package. You can use `@NumberFormat` to format `Number` fields such as `Double` and
283-
`Long`, and `@DateTimeFormat` to format `java.util.Date`, `java.util.Calendar`, `Long`
284+
`Long`, `@DurationFormat` to format `Duration` fields in ISO8601 and simplified styles,
285+
and `@DateTimeFormat` to format `java.util.Date`, `java.util.Calendar`, `Long`
284286
(for millisecond timestamps) as well as JSR-310 `java.time`.
285287

286288
The following example uses `@DateTimeFormat` to format a `java.util.Date` as an ISO Date

Diff for: framework-docs/modules/ROOT/pages/web/webflux/config.adoc

+1-1
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ class WebConfig : WebFluxConfigurer {
9494
[.small]#xref:web/webmvc/mvc-config/conversion.adoc[See equivalent in the Servlet stack]#
9595

9696
By default, formatters for various number and date types are installed, along with support
97-
for customization via `@NumberFormat` and `@DateTimeFormat` on fields.
97+
for customization via `@NumberFormat`, `@DurationFormat` and `@DateTimeFormat` on fields.
9898

9999
To register custom formatters and converters in Java config, use the following:
100100

Diff for: framework-docs/modules/ROOT/pages/web/webmvc/mvc-config/conversion.adoc

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
[.small]#xref:web/webflux/config.adoc#webflux-config-conversion[See equivalent in the Reactive stack]#
55

66
By default, formatters for various number and date types are installed, along with support
7-
for customization via `@NumberFormat` and `@DateTimeFormat` on fields.
7+
for customization via `@NumberFormat`, `@DurationFormat` and `@DateTimeFormat` on fields.
88

99
To register custom formatters and converters, use the following:
1010

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
/*
2+
* Copyright 2002-2024 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.format.annotation;
18+
19+
import java.lang.annotation.Documented;
20+
import java.lang.annotation.ElementType;
21+
import java.lang.annotation.Retention;
22+
import java.lang.annotation.RetentionPolicy;
23+
import java.lang.annotation.Target;
24+
import java.time.Duration;
25+
import java.time.temporal.ChronoUnit;
26+
import java.util.function.Function;
27+
28+
import org.springframework.lang.Nullable;
29+
30+
/**
31+
* Declares that a field or method parameter should be formatted as a {@link java.time.Duration},
32+
* according to the specified {@code style}.
33+
*
34+
* @author Simon Baslé
35+
* @since 6.2
36+
*/
37+
@Documented
38+
@Retention(RetentionPolicy.RUNTIME)
39+
@Target({ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER, ElementType.ANNOTATION_TYPE})
40+
public @interface DurationFormat {
41+
42+
/**
43+
* Which {@code Style} to use for parsing and printing a {@code Duration}. Defaults to
44+
* the JDK style ({@link Style#ISO8601}).
45+
*/
46+
Style style() default Style.ISO8601;
47+
48+
/**
49+
* Define which {@link Unit} to fall back to in case the {@code style()}
50+
* needs a unit for either parsing or printing, and none is explicitly provided in
51+
* the input ({@code Unit.MILLIS} if unspecified).
52+
*/
53+
Unit defaultUnit() default Unit.MILLIS;
54+
55+
/**
56+
* Duration format styles.
57+
*/
58+
enum Style {
59+
60+
/**
61+
* Simple formatting based on a short suffix, for example '1s'.
62+
* Supported unit suffixes are: {@code ns, us, ms, s, m, h, d}.
63+
* This corresponds to nanoseconds, microseconds, milliseconds, seconds,
64+
* minutes, hours and days respectively.
65+
* <p>Note that when printing a {@code Duration}, this style can be lossy if the
66+
* selected unit is bigger than the resolution of the duration. For example,
67+
* {@code Duration.ofMillis(5).plusNanos(1234)} would get truncated to {@code "5ms"}
68+
* when printing using {@code ChronoUnit.MILLIS}.
69+
*/
70+
SIMPLE,
71+
72+
/**
73+
* ISO-8601 formatting.
74+
* <p>This is what the JDK uses in {@link java.time.Duration#parse(CharSequence)}
75+
* and {@link Duration#toString()}.
76+
*/
77+
ISO8601
78+
}
79+
80+
/**
81+
* Duration format unit, which mirrors a subset of {@link ChronoUnit} and allows conversion to and from
82+
* supported {@code ChronoUnit} as well as converting durations to longs.
83+
* The enum includes its corresponding suffix in the {@link Style#SIMPLE simple} Duration format style.
84+
*/
85+
enum Unit {
86+
/**
87+
* Nanoseconds ({@code "ns"}).
88+
*/
89+
NANOS(ChronoUnit.NANOS, "ns", Duration::toNanos),
90+
91+
/**
92+
* Microseconds ({@code "us"}).
93+
*/
94+
MICROS(ChronoUnit.MICROS, "us", duration -> duration.toNanos() / 1000L),
95+
96+
/**
97+
* Milliseconds ({@code "ms"}).
98+
*/
99+
MILLIS(ChronoUnit.MILLIS, "ms", Duration::toMillis),
100+
101+
/**
102+
* Seconds ({@code "s"}).
103+
*/
104+
SECONDS(ChronoUnit.SECONDS, "s", Duration::getSeconds),
105+
106+
/**
107+
* Minutes ({@code "m"}).
108+
*/
109+
MINUTES(ChronoUnit.MINUTES, "m", Duration::toMinutes),
110+
111+
/**
112+
* Hours ({@code "h"}).
113+
*/
114+
HOURS(ChronoUnit.HOURS, "h", Duration::toHours),
115+
116+
/**
117+
* Days ({@code "d"}).
118+
*/
119+
DAYS(ChronoUnit.DAYS, "d", Duration::toDays);
120+
121+
private final ChronoUnit chronoUnit;
122+
123+
private final String suffix;
124+
125+
private final Function<Duration, Long> longValue;
126+
127+
Unit(ChronoUnit chronoUnit, String suffix, Function<Duration, Long> toUnit) {
128+
this.chronoUnit = chronoUnit;
129+
this.suffix = suffix;
130+
this.longValue = toUnit;
131+
}
132+
133+
/**
134+
* Convert this {@code DurationFormat.Unit} to its {@link ChronoUnit} equivalent.
135+
*/
136+
public ChronoUnit asChronoUnit() {
137+
return this.chronoUnit;
138+
}
139+
140+
/**
141+
* Convert this {@code DurationFormat.Unit} to a simple {@code String} suffix,
142+
* suitable for the {@link Style#SIMPLE} style.
143+
*/
144+
public String asSuffix() {
145+
return this.suffix;
146+
}
147+
148+
/**
149+
* Parse a {@code long} from a {@code String} and interpret it to be a {@code Duration}
150+
* in the current unit.
151+
* @param value the String representation of the long
152+
* @return the corresponding {@code Duration}
153+
*/
154+
public Duration parse(String value) {
155+
return Duration.of(Long.parseLong(value), asChronoUnit());
156+
}
157+
158+
/**
159+
* Print a {@code Duration} as a {@code String}, converting it to a long value
160+
* using this unit's precision via {@link #longValue(Duration)} and appending
161+
* this unit's simple {@link #asSuffix() suffix}.
162+
* @param value the {@code Duration} to convert to String
163+
* @return the String representation of the {@code Duration} in the {@link Style#SIMPLE SIMPLE style}
164+
*/
165+
public String print(Duration value) {
166+
return longValue(value) + asSuffix();
167+
}
168+
169+
/**
170+
* Convert the given {@code Duration} to a long value in the resolution of this
171+
* unit. Note that this can be lossy if the current unit is bigger than the
172+
* actual resolution of the duration.
173+
* <p>For example, {@code Duration.ofMillis(5).plusNanos(1234)} would get truncated
174+
* to {@code 5} for unit {@code MILLIS}.
175+
* @param value the {@code Duration} to convert to long
176+
* @return the long value for the Duration in this Unit
177+
*/
178+
public long longValue(Duration value) {
179+
return this.longValue.apply(value);
180+
}
181+
182+
/**
183+
* Get the {@code Unit} corresponding to the given {@code ChronoUnit}.
184+
* @throws IllegalArgumentException if that particular ChronoUnit isn't supported
185+
*/
186+
public static Unit fromChronoUnit(@Nullable ChronoUnit chronoUnit) {
187+
if (chronoUnit == null) {
188+
return Unit.MILLIS;
189+
}
190+
for (Unit candidate : values()) {
191+
if (candidate.chronoUnit == chronoUnit) {
192+
return candidate;
193+
}
194+
}
195+
throw new IllegalArgumentException("No matching Unit for ChronoUnit." + chronoUnit.name());
196+
}
197+
198+
/**
199+
* Get the {@code Unit} corresponding to the given {@code String} suffix.
200+
* @throws IllegalArgumentException if that particular suffix is unknown
201+
*/
202+
public static Unit fromSuffix(String suffix) {
203+
for (Unit candidate : values()) {
204+
if (candidate.suffix.equalsIgnoreCase(suffix)) {
205+
return candidate;
206+
}
207+
}
208+
throw new IllegalArgumentException("'" + suffix + "' is not a valid simple duration Unit");
209+
}
210+
211+
}
212+
213+
}

Diff for: spring-context/src/main/java/org/springframework/format/datetime/standard/DateTimeFormatterRegistrar.java

+1
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,7 @@ public void registerFormatters(FormatterRegistry registry) {
198198
registry.addFormatterForFieldType(MonthDay.class, new MonthDayFormatter());
199199

200200
registry.addFormatterForFieldAnnotation(new Jsr310DateTimeFormatAnnotationFormatterFactory());
201+
registry.addFormatterForFieldAnnotation(new DurationFormatAnnotationFormatterFactory());
201202
}
202203

203204
private DateTimeFormatter getFormatter(Type type) {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/*
2+
* Copyright 2002-2024 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.format.datetime.standard;
18+
19+
import java.time.Duration;
20+
import java.util.Set;
21+
22+
import org.springframework.context.support.EmbeddedValueResolutionSupport;
23+
import org.springframework.format.AnnotationFormatterFactory;
24+
import org.springframework.format.Parser;
25+
import org.springframework.format.Printer;
26+
import org.springframework.format.annotation.DurationFormat;
27+
28+
/**
29+
* Formats fields annotated with the {@link DurationFormat} annotation using the
30+
* selected style for parsing and printing JSR-310 {@code Duration}.
31+
*
32+
* @author Simon Baslé
33+
* @since 6.2
34+
* @see DurationFormat
35+
* @see DurationFormatter
36+
*/
37+
public class DurationFormatAnnotationFormatterFactory extends EmbeddedValueResolutionSupport
38+
implements AnnotationFormatterFactory<DurationFormat> {
39+
40+
// Create the set of field types that may be annotated with @DurationFormat.
41+
private static final Set<Class<?>> FIELD_TYPES = Set.of(Duration.class);
42+
43+
@Override
44+
public final Set<Class<?>> getFieldTypes() {
45+
return FIELD_TYPES;
46+
}
47+
48+
@Override
49+
public Printer<?> getPrinter(DurationFormat annotation, Class<?> fieldType) {
50+
return new DurationFormatter(annotation.style(), annotation.defaultUnit());
51+
}
52+
53+
@Override
54+
public Parser<?> getParser(DurationFormat annotation, Class<?> fieldType) {
55+
return new DurationFormatter(annotation.style(), annotation.defaultUnit());
56+
}
57+
}

0 commit comments

Comments
 (0)