您现在的位置是:首页 >技术交流 >从零开始 Spring Boot 29:类型转换网站首页技术交流

从零开始 Spring Boot 29:类型转换

魔芋红茶 2024-06-17 10:26:13
简介从零开始 Spring Boot 29:类型转换

从零开始 Spring Boot 29:类型转换

spring boot

图源:简书 (jianshu.com)

PropertyEditor

Spring使用PropertyEditor进行String和具体类型之间的转换:

public interface PropertyEditor {
	void setValue(Object value);
	Object getValue();
	String getAsText();
	void setAsText(String text) throws java.lang.IllegalArgumentException;
	// ...
}

这个接口主要有这几个方法:

  • setValue,设置修改后的属性值。
  • getValue,获取属性值。
  • getAsText,获取属性值对应的字符串。
  • setAsText,用字符串设置属性值。

PropertyEditor和之后介绍的ProertyEditorSupport并不属于Spring框架,这都是java标准包的一部分,属于java.beans包。这个包定义了java bean相关的组件,更多信息可以阅读java.beans (Java Platform SE 8 ) — beans(Java Platform SE 8) (oracle.com)

自定义PropertyEditor时并不需要直接实现PropertyEditor接口,只需要从PropertyEditorSupport继承即可:

public class PropertyEditorSupport implements PropertyEditor {
	// ...
}

下面具体举例如何在Spring Boot中使用PropertyEditor进行类型转换。

假设有这么两个实体类:

public class Dog {
    private String name;

    public Dog(String name) {
        this.name = name;
    }

    @Override
    public String toString() {
        return "Dog(%s)".formatted(name);
    }
}

@Data
public class Person {

    private String name;
    private int age = 0;

    public Person(String name) {
        this.name = name;
    }

    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    @Override
    public String toString() {
        return "Person(name:%s,age:%d)".formatted(name, age);
    }
}

我们想直接在Controller中通过传递字符串形式的参数来获取相应的实体类对象:

@RestController
@RequestMapping("/hello")
public class HelloController {
    @GetMapping("")
    public String hello(@RequestParam Person person, @RequestParam Dog dog){
        System.out.println(person);
        System.out.println(dog);
        return Result.success().toString();
    }
}

