Преамбула

В статье я хотел бы рассмотреть написание собственных конвертеров типов и форматтеров полей 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) {  
  
        // ...
    }  
}

Конспект

  1. Создали классы конвертеров, имплементирующие Converter<S, T>

  2. Создали бины классов (через xml, аннотацию или конфигурационный класс)

  3. Создали через конфигурацию бин conversionService и указали в нем наши конвертеры

  4. Указали 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) {  
  
        // ...
    }  
}

Конспект

  1. Создали классы форматтеров, имплементирующие Formatter< T >

  2. Создали бины классов (через xml, аннотацию или конфигурационный класс)

  3. Создали через конфигурацию бин conversionService и указали в нем наши форматтеры

  4. Указали 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) {  
  
        // ...
    }  
}

Конспект

  1. Классы форматтеров у нас уже были

  2. Создали интерфейс-аннотацию

  3. Создали класс-привязку аннотации к форматтеру

  4. Создали через конфигурацию бин conversionService и указали в нем класс, привязывающий аннотации к форматтерам

  5. Указали Spring MVC, что надо пользоваться нашей conversionService

Заключение

Искренне надеюсь, что эта статья окажется полезной тем, кто только начинает разбираться с темой конвертирования типов и форматирования полей с помощью Spring Framework. Я понимаю, что за бортом осталась неразобранной значительная часть информации по этим темам. Но у меня не было цели перевести документацию к Спрингу или создать некое всеобъемлющее руководство. Эта статья - лишь способ помочь сдвинуться с мертвой точки.

Спасибо, что дочитали до конца.

Комментарии (6)


  1. sshikov
    04.12.2022 22:45

    Форматтер — конвертирует только тип String в какой-то другой тип

    Вы тут забыли упомянуть обратное преобразование.


    1. grossws
      05.12.2022 00:35

      Сначала фраза смутила, потом уже глянул на код. Хорошее название Formatter для того что на самом деле является Codec'ом xD


    1. leva1981 Автор
      05.12.2022 16:43

      Не соображу, о чем речь. Возможно ошибаюсь, но Formatter это про преобразование только в одну сторону.


      1. sshikov
        05.12.2022 18:59

        Ну смотрите, вы ссылаетесь на SO, где написано:

        Formatters are used to convert String to another Java type and back.


        and back, угу? Одно из двух — либо приведенный вами ответ неверен/неточен, либо вы неполно перевели. И да, я согласен с предыдущим комментарием, название интерфейса вводит в заблуждение.


      1. sshikov
        05.12.2022 19:02
        +2

        Ну или давайте проще подойдем — наличие двух методов, parse и print вас не смущает?

        T parse(String text, Locale locale) Parse a text String to produce a T. Это в какую сторону, по вашему? По-моему это и есть то самое «и обратно».


        1. leva1981 Автор
          05.12.2022 20:12
          +1

          Норм, понял. Правки внес, спасибо! Текст не переведенный, писал сам. Но этот момент пропустил.