配置体系全解:properties、YAML、绑定与多环境 Profile

bee2026-10-0861 分钟0 次阅读
配置文件加载顺序、@ConfigurationProperties 的松散绑定与校验、Profile 多环境切换、配置加密与外部化——工程里关于配置的所有问题一次讲清。
1 / 124
小节
〇、30 秒看懂
2 / 124

一个 Spring Boot 程序启动时,会同时从很多个地方读配置:jar 包里躺着的 application.yml、jar 旁边你新放的一份、服务器上的环境变量、启动命令里的 -D 参数、最后还有敲在命令行末尾的 --server.port=9090。同一个 key(比如 server.port)如果好几处都写了,Spring Boot 不会报错,也不会「合并」——它按一套固定的优先级顺序去问,第一个给出答案的那一处胜出。本篇讲的就是四件事:配置能放在哪、谁盖住谁、怎么把配置读到 Java 字段里(绑定)、以及怎么用 Profile 让同一份代码在开发/测试/生产各用一套值。

3 / 124
类比

配置优先级像规则层级。你家的家规(jar 包里的 application.yml)管日常;公司的规定(服务器上外部 config/ 与环境变量)比家规大;国家的法律(命令行参数)谁都得听。同一条「几点到家」的规定,越具体、越贴近当下场景的那条赢——所以你在启动命令里写了 --server.port=9090,改文件就白改,因为「法律」压过「家规」。

4 / 124
类比

Profile 像同一套房子换三套家具布置。毛坯房(application.yml)是公共骨架——水电、户型不动;居家模式(application-dev.yml)铺地毯、开暖光;出租模式(application-prod.yml)换耐用家具、锁好储物间。房间结构完全一样,换的只是「这一档要用的一套配置」。所谓激活 prod,就是把这套房子切到出租布置。

5 / 124

本篇地图:

6 / 124
text
配置体系├── 载体        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
7 / 124

学完这一篇,你应该能回答三个问题:

8 / 124
  • 我改了 jar 旁边的 application.yml,为什么服务重启后端口还是老样子?被哪一层盖住了?
  • @Value("${bee.max-size}") 和 @ConfigurationProperties(prefix = "bee"),同一个环境变量能不能生效?为什么两者答案不同?
  • 上线时 spring.profiles.active 的名字少写一个字母,会发生什么?我怎么自己证明 profile 真的生效了?
9 / 124
小节
一、三种配置载体:properties、yml、yaml
10 / 124

Spring Boot 允许你用三种文件承载配置,它们最终都会被解析成同一份 PropertySource。选择哪一种更多是团队习惯问题,但差异是真实存在的:

11 / 124
对照表
载体可读性层级表达列表表达适合
application.properties平铺,长列表好读靠 a.b.c= 点号,层级越深越啰嗦list[0]=x 较别扭简单、少量配置
application.yml缩进即层级,视觉清晰原生树形- x 一目了然主流选择
application.yaml与 yml 完全等价同上同上只影响文件名
12 / 124

同样的一段配置,两种写法对照:

13 / 124
properties
# application.propertiessms.endpoint=https://sms.example.com/apisms.sign-name=某某科技sms.retry.attempts=3sms.retry.enabled=true
14 / 124
代码对照
代码yaml
# 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 会被当成一个普通字符串。

15 / 124
小节
二、配置加载顺序:同名配置谁赢
16 / 124

这是配置体系里最值得吃透的一张表。Spring Boot 会从多个位置收集配置,当同一个 key 出现在多处时,优先级高的那层胜出(注意:不是「后者覆盖前者」,而是「按优先级取第一个命中的」):

17 / 124
架构图
图 1 · 配置的优先级金字塔
图 1 · 配置的优先级金字塔
18 / 124
对照表
优先级配置来源说明
1命令行参数java -jar app.jar --server.port=9090,最高优先级
2SPRING_APPLICATION_JSON环境变量里塞一段 JSON
3ServletConfig / ServletContext 初始化参数Web 容器级
4JNDI(java:comp/env)传统应用服务器
5Java 系统属性System.getProperties(),即 -Dkey=value
6操作系统环境变量SERVER_PORT=9090
7random.*随机值属性源
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(...)
19 / 124
  • 命令行 > 环境变量 > 外部文件 > 包内文件 > 默认值,这就是记忆主线
  • 「外部」指 jar 包所在目录或其下的 config/,用于不改包、不重新打包地覆盖配置
  • profile 专属文件(application-prod.yml)优先级高于通用文件(application.yml),且都在包内/包外之后