比如我们希望能处理这样的请求:[localhost:8080/hello?person=tom:11&dog=jerry](http://localhost:8080/hello?person=tom:11&dog=jerry)

为了能让Spring将字符串转换为实体类,我们需要为实体类创建对应的PropertyEditor

public class PersonEditor extends PropertyEditorSupport {
    @Override
    public String getAsText() {
        Person person = (Person) this.getValue();
        return "%s:%d".formatted(person.getName(), person.getAge());
    }

    @Override
    public void setAsText(String text) throws IllegalArgumentException {
        if (text == null || text.isEmpty()) {
            throw new IllegalArgumentException("字符串不能为空");
        }
        int index = text.indexOf(":");
        if (index <= 0) {
            throw new IllegalArgumentException("缺少:符号");
        }
        if (text.length() <= index + 1) {
            throw new IllegalArgumentException("缺少年龄信息");
        }
        String name = text.substring(0, index);
        String ageText = text.substring(index + 1);
        int age;
        try {
            age = Integer.parseInt(ageText);
        } catch (NumberFormatException e) {
            throw new IllegalArgumentException("年龄不是整数");
        }
        setValue(new Person(name, age));
    }
}

public class DogEditor extends PropertyEditorSupport {
    @Override
    public String getAsText() {
        Dog dog = (Dog) this.getValue();
        return super.getAsText();
    }

    @Override
    public void setAsText(String text) throws IllegalArgumentException {
        setValue(new Dog(text));
    }
}

将自定义PropertyEditor命名为xxxEditor是一种习惯。

主要覆盖getAsTextsetAsText方法,并实现字符串与具体类型的转换逻辑即可。

只有实体类和对应的ProertyEditor也是不行的,我们还需要将对应关系注册到Spring中:

@RestController
@RequestMapping("/hello")
public class HelloController {
    // ...
    @InitBinder
    void initBinder(WebDataBinder binder) {
        binder.registerCustomEditor(Person.class, new PersonEditor());
        binder.registerCustomEditor(Dog.class, new DogEditor());
    }
}

这样做在HelloController中,所有请求处理都可以直接将字符串形式的参数转换为对应的实体类对象。

如果将实体类和对应的PropertyEntity放在同一个包下面,并且PropertyEntity类被命名为xxxEditor,Spring会自动识别检测,不需要手动进行注册。

CustomEditorConfigurer

上面示例中这种通过@InitBinder注解绑定自定义属性编辑器的做法,只针对当前Controller有效,如果要让Spring框架默认生效,需要使用CustomEditorConfigurer,这是一个BeanFactoryPostProcessor,所以可以利用它来改变IoC的行为:

@Configuration
public class AppConfig {
    @Bean
    public CustomEditorConfigurer customEditorConfigurer(){
        CustomEditorConfigurer customEditorConfigurer = new CustomEditorConfigurer();
        Map<Class<?>, Class<? extends PropertyEditor>> editors = new HashMap<>();
        editors.put(Dog.class, DogEditor.class);
        editors.put(Person.class, PersonEditor.class);
        customEditorConfigurer.setCustomEditors(editors);
        customEditorConfigurer.setOrder(Ordered.HIGHEST_PRECEDENCE);
        return customEditorConfigurer;
    }
}

关于更多BeanFactoryPostProcessor的内容,可以阅读从零开始 Spring Boot 27:IoC - 红茶的个人站点 (icexmoon.cn)

这样做了之后,就像在从零开始 Spring Boot 28:资源 - 红茶的个人站点 (icexmoon.cn)中展示的那样,即使是通过@Value注解从配置文件中注入对象,也可以被正确解析,比如application.properties文件中有如下配置信息:

my.person=tom:11

在Controller中使用@Value注解直接将配置信息注入为Person对象:

@RestController
@RequestMapping("/home")
public class HomeController {
    @Value("${my.person}")
    private Person person;
    // ...
    @GetMapping("/prop")
    public String prop(){
        System.out.println(this.person);
        return Result.success().toString();
    }
}

需要特别说明的是,即使创建了CustomEditorConfigurer这个bean,通过Web传入的请求参数依然不会被处理,除非像之前那样在@InitBinder方法中绑定了相应的属性编辑器

这点并没有在Spring官方文档中指出。

也就是说下面这样的类型转换尝试会报错:

@RestController
@RequestMapping("/home")
public class HomeController {
	//...
    @GetMapping("")
    public String home(@RequestParam("person") Person person){
        System.out.println(person);
        return Result.success().toString();
    }
	//...
}

相关的错误信息是缺少Converter或类型处理器,这很容易让人迷惑,因为实际上我们通过CustomEditorConfigurer注入了属性编辑器相关的内容。只能是认为这是Spring MVC对通过Web传入信息的特殊处理。

CustomPropertyEditorRegistrar

如果在项目中经常需要用到一组属性编辑器,可以定义一个PropertyEditorRegistrar类:

public class CustomPropertyEditorRegistrar implements PropertyEditorRegistrar {

    @Override
    public void registerCustomEditors(PropertyEditorRegistry registry) {
        registry.registerCustomEditor(Person.class, new PersonEditor());
        registry.registerCustomEditor(Dog.class, new DogEditor());
    }
}

定义之后可以更方便地配置和使用属性编辑器:

@Configuration
public class AppConfig {
    @Bean
    public CustomEditorConfigurer customEditorConfigurer(){
        CustomEditorConfigurer customEditorConfigurer = new CustomEditorConfigurer();
        customEditorConfigurer.setPropertyEditorRegistrars(new PropertyEditorRegistrar[]{customPropertyEditorRegistrar()});
        customEditorConfigurer.setOrder(Ordered.HIGHEST_PRECEDENCE);
        return customEditorConfigurer;
    }

    @Bean
    public CustomPropertyEditorRegistrar customPropertyEditorRegistrar(){
        return new CustomPropertyEditorRegistrar();
    }
}

为了方便在其他地方复用,这里直接将CustomPropertyEditorRegistrar定义为bean。如果不需要这么做,也可以直接new

也可以像之前那样,在@InitBinder方法中使用:

@RestController
@RequestMapping("/hello")
public class HelloController {
    @Autowired
    private CustomPropertyEditorRegistrar customPropertyEditorRegistrar;
	// ...
    @InitBinder
    void initBinder(WebDataBinder binder) {
        customPropertyEditorRegistrar.registerCustomEditors(binder);
    }
}

ConfigurableWebBindingInitializer

之前说了,CustomEditorConfigurer并不能提供对Web传入参数的自定义类型转换,所以要定义一个ConfigurableWebBindingInitializer类型的bean:

@Configuration
public class AppConfig {
	// ...
    @Bean
    public ConfigurableWebBindingInitializer configurableWebBindingInitializer(){
        ConfigurableWebBindingInitializer configurableWebBindingInitializer = new ConfigurableWebBindingInitializer();
        configurableWebBindingInitializer.setPropertyEditorRegistrar(customPropertyEditorRegistrar());
        return configurableWebBindingInitializer;
    }
}

关于ConfigurableWebBindingInitializer可以阅读Web (springdoc.cn)

现在即使Controller中没有@InitBinder方法,也可以正常将Web传入的字符串参数转换为对应类型:

@RestController
@RequestMapping("/home")
public class HomeController {
	//...
	@GetMapping("")
    public String home(@RequestParam("person") Person person){
        System.out.println(person);
        return Result.success().toString();
    }
	//...
}

这种方式可以看做是设置了全局的WebDataBinder

预设PropertyEditor

Spring提供了一些预设的PropertyEditor,有的是默认开启的,有的则不是。详细内容可以参考核心技术 (springdoc.cn)

Converter

除了使用PropertyEditor,还可以使用Converter实现类型转换,这里同样用String转换Person的例子:

public class StringToPersonConverter implements Converter<String, Person> {

    @Override
    public Person convert(String source) {
        return PersonEditor.str2Person(source);
    }
}

这里的PersonEditor.str2Person方法是对已有代码的重构,以避免代码重复出现。

ConversionService

Spring使用一个ConversionService来管理Converter,可以利用一个工厂类ConversionServiceFactoryBean来创建ConversionService,并添加自定义转换器:

@Configuration
public class AppConfig {
	// ...
	@Bean
    public ConversionServiceFactoryBean conversionService(){
        ConversionServiceFactoryBean conversionServiceFactoryBean = new ConversionServiceFactoryBean();
        HashSet<Object> converters = new HashSet<>();
        converters.add(new StringToPersonConverter());
        conversionServiceFactoryBean.setConverters(converters);
        return conversionServiceFactoryBean;
    }
}

之后就可以在需要转换时注入conversionService这个bean,然后调用convert方法转换:

@RestController
@RequestMapping("/converter")
public class ConverterController {
    @Resource
    private ConversionService conversionService;

    @GetMapping("")
    public String converter(@RequestParam("person") String personText){
        Person person = conversionService.convert(personText, Person.class);
        System.out.println(person);
        return Result.success().toString();
    }
}

和使用PropertyEditor时一样,如果要让系统自动转换Web入参,需要在ConfigurableWebBindingInitializer中添加ConversionService

@Configuration
public class AppConfig {
	// ...
    @Bean
    public ConfigurableWebBindingInitializer configurableWebBindingInitializer(ConversionService conversionService){
        ConfigurableWebBindingInitializer configurableWebBindingInitializer = new ConfigurableWebBindingInitializer();
        configurableWebBindingInitializer.setConversionService(conversionService);
        return configurableWebBindingInitializer;
    }
	// ...
}

现在就可以在Controller中直接转换入参:

@RestController
@RequestMapping("/converter")
public class ConverterController {
	// ...
    @GetMapping("/auto")
    public String converter(@RequestParam("person") Person person){
        System.out.println(person);
        return Result.success().toString();
    }
}

除了ConfigurableWebBindingInitializer以外,还可以通过WebMvcConfigurer将自定义转换器添加到全局设置:

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addFormatters(FormatterRegistry registry) {
        WebMvcConfigurer.super.addFormatters(registry);
        registry.addConverter(new StringToPersonConverter());
    }
}

