Преамбула
В статье я хотел бы рассмотреть написание собственных конвертеров типов и форматтеров полей Spring Framework (в том числе с использованием аннотаций).
Статья написана by junior for junior, поэтому прошу отнестись к изложенному ниже с изрядной долей снисхождения :)
Конвертер - конвертирует один тип данных в другой
Форматтер - конвертирует только тип String в какой-то другой тип (и обратно в String)
Неплохое объяснение со stackoverflow
Рассматривать работу конвертеров и форматтеров будем на совершенно банальном примере - в @RequestParam контроллера приходит строка с датой или временем и надо ее сконвертировать в LocalDate или LocalTime. На месте строки с датой/временем может быть все, что угодно, например, описание сущности базы данных. Но думаю это усложнило бы пример, поэтому для простоты пускай остаются дата и время.
@RestController
@RequestMapping(value = "/api/v1/some", produces = MediaType.APPLICATION_JSON_VALUE)
public class SomeRestController {
// autowired services and others
// ...
@Override
@GetMapping("/filter")
public List<SomeDto> getFiltered(
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.TIME) LocalTime startTime,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.TIME) LocalTime endTime) {
// ...
}
}
В приведенной выше реализации все конвертируется с помощью аннотаций форматирования Spring Framework. Для тренировки откажемся от использования стандартных аннотаций и напишем свои собственные конвертеры, потом форматтеры и аннотации.
Конфигурирование буду указывать только в xml, как самое мутное.
Конвертер
В самом простом случае для создания собственного конвертера надо создать класс, имплементирующий интерфейс Converter<S, T>. S - тип источника данных, T - тип данных, который должен быть получен в результате конвертации.
В нашем случае источником выступает тип String, а результирующими типами будут LocalDate и LocalTime. Таким образом, классов конвертеров у нас будет два. Вариант реализации:
import org.springframework.core.convert.converter.Converter;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
public class StringToLocalDateConverter implements Converter<String, LocalDate> {
private String datePattern = "yyyy-MM-dd";
public String getDatePattern() {
return datePattern;
}
public void setDatePattern(String datePattern) {
this.datePattern = datePattern;
}
@Override
public LocalDate convert(String dateString) {
return LocalDate.parse(dateString, DateTimeFormatter.ofPattern(datePattern));
}
}
import org.springframework.core.convert.converter.Converter;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
public class StringToLocalTimeConverter implements Converter<String, LocalTime> {
private String timePattern = "HH:mm";
public String getTimePattern() {
return timePattern;
}
public void setTimePattern(String timePattern) {
this.timePattern = timePattern;
}
@Override
public LocalTime convert(String timeString) {
return LocalTime.parse(timeString, DateTimeFormatter.ofPattern(timePattern));
}
}
Теперь надо рассказать Спрингу про наши конвертеры. Для этого надо создать бины конвертеров, зарегистрировать экземпляр класса ConversionService с именем conversionService и добавить в него наши конвертеры.
<bean id="stringToLocalTimeConverter" class="ru.jsft.util.converter.StringToLocalTimeConverter"/>
<bean id="stringToLocalDateConverter" class="ru.jsft.util.converter.StringToLocalDateConverter"/>
<bean id="conversionService" class="org.springframework.context.support.ConversionServiceFactoryBean">
<property name="converters">
<set>
<ref bean="stringToLocalDateConverter"/>
<ref bean="stringToLocalTimeConverter"/>
</set>
</property>
</bean>
Остался последний штрих - сказать Spring MVC, что надо использовать наш ConversionService
в xml-конфигурации mvc:annotation-driven необходимо дополнить указанием conversionService
<mvc:annotation-driven conversion-service="conversionService"/>
Теперь можно переписать метод контроллера
@RestController
@RequestMapping(value = "/api/v1/some", produces = MediaType.APPLICATION_JSON_VALUE)
public class SomeRestController {
// autowired services and others
// ...
@Override
@GetMapping("/filter")
public List<SomeDto> getFiltered(
@RequestParam(required = false) LocalDate startDate,
@RequestParam(required = false) LocalTime startTime,
@RequestParam(required = false) LocalDate endDate,
@RequestParam(required = false) LocalTime endTime) {
// ...
}
}
Конспект
Создали классы конвертеров, имплементирующие Converter<S, T>
Создали бины классов (через xml, аннотацию или конфигурационный класс)
Создали через конфигурацию бин conversionService и указали в нем наши конвертеры
Указали Spring MVC, что надо пользоваться нашим conversionService
Форматтер
Теперь проделаем то же самое, только через форматирование строк.
Необходимо создать класс, имплементирующий интерфейс Formatter< T > где T - тип данных, которые будут получены в результате форматирования входящей строки. Напомню - форматтер работает только со String, поэтому входящий тип данных не нужен.
Вариант реализации
import org.springframework.format.Formatter;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
public class CustomDateFormatter implements Formatter<LocalDate> {
@Override
public LocalDate parse(String text, Locale locale) {
return LocalDate.parse(text, DateTimeFormatter.ofPattern("yyyy-MM-dd"));
}
@Override
public String print(LocalDate localDate, Locale locale) {
return localDate.toString();
}
}
import org.springframework.format.Formatter;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
public class CustomTimeFormatter implements Formatter<LocalTime> {
@Override
public LocalTime parse(String text, Locale locale) {
return LocalTime.parse(text, DateTimeFormatter.ofPattern("HH:mm"));
}
@Override
public String print(LocalTime localTime, Locale locale) {
return localTime.toString();
}
}
parse() - метод возвращает конвертированное из String значение
print() - в этом методе осуществляется обратная конвертация, значение в String. Здесь просто отконвертируем в строку штатными средствами.
Расскажем Спрингу про форматтеры. Укажем Spring MVC использовать наш conversionService.
Обратите внимание - для бина conversionService используется класс, отличный от использованного для конвертеров. При использовании этого класса, кстати, можно кроме форматтеров добавить и конвертеры. См. документацию.
<bean id="customDateFormatter" class="ru.jsft.util.formatter.CustomDateFormatter"/>
<bean id="customTimeFormatter" class="ru.jsft.util.formatter.CustomTimeFormatter"/>
<bean id="conversionService" class="org.springframework.format.support.FormattingConversionServiceFactoryBean">
<property name="formatters">
<set>
<ref bean="customDateFormatter"/>
<ref bean="customTimeFormatter"/>
</set>
</property>
</bean>
<mvc:annotation-driven conversion-service="conversionService"/>
Метод контроллера будет выглядеть так же, как и в случае с использованием конвертеров
@RestController
@RequestMapping(value = "/api/v1/some", produces = MediaType.APPLICATION_JSON_VALUE)
public class SomeRestController {
// autowired services and others
// ...
@Override
@GetMapping("/filter")
public List<SomeDto> getFiltered(
@RequestParam(required = false) LocalDate startDate,
@RequestParam(required = false) LocalTime startTime,
@RequestParam(required = false) LocalDate endDate,
@RequestParam(required = false) LocalTime endTime) {
// ...
}
}
Конспект
Создали классы форматтеров, имплементирующие Formatter< T >
Создали бины классов (через xml, аннотацию или конфигурационный класс)
Создали через конфигурацию бин conversionService и указали в нем наши форматтеры
Указали Spring MVC, что надо пользоваться нашей conversionService
Форматирование с использованием аннотаций
Теперь давайте сделаем так, чтобы форматирование происходило только там, где мы укажем соответствующие аннотации. В случае с предыдущими вариантами реализации применение конвертеров/контроллеров происходит по всему коду.
Я хочу, чтобы аннотация с помощью параметра могла применяться как для конвертации в LocalDate, так и в LocalTime. То есть не делать две разные аннотации, а сделать одну, но с уточняющим параметром (как это реализовано в случае @DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
)
Первым делом создадим интерфейс для новой аннотации. В интерфейсе объявим параметр, в котором будет храниться указание, во что надо конвертировать - в LocalDate или в LocalTime. В качестве типа параметра укажем объявленный тут же enum. Значение по умолчанию для параметра я специально не делал.
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target({ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
public @interface CustomDateTimeFormat {
Type type();
public enum Type {
DATE,
TIME
}
}
Теперь надо привязать аннотацию к форматтеру. Это делается с помощью реализации класса, имплементирующего интерфейс AnnotationFormatterFactory< A extends Annotation >
import org.springframework.format.AnnotationFormatterFactory;
import org.springframework.format.Formatter;
import org.springframework.format.Parser;
import org.springframework.format.Printer;
import java.time.LocalDate;
import java.time.LocalTime;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
public class CustomDateTimeFormatAnnotationFormatterFactory implements AnnotationFormatterFactory<CustomDateTimeFormat> {
@Override
public Set<Class<?>> getFieldTypes() {
return new HashSet<>(List.of(LocalDate.class, LocalTime.class));
}
@Override
public Printer<?> getPrinter(CustomDateTimeFormat annotation, Class<?> fieldType) {
return getFormatter(annotation, fieldType);
}
@Override
public Parser<?> getParser(CustomDateTimeFormat annotation, Class<?> fieldType) {
return getFormatter(annotation, fieldType);
}
private Formatter<?> getFormatter(CustomDateTimeFormat annotation, Class<?> fieldType) {
switch (annotation.type()) {
case DATE -> {
return new CustomDateFormatter();
}
case TIME -> {
return new CustomTimeFormatter();
}
}
return null;
}
}
Тут давайте разберемся поподробнее с переопределенными методами интерфейса AnnotationFormatterFactory.
getFieldTypes() - метод возвращает список классов-типов данных, с которыми будет использоваться аннотация. Обратите внимание, если вы аннотируете тип, которого не будет в этом списке, то, несмотря на наличие аннотации, ничего не произойдет.
getPrinter() и getParser() - первый возвращает Printer для вывода значения аннотированного поля, второй возвращает Parser для разбора полученного значения. В обоих случаях у нас код будет одинаковый. Идея в том, что если у аннотации стоит параметр type == DATE, то вернется уже написанный нами ранее экземпляр класса CustomDateFormatter. А для type == TIME вернется экземпляр CustomTimeFormatter соответственно. Таким образом мы добиваемся того, что аннотация одна, а возвращаемый результат - разный.
Ну вот, теперь осталось познакомить Спринг с нашей привязкой аннотации. Не забудем указать Spring MVC наш conversionService.
<bean id="customDateTimeFormatAnnotationFormatterFactory" class="ru.jsft.util.formatter.CustomDateTimeFormatAnnotationFormatterFactory"/>
<bean id="conversionService" class="org.springframework.format.support.FormattingConversionServiceFactoryBean">
<property name="formatters">
<set>
<ref bean="customDateTimeFormatAnnotationFormatterFactory"/>
</set>
</property>
</bean>
<mvc:annotation-driven conversion-service="conversionService"/>
Теперь изменим метод контроллера, чтобы входящие параметры форматировались с использованием аннотаций
@RestController
@RequestMapping(value = "/api/v1/some", produces = MediaType.APPLICATION_JSON_VALUE)
public class SomeRestController {
// autowired services and others
// ...
@Override
@GetMapping("/filter")
public List<SomeDto> getFiltered(
@RequestParam(required = false) @CustomDateTimeFormat(type = CustomDateTimeFormat.Type.DATE) LocalDate startDate,
@RequestParam(required = false) @CustomDateTimeFormat(type = CustomDateTimeFormat.Type.TIME) LocalTime startTime,
@RequestParam(required = false) @CustomDateTimeFormat(type = CustomDateTimeFormat.Type.DATE) LocalDate endDate,
@RequestParam(required = false) @CustomDateTimeFormat(type = CustomDateTimeFormat.Type.TIME) LocalTime endTime) {
// ...
}
}
Конспект
Классы форматтеров у нас уже были
Создали интерфейс-аннотацию
Создали класс-привязку аннотации к форматтеру
Создали через конфигурацию бин conversionService и указали в нем класс, привязывающий аннотации к форматтерам
Указали Spring MVC, что надо пользоваться нашей conversionService
Заключение
Искренне надеюсь, что эта статья окажется полезной тем, кто только начинает разбираться с темой конвертирования типов и форматирования полей с помощью Spring Framework. Я понимаю, что за бортом осталась неразобранной значительная часть информации по этим темам. Но у меня не было цели перевести документацию к Спрингу или создать некое всеобъемлющее руководство. Эта статья - лишь способ помочь сдвинуться с мертвой точки.
Спасибо, что дочитали до конца.
sshikov
Вы тут забыли упомянуть обратное преобразование.
grossws
Сначала фраза смутила, потом уже глянул на код. Хорошее название
Formatter
для того что на самом деле являетсяCodec
'ом xDleva1981 Автор
Не соображу, о чем речь. Возможно ошибаюсь, но Formatter это про преобразование только в одну сторону.
sshikov
Ну смотрите, вы ссылаетесь на SO, где написано:
and back, угу? Одно из двух — либо приведенный вами ответ неверен/неточен, либо вы неполно перевели. И да, я согласен с предыдущим комментарием, название интерфейса вводит в заблуждение.
sshikov
Ну или давайте проще подойдем — наличие двух методов, parse и print вас не смущает?
T parse(String text, Locale locale) Parse a text String to produce a T. Это в какую сторону, по вашему? По-моему это и есть то самое «и обратно».
leva1981 Автор
Норм, понял. Правки внес, спасибо! Текст не переведенный, писал сам. Но этот момент пропустил.