20 / 124
提示

这解释了运维的常见问题——「我明明改了容器里的 application.yml,怎么没生效?」如果启动命令里带了 --server.port=8081,命令行那层永远赢,改文件白改。排查配置问题的第一步,永远是先问「是不是被更高优先级盖住了」。

21 / 124

上面那张表有十四行,但真要排查时你只会走到其中三四层。把这条链摊成能点的路径,一格一格问下去——注意第 ③ 格那句「不是合并,是先被问到」,它是本节唯一的考点:

22 / 124
交互图解
流程优先级这条链:一格一格问下去1 / 6
从 ① 点到 ⑥,同一个 key 只有一个赢家;记住「先被问到」而不是「后写的赢」
→
→
→
→
→
① 命令行参数
`java -jar app.jar --server.port=9090` 写在进程启动的那一刻,位置最靠前,也最容易被人忘记——K8s 的 args、启动脚本、CI 的命令行都算这一层。排查「改了文件没生效」时,第一个要打开的就是启动命令。
全部看懂了一句话:优先级是「谁先被问到」,不是「谁后写」——同名 key 只认第一个答复。
23 / 124
原理动画
动图 · 启动时配置如何找到自己的位置
动图 · 启动时配置如何找到自己的位置
24 / 124
小节
三、@Value 与 @ConfigurationProperties:到底用哪个
25 / 124

两个注解都能把配置读进 Java,但深浅差很多:

26 / 124
对照表
维度@Value@ConfigurationProperties
绑定方式单键精确匹配松散绑定、批量绑定整棵前缀
复杂类型需 SpEL 手工处理List / Map / 嵌套对象 / Duration 原生支持
校验不支持配合 @Validated 支持
SpEL支持(本质是表达式)不支持(要 SpEL 就用 @Value)
刷新@RefreshScope 可刷新需配合刷新机制
适合零散单值(如 @Value("${app.name}"))成组的业务配置(强烈推荐)
27 / 124
java
// 场景一:只是取一个零散的值,@Value 更直接@Componentpublic class HealthController {    @Value("${app.version:unknown}")   // 冒号后是「找不到时的默认值」    private String version;}
28 / 124
代码对照
代码java
// 场景二:成组配置,一律用 @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
29 / 124
小节
四、松散绑定全解
30 / 124

所谓「松散绑定(Relaxed Binding)」,是指配置项的书写形式可以有多种,但都能绑到同一个 Java 字段上。这解决了「yml 用短横线、常量用下划线、字段用驼峰」三套命名习惯的冲突。

31 / 124
架构图
图 2 · @Value 与 @ConfigurationProperties 走的是两条路
图 2 · @Value 与 @ConfigurationProperties 走的是两条路
32 / 124

上图要表达的核心对比是:@Value("${sms.template-id}") 走的是字面量精确匹配——你写 sms.templateId 它就不认;而 @ConfigurationProperties(prefix = "sms") 走的是归一化后比较,把配置名与字段名都剥掉分隔符、转小写再比,所以下面五种写法全部命中同一个字段:

33 / 124
对照表
配置写法能否绑定到字段 templateId常见场景
sms.template-id✅yml(推荐)
sms.templateId✅properties
sms.templateid✅无所谓大小写
SMS_TEMPLATE_ID✅环境变量(最推荐)
sms.template_id✅properties 里手滑
34 / 124
代码对照
代码java
@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 里写短横线,环境变量里写下划线,都能对。

35 / 124
小节
五、复杂类型绑定:List、Map、嵌套对象、Duration、DataSize
36 / 124

@ConfigurationProperties 的真正威力在复杂类型上,这是 @Value 很难做到的:

37 / 124
yaml
# 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
38 / 124
代码对照
代码java
@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.DataSize
  • Duration:自动解析 2s、500ms、PT2S 等写法;不写单位会被当成毫秒,务必带单位

坑:Duration 与 DataSize 在没有单位时行为不同——read: 500 会被当作毫秒,而 max-file-size: 10 默认是字节。这类「数字没单位」的配置在跨环境时最容易出问题,约定是永远带单位,既省心又能自解释。

39 / 124
小节
六、配置校验:@Validated 是那把钥匙
40 / 124

配置写错时,最理想的状态是启动就失败、并指出是哪个字段。这需要三件套:@Validated + 校验注解 + 校验实现:

41 / 124
java
@Validated                                   // ① 必须加,否则校验不触发@ConfigurationProperties(prefix = "sms")public class SmsProperties {    @NotBlank                                // ② 非空且非空白    private String endpoint;    @Min(500) @Max(10000)    private int timeout = 3000;    @Email    private String alertEmail;}
42 / 124
xml
<!-- ③ 引入校验实现(Spring Boot 2.3+ 需显式引入) --><dependency>    <groupId>org.springframework.boot</groupId>    <artifactId>spring-boot-starter-validation</artifactId></dependency>
43 / 124

配置不合法时,启动直接报错,且信息精准到字段:

44 / 124
代码对照
代码text
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 必须在合理区间——这些约束写进属性类,等于给整个团队立了规矩,配置错了在启动期就拦下来,而不是等到半夜短信发不出去才发现。

45 / 124

三件套凑齐以后,属性类到底该长什么样、以及它配套的那段 yml 从哪来,勾一遍比抄一遍记得牢。特别注意最后一项「生成挂载方式」:同一个属性类,@EnableConfigurationProperties、@ConfigurationPropertiesScan、类上加 @Component 三条进容器的路,生成的代码完全不同:

46 / 124
生成器
生成器一个能自证清白的属性类Properties 类 + yml3 / 7
先只勾「加校验」和「生成配套 yml」,看 @Validated 与短横线字段怎么成对出现;再叠「嵌套对象」「Duration / DataSize」,对照第五节那两个「不带单位」的坑;最后切「生成挂载方式」,看 @EnableConfigurationProperties 与 @ConfigurationPropertiesScan 的区别——第十一节 beanin 实验跑的就是这四条门
产物
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:}   # 密钥从环境变量进来,别写进仓库
勾了这些,代价与理由在这里
加校验启动即失败,比运行到第一天中午才发现 timeout=0 好得多。
Duration / DataSize用 Duration 而不是 int:配置里能写 30s / 5m,单位错了启动就报错。
生成配套 application.yml 片段字段名转短横线:accessKeySecret → access-key-secret,这就是松散绑定。
松散绑定同一个属性可以有 access-key-secret / accessKeySecret / ACCESS_KEY_SECRET / access_key_secret 四种写法,Spring 都能绑上;但同一份配置里出现两种写法时,后者优先级更低的会被覆盖——统一短横线最省心。
47 / 124
小节
七、Profile 全解:一套代码,多套环境
48 / 124

Profile 是 Spring 应对多环境的官方方案:同一套代码,按激活的 profile 加载不同配置、装配不同 Bean。

49 / 124

先看 @Profile 注解的用法——它本质是条件装配的一个预设:

50 / 124
java
@Configuration@Profile("dev")public class DevMailConfig {    @Bean    public MailSender mailSender() {        return new ConsoleMailSender();   // 开发环境:邮件只打到控制台    }}
51 / 124
java
@Configuration@Profile("prod")public class ProdMailConfig {    @Bean    public MailSender mailSender() {        return new SmtpMailSender();      // 生产环境:真实 SMTP 发送    }}
52 / 124

更常见的做法是用多个 profile 专属配置文件,把差异收敛进文件而不是代码:

53 / 124
yaml
# application.yml —— 公共配置spring:  application:    name: demosms:  sign-name: 某某科技
54 / 124
yaml
# application-dev.yml —— 开发spring:  datasource:    url: jdbc:h2:mem:demo    username: sa    password: ""logging:  level:    com.example: debug
55 / 124
yaml
# application-prod.yml —— 生产spring:  datasource:    url: jdbc:mysql://db.internal:3306/demo    username: demo    password: ${DB_PASSWORD}     # 从环境变量注入,绝不写死logging:  level:    com.example: info
56 / 124

spring.profiles.active 有四种常见激活方式:

57 / 124
对照表
激活方式写法适用
配置文件spring.profiles.active: prod默认兜底
命令行参数--spring.profiles.active=prod临时覆盖(优先级最高)
环境变量SPRING_PROFILES_ACTIVE=prod容器 / CI 环境(推荐)
代码SpringApplication.setAdditionalProfiles("prod")编程式设置
58 / 124

Profile 表达式还支持逻辑组合:

59 / 124
代码对照
代码yaml
# 非 dev 环境都启用spring:  profiles:    active: "!dev"
解读
  • @Profile("dev"):只有 dev 激活时该配置类才生效;多个 profile 可写成数组 @Profile({"dev", "test"})(任一命中即可)
  • 否定与组合:@Profile("!prod") 表示「非 prod」,@Profile({"prod", "cloud"}) 是「或」关系,更复杂的组合用表达式
  • profile 专属文件自动随激活的 profile 加载,无需手动 import
60 / 124

「激活 prod」这四个字背后同时发生了两件事:一份文件被并进配置链、一批 Bean 的装配条件变绿。这段动画把两件事并排走了一遍,注意第 ⑥ 帧——@Profile 和配置文件其实吃的是同一个开关:

61 / 124
原理动画
动图 · 激活一个 Profile 到底触发了什么
动图 · 激活一个 Profile 到底触发了什么
62 / 124
坑

yml 与 properties 同时存在时,两者都会被加载,且 properties 优先。 如果项目里既有 application.yml 又有 application.properties,同名 key 会以 .properties 为准——很多人「改了 yml 没生效」,根因就是这里还躺着一个老的 .properties。另外 profile 文件名大小写敏感:application-Prod.yml 不会匹配 prod。

63 / 124

多环境这套配置到底该写哪几行,勾一遍最快:先只勾「分档配置」看 --- 与 spring.config.activate.on-profile 怎么配对;再叠上数据源与日志,对照本节那两个 application-dev.yml / application-prod.yml 的分工;最后加服务器项,看 server.port 为什么在生产 profile 里常常被环境变量盖掉(第十一节 lab prop order 就是这条链的现场)。

64 / 124
生成器
生成器把多环境该写的几行一次配全application.yml2 / 5
按「分档配置 → 数据源 → 日志 → 服务器」的顺序往上勾:每加一项,看它该落在通用文件还是 profile 专属文件——第七节的分工全在这份产物里
产物
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
勾了这些,代价与理由在这里
datasource池参数写在这里才生效;写在代码里 new HikariDataSource() 就白配了。
profiles + 分档配置多文档块用 --- 分隔,spring.config.activate.on-profile 指定生效条件。
65 / 124
小节
八、配置加密与敏感信息
66 / 124

生产环境的数据库密码、密钥绝不能明文躺在包里。工程上有两条主流路线:

67 / 124
  • Jasypt 一句话版:引入 jasypt-spring-boot-starter,用 ENC(密文) 写在配置里,启动时用密钥解密。适合「必须把密文放在配置文件里」的场景
  • 环境变量注入(首选):配置里只写占位符 ${DB_PASSWORD},真值由部署平台(K8s Secret、CI 变量)注入,密码根本不出现在代码库里
68 / 124
yaml
spring:  datasource:    password: ${DB_PASSWORD}   # 部署时注入,代码库中看不到真值
69 / 124

${DB_PASSWORD} 这种写法叫占位符解析:容器在绑定阶段会把它替换成真正的值,替换完的结果还会再走一遍优先级链——也就是说 DB_PASSWORD 这个名字本身也参与优先级排序。

70 / 124
原理动画
动图 · ${DB_PASSWORD} 是怎么被解析成真值的
动图 · ${DB_PASSWORD} 是怎么被解析成真值的
71 / 124

解析顺序有三条规则,记住就不会被「为什么 yml 里的占位符读不到环境变量」问倒:

72 / 124
  • 先去优先级链上找同名 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}",搜前半句就能搜到
73 / 124
坑

占位符不能自己引自己。password: ${DB_PASSWORD:${DB_PASSWORD:123456}} 这种「兜底再兜底」的写法虽然语法合法,但一旦第一层真的取到了值就没事,取不到时错误信息会嵌套成一长串难以定位的 ${...}。兜底最多一层,并且要把默认值写清楚。

74 / 124
要点

占位符解析发生在绑定之前,所以它既能取环境变量(${DB_PASSWORD}),也能取别的配置项(${server.port})、甚至取系统属性。正因如此,password: ${DB_PASSWORD} 与 password: ${spring.datasource.password} 写在同一份 yml 里毫无区别——但在排查问题时,前者要去看环境变量,后者要回到 yml 内部去找引用链。

75 / 124

「绑定之前」这四个字决定了报错会长成什么样。把它摊成一次单步执行:左边六行是启动过程,右边同步刷新此刻的变量和调用栈。连点下一步,重点停第 ④ 拍——那一拍问的是属性名,不是操作系统的变量名:

76 / 124
单步调试台
单步台逐行走一遍:${DB_PASSWORD:guest} 是怎么被问出来的1 / 6
六拍。盯右侧「此刻在问哪个源」与「最终值」两格;第 ④ 拍问不到才会有第 ⑥ 拍那个默认值
被调试的代码
1// application.yml 里这一行:password: ${DB_PASSWORD:guest}
2// SpringApplication.run 第一件事:把六层配置源排好队放进 Environment
3// PropertySourcesPlaceholderConfigurer 开始遍历所有 ${...}
4// 沿队列逐个源问:你这里有没有一个叫 DB_PASSWORD 的属性
5// 命中就替换;一路问到底都没有 → 取冒号后面的 guest
6// 替换完的字符串才交给 Binder 做松散绑定与类型转换
此刻的变量
这一行原文password: ${DB_PASSWORD:guest}
此刻它还是一整串字符
被谁读过还没有
调用栈
1classpath:/application.yml
1配置文件里这行此刻什么都不是——冒号后面那半截也只是字符串的一部分。占位符是被「解析」出来的,不是被「读」出来的,这一句解释了为什么它报错的时机比你预期的早。
77 / 124
要点

能靠环境变量就别用配置加密。 加密只是把「明文」换成「需要一把钥匙的密文」,钥匙本身还是要有地方放;而环境变量注入让敏感信息彻底离开代码库,配合 K8s Secret、Vault 这类平台最干净。Jasypt 更适合无法改造部署流程的老系统。

78 / 124

上面第 ④ 拍那格,是线上出现频率最高的一种启动失败。这段堆栈练的就是它——先别看解析,点出你认为的凶手行:

79 / 124
报错急救
报错急救IllegalArgumentException: Could not resolve placeholder 'DB_PASSWORD'
本地好好的,prod 一上线就起不来:一个查不到名字的占位符

开发机用 IDE 的运行配置带着 DB_PASSWORD 跑通了;交到 K8s 之后 Secret 注入的是 SPRING_DATASOURCE_PASSWORD,启动第一秒就失败,日志里一个业务栈帧都没有。

APPLICATION FAILED TO START
org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'smsClient' defined in class path resource [com/example/sms/autoconfigure/SmsAutoConfiguration.class]: Invocation of init method failed; nested exception is org.springframework.beans.factory.BeanCreationException: Could not resolve placeholder 'DB_PASSWORD' in value "${DB_PASSWORD}"
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.initializeBean(AbstractAutowireCapableBeanFactory.java:1786)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:600)
at org.springframework.context.support.PropertySourcesPlaceholderConfigurer.processProperties(PropertySourcesPlaceholderConfigurer.java:188)
at org.springframework.util.PropertyPlaceholderHelper.parseStringValue(PropertyPlaceholderHelper.java:180)
at com.example.notice.NoticeService.<init>(NoticeService.java:21)
Caused by: java.lang.IllegalArgumentException: Could not resolve placeholder 'DB_PASSWORD' in value "${DB_PASSWORD}"
Environment in use (6 property sources, highest first):
commandLineArgs, systemProperties, systemEnvironment, config/application-prod.yml, classpath:/application.yml, classpath:/application.properties
点你认为的「凶手行」(可反复试)
不会也没关系:先猜异常名,再猜哪一行在做决定。
80 / 124
小节
九、@RefreshScope 与动态刷新(预告)
81 / 124

上面的配置都是「启动时读一次,之后不再变」。在 Spring Cloud 场景里,有时需要不重启就更新配置——这时用到 @RefreshScope:它把 Bean 包成代理,配置变更时销毁并重建 Bean,从而读到新值。

82 / 124
代码对照
代码java
@RefreshScope@Component@ConfigurationProperties(prefix = "sms")public class SmsProperties {    // 配置中心推送变更后,此处会拿到新值}
解读
  • @RefreshScope 会让 Bean 变成懒加载的代理,刷新时才重建
  • 它属于 Spring Cloud 的范畴,单体应用基本用不到;这里只需知道「配置动态刷新靠它」即可,后续 Spring Cloud 篇会展开
83 / 124
小节
十、两个高频坑
84 / 124
对照表
现象根因解决
改了 yml 不生效同目录还有 application.properties,且它优先删掉或统一到一种载体
profile 没切换成功文件名大小写不对(application-Prod.yml)/ 激活项写错用小写 application-prod.yml,核对 spring.profiles.active
85 / 124
坑

yml 与 properties 同时存在会都加载,且 .properties 优先。 这不是「合并成一份」,而是两个独立的 PropertySource 按优先级排序,properties 排在前面。清理配置时的第一步,就是全局搜一遍项目里到底有几个 application.*。

86 / 124
坑

Profile 文件名大小写敏感。 application-dev.yml 能被 dev 命中,application-Dev.yml 不行;而 spring.profiles.active=Dev 与 dev 也是两个不同的 profile。约定是一律小写,把大写的可能性彻底排除。

87 / 124

配置这一章的报错有个共同脾气:消息里从来不写真正的成因。mapping values are not allowed here 不会提 Tab,Could not resolve placeholder 不会提「名字对不上」。所以与其背,不如点一次——左边是原文,右边是它真正在说什么:

88 / 124
配对闯关
闯关报错原文配真实成因已配对 0/6 · 配错 0
六条都是能整段粘进搜索框的原文;右列是它真正指的那件事,配错会当场解释
先点左边一个
89 / 124
小节
十一、动手体验:配置是否读到,Bean 结果不一样
90 / 124

这一章有九个内核实验,按顺序走一遍就够:先 ① 看「配置读到与否」如何改变 Bean,再 ②③④⑤ 把优先级、松散绑定、占位符、Profile 四件事各自按出来,接着 ⑥ 往上追到启动主线里配置究竟在哪一步就位,最后 ⑦⑧⑨ 把它接回条件装配、Bean 进场与自动配置这三条底层机制。

91 / 124

第一个演示把「配置已读取」做成开关。切换它,观察 DataSource 连接池参数(最大连接数、超时)如何随配置变化:

92 / 124
内核实验
93 / 124

第二个实验专治「我改了配置怎么没生效」:把同一个 server.port 分别写进命令行、环境变量和包内文件,看优先级链怎么一层层问下来。

94 / 124
内核实验
TeaVM同名配置谁赢:优先级链现场问一圈未启动
切到 order,逐层比较同一个 key 在命令行、环境变量、包内文件里的取值
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
95 / 124

第三个实验把第四节那张表变成可点的:切换配置项的书写形式,看它能不能绑到 templateId 字段上。

96 / 124
内核实验
TeaVM松散绑定:五种写法撞同一个字段未启动
切到 relaxed,把 sms.template-id / templateId / SMS_TEMPLATE_ID 逐个试一遍
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
97 / 124

第四个实验演示占位符的解析顺序,包括默认值的兜底与找不到时的报错。

98 / 124
内核实验
TeaVM占位符解析:${DB_PASSWORD} 是怎么被替换的未启动
切到 ph,看有值、无值、写了默认值三种情况下占位符各自的结局
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
99 / 124

第五个实验把第七节那条「少写一个字母」的坑演出来:profile 的名字要在激活项和文件名两处同时对上,缺一处就退回通用配置,而启动照样成功。

100 / 124
内核实验
TeaVMProfile 激活:四种写法与两处必须同名的地方未启动
切到 profile,逐条试命令行、环境变量、yml 与代码写法;注意 application-Prod.yml 那一格为什么安静地什么也不做
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
101 / 124

第六个实验回答一个更前置的问题:配置到底在启动的哪一步被读进来?答案是比容器刷新更早——第八节那句「占位符解析发生在绑定之前」、第十三节那段 ConfigProbe 为什么能在 @PostConstruct 里就读到终值,根都在这里:

102 / 124
内核实验
TeaVM八步启动主线里,环境是在第几步准备好的未启动
按顺序走八步,盯住 prepareEnvironment 与 refreshContext 的先后;再切 fail 看「配置没读到」在启动日志里长什么样
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
103 / 124

第七个实验把第七节的 @Profile 放回它本来的位置——条件装配。落选的配置类不会报错,只会在报告里留下一行判决,和第十九篇那套机制一模一样:

104 / 124
内核实验
TeaVM@Profile 也是条件:在评估报告里看它怎么落选未启动
看清 DevMailConfig 与 ProdMailConfig 各自落在 Positive 还是 Negative,以及那行判决里写的条件类名
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
105 / 124

第八个实验专治本节两个「找不到 Bean」的混淆:属性类声明了 @ConfigurationProperties 却没人让它进场,和它压根不在扫描范围里,报错措辞几乎相同。四条门各点一次,再用 miss 对比:

106 / 124
内核实验
TeaVM属性类走哪条门进容器:@Component、@EnableConfigurationProperties 还是 Scan未启动
依次点 scan / bean / import / auto,看清三种挂载方式各自把定义写进注册表的时机;最后点 miss 对照报错
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
107 / 124

第九个实验回答「@Value 凭什么不用配任何东西就能用」:那个负责解析占位符的 PropertySourcesPlaceholderConfigurer 本身就是一条自动配置的产物,它在候选清单里过的是哪几问:

108 / 124
内核实验
TeaVM@Value 开箱可用的背后:那份候选清单与它的过滤站未启动
先看候选清单怎么来,再点 filter 看这条自动配置过的是哪几问;对照第十三节那张四种取法的表
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
109 / 124

实验按完,换成命令行自己敲。这台控制台连着浏览器里的同一个内核,回显全部由内核算出来:

110 / 124
内核控制台
111 / 124
说明

cond configLoaded false 之后必须 restart 才会重建容器——开关只改配置,不重刷 Environment 你看不到任何变化。这一串动作就是第十节那张对照表的现场版。

112 / 124
小节
沙盘:同一个 key,五处配置,谁能赢
113 / 124
沙盘
沙盘同一个 key,五处配置,谁能赢
运行结果
最终 server.port = 8080
理由:只剩包内通用文件这一层有值
它也是本项目 application.yml 里一直写的那个默认值
什么高优先级的东西都没有时,值就来自包内的 application.yml。多数「端口莫名变成 8080」都是这个结局。
114 / 124
小节
自测
115 / 124
随堂自测
随堂自测`application.yml` 里写着 `server.port: 8080`,启动命令是 `java -jar app.jar --server.port=9090`。服务实际监听哪个端口?
先自己选一个,选中立刻告诉你对不对
116 / 124
随堂自测
随堂自测`@Value("${sms.template-id}")` 取不到值,但同样的 key 写成 `SMS_TEMPLATE_ID` 环境变量后,属性类 `SmsProperties` 上的 `templateId` 字段能绑上。问题出在哪?
先自己选一个,选中立刻告诉你对不对
117 / 124
小节
十二、决策:生产环境的数据库密码放哪
118 / 124
决策
决策生产环境的数据库密码,应该放在哪里?
119 / 124
小节
十三、四种把配置读出来的方式(以及一个陷阱)
120 / 124

前几节讲了 @Value 和 @ConfigurationProperties,但工程里其实有四条路能把配置取到,取舍点在两个维度上:类型安全(拿到的是 int/Duration 还是一串字符串要自己转)与批量程度(一次取一个 key,还是一次取一整棵前缀)。

121 / 124
架构图
图 · 四种取配置方式的取舍象限
图 · 四种取配置方式的取舍象限
122 / 124
对照表
取法怎么写松散绑定批量校验什么时候用
@Value("${sms.endpoint}")单字段注解❌ 严格按字面量一个 key❌就一两个零散值;需要 SpEL
@ConfigurationProperties(prefix = "sms")属性类✅整棵前缀✅ 配 @Validated成组业务配置的首选
Environment env; env.getProperty("sms.endpoint")注入容器对象后手工取❌任意 key,运行时才决定❌需要在运行时按名字动态取、或遍历所有源
System.getProperty("...") / System.getenv("...")JDK 原生,绕过 Spring❌无❌只在框架初始化之前(如日志配置)不得已时用
123 / 124
代码对照
代码java
@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。

124 / 124
总结

配置体系的骨架是三层——载体(properties / yml / yaml,最终都成为 PropertySource)、优先级(命令行 > 环境变量 > 外部文件 > 包内文件 > 默认值,同名高者胜)、绑定(@Value 管单值,@ConfigurationProperties 管成组、支持松散绑定与校验)。多环境靠 Profile:@Profile 控制 Bean,application-{profile}.yml 控制配置,激活方式四种任选。两条铁律收尾:@ConfigurationProperties 一定要配 @Validated 与校验实现,配置错了启动就失败;敏感信息靠环境变量注入,让密码离开代码库。