这样同样对处理Web入参是有效的。

预定义Converter

查看ConversionServiceFactoryBean的源码就能发现,通过这种方式创建的ConversionService实际上包含很多预定义的Converter

public class ConversionServiceFactoryBean implements FactoryBean<ConversionService>, InitializingBean {
	// ...
    public void afterPropertiesSet() {
        this.conversionService = this.createConversionService();
        ConversionServiceFactory.registerConverters(this.converters, this.conversionService);
    }

    protected GenericConversionService createConversionService() {
        return new DefaultConversionService();
    }
}

public class DefaultConversionService extends GenericConversionService {
	@Nullable
    private static volatile DefaultConversionService sharedInstance;

    public DefaultConversionService() {
        addDefaultConverters(this);
    }
    
    public static void addDefaultConverters(ConverterRegistry converterRegistry) {
        //这里添加的常用的转换器
        addScalarConverters(converterRegistry);
        addCollectionConverters(converterRegistry);
        converterRegistry.addConverter(new ByteBufferConverter((ConversionService)converterRegistry));
        // ...
    }
    
      public static void addCollectionConverters(ConverterRegistry converterRegistry) {
        //这里添加了容器类相关的转换器
        ConversionService conversionService = (ConversionService)converterRegistry;
        converterRegistry.addConverter(new ArrayToCollectionConverter(conversionService));
		// ...
     }

    private static void addScalarConverters(ConverterRegistry converterRegistry) {
        //这里添加的和数字相关的转换器
        converterRegistry.addConverterFactory(new NumberToNumberConverterFactory());
        converterRegistry.addConverterFactory(new StringToNumberConverterFactory());
        converterRegistry.addConverter(Number.class, String.class, new ObjectToStringConverter());
		// ...
    }
}

所以默认情况下Spring会处理一般的类型转换,比如默认情况下Spring会将枚举类型的字面量转换为对应的枚举值,这是因为有StringToEnumConverterFactory

final class StringToEnumConverterFactory implements ConverterFactory<String, Enum> {
    StringToEnumConverterFactory() {
    }

    public <T extends Enum> Converter<String, T> getConverter(Class<T> targetType) {
        return new StringToEnumConverterFactory.StringToEnum(ConversionUtils.getEnumType(targetType));
    }

    private static class StringToEnum<T extends Enum> implements Converter<String, T> {
        private final Class<T> enumType;

        StringToEnum(Class<T> enumType) {
            this.enumType = enumType;
        }

        @Nullable
        public T convert(String source) {
            return source.isEmpty() ? null : Enum.valueOf(this.enumType, source.trim());
        }
    }
}

因此Spring默认会将枚举和字符串之间的转换都是针对字面量的,如果想要实现其他形式的转换,比如一个定义的整形值和枚举类型之间的转换,可以参考从零开始 Spring Boot 16:枚举 - 红茶的个人站点 (icexmoon.cn)

Formatter

Formatter是Spring中负责格式化字符串的接口:

package org.springframework.format;

public interface Formatter<T> extends Printer<T>, Parser<T> {
}

这个接口由PrinterParser接口扩展而来:

public interface Printer<T> {

    String print(T fieldValue, Locale locale);
}

import java.text.ParseException;

public interface Parser<T> {

    T parse(String clientValue, Locale locale) throws ParseException;
}

下面用一个示例说明。

假如有一个表示姓名的实体类:

@Getter
public class Name {

    private String firstName;
    private String lastName;

    public Name(String firstName, String lastName) {
        this.firstName = firstName;
        this.lastName = lastName;
    }

    @Override
    public String toString() {
        return "Name(firstName:%s, lastName:%s)".formatted(firstName, lastName);
    }
}

创建与之对应的Formatter

public class NameFormatter implements Formatter<Name> {

    @Override
    public Name parse(String text, Locale locale) throws ParseException {
        if (text == null || text.isEmpty()) {
            throw new ParseException("字符串不能为空", 0);
        }
        int spaceIndex = text.indexOf(" ");
        if (spaceIndex <= 0 || spaceIndex == text.length() - 1) {
            throw new ParseException("缺少空格作为分隔符", spaceIndex);
        }
        String firstName = text.substring(0, spaceIndex);
        String lastName = text.substring(spaceIndex + 1);
        if (!"zh".equals(locale.getLanguage())){
            //非中文地区,姓在前名在后
            String temp = firstName;
            firstName = lastName;
            lastName = temp;
        }
        return new Name(firstName, lastName);
    }

    @Override
    public String print(Name object, Locale locale) {
        if ("zh".equals(locale.getLanguage())){
            return "%s %s".formatted(object.getFirstName(), object.getLastName());
        }
        //非中文地区,名在前姓在后
        return "%s %s".formatted(object.getLastName(), object.getFirstName());
    }
}

之后通过Spring MVC的配置将自定义Formatter加入默认配置:

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addFormatters(FormatterRegistry registry) {
        // ...
        registry.addFormatter(new NameFormatter());
    }
}

之后就可以以具体类型而不是String接收参数:

@RestController
@RequestMapping("/name")
public class NameController {
    @GetMapping("")
    public String name(@RequestParam Name name) {
        System.out.println(name);
        return Result.success().toString();
    }
}

可以看到,FormatterPropertyEditor的用法与用途类似,实际上都可以将String与具体类型之间进行转换。区别是后者更单纯,仅仅是类型转换,而前者附加了一种本地化的功能,更强调是在客户端的字符串和服务端的业务对象之间的转换,并且可以根据具体客户端的地区和语言的不同来以不同的方式处理数据或拼接字符串。

这种本地化体现在编程上就是方法中的Local参数,具体可以参考上面的示例。

注解驱动的格式化

Formatter可以搭配注解使用,实际上Spring已经定义了一些用于格式化的注解,比如:

package cn.icexmoon.books2.book.controller;
// ...
@RestController
@RequestMapping("/book/coupon")
public class CouponController {
    // ...
    @PostMapping("/params-add")
    Result addCouponWithParams(@RequestParam Integer addUserId,
                               @RequestParam Double amount,
                               @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
                               @RequestParam LocalDateTime expireTime,
                               @RequestParam Double enoughAmount,
                               @RequestParam CouponType type){
        // ...
    }
}

这里的@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")就是一个这样的注解,用它标记的参数或属性会如果原始数据是String,就会被Spring用时间相关的Formatter进行格式化。

要想让自定义的Formatter实现类似的功能,就需要定义一个注解,并且实现一个AnnotationFormatterFactory接口:

package org.springframework.format;

public interface AnnotationFormatterFactory<A extends Annotation> {

    Set<Class<?>> getFieldTypes();

    Printer<?> getPrinter(A annotation, Class<?> fieldType);

    Parser<?> getParser(A annotation, Class<?> fieldType);
}

接口中的泛型参数A是注解的类型,getFieldTypes返回的是所有注解可以处理的类型。

下面以为NameFormatter创建对应的注解处理进行说明。

先创建一个与NameFormatter对应的自定义注解:

@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER, ElementType.ANNOTATION_TYPE})
public @interface NameFormat {
}

实现AnnotationFormatterFactory接口:

public class NameFormatAnnotationFormatterFactory implements AnnotationFormatterFactory<NameFormat> {
    private static final Set<Class<?>> FIELD_TYPES = Set.of(Name.class);

    @Override
    public Set<Class<?>> getFieldTypes() {
        return FIELD_TYPES;
    }

    @Override
    public Printer<?> getPrinter(NameFormat annotation, Class<?> fieldType) {
        return new NameFormatter();
    }

    @Override
    public Parser<?> getParser(NameFormat annotation, Class<?> fieldType) {
        return new NameFormatter();
    }
}

最后通过Spring MVC配置设置,使其生效:

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addFormatters(FormatterRegistry registry) {
        // ...
        registry.addFormatterForFieldAnnotation(new NameFormatAnnotationFormatterFactory());
    }
}

现在,即使默认没有启用NameFormatter,也可以直接通过注解来处理Name类型的入参:

@RestController
@RequestMapping("/name")
public class NameController {
    @GetMapping("")
    public String name(@NameFormat @RequestParam Name name) {
        System.out.println(name);
        return Result.success().toString();
    }
}

当然,可以根据需要在注解中添加属性,实现一些更复杂的逻辑,这里不再展示。

日期和时间

默认情况下,Spring处理时间的格式是这样的:

@RestController
@RequestMapping("/time")
public class TimeController {

    public TimeController(FormattingConversionService conversionService) {
        String converted = conversionService.convert(LocalDate.of(2023, 5, 16), String.class);
        System.out.println(converted);
        converted = conversionService.convert(LocalDateTime.of(2023,5,16,20,15), String.class);
        System.out.println(converted);
    }
    // ...
}

可以看到如下输出:

2023/5/16
2023/5/16 下午8:15

这里的FormattingConversionService是一个管理Formatter的类,它也是一个ConversionService。在这里通过注入它来利用Formatter进行类型转换。

如果要变更这种行为,可以在Spring MVC配置中设置:

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addFormatters(FormatterRegistry registry) {
    	// ...
        DateTimeFormatterRegistrar registrar = new DateTimeFormatterRegistrar();
        registrar.setDateFormatter(DateTimeFormatter.ISO_LOCAL_DATE);
        registrar.setDateTimeFormatter(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
        registrar.registerFormatters(registry);
    }
}

会变成如下的输出:

2023-05-16
2023-05-16 20:15:00
  • 即使不更改,Spring也能正常将2023-05-16这样的内容转换为DateLocalDate类型。但是无法处理2023-05-16 20:15:00这样的时间,无法将其转换为TimeLocalDateTime
  • DateTimeFormatter.ISO_LOCAL_DATE_TIME对应的时间字符串是类似2023-05-16T20:30:01这样的,所以只能通过DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"的方式自定义。

这样就可以正常接收和处理类似2023-05-16 20:15:00这样的时间字符串了:

@RestController
@RequestMapping("/time")
public class TimeController {
	// ...
    @GetMapping("")
    public String time(@RequestParam LocalDate date,
                       @RequestParam LocalDateTime time) {
        // ...
    }
}

请求类似下面这样:

http://localhost:8080/time?date=2018-03-09&time=2023-05-16%2008:15:11

其中的空格被url编码。

本文所有的示例代码可以通过ch29/validator · 魔芋红茶/learn_spring_boot - 码云 - 开源中国 (gitee.com)获取。

就到这里了,谢谢阅读。

参考资料

风语者!平时喜欢研究各种技术,目前在从事后端开发工作,热爱生活、热爱工作。