用 @ConfigurationProperties 替代 @Value 的配置方式

1. 总体介绍

当你只需要读取一两个配置值时,@Value 很方便。但随着 property 数量增加,${...} 这样的字符串开始散落在各个 bean 中,变得难以查找、容易输入错误,而且完全没有类型检查。

本文将指导你在 Spring Boot 3.x 中转向使用 @ConfigurationProperties。阅读完毕后,你将学会把配置汇总到一个类型明确的 record 中,并绑定 Duration、List、Map、嵌套对象这样的复杂类型。你还会学到如何在启动时验证配置,以及如何为这部分配置编写测试。

2. @Value 的问题

考虑一个 PaymentService 需要调用支付网关:

@Service
public class PaymentService {

    @Value("${app.payment.gateway-url}")
    private String gatewayUrl;

    @Value("${app.payment.merchant-id}")
    private String merchantId;

    @Value("${app.payment.timeout:10s}")
    private Duration timeout;

    @Value("${app.payment.max-retries:3}")
    private int maxRetries;

    // ...
}

这段代码能运行,但有几个问题:

  • Key 是自由形式的字符串。 输入 app.payment.gatewayUrl 而不是 gateway-url,你只能在运行时才能发现错误。
  • 没有验证。 max-retries=-5 同样会被正常注入。
  • 配置分散。 如果 RefundService 也需要 merchant-id,你就要在那里重复 @Value。
  • 难以测试。 要在不启动 Spring 容器的情况下测试 PaymentService,你必须用反射来设置字段。
  • 复杂类型很笨拙。 用 @Value 注入 Map<String, BigDecimal> 需要相当复杂的 SpEL。

3. 使用 @ConfigurationProperties 声明配置类

3.1. 使用 record(推荐)

application.yml 文件:

app:
  payment:
    gateway-url: https://pay.example.com/api
    merchant-id: NONLA-001
    timeout: 5s
    max-retries: 3

对应的配置类:

package com.nonla.payment;

import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;

@ConfigurationProperties(prefix = "app.payment")
public record PaymentProperties(
        String gatewayUrl,
        String merchantId,
        @DefaultValue("10s") Duration timeout,
        @DefaultValue("3") int maxRetries) {
}

解释:

  • prefix = "app.payment" 告诉 Spring 这个 record 接收 app.payment 下的所有 key。
  • Spring Boot 3 自动对 record 使用构造函数绑定。你不需要 setter 或 @ConstructorBinding(当类只有一个构造函数时)。
  • 灵活绑定把 gateway-url、gatewayUrl、GATEWAY_URL(环境变量 APP_PAYMENT_GATEWAYURL)映射到同一个参数 gatewayUrl。
  • @DefaultValue 在 key 不存在于配置时提供默认值。
  • 字符串 5s 自动转换为 Duration.ofSeconds(5)。

3.2. 注册和使用

上面的 record 还不是一个 bean。你需要在主类中启用它:

@SpringBootApplication
@ConfigurationPropertiesScan
public class ShopApplication {
    public static void main(String[] args) {
        SpringApplication.run(ShopApplication.class, args);
    }
}

@ConfigurationPropertiesScan 扫描当前包及其子包,找到所有带 @ConfigurationProperties 的类。如果你想显式声明每个类,可以在 @Configuration 类上使用 @EnableConfigurationProperties(PaymentProperties.class)。

现在 PaymentService 只需要注入一个对象:

@Service
public class PaymentService {

    private final PaymentProperties props;

    public PaymentService(PaymentProperties props) {
        this.props = props;
    }

    public String describe() {
        return "调用 %s,商户 %s,超时 %s,重试 %d 次"
                .formatted(props.gatewayUrl(), props.merchantId(),
                           props.timeout(), props.maxRetries());
    }
}

调用 describe() 的结果:

调用 https://pay.example.com/api,商户 NONLA-001,超时 PT5S,重试 3 次

PaymentService 现在是一个纯粹的 Java 类。在单元测试中,你只需要 new PaymentService(new PaymentProperties(...)),无需 Spring。

注意: 不要在使用构造函数绑定的 record 上添加 @Component。这样 Spring 会把它当作普通 bean,试图像注入其他 bean 一样注入 String gatewayUrl。应用会在启动时出错。应该用 @ConfigurationPropertiesScan 或 @EnableConfigurationProperties 来注册。

3.3. 有 setter 的 JavaBean 风格

如果需要可变对象,比如为了兼容旧代码,你可以用有 getter/setter 的类:

@ConfigurationProperties(prefix = "app.mail")
public class MailProperties {
    private String host = "localhost";
    private int port = 25;

    public String getHost() { return host; }
    public void setHost(String host) { this.host = host; }
    public int getPort() { return port; }
    public void setPort(int port) { this.port = port; }
}

Spring 调用默认构造函数,然后为每个找到的 key 调用 setter。默认值就是字段的初始化值。这种方式运行良好,但对象可以在运行时的任何地方被修改。因此 record 仍然是更安全的选择。

4. 绑定复杂数据类型

扩展支付配置,添加列表、map 和嵌套对象:

app:
  payment:
    gateway-url: https://pay.example.com/api
    merchant-id: NONLA-001
    timeout: 5s
    max-retries: 3
    max-payload: 2MB
    supported-currencies:
      - VND
      - USD
    fees:
      card: 1.5
      wallet: 0.8
    webhook:
      enabled: true
      secret: s3cr3t
@ConfigurationProperties(prefix = "app.payment")
public record PaymentProperties(
        String gatewayUrl,
        String merchantId,
        @DefaultValue("10s") Duration timeout,
        @DefaultValue("3") int maxRetries,
        @DefaultValue("1MB") DataSize maxPayload,
        @DefaultValue({"VND"}) List<String> supportedCurrencies,
        Map<String, BigDecimal> fees,
        @DefaultValue Webhook webhook) {

    public record Webhook(
            @DefaultValue("false") boolean enabled,
            String secret) {
    }
}

各个参数的绑定方式如下:

  • DataSize 理解 B、KB、MB、GB 等单位。2MB 产生 DataSize.ofMegabytes(2)。
  • List<String> 接受 YAML 列表格式和逗号分隔的字符串(VND,USD)。
  • Map<String, BigDecimal> 使用子 key(card、wallet)作为 map 的 key。
  • Webhook 是嵌套 record,按前缀 app.payment.webhook 递归绑定。
  • @DefaultValue 没有值时,放在 webhook 上,当整个 webhook 块缺失时,Spring 创建一个 Webhook 实例并使用默认值。没有这个注解,webhook() 会返回 null。

注意: 对于 Duration,没有单位的数字被理解为毫秒。timeout: 30 意思是 30ms 而不是 30 秒。你应该总是指定单位(30s、500ms),或在参数上使用 @DurationUnit(ChronoUnit.SECONDS)。

5. 在启动时验证配置

添加验证 starter:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

然后标记 @Validated 并添加 Jakarta Bean Validation 约束:

@Validated
@ConfigurationProperties(prefix = "app.payment")
public record PaymentProperties(
        @NotBlank String gatewayUrl,
        @NotBlank String merchantId,
        @DefaultValue("10s") Duration timeout,
        @Min(0) @Max(10) @DefaultValue("3") int maxRetries,
        @DefaultValue("1MB") DataSize maxPayload,
        @NotEmpty @DefaultValue({"VND"}) List<String> supportedCurrencies,
        Map<String, BigDecimal> fees,
        @Valid @DefaultValue Webhook webhook) {

    public record Webhook(
            @DefaultValue("false") boolean enabled,
            String secret) {
    }
}
  • @Validated 在绑定完成后立即激活验证。
  • webhook 上的 @Valid 确保 Webhook 内部的约束也被检查。没有 @Valid,Spring 会跳过嵌套对象。

如果有人留下 merchant-id 为空,应用会在启动时停止并显示清晰的错误信息:

***************************
APPLICATION FAILED TO START
***************************

Description:

Binding to target com.nonla.payment.PaymentProperties failed:

    Property: app.payment.merchantId
    Value: ""
    Origin: class path resource [application.yml] - 4:17
    Reason: must not be blank

Action:

Update your application's configuration

系统在部署时立即报告错误,而不是运行几个小时后才在第一个支付请求时失败。这是相比 @Value 最大的优势。

6. 通过 configuration processor 在 IDE 中获得提示

添加 annotation processor:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-configuration-processor</artifactId>
    <optional>true</optional>
</dependency>

编译时,processor 生成 META-INF/spring-configuration-metadata.json 文件,描述 app.payment 下的所有 key:类型、默认值和描述。IntelliJ IDEA 和 VS Code(带 Spring Tools)使用这个文件来提示 key、警告不存在的 key,以及在 application.yml 中悬停时显示文档。

每个 property 的描述来自 Javadoc。对于 record,你用 @param 写 Javadoc:

/**
 * 支付网关配置。
 *
 * @param gatewayUrl 支付网关的基础 URL
 * @param maxRetries 调用网关失败时的最大重试次数
 */

注意: 如果你的项目已在 maven-compiler-plugin 中声明了 annotationProcessorPaths(例如用于 Lombok 或 MapStruct),你必须把 spring-boot-configuration-processor 添加到该列表。否则 processor 不会运行。

7. 测试配置类

你不需要重量级的 @SpringBootTest。ApplicationContextRunner 构建一个轻量级容器,只包含你需要的东西:

package com.nonla.payment;

import static org.assertj.core.api.Assertions.assertThat;

import java.math.BigDecimal;
import java.time.Duration;
import org.junit.jupiter.api.Test;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.context.properties.bind.validation.BindValidationException;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.context.annotation.Configuration;

class PaymentPropertiesTest {

    @Configuration
    @EnableConfigurationProperties(PaymentProperties.class)
    static class TestConfig {
    }

    private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
            .withUserConfiguration(TestConfig.class);

    @Test
    void shouldBindValuesAndApplyDefaults() {
        contextRunner
                .withPropertyValues(
                        "app.payment.gateway-url=https://pay.example.com/api",
                        "app.payment.merchant-id=NONLA-001",
                        "app.payment.timeout=2s",
                        "app.payment.fees.card=1.5")
                .run(context -> {
                    PaymentProperties props = context.getBean(PaymentProperties.class);

                    assertThat(props.timeout()).isEqualTo(Duration.ofSeconds(2));
                    assertThat(props.maxRetries()).isEqualTo(3);
                    assertThat(props.supportedCurrencies()).containsExactly("VND");
                    assertThat(props.fees()).containsEntry("card", new BigDecimal("1.5"));
                    assertThat(props.webhook().enabled()).isFalse();
                });
    }

    @Test
    void shouldFailWhenMaxRetriesOutOfRange() {
        contextRunner
                .withPropertyValues(
                        "app.payment.gateway-url=https://pay.example.com/api",
                        "app.payment.merchant-id=NONLA-001",
                        "app.payment.max-retries=50")
                .run(context -> assertThat(context)
                        .hasFailed()
                        .getFailure()
                        .hasRootCauseInstanceOf(BindValidationException.class));
    }
}

这两个测试检查以下几点:

  • 第一个测试确认值被绑定到正确的类型(2s 到 Duration、1.5 到 BigDecimal)。它也确认 @DefaultValue 被应用于 maxRetries、supportedCurrencies 和 webhook。
  • 第二个测试确认 max-retries=50 违反了 @Max(10) 并导致容器启动失败,抛出 BindValidationException。

每个测试运行只需几百毫秒,因为没有 web 服务器或其他自动配置。

8. 何时仍然使用 @Value

@ConfigurationProperties 不是 @Value 的完全替代品。在以下情况你仍可以使用 @Value:

  • 需要 SpEL。 例如 @Value("#{${app.payment.timeout-seconds} * 1000}")。@ConfigurationProperties 不支持 SpEL 表达式。
  • 只需要一个单独的值,在一个地方使用。 例如在日志 bean 中 @Value("${spring.application.name}")。为这种情况创建整个 record 过于复杂。

转向 @ConfigurationProperties 时的一些常见错误:

  • 忘记注册类。 缺少 @ConfigurationPropertiesScan 或 @EnableConfigurationProperties 会导致错误“No qualifying bean of type PaymentProperties”。
  • 前缀不遵循约定。 前缀必须是 kebab-case、小写(app.payment,不是 app.Payment)。如果错了,Spring 在启动时报告错误。
  • 有多个构造函数。 当 record 或类有多个构造函数时,你必须在用于绑定的构造函数上标记 @ConstructorBinding。
  • 用一个类存放所有东西。 不要把所有应用配置塞进一个巨大的 AppProperties。按业务域分割:PaymentProperties、MailProperties、StorageProperties 等等。

注意: 配置 record 是不可变的。如果你使用 Spring Cloud 在运行时刷新配置,bean 必须被重新创建(通过 @RefreshScope 或 rebind 机制)新值才能生效。不要期望 record 自动改变。

9. 总结

在本文中,你将分散的 @Value 替换为类型明确的 PaymentProperties record。具体来说,你:

  • 绑定了 Duration、DataSize、List、Map 和嵌套 record,通过 @DefaultValue 提供默认值;
  • 使用 @Validated 和 Jakarta 约束在启动时验证配置;
  • 通过 spring-boot-configuration-processor 在 IDE 中获得 key 提示;
  • 用 ApplicationContextRunner 快速测试配置。

对于任何有两个或更多 key 的配置组,@ConfigurationProperties 应该是你的默认选择。将 @Value 留给单个值或当你确实需要 SpEL 时。

spring-bootconfigurationconfigurationpropertiesvalidationjava