用 @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 时。