配置体系全解:properties、YAML、绑定与多环境 Profile
一个 Spring Boot 程序启动时,会同时从很多个地方读配置:jar 包里躺着的 application.yml、jar 旁边你新放的一份、服务器上的环境变量、启动命令里的 -D 参数、最后还有敲在命令行末尾的 --server.port=9090。同一个 key(比如 server.port)如果好几处都写了,Spring Boot 不会报错,也不会「合并」——它按一套固定的优先级顺序去问,第一个给出答案的那一处胜出。本篇讲的就是四件事:配置能放在哪、谁盖住谁、怎么把配置读到 Java 字段里(绑定)、以及怎么用 Profile 让同一份代码在开发/测试/生产各用一套值。
配置优先级像规则层级。你家的家规(jar 包里的 application.yml)管日常;公司的规定(服务器上外部 config/ 与环境变量)比家规大;国家的法律(命令行参数)谁都得听。同一条「几点到家」的规定,越具体、越贴近当下场景的那条赢——所以你在启动命令里写了 --server.port=9090,改文件就白改,因为「法律」压过「家规」。
Profile 像同一套房子换三套家具布置。毛坯房(application.yml)是公共骨架——水电、户型不动;居家模式(application-dev.yml)铺地毯、开暖光;出租模式(application-prod.yml)换耐用家具、锁好储物间。房间结构完全一样,换的只是「这一档要用的一套配置」。所谓激活 prod,就是把这套房子切到出租布置。
本篇地图:
配置体系├── 载体 properties / yml / yaml → 都变成 PropertySource├── 优先级 jar内 < jar外 < profile 文件 < 环境变量 < -D < 命令行├── 读取方式 @Value(严格)/ @ConfigurationProperties(松散+校验)│ Environment(手工)/ System.getProperty(只看 -D)├── Profile application-{profile}.yml + @Profile + 四种激活方式├── 占位符 ${KEY:默认值} 找不到就抛 Could not resolve placeholder└── 排查 打印 active profiles、遍历 getPropertySources、Actuator学完这一篇,你应该能回答三个问题:
- 我改了 jar 旁边的
application.yml,为什么服务重启后端口还是老样子?被哪一层盖住了? @Value("${bee.max-size}")和@ConfigurationProperties(prefix = "bee"),同一个环境变量能不能生效?为什么两者答案不同?- 上线时
spring.profiles.active的名字少写一个字母,会发生什么?我怎么自己证明 profile 真的生效了?
Spring Boot 允许你用三种文件承载配置,它们最终都会被解析成同一份 PropertySource。选择哪一种更多是团队习惯问题,但差异是真实存在的:
| 载体 | 可读性 | 层级表达 | 列表表达 | 适合 |
|---|---|---|---|---|
application.properties | 平铺,长列表好读 | 靠 a.b.c= 点号,层级越深越啰嗦 | list[0]=x 较别扭 | 简单、少量配置 |
application.yml | 缩进即层级,视觉清晰 | 原生树形 | - x 一目了然 | 主流选择 |
application.yaml | 与 yml 完全等价 | 同上 | 同上 | 只影响文件名 |
同样的一段配置,两种写法对照:
# application.propertiessms.endpoint=https://sms.example.com/apisms.sign-name=某某科技sms.retry.attempts=3sms.retry.enabled=true# application.ymlsms: endpoint: https://sms.example.com/api sign-name: 某某科技 retry: attempts: 3 enabled: true- yml 用缩进表达层级,一眼看清哪个配置属于哪个前缀;properties 用点号拼接 key,层级一深就难读
- yml 表达列表更自然:
hosts:\n - a\n - b,对应 properties 的hosts[0]=a要繁琐得多
坑:YAML 的缩进只允许空格,不允许 Tab。从网页复制配置时最容易被带进 Tab 字符,启动时报 mapping values are not allowed here 或 found character '\t' that cannot start any token——这类报错信息完全不提「Tab」,让人一头雾水。记住:冒号后必须有一个空格(key: value),key:value 会被当成一个普通字符串。
这是配置体系里最值得吃透的一张表。Spring Boot 会从多个位置收集配置,当同一个 key 出现在多处时,优先级高的那层胜出(注意:不是「后者覆盖前者」,而是「按优先级取第一个命中的」):

| 优先级 | 配置来源 | 说明 |
|---|---|---|
| 1 | 命令行参数 | java -jar app.jar --server.port=9090,最高优先级 |
| 2 | SPRING_APPLICATION_JSON | 环境变量里塞一段 JSON |
| 3 | ServletConfig / ServletContext 初始化参数 | Web 容器级 |
| 4 | JNDI(java:comp/env) | 传统应用服务器 |
| 5 | Java 系统属性 | System.getProperties(),即 -Dkey=value |
| 6 | 操作系统环境变量 | SERVER_PORT=9090 |
| 7 | random.* | 随机值属性源 |
| 8 | 外部 config/ 下的 profile 专属文件 | ./config/application-prod.yml |
| 9 | 外部 profile 专属文件 | ./application-prod.yml |
| 10 | 包内 profile 专属文件 | classpath:/application-prod.yml |
| 11 | 外部 config/ 下的通用文件 | ./config/application.yml |
| 12 | 外部通用文件 | ./application.yml |
| 13 | 包内通用文件 | classpath:/application.yml |
| 14 | 默认属性 | SpringApplication.setDefaultProperties(...) |
- 命令行 > 环境变量 > 外部文件 > 包内文件 > 默认值,这就是记忆主线
- 「外部」指 jar 包所在目录或其下的
config/,用于不改包、不重新打包地覆盖配置 - profile 专属文件(
application-prod.yml)优先级高于通用文件(application.yml),且都在包内/包外之后
这解释了运维的常见问题——「我明明改了容器里的 application.yml,怎么没生效?」如果启动命令里带了 --server.port=8081,命令行那层永远赢,改文件白改。排查配置问题的第一步,永远是先问「是不是被更高优先级盖住了」。
上面那张表有十四行,但真要排查时你只会走到其中三四层。把这条链摊成能点的路径,一格一格问下去——注意第 ③ 格那句「不是合并,是先被问到」,它是本节唯一的考点:

两个注解都能把配置读进 Java,但深浅差很多:
| 维度 | @Value | @ConfigurationProperties |
|---|---|---|
| 绑定方式 | 单键精确匹配 | 松散绑定、批量绑定整棵前缀 |
| 复杂类型 | 需 SpEL 手工处理 | List / Map / 嵌套对象 / Duration 原生支持 |
| 校验 | 不支持 | 配合 @Validated 支持 |
| SpEL | 支持(本质是表达式) | 不支持(要 SpEL 就用 @Value) |
| 刷新 | @RefreshScope 可刷新 | 需配合刷新机制 |
| 适合 | 零散单值(如 @Value("${app.name}")) | 成组的业务配置(强烈推荐) |
// 场景一:只是取一个零散的值,@Value 更直接@Componentpublic class HealthController { @Value("${app.version:unknown}") // 冒号后是「找不到时的默认值」 private String version;}// 场景二:成组配置,一律用 @ConfigurationProperties@Component@ConfigurationProperties(prefix = "sms")public class SmsProperties { private String endpoint; private int timeout = 3000; private Retry retry = new Retry(); public static class Retry { private boolean enabled; private int attempts; // getter / setter }}@Value("${app.version:unknown}"):冒号后是默认值;但@Value一旦写错 key 名,只能在运行时发现,且不支持松散绑定@ConfigurationProperties:一次性绑定整棵前缀,IDE 有提示、可校验、可测试,是工程里的默认选择- 判据很简单:一个值用
@Value,一组值用@ConfigurationProperties
所谓「松散绑定(Relaxed Binding)」,是指配置项的书写形式可以有多种,但都能绑到同一个 Java 字段上。这解决了「yml 用短横线、常量用下划线、字段用驼峰」三套命名习惯的冲突。

上图要表达的核心对比是:@Value("${sms.template-id}") 走的是字面量精确匹配——你写 sms.templateId 它就不认;而 @ConfigurationProperties(prefix = "sms") 走的是归一化后比较,把配置名与字段名都剥掉分隔符、转小写再比,所以下面五种写法全部命中同一个字段:
| 配置写法 | 能否绑定到字段 templateId | 常见场景 |
|---|---|---|
sms.template-id | ✅ | yml(推荐) |
sms.templateId | ✅ | properties |
sms.templateid | ✅ | 无所谓大小写 |
SMS_TEMPLATE_ID | ✅ | 环境变量(最推荐) |
sms.template_id | ✅ | properties 里手滑 |
@ConfigurationProperties(prefix = "sms")public class SmsProperties { private String templateId; // 上面五种写法都能绑到它}- 前缀
prefix = "sms"也必须以松散形式匹配,SMS_ENDPOINT能对上前缀sms下的endpoint - 环境变量的推荐写法是全大写 + 下划线:
SMS_ACCESS_KEY对应sms.access-key - 有个细节:环境变量在 Linux 上全大写匹配没问题,但要绑到带短横线的 key,用下划线才稳——
SMS_ACCESS_KEY会被自动翻译成sms.access-key
要点:面试问「为什么环境变量和 yml 写法不一样还能绑上」,标准答案就是:@ConfigurationProperties 内部会把配置名与字段名都归一化成「去掉分隔符、转小写」的形式再比较。理解这一点,你就不会再纠结「到底该写 template-id 还是 templateId」——yml 里写短横线,环境变量里写下划线,都能对。
@ConfigurationProperties 的真正威力在复杂类型上,这是 @Value 很难做到的:
# application.ymlapp: name: Demo hosts: # List<String> - 10.0.0.1 - 10.0.0.2 channels: # Map<String, Integer>:键值都是标量 wechat: 1 sms: 2 datasource: # 嵌套对象 url: jdbc:mysql://localhost:3306/demo pool: max-size: 20 min-idle: 5 timeouts: connect: 2s # Duration read: 500ms upload: max-file-size: 10MB # DataSize@ConfigurationProperties(prefix = "app")public class AppProperties { private String name; private List<String> hosts; private Map<String, Integer> channels; private DataSource dataSource = new DataSource(); private Timeouts timeouts = new Timeouts(); private DataSize uploadMaxFileSize; // 也可写成 upload.max-file-size 的嵌套 public static class DataSource { private String url; private Pool pool = new Pool(); public static class Pool { private int maxSize; private int minIdle; } } public static class Timeouts { private Duration connect; // "2s" / "500ms" / "PT2S" 都能解析 private Duration read; }}List<String>:yml 里用-短横线列表,properties 里用hosts[0]=加下标Map<String, Integer>:yml 里直接以「键: 值」罗列DataSize:自动解析10MB、1GB等带单位的写法,绑成org.springframework.util.unit.DataSizeDuration:自动解析2s、500ms、PT2S等写法;不写单位会被当成毫秒,务必带单位
坑:Duration 与 DataSize 在没有单位时行为不同——read: 500 会被当作毫秒,而 max-file-size: 10 默认是字节。这类「数字没单位」的配置在跨环境时最容易出问题,约定是永远带单位,既省心又能自解释。
配置写错时,最理想的状态是启动就失败、并指出是哪个字段。这需要三件套:@Validated + 校验注解 + 校验实现:
@Validated // ① 必须加,否则校验不触发@ConfigurationProperties(prefix = "sms")public class SmsProperties { @NotBlank // ② 非空且非空白 private String endpoint; @Min(500) @Max(10000) private int timeout = 3000; @Email private String alertEmail;}<!-- ③ 引入校验实现(Spring Boot 2.3+ 需显式引入) --><dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId></dependency>配置不合法时,启动直接报错,且信息精准到字段:
Failed to bind properties under 'sms' to com.example.sms.autoconfigure.SmsProperties: Property: sms.endpoint Value: "" Origin: class path resource [application.yml] - 3:13 Reason: must not be blank@Validated是开关:没有它,字段上的@NotBlank/@Min一律不执行- 校验实现(
hibernate-validator)自 Spring Boot 2.3 起不再默认包含,必须显式加spring-boot-starter-validation - 报错信息里的
Property/Value/Origin(哪个文件第几行)极其好用,直接定位到配置源头
提示:把校验当成配置的契约。endpoint 必须非空、timeout 必须在合理区间——这些约束写进属性类,等于给整个团队立了规矩,配置错了在启动期就拦下来,而不是等到半夜短信发不出去才发现。
三件套凑齐以后,属性类到底该长什么样、以及它配套的那段 yml 从哪来,勾一遍比抄一遍记得牢。特别注意最后一项「生成挂载方式」:同一个属性类,@EnableConfigurationProperties、@ConfigurationPropertiesScan、类上加 @Component 三条进容器的路,生成的代码完全不同:
package com.example.sms;
import java.time.Duration;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;
/** 配置前缀 sms:所有 sms.* 的键都绑进这个类 */
@ConfigurationProperties(prefix = "sms")
@Validated
public class SmsProperties {
/** 必填,空值启动即失败 */
@NotEmpty
private String name;
/** 配置里可以写 30s / 5m */
private Duration timeout = Duration.ofSeconds(3);
private String accessKeySecret;
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getAccessKeySecret() { return accessKeySecret; }
public void setAccessKeySecret(String accessKeySecret) { this.accessKeySecret = accessKeySecret; }
public Duration getTimeout() { return timeout; }
public void setTimeout(Duration timeout) { this.timeout = timeout; }
}
# ---------------- 配套 application.yml ----------------
sms:
name: bee-order # 短横线/驼峰都能绑,推荐统一写短横线
timeout: 30s
access-key-secret: ${OSS_SECRET:} # 密钥从环境变量进来,别写进仓库Profile 是 Spring 应对多环境的官方方案:同一套代码,按激活的 profile 加载不同配置、装配不同 Bean。
先看 @Profile 注解的用法——它本质是条件装配的一个预设:
@Configuration@Profile("dev")public class DevMailConfig { @Bean public MailSender mailSender() { return new ConsoleMailSender(); // 开发环境:邮件只打到控制台 }}@Configuration@Profile("prod")public class ProdMailConfig { @Bean public MailSender mailSender() { return new SmtpMailSender(); // 生产环境:真实 SMTP 发送 }}更常见的做法是用多个 profile 专属配置文件,把差异收敛进文件而不是代码:
# application.yml —— 公共配置spring: application: name: demosms: sign-name: 某某科技# application-dev.yml —— 开发spring: datasource: url: jdbc:h2:mem:demo username: sa password: ""logging: level: com.example: debug# application-prod.yml —— 生产spring: datasource: url: jdbc:mysql://db.internal:3306/demo username: demo password: ${DB_PASSWORD} # 从环境变量注入,绝不写死logging: level: com.example: infospring.profiles.active 有四种常见激活方式:
| 激活方式 | 写法 | 适用 |
|---|---|---|
| 配置文件 | spring.profiles.active: prod | 默认兜底 |
| 命令行参数 | --spring.profiles.active=prod | 临时覆盖(优先级最高) |
| 环境变量 | SPRING_PROFILES_ACTIVE=prod | 容器 / CI 环境(推荐) |
| 代码 | SpringApplication.setAdditionalProfiles("prod") | 编程式设置 |
Profile 表达式还支持逻辑组合:
# 非 dev 环境都启用spring: profiles: active: "!dev"@Profile("dev"):只有 dev 激活时该配置类才生效;多个 profile 可写成数组@Profile({"dev", "test"})(任一命中即可)- 否定与组合:
@Profile("!prod")表示「非 prod」,@Profile({"prod", "cloud"})是「或」关系,更复杂的组合用表达式 - profile 专属文件自动随激活的 profile 加载,无需手动 import
「激活 prod」这四个字背后同时发生了两件事:一份文件被并进配置链、一批 Bean 的装配条件变绿。这段动画把两件事并排走了一遍,注意第 ⑥ 帧——@Profile 和配置文件其实吃的是同一个开关:

yml 与 properties 同时存在时,两者都会被加载,且 properties 优先。 如果项目里既有 application.yml 又有 application.properties,同名 key 会以 .properties 为准——很多人「改了 yml 没生效」,根因就是这里还躺着一个老的 .properties。另外 profile 文件名大小写敏感:application-Prod.yml 不会匹配 prod。
多环境这套配置到底该写哪几行,勾一遍最快:先只勾「分档配置」看 --- 与 spring.config.activate.on-profile 怎么配对;再叠上数据源与日志,对照本节那两个 application-dev.yml / application-prod.yml 的分工;最后加服务器项,看 server.port 为什么在生产 profile 里常常被环境变量盖掉(第十一节 lab prop order 就是这条链的现场)。
server:
port: 8080
spring:
application:
name: demo-service
datasource:
url: jdbc:mysql://127.0.0.1:3306/bee_order?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: ${DB_USER:root} # ${} 占位符:环境变量优先,冒号后是默认值
password: ${DB_PASS:}
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
max-lifetime: 1740000 # 必须小于 MySQL 的 wait_timeout
pool-name: beeHikari
---
spring:
config:
activate:
on-profile: prod
logging:
level: { root: WARN }
---
spring:
config:
activate:
on-profile: dev
spring:
jpa:
show-sql: true生产环境的数据库密码、密钥绝不能明文躺在包里。工程上有两条主流路线:
- Jasypt 一句话版:引入
jasypt-spring-boot-starter,用ENC(密文)写在配置里,启动时用密钥解密。适合「必须把密文放在配置文件里」的场景 - 环境变量注入(首选):配置里只写占位符
${DB_PASSWORD},真值由部署平台(K8s Secret、CI 变量)注入,密码根本不出现在代码库里
spring: datasource: password: ${DB_PASSWORD} # 部署时注入,代码库中看不到真值${DB_PASSWORD} 这种写法叫占位符解析:容器在绑定阶段会把它替换成真正的值,替换完的结果还会再走一遍优先级链——也就是说 DB_PASSWORD 这个名字本身也参与优先级排序。

解析顺序有三条规则,记住就不会被「为什么 yml 里的占位符读不到环境变量」问倒:
- 先去优先级链上找同名 key。若
DB_PASSWORD恰好在某个application.yml里也被定义了,那个值会赢,而不是操作系统环境变量 - 冒号给默认值。写成
${DB_PASSWORD:guest}时,找不到就用guest,不会报错;写成${DB_PASSWORD}则找不到就抛异常 - 找不到就抛异常,且异常信息带 key 名。原文是
org.springframework.beans.factory.BeanCreationException: Could not resolve placeholder 'DB_PASSWORD' in value "${DB_PASSWORD}",搜前半句就能搜到
占位符不能自己引自己。password: ${DB_PASSWORD:${DB_PASSWORD:123456}} 这种「兜底再兜底」的写法虽然语法合法,但一旦第一层真的取到了值就没事,取不到时错误信息会嵌套成一长串难以定位的 ${...}。兜底最多一层,并且要把默认值写清楚。
占位符解析发生在绑定之前,所以它既能取环境变量(${DB_PASSWORD}),也能取别的配置项(${server.port})、甚至取系统属性。正因如此,password: ${DB_PASSWORD} 与 password: ${spring.datasource.password} 写在同一份 yml 里毫无区别——但在排查问题时,前者要去看环境变量,后者要回到 yml 内部去找引用链。
「绑定之前」这四个字决定了报错会长成什么样。把它摊成一次单步执行:左边六行是启动过程,右边同步刷新此刻的变量和调用栈。连点下一步,重点停第 ④ 拍——那一拍问的是属性名,不是操作系统的变量名:
// application.yml 里这一行:password: ${DB_PASSWORD:guest}// SpringApplication.run 第一件事:把六层配置源排好队放进 Environment// PropertySourcesPlaceholderConfigurer 开始遍历所有 ${...}// 沿队列逐个源问:你这里有没有一个叫 DB_PASSWORD 的属性// 命中就替换;一路问到底都没有 → 取冒号后面的 guest// 替换完的字符串才交给 Binder 做松散绑定与类型转换| 这一行原文 | password: ${DB_PASSWORD:guest} |
| 此刻它还是 | 一整串字符 |
| 被谁读过 | 还没有 |
classpath:/application.yml能靠环境变量就别用配置加密。 加密只是把「明文」换成「需要一把钥匙的密文」,钥匙本身还是要有地方放;而环境变量注入让敏感信息彻底离开代码库,配合 K8s Secret、Vault 这类平台最干净。Jasypt 更适合无法改造部署流程的老系统。
上面第 ④ 拍那格,是线上出现频率最高的一种启动失败。这段堆栈练的就是它——先别看解析,点出你认为的凶手行:
开发机用 IDE 的运行配置带着 DB_PASSWORD 跑通了;交到 K8s 之后 Secret 注入的是 SPRING_DATASOURCE_PASSWORD,启动第一秒就失败,日志里一个业务栈帧都没有。
上面的配置都是「启动时读一次,之后不再变」。在 Spring Cloud 场景里,有时需要不重启就更新配置——这时用到 @RefreshScope:它把 Bean 包成代理,配置变更时销毁并重建 Bean,从而读到新值。
@RefreshScope@Component@ConfigurationProperties(prefix = "sms")public class SmsProperties { // 配置中心推送变更后,此处会拿到新值}@RefreshScope会让 Bean 变成懒加载的代理,刷新时才重建- 它属于 Spring Cloud 的范畴,单体应用基本用不到;这里只需知道「配置动态刷新靠它」即可,后续 Spring Cloud 篇会展开
| 现象 | 根因 | 解决 |
|---|---|---|
| 改了 yml 不生效 | 同目录还有 application.properties,且它优先 | 删掉或统一到一种载体 |
| profile 没切换成功 | 文件名大小写不对(application-Prod.yml)/ 激活项写错 | 用小写 application-prod.yml,核对 spring.profiles.active |
yml 与 properties 同时存在会都加载,且 .properties 优先。 这不是「合并成一份」,而是两个独立的 PropertySource 按优先级排序,properties 排在前面。清理配置时的第一步,就是全局搜一遍项目里到底有几个 application.*。
Profile 文件名大小写敏感。 application-dev.yml 能被 dev 命中,application-Dev.yml 不行;而 spring.profiles.active=Dev 与 dev 也是两个不同的 profile。约定是一律小写,把大写的可能性彻底排除。
配置这一章的报错有个共同脾气:消息里从来不写真正的成因。mapping values are not allowed here 不会提 Tab,Could not resolve placeholder 不会提「名字对不上」。所以与其背,不如点一次——左边是原文,右边是它真正在说什么:
这一章有九个内核实验,按顺序走一遍就够:先 ① 看「配置读到与否」如何改变 Bean,再 ②③④⑤ 把优先级、松散绑定、占位符、Profile 四件事各自按出来,接着 ⑥ 往上追到启动主线里配置究竟在哪一步就位,最后 ⑦⑧⑨ 把它接回条件装配、Bean 进场与自动配置这三条底层机制。
第一个演示把「配置已读取」做成开关。切换它,观察 DataSource 连接池参数(最大连接数、超时)如何随配置变化:
第二个实验专治「我改了配置怎么没生效」:把同一个 server.port 分别写进命令行、环境变量和包内文件,看优先级链怎么一层层问下来。
第三个实验把第四节那张表变成可点的:切换配置项的书写形式,看它能不能绑到 templateId 字段上。
第四个实验演示占位符的解析顺序,包括默认值的兜底与找不到时的报错。
第五个实验把第七节那条「少写一个字母」的坑演出来:profile 的名字要在激活项和文件名两处同时对上,缺一处就退回通用配置,而启动照样成功。
第六个实验回答一个更前置的问题:配置到底在启动的哪一步被读进来?答案是比容器刷新更早——第八节那句「占位符解析发生在绑定之前」、第十三节那段 ConfigProbe 为什么能在 @PostConstruct 里就读到终值,根都在这里:
第七个实验把第七节的 @Profile 放回它本来的位置——条件装配。落选的配置类不会报错,只会在报告里留下一行判决,和第十九篇那套机制一模一样:
第八个实验专治本节两个「找不到 Bean」的混淆:属性类声明了 @ConfigurationProperties 却没人让它进场,和它压根不在扫描范围里,报错措辞几乎相同。四条门各点一次,再用 miss 对比:
第九个实验回答「@Value 凭什么不用配任何东西就能用」:那个负责解析占位符的 PropertySourcesPlaceholderConfigurer 本身就是一条自动配置的产物,它在候选清单里过的是哪几问:
实验按完,换成命令行自己敲。这台控制台连着浏览器里的同一个内核,回显全部由内核算出来:
cond configLoaded false 之后必须 restart 才会重建容器——开关只改配置,不重刷 Environment 你看不到任何变化。这一串动作就是第十节那张对照表的现场版。
最终 server.port = 8080理由:只剩包内通用文件这一层有值它也是本项目 application.yml 里一直写的那个默认值
前几节讲了 @Value 和 @ConfigurationProperties,但工程里其实有四条路能把配置取到,取舍点在两个维度上:类型安全(拿到的是 int/Duration 还是一串字符串要自己转)与批量程度(一次取一个 key,还是一次取一整棵前缀)。

| 取法 | 怎么写 | 松散绑定 | 批量 | 校验 | 什么时候用 |
|---|---|---|---|---|---|
@Value("${sms.endpoint}") | 单字段注解 | ❌ 严格按字面量 | 一个 key | ❌ | 就一两个零散值;需要 SpEL |
@ConfigurationProperties(prefix = "sms") | 属性类 | ✅ | 整棵前缀 | ✅ 配 @Validated | 成组业务配置的首选 |
Environment env; env.getProperty("sms.endpoint") | 注入容器对象后手工取 | ❌ | 任意 key,运行时才决定 | ❌ | 需要在运行时按名字动态取、或遍历所有源 |
System.getProperty("...") / System.getenv("...") | JDK 原生,绕过 Spring | ❌ | 无 | ❌ | 只在框架初始化之前(如日志配置)不得已时用 |
@Componentpublic class ConfigProbe { private final Environment env; public ConfigProbe(Environment env) { // Environment 是容器持有的「配置总入口」 this.env = env; } @PostConstruct public void report() { // 1) 单个值:按优先级问一圈,第一个命中的赢 System.out.println("server.port = " + env.getProperty("server.port")); // 2) 自证 profile 到底生效了没有——排查配置问题的第一行代码 System.out.println("active profiles = " + Arrays.toString(env.getActiveProfiles())); // 3) 看清一共有多少层配置源,以及它们的先后顺序 ((AbstractEnvironment) env).getPropertySources() .forEach(ps -> System.out.println("source: " + ps.getName())); }}坑:System.getProperty("server.port") 在 Spring Boot 里大概率返回 null。它读的是 JVM 自己的系统属性表,只有 -Dserver.port=9090 这种写法才会进去;写在 application.yml 里的值根本不在那张表上。新手把两者当成一回事,就会得出「配置明明写了却读不到」的错误结论——要用 Environment 或注解,别用 System.getProperty。同理 System.getenv("SERVER_PORT") 只能读真正的操作系统环境变量,读不到 yml。
决策:同一个配置项,团队里三种写法都有人用(@Value、属性类、直接 env.getProperty),要不要统一?
- 统一成 @Value,最直观、改动最小
- 业务配置统一进 @ConfigurationProperties 属性类,只有框架级零散值保留 @Value
- 全项目禁止注解注入配置,一律手工 Environment.getProperty,方便「看到真相」
结论:B。属性类能被 @Validated 校验、能被 IDE 补全、能单元测试(new 出来 set 几个值就行),这三条 Environment 手工取都拿不到;而 A 的问题在于 @Value 不参与松散绑定,一旦某一项要靠环境变量覆盖就会静默失败(第四节的对照表就是这件事的现场);C 看起来「透明」,实则把类型转换、默认值、校验全部推回手写,代码量反而最多。留一个例外:确需在运行时按变量名取 key(例如多租户按 tenantId 取不同配置)时,Environment 是唯一选择。
类比:这四种取法像问价格的四个渠道。@ConfigurationProperties 是把整张价目表拍照存进手机(一次拿全、还能核对有没有缺项);@Value 是记住某一个菜的名字去问(说错一个字就没这道菜);Environment 是走到前台随时问任意一项(灵活,但每次都得自己听清、自己换算单位);System.getProperty 则是翻自己口袋里的旧收据——店里现在的价格它一概不知道。
要点:判断标准只有一句——这项配置是不是「一组」且「需要约束」。是,就上属性类 + @Validated;只是零星一个值,@Value 足够;要在运行时决定取哪个 key,才动用 Environment。
配置体系的骨架是三层——载体(properties / yml / yaml,最终都成为 PropertySource)、优先级(命令行 > 环境变量 > 外部文件 > 包内文件 > 默认值,同名高者胜)、绑定(@Value 管单值,@ConfigurationProperties 管成组、支持松散绑定与校验)。多环境靠 Profile:@Profile 控制 Bean,application-{profile}.yml 控制配置,激活方式四种任选。两条铁律收尾:@ConfigurationProperties 一定要配 @Validated 与校验实现,配置错了启动就失败;敏感信息靠环境变量注入,让密码离开代码库。