在 Spring Boot 中通过 @Value 绑定属性到枚举(不分区大小写)

1、概览

Spring 提供了自动配置功能,可以用它来绑定组件、配置 Bean 以及从属性源中设置值。

当我们不想硬编码值,而希望使用 properties 文件或系统环境提供值时,@Value 注解就非常有用。

本文将带你了解如何通过 Spring 自动配置将属性值映射到 Enum 实例,不区分大小写。

2、Converter<F,T>

Spring 使用 Converter@Value 注解中的 String 值映射到所需的类型。一个专用的 BeanPostProcessor 会遍历所有组件,并检查它们是否需要额外的配置或注入。然后,找到一个合适的 Converter,并将源 Converter 中的数据绑定目标。Spring 提供了一个内置的 StringEnum 类型的 Converter,让我们来详细了解一下。

2.1、LenientToEnumConverter

顾名思义,该 Converter 在转换过程中可以自由解释数据。最初,它假定提供的数值是正确的:

@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);
    }
}

不过,如果无法将源映射到 Enum,它就会尝试另一种方法。它会获取 Enumvalue 的规范名称:

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();
}

这一过程使 Converter 具有很强的适应性,但如果没有考虑到一些问题,可能会引入一些问题。与此同时,它还提供了对大小写不敏感的 Enum 匹配的出色支持,无需任何额外配置。

2.2、宽松的转换

以一个简单的 Enum 类为例:

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

使用 @Value 注解把所有这些常量注入到一个专用的 holder 类中:

@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;
    // get、Set 方法省略
}

使用宽松转换,不仅可以使用不同的大小写传递值,而且如前所述,还可以在这些值的周围和内部添加特殊字符,Converter 仍会对其进行映射:

@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

在映射过程中处理特定规则的最佳方法是创建自己定义的 Converter 实现。在了解了 LenientToEnumConverter 的功能后,让我们来创建一个限制性更强的 Converter。

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;
        }
    }
}

Converter 会对源字符串进行细微调整。在这里,唯一做的就是去除值周围的空白。另外,注意,返回 null 值并不是最佳的设计决策,因为这会允许在不正确的状态下创建 Context。在这里使用 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 {
    }

    @Autowired
    private WeekDaysHolder holder;

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

此时,如果以大写字母提供值,就会注入正确的值。要使用这个 Converter,需要在 Spring 中注册:

public static class WeekDayConverterConfiguration {
    @Bean
    public ConversionService conversionService() {
        DefaultConversionService defaultConversionService = new DefaultConversionService();
        // 添加自定义转换器
        defaultConversionService.addConverter(new StrictNullableWeekDayConverter());
        return defaultConversionService;
    }
}

在某些 Spring Boot 版本或配置中,类似的 Converter 可能是默认 Converter,这比 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());
        }
    }
}

如上,没有考虑到 Enum 名称是小写或使用混合大小写的情况。不过,这种情况是可以解决的,只需增加几行代码和 try-catch 块即可。可以为枚举创建一个查找 Map 并将其缓存起来,但是文本不采用。

测试结果看起来很相似,也能正确映射值。为简单起见,这里只检查使用此 Converter 能够正确映射的属性:

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

使用自定义 Converter,可以根据自己的需求或想要遵循的惯例调整映射过程。

4、SpEL

SpEL 是一个功能强大的工具,几乎无所不能。可以在映射 Enum 之前,调整从 Properties 文件接收到的值,显式地将所提供的值改为大写:

@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;

    // Get、Set
}

要检查值是否正确映射,可以使用之前创建的 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);
    }
}

尽管 Converter 只能理解大写值,但通过使用 SpEL,可以将属性转换为正确的格式。这种技术对于简单的转换和映射可能很有帮助,因为它直接存在于 @Value 注解中,使用起来相对简单。不过,需要避免在 SpEL 中加入大量复杂的逻辑。

5、总结

@Value 注解强大而灵活,支持 SpEL 和属性注入。通过自定义 Converter 还可以实现更细粒度的转换控制。


Ref:https://www.baeldung.com/spring-boot-enum-bind-case-insensitive-value