1. 概述

Spring提供了强大的自动配置功能,可用于绑定组件、配置Bean以及从属性源设置值。当需要避免硬编码值,而更倾向于通过属性文件或系统环境提供时,@Value注解就显得尤为实用。

本教程将探讨如何利用Spring的自动配置机制,将属性值映射到Enum实例。

2. Converters<F,T> 机制

Spring使用转换器将@Value中的字符串值转换为所需类型。一个专门的BeanPostProcessor会遍历所有组件,检查它们是否需要额外配置或注入。随后,系统会找到合适的转换器,将源数据转换为目标类型。 Spring内置了字符串到枚举的转换器,下面我们来分析它。

2.1. LenientToEnumConverter 解析

顾名思义,这个转换器在转换过程中对数据的解释相当宽松。它首先假设值是正确提供的:

@Override
public E convert(T source) {
    String value = source.toString().trim();
    if (value.isEmpty()) {
        return null;
    }
    try {
        return (E) Enum.valueOf(this.enumType, value);
    }
    catch (Exception ex) {
        return findEnum(value);
    }
}

但当无法将源数据映射到枚举时,它会尝试另一种方法:获取枚举和值的规范名称:

private E findEnum(String value) {
    String name = getCanonicalName(value);
    List<String> aliases = ALIASES.getOrDefault(name, Collections.emptyList());
    for (E candidate : (Set<E>) EnumSet.allOf(this.enumType)) {
        String candidateName = getCanonicalName(candidate.name());
        if (name.equals(candidateName) || aliases.contains(candidateName)) {
            return candidate;
        }
    }
    throw new IllegalArgumentException("No enum constant " + this.enumType.getCanonicalName() + "." + value);
}

getCanonicalName(String)方法会过滤所有特殊字符并将字符串转为小写:

private String getCanonicalName(String name) {
    StringBuilder canonicalName = new StringBuilder(name.length());
    name.chars()
      .filter(Character::isLetterOrDigit)
      .map(Character::toLowerCase)
      .forEach((c) -> canonicalName.append((char) c));
    return canonicalName.toString();
}

这种处理方式使转换器极具适应性,但如果不加注意也可能引入问题。 同时,它免费提供了对枚举的大小写不敏感匹配支持,无需额外配置。

2.2. 宽松转换实践

以简单枚举类为例:

public enum SimpleWeekDays {
    MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY
}

使用@Value注入这些常量:

@Component
public class WeekDaysHolder {
    @Value("${monday}")
    private WeekDays monday;
    @Value("${tuesday}")
    private WeekDays tuesday;
    @Value("${wednesday}")
    private WeekDays wednesday;
    @Value("${thursday}")
    private WeekDays thursday;
    @Value("${friday}")
    private WeekDays friday;
    @Value("${saturday}")
    private WeekDays saturday;
    @Value("${sunday}")
    private WeekDays sunday;
    // getters and setters
}

利用宽松转换,我们不仅能使用不同大小写传递值,甚至可以在值中添加特殊字符:

@SpringBootTest(properties = {
    "monday=Mon-Day!",
    "tuesday=TuesDAY#",
    "wednesday=Wednes@day",
    "thursday=THURSday^",
    "friday=Fri:Day_%",
    "saturday=Satur_DAY*",
    "sunday=Sun+Day",
}, classes = WeekDaysHolder.class)
class LenientStringToEnumConverterUnitTest {
    @Autowired
    private WeekDaysHolder propertyHolder;

    @ParameterizedTest
    @ArgumentsSource(WeekDayHolderArgumentsProvider.class)
    void givenPropertiesWhenInjectEnumThenValueIsPresent(
        Function<WeekDaysHolder, WeekDays> methodReference, WeekDays expected) {
        WeekDays actual = methodReference.apply(propertyHolder);
        assertThat(actual).isEqualTo(expected);
    }
}

⚠️ 这种做法未必可取,尤其当开发者不了解其机制时。错误的假设可能产生难以排查的隐藏问题。

2.3. 极端宽松转换

这种转换方式对双方都有效,即使我们打破所有命名约定也能正常工作:

public enum NonConventionalWeekDays {
    Mon$Day, Tues$DAY_, Wednes$day, THURS$day_, Fri$Day$_$, Satur$DAY_, Sun$Day
}

问题在于它可能产生正确结果,将所有值映射到对应枚举:

@SpringBootTest(properties = {
    "monday=Mon-Day!",
    "tuesday=TuesDAY#",
    "wednesday=Wednes@day",
    "thursday=THURSday^",
    "friday=Fri:Day_%",
    "saturday=Satur_DAY*",
    "sunday=Sun+Day",
}, classes = NonConventionalWeekDaysHolder.class)
class NonConventionalStringToEnumLenientConverterUnitTest {
    @Autowired
    private NonConventionalWeekDaysHolder holder;

    @ParameterizedTest
    @ArgumentsSource(NonConventionalWeekDayHolderArgumentsProvider.class)
    void givenPropertiesWhenInjectEnumThenValueIsPresent(
        Function<NonConventionalWeekDaysHolder, NonConventionalWeekDays> methodReference, NonConventionalWeekDays expected) {
        NonConventionalWeekDays actual = methodReference.apply(holder);
        assertThat(actual).isEqualTo(expected);
    }
}

"Mon-Day!"映射到"Mon$Day"而不报错,可能掩盖问题并诱导开发者跳过既定约定。 虽然支持大小写不敏感,但这种假设过于随意。

3. 自定义转换器

实现特定映射规则的最佳方式是创建自定义的Converter。见识过LenientToEnumConverter的能力后,我们退一步创建更严格的实现。

3.1. StrictNullableWeekDayConverter 实现

假设我们决定仅在属性正确匹配枚举名称时才进行映射。这可能导致初期的大小写约定问题,但整体是可靠的解决方案:

public class StrictNullableWeekDayConverter implements Converter<String, WeekDays> {
    @Override
    public WeekDays convert(String source) {
        try {
            return WeekDays.valueOf(source.trim());
        } catch (IllegalArgumentException e) {
            return null;
        }
    }
}

此转换器仅对源字符串做最小调整(去除空格)。注意:返回null并非最佳设计,可能导致上下文处于错误状态。 但这里为简化测试使用null:

@SpringBootTest(properties = {
    "monday=monday",
    "tuesday=tuesday",
    "wednesday=wednesday",
    "thursday=thursday",
    "friday=friday",
    "saturday=saturday",
    "sunday=sunday",
}, classes = {WeekDaysHolder.class, WeekDayConverterConfiguration.class})
class StrictStringToEnumConverterNegativeUnitTest {
    public static class WeekDayConverterConfiguration {
        // configuration
    }

    @Autowired
    private WeekDaysHolder holder;

    @ParameterizedTest
    @ArgumentsSource(WeekDayHolderArgumentsProvider.class)
    void givenPropertiesWhenInjectEnumThenValueIsNull(
        Function<WeekDaysHolder, WeekDays> methodReference, WeekDays ignored) {
        WeekDays actual = methodReference.apply(holder);
        assertThat(actual).isNull();
    }
}

若提供大写值,则能正确注入。要使用此转换器,需告知Spring:

public static class WeekDayConverterConfiguration {
    @Bean
    public ConversionService conversionService() {
        DefaultConversionService defaultConversionService = new DefaultConversionService();
        defaultConversionService.addConverter(new StrictNullableWeekDayConverter());
        return defaultConversionService;
    }
}

在某些Spring Boot版本中,类似转换器可能是默认选项,比LenientToEnumConverter更合理。

3.2. CaseInsensitiveWeekDayConverter 实现

折中方案:支持大小写不敏感匹配,但不允许其他差异:

public class CaseInsensitiveWeekDayConverter implements Converter<String, WeekDays> {
    @Override
    public WeekDays convert(String source) {
        try {
            return WeekDays.valueOf(source.trim());
        } catch (IllegalArgumentException exception) {
            return WeekDays.valueOf(source.trim().toUpperCase());
        }
    }
}

这里未考虑枚举名非大写或混合大小写的情况。但这可解决,只需增加几行代码和try-catch块。 我们可以为枚举创建查找映射并缓存,但这里暂不实现。

测试类似,能正确映射值。为简化,仅检查能正确映射的属性:

@SpringBootTest(properties = {
    "monday=monday",
    "tuesday=tuesday",
    "wednesday=wednesday",
    "thursday=THURSDAY",
    "friday=Friday",
    "saturday=saturDAY",
    "sunday=sUndAy",
}, classes = {WeekDaysHolder.class, WeekDayConverterConfiguration.class})
class CaseInsensitiveStringToEnumConverterUnitTest {
    // ...
}

通过自定义转换器,可根据需求或约定调整映射过程。

4. SpEL 表达式方案

SpEL是功能强大的工具。针对我们的问题,可在映射枚举前调整属性文件中的值。 实现方式是将提供的值显式转为大写:

@Component
public class SpELWeekDaysHolder {
    @Value("#{'${monday}'.toUpperCase()}")
    private WeekDays monday;
    @Value("#{'${tuesday}'.toUpperCase()}")
    private WeekDays tuesday;
    @Value("#{'${wednesday}'.toUpperCase()}")
    private WeekDays wednesday;
    @Value("#{'${thursday}'.toUpperCase()}")
    private WeekDays thursday;
    @Value("#{'${friday}'.toUpperCase()}")
    private WeekDays friday;
    @Value("#{'${saturday}'.toUpperCase()}")
    private WeekDays saturday;
    @Value("#{'${sunday}'.toUpperCase()}")
    private WeekDays sunday;

    // getters and setters
}

验证映射是否正确,可使用之前创建的StrictNullableWeekDayConverter

@SpringBootTest(properties = {
    "monday=monday",
    "tuesday=tuesday",
    "wednesday=wednesday",
    "thursday=THURSDAY",
    "friday=Friday",
    "saturday=saturDAY",
    "sunday=sUndAy",
}, classes = {SpELWeekDaysHolder.class, WeekDayConverterConfiguration.class})
class SpELCaseInsensitiveStringToEnumConverterUnitTest {
    public static class WeekDayConverterConfiguration {
        @Bean
        public ConversionService conversionService() {
            DefaultConversionService defaultConversionService = new DefaultConversionService();
            defaultConversionService.addConverter(new StrictNullableWeekDayConverter());
            return defaultConversionService;
        }
    }

    @Autowired
    private SpELWeekDaysHolder holder;

    @ParameterizedTest
    @ArgumentsSource(SpELWeekDayHolderArgumentsProvider.class)
    void givenPropertiesWhenInjectEnumThenValueIsNull(
        Function<SpELWeekDaysHolder, WeekDays> methodReference, WeekDays expected) {
        WeekDays actual = methodReference.apply(holder);
        assertThat(actual).isEqualTo(expected);
    }
}

虽然转换器只理解大写值,但通过SpEL将属性转为正确格式。此技术适用于简单转换和映射,因其直接在@Value注解中使用且相对直观。 但应避免在SpEL中放入复杂逻辑。

5. 总结

@Value注解功能强大且灵活,支持SpEL和属性注入。自定义转换器可进一步增强其能力,使其支持自定义类型或实现特定约定。

本文所有代码可在GitHub获取。


原始标题:Bind Case Insensitive @Value to Enum in Spring Boot | Baeldung