本节目标:把图书管理服务的一整块配置绑定成类型安全的 Java 对象,并掌握松散绑定、嵌套集合、配置校验与 IDE 补全这四件事。
适用版本:Spring Boot 4.1.x(Java 21)
6.2 @ConfigurationProperties 类型安全配置
6.1 节我们为图书服务建好了三套配置文件,但取值的写法还很原始。最直接的方式是用 @Value 一个一个注入:
@Component
class BorrowService {
@Value("${book.name}")
private String name;
@Value("${book.max-borrow-days}")
private int maxBorrowDays;
@Value("${book.page-size}")
private int pageSize;
@Value("${book.contact.email}")
private String contactEmail;
}
配置只有五项时还能忍,一旦涨到二十项,这个类就会被注入语句淹没。本节用 @ConfigurationProperties 把「前缀下的一整块配置」一次性绑成一个对象。
@Value 的五个痛点
上面那段代码暴露了 @Value 的五个问题。
第一,字段注入难以测试。 单元测试里没法直接 new 出这个对象,只能靠反射或起一个 Spring 上下文。
第二,没有类型安全。 键名写错、类型不匹配都要等到启动时才知道;@Value("${book.maxBorrowDays}") 里的 camelCase 在 yml 里根本不存在,却不会在编译期报错。
第三,不支持松散绑定。 @Value 要求键名逐字符匹配,max-borrow-days 和 maxBorrowDays 在它眼里是两个不同的键。
第四,无法校验。 想把 max-borrow-days 限制在 1 到 365 之间,@Value 做不到。
第五,集合与嵌套对象很别扭。 绑一个 List<String> 得写 SpEL,绑一个嵌套对象更是噩梦。
@ConfigurationProperties 正是为这五点设计的。
两者的对比
| 维度 | @Value | @ConfigurationProperties |
|---|---|---|
| 批量绑定 | 一个键一个注解 | 一个前缀下全部键 |
| 类型安全 | 弱,靠 SpEL 与转换器 | 强,绑定到 POJO 或 record |
| 松散绑定 | 不支持 | 支持 kebab、camel、下划线、大写 |
| 配置校验 | 不支持 | @Validated + JSR-380 |
| 嵌套与集合 | 需 SpEL,写法繁琐 | 天然支持 |
| IDE 提示 | 无 | 靠生成的元数据 |
| 默认值 | ${x:default} | 字段初始化或构造器参数 |
| 注入方式 | 字段或参数 | 构造器,便于测试 |
| 适用场景 | 一两个零散值 | 成块的、同前缀的配置 |
判断标准很简单:同一个前缀下有三个以上键,就用 @ConfigurationProperties;只有一两个零散值,@Value 反而更轻。
第一个 @ConfigurationProperties
先看配置。application.yml:
book:
name: 图书管理服务
max-borrow-days: 30
page-size: 20
categories:
- 小说
- 技术
contact:
email: ops@example.com
phone: 010-00000000
再用一个 record 接住它:
package com.example.book.config;
import java.util.List;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "book")
public record BookProperties(
String name,
int maxBorrowDays,
int pageSize,
List<String> categories,
Contact contact) {
public record Contact(String email, String phone) {
}
}
三处细节值得注意:prefix = "book" 表示绑定 book.* 下的所有键;max-borrow-days 通过松散绑定落到 maxBorrowDays,不需要额外配置;record 的访问器没有 get 前缀,读值写 props.name() 而不是 props.getName()。
注册它,最省事的是在主类上开扫描:
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
@SpringBootApplication
@ConfigurationPropertiesScan
public class BookApplication {
public static void main(String[] args) {
SpringApplication.run(BookApplication.class, args);
}
}
然后在业务类里构造器注入:
@Service
public class NotificationService {
private final BookProperties props;
public NotificationService(BookProperties props) {
this.props = props;
}
public void notifyOverdue(String isbn) {
System.out.printf("发送逾期提醒:%s -> %s%n", isbn, props.contact().email());
}
}
注意这里没有任何 @Value,也没有字段注入;props 是构造器参数,单测里可以直接 new NotificationService(new BookProperties(...)) 构造出来。
构造器绑定与 record
@ConfigurationProperties 有两种绑定方式。
| 绑定方式 | 触发条件 | 特点 |
|---|---|---|
| 构造器绑定 | 只有一个带参构造器,或类型是 record | 不可变、字段可 final、易测试 |
| JavaBean 绑定 | 有默认构造器与 setter | 可变,适合第三方类 |
关键变化:Spring Boot 3.0 起,只有一个带参构造器的类会自动走构造器绑定,@ConstructorBinding 不再需要显式标注。到 4.x 依然如此。只有当你提供了多个构造器、需要指明用哪一个时,才在目标构造器上补 @ConstructorBinding 消歧。
record 天生只有一个全参构造器,所以上面那段代码直接可用,不需要任何额外注解。反过来,如果你用 class 加 getter/setter,就走 JavaBean 绑定,要求每个字段都有 setter——这也是为什么本书示例统一用 record。
嵌套对象、List 与 Map
嵌套对象不需要注解,Spring 会递归绑定。application.yml:
book:
name: 图书管理服务
limits:
max-books-per-user: 5
max-reservations: 3
categories:
- 小说
- 技术
metadata:
region: cn-north-1
owner: ops
对应的类型:
import java.util.List;
import java.util.Map;
@ConfigurationProperties(prefix = "book")
public record BookProperties(
String name,
Limits limits,
List<String> categories,
Map<String, String> metadata) {
public record Limits(int maxBooksPerUser, int maxReservations) {
}
}
几点经验:
List可以用 YAML 列表,也可以在.properties里写成逗号分隔的book.categories=小说,技术。Map<String, String>的 key 不做松散绑定,region就是region。如果 key 含点号或大写等特殊字符,要用metadata.[some.key]=v这种方括号语法。- 从环境变量来的 Map key 会被转成小写,写跨环境配置时留意。
- 嵌套层级再深也会递归绑定,但校验不会自动递归(见下一节)。
松散绑定规则
松散绑定(relaxed binding)是 @ConfigurationProperties 最有用的特性之一:同一个属性可以有多种写法,Spring 都能对上。
| 配置里的写法 | 形式 | 说明 |
|---|---|---|
book.max-borrow-days | kebab-case | 规范形式,YAML 与 properties 推荐 |
book.maxBorrowDays | camelCase | 代码风格,也能绑上 |
book.max_borrow_days | 下划线 | 从老系统迁移时常见 |
BOOK_MAXBORROWDAYS | 全大写 | 环境变量形式:点变下划线、删连字符 |
最后一行是重点:max-borrow-days 转环境变量时连字符被直接删除,正确写法是 BOOK_MAXBORROWDAYS,而不是 BOOK_MAX_BORROW_DAYS(后者会被解析成 book.max.borrow.days,是另一个键)。环境变量的完整规则见 6.3。
反过来要记住:@Value 不支持松散绑定。yml 里写 max-borrow-days,@Value("${book.maxBorrowDays}") 会直接抛 Could not resolve placeholder 'book.maxBorrowDays',而 @ConfigurationProperties 的 maxBorrowDays 字段照样绑得上。
用 @Validated 做配置校验
配置错了要在启动时就炸,而不是等第一个请求进来才暴露。加一个 starter:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
然后在属性类上标 @Validated 并加 JSR-380 注解:
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;
@Validated
@ConfigurationProperties(prefix = "book")
public record BookProperties(
@NotBlank String name,
@Min(1) @Max(365) int maxBorrowDays,
@Valid @NotNull Contact contact) {
public record Contact(@NotBlank String email, String phone) {
}
}
把 book.max-borrow-days 改成 0,启动会失败并给出可读的提示:
Description:
Binding to target org.springframework.boot.context.properties.bind.BindException:
Failed to bind properties under 'book' to com.example.book.config.BookProperties
Reason: maxBorrowDays must be greater than or equal to 1
三个易错点:@Validated 要用 org.springframework.validation.annotation.Validated(不是 Jakarta 的那个);嵌套对象必须加 @Valid,否则内层约束不生效;注解包是 jakarta.validation.constraints(Jakarta EE 11,4.x 用 Hibernate Validator 9.0)。
注册方式:Scan 还是 Enable
| 维度 | @ConfigurationPropertiesScan | @EnableConfigurationProperties |
|---|---|---|
| 作用 | 扫描包及子包下所有 @ConfigurationProperties 类并注册 | 显式注册列出的类 |
| 粒度 | 粗,整个包 | 细,逐个指定 |
| 位置 | 主类或任意配置类 | 任意 @Configuration 类 |
| 参数 | basePackages 可指定范围 | 直接列出 Class |
| 适用 | 自有配置类、包结构规整 | 第三方类、需要精确控制 |
两条硬性提醒:被扫描或被启用的类不要再标 @Component,否则会与扫描结果重复注册并报 bean 名冲突;只标 @Component 也能注册(走组件扫描),但那样配置类就混在普通 Bean 里,失去了集中管理的意义,不推荐。
给第三方类绑定
有些类来自第三方库,源码不能改,没法加 @ConfigurationProperties。这时用 @Bean 方法把它接进来:
@Configuration
class ClientConfig {
@Bean
@ConfigurationProperties(prefix = "book.client")
ClientSettings clientSettings() {
return new ClientSettings();
}
}
这种方式走的是 JavaBean 绑定,ClientSettings 需要 setter。
生成元数据获得 IDE 补全
上面每个属性类都可以配一个编译期处理器,生成 IDE 用的元数据:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
它会在编译期生成 META-INF/spring-configuration-metadata.json。有了它,在 application.yml 里敲 book. 就能得到自动补全、类型提示,以及字段上 Javadoc 的内容。
两个注意点:标 optional 是为了不把这个处理器传递给依赖你的项目;如果还想补充手写的描述(例如某个属性的取值枚举),放到 src/main/resources/META-INF/additional-spring-configuration-metadata.json。
本节常见坑速查
| 现象 | 原因 | 处理 |
|---|---|---|
启动报找不到 BookProperties bean | 既没扫描也没显式启用 | 加 @ConfigurationPropertiesScan |
| 字段全是默认值 | prefix 拼错,或类没注册 | 核对前缀与注册方式 |
| 校验完全不生效 | 忘了 @Validated | 补注解与 validation starter |
| 嵌套对象里的约束不生效 | 忘了在内层字段加 @Valid | 加 @Valid |
| 环境变量不生效 | 写成了带下划线的 BOOK_MAX_BORROW_DAYS | 改成 BOOK_MAXBORROWDAYS |
值读成 "750" 或 false | YAML 隐式类型转换 | 给字符串加引号,见 6.1 |
小结
@ConfigurationProperties把「一个前缀下的一整块配置」绑成类型安全的对象;@Value只适合一两个零散值。- record 与单个带参构造器走构造器绑定,4.x 不需要
@ConstructorBinding,只有多构造器消歧时才写。 - 嵌套对象、
List、Map都能直接绑;Map 的 key 不做松散绑定。 - 松散绑定支持 kebab、camel、下划线、大写四种写法;环境变量形式是「点变下划线、删连字符、全大写」。
@Validated加 JSR-380 注解让配置在启动时校验,嵌套对象记得加@Valid。- 用
@ConfigurationPropertiesScan或@EnableConfigurationProperties注册,二选一;spring-boot-configuration-processor提供 IDE 补全。
配置对象有了,但「同一个键在五个地方都写了值,到底哪个生效」还没讲清。下一节我们把配置源按官方顺序排一遍,并用一个实测把这些规则验证出来。
阅读导航:上一节:6.1 application.yml 与 Profile · 下一节:6.3 外部化配置与优先级 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。