Преамбула
В статье я хотел бы рассмотреть написание собственных конвертеров типов и форматтеров полей 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. Я понимаю, что за бортом осталась неразобранной значительная часть информации по этим темам. Но у меня не было цели перевести документацию к Спрингу или создать некое всеобъемлющее руководство. Эта статья - лишь способ помочь сдвинуться с мертвой точки.
Спасибо, что дочитали до конца.