Skip to content

Spring Boot 配置管理与多环境 ​

S3-025必学进阶约 60 分钟JDK 21企业开发基础 · Spring Boot

1. 本节定位 ​

《Spring Boot 自动配置与项目结构》 讲了"自动配置怎么生效",本节讲**"配置从哪来、怎么改、怎么按环境切换"**。

一句话概括本节的取舍:

text
同一份构建产物(jar),不重新打包,换一套配置就能在开发 / 测试 / 生产运行。

这也是"配置外置"这件事的全部价值。

2. 学习目标 ​

完成本节后,你应该能够:

  • 说出 Spring Boot 配置的四种来源与优先级,并能解释"为什么命令行最高";
  • 用 profile 做多环境隔离,并说出"什么该放 profile、什么不该";
  • 说出 @ConfigurationProperties 相对 @Value 的三个优势;
  • 说出敏感配置(密码、密钥)为什么不能写进 application.yml,以及正确的做法;
  • 用 Environment 读出"合并后的最终值"(而不是去读原始文件);
  • 说出"按 profile 写业务分支"为什么是反模式。

3. 前置知识检查 ​

4. 为什么需要这个知识 ​

先看"把配置写死在代码里"会发生什么:

java
private static final String DB_URL = "jdbc:mysql://localhost:3306/shop";   // ✗
private static final String DB_PASSWORD = "123456";                        // ✗ 更糟
问题后果
换环境要改代码开发/测试/生产的地址不同 → 要改代码、重新构建、重新测试 ✗
密码进 Git一次提交,永久留痕——即使后来删掉,历史里还在 ✗
改一个值要重新打包运维无法自己调整,必须等发版
没有"同构建"保证生产跑的不是测试过的那份产物 ✗

正确做法的一句话:

代码只描述"逻辑",配置描述"环境差异"。

打包一次,用不同的配置启动——这样"测试过的产物"和"上线的产物"是同一个。 ✓

5. 核心概念 ​

5.1 配置的四种来源与优先级(本节重点) ​

按优先级从高到低(实测输出的顺序):

text
1. 命令行参数               --server.port=19000
2. 环境变量                 SERVER_PORT=19000(大写下划线)
3. profile 专属配置文件      application-dev.yml / application-prod.yml
4. 主配置文件               application.yml
5. 代码里的默认值            (最低)

本节实测:把"来源清单"打印出来(真实运行输出):

text
配置来源(按优先级从高到低):
  - commandLineArgs
  - systemEnvironment
  - Config resource 'class path resource [application-dev.yml]' via location 'optional:classpath:/'
  - Config resource 'class path resource [application.yml]' via location 'optional:classpath:/'
  - applicationInfo

三处值得注意:

观察含义
commandLineArgs 排第一命令行是最后生效的——所以"临时改一个值"不用改文件 ✓
systemEnvironment 排第二环境变量高于任何配置文件 ✓ 这是"密码走环境变量"的依据
application-dev.yml 排在 application.yml 之前profile 专属配置优先级更高——这就是它"覆盖主配置"的机制

⚠️ 一个容易记反的点:getPropertySources() 的迭代顺序是**"高优先级在前"**(不是"低在前")✗

怎么记住:"越靠后加进来的来源,优先级越高"——命令行参数是启动时最后解析的,所以它最高 ✓

验证优先级的三种方式(本项目都实测过):

powershell
# ① 配置文件里写的是 18080,命令行改成 19000 —— 生效的是 19000
java -jar app.jar --server.port=19000

# ② 环境变量(PowerShell 语法)—— 覆盖配置文件
$env:SERVER_PORT = "19001"; java -jar app.jar

# ③ 同时给:命令行 > 环境变量
$env:SERVER_PORT = "19001"; java -jar app.jar --server.port=19002     # 生效 19002

Spring 的"松散绑定"(relaxed binding):同一个配置项有多种写法:

写法用在哪
server.portyml / properties / 命令行前缀
SERVER_PORT环境变量(大写下划线)
serverPort某些场景(如 @Value 里)

这条规则的实际价值:app.page-size-limit 这个配置,在环境变量里要写成 APP_PAGESIZELIMIT 或 APP_PAGE_SIZE_LIMIT ✓(去掉点号和横线,全大写)

5.2 profile:多环境隔离 ​

三个文件的分工:

text
application.yml            公共配置(所有环境都一样的部分)
application-dev.yml        开发环境(日志详细、功能开关打开)
application-prod.yml       生产环境(日志收敛、开关保守)

实测:同一份 jar,三种启动方式的差异

启动方式page-size-limitnew-checkout日志级别
默认(不指定 profile)100falseinfo
--spring.profiles.active=dev20truedebug
--spring.profiles.active=prod100falseinfo

读这张表要看出三件事:

观察含义
默认与 prod 一致因为 application.yml 的默认值就是保守值 ✓ 这是"默认安全"的设计
dev 覆盖了两项profile 文件只写差异,不重复主配置 ✓
同类配置分级日志级别这种"环境差异明显的"放 profile;app.name 这种"哪都一样"的放主配置 ✓

profile 的三条使用规则:

规则说明
profile 文件只写差异与主配置重复的内容会造成"改一处漏一处" ✗
激活方式优先级(高到低):命令行 > 环境变量 SPRING_PROFILES_ACTIVE > 主配置里的 spring.profiles.active与 5.1 节一致
可以同时激活多个 profile--spring.profiles.active=dev,local(后者优先级更高)

⚠️ profile 的边界(重要):

该放 profile不该放 profile
日志级别、连接串、开关业务逻辑分支(if (profile == "prod") { ... })✗
与部署环境相关的参数密钥明文
功能开关的默认值与业务无关的技术细节(如线程池大小可以,但"某种支付方式"的差异不该是 profile)

为什么"按 profile 走不同业务逻辑"是反模式:

java
if (environment.acceptsProfiles(Profiles.of("prod"))) {
    doProductionLogic();          // ✗
} else {
    doTestLogic();
}

后果:测试环境走的分支与生产不同——"测试通过"对生产没有任何保证 ✗ ✗

业务逻辑只应该有一份;环境差异只体现在"配置"上。

功能开关(Feature Flag)不是"环境分支"——它是"同一套逻辑的可配置参数"(本节的 new-checkout 就是这么用的:代码里判断的是配置值,不是 profile)✓

5.3 @ConfigurationProperties:类型安全的配置绑定 ​

三种读配置的方式对比:

java
// ① @Value:散落式
@Value("${app.name}")
private String appName;

// ② Environment:运行时查(灵活但无类型)
String name = environment.getProperty("app.name");

// ③ @ConfigurationProperties:集中、类型安全(推荐)
@ConfigurationProperties(prefix = "app")
public record AppProperties(String name, int pageSizeLimit, Feature feature) {
    public record Feature(boolean newCheckout) { }
}
维度@ValueEnvironment@ConfigurationProperties
类型安全⚠️ 弱❌ 全是 String✅ 强
集中管理❌ 散落在各处⚠️ 字符串 key✅ 一个类
嵌套结构❌ 难❌ 难✅ record 嵌套
IDE 支持⚠️❌✅ 能补全、能跳转
校验❌❌✅ 配合 @Validated
配置写错时用到才炸用到才炸✅ 启动就失败

最后一行是它的核心竞争力:

配置错误应该在启动时暴露,而不是在第一个用户访问时暴露。

启用方式(本项目用的):

java
@SpringBootApplication
@ConfigurationPropertiesScan          // 扫描 @ConfigurationProperties 类
public class WebApplication { ... }

另一种方式:在配置类上 @EnableConfigurationProperties(AppProperties.class) ✓

加校验(配置写错了直接启动失败):

java
@Validated
@ConfigurationProperties(prefix = "app")
public record AppProperties(
        @NotBlank String name,
        @Min(1) @Max(1000) int pageSizeLimit,
        @NotNull Feature feature) {
    public record Feature(boolean newCheckout) { }
}

注意:这里用的是 《参数校验与全局异常处理》 学的校验注解 ✓ 只不过触发点从"HTTP 请求"变成了"应用启动" ✓

有了校验之后:page-size-limit: 0 会让应用启动失败并给出清晰提示 ✓ 而不是等到某个分页请求时算出奇怪的结果 ✗

5.4 敏感配置:为什么不能写进 application.yml ​

先明确一件事:application.yml 会被打进 jar ✓ 而且在 Git 里 ✗

yaml
spring:
  datasource:
    password: 123456            # ✗ 三重暴露:代码库、构建产物、镜像

正确做法:

yaml
spring:
  datasource:
    password: ${DB_PASSWORD}    # 从环境变量读;没有就启动失败(而不是用默认值悄悄跑起来)

三种来源的选择:

方式适用说明
环境变量绝大多数场景容器/K8s 的原生方式 ✓
启动参数(--spring.datasource.password=...)临时调试⚠️ 会出现在进程列表里(ps -ef 能看到)✗
密钥管理服务(Vault、KMS、云厂商的 Secrets Manager)生产推荐有审计、能轮换 ✓

⚠️ 两个容易被忽略的点:

点说明
环境变量也可能泄漏崩溃转储(heap dump)、容器 inspect、CI 日志——别在日志里打印它 ✗
默认值是个陷阱${DB_PASSWORD:123456} ⚠️ 配错了会用默认密码悄悄跑起来——生产环境宁可"启动失败" ✓

本项目的一个实践(ConfigLessonRunner 里):

java
System.out.println("数据库密码(演示不落盘) = " + mask(environment.getProperty("DB_PASSWORD")));

private String mask(String value) {
    if (value == null || value.isBlank()) {
        return "(未设置)";
    }
    return "***已设置(长度 " + value.length() + ")***";     // 只证明"有值",不打印值
}

实测输出:

text
数据库密码(演示不落盘) = (未设置)

这条规则值得写进团队规范:

日志里永远不打印敏感配置的明文——需要确认"配没配上"时,打印长度或掩码后的形式 ✓

5.5 用 Environment 读"最终值" ​

Environment 已经帮你做完了"多来源合并" ✓

java
// ✓ 读到的就是"应用真正在用的值"(命令行、环境变量、profile 全都算进去了)
environment.getProperty("server.port");

// ✗ 直接读 application.yml —— 只能拿到文件里写的值,看不到命令行覆盖

实测的差异(同一个 server.port):

启动方式application.yml 里的值environment.getProperty 读到的
默认1808018080
--server.port=190001808019000 ✓

这也是 《Spring Boot 自动配置与项目结构》 里那条建议的展开:

不要绕过框架去读原始数据源,用它提供的抽象。

一个实用技巧:想排查"某个配置到底从哪来的",可以遍历 PropertySources(本节的 ConfigLessonRunner 就是这么做的)✓

java
environment.getPropertySources().forEach(source -> System.out.println(source.getName()));

注意顺序是"高优先级在前"(5.1 节)✓

6. 递进示例 ​

6.1 最小示例:一个配置项 ​

yaml
# application.yml
app:
  name: kb-shop
java
@ConfigurationProperties(prefix = "app")
public record AppProperties(String name) { }
java
System.out.println(appProperties.name());

6.2 业务示例:多环境 + 开关 ​

见第 7 节。

6.3 反例:业务逻辑按 profile 分支 ​

java
@Service
public class PaymentService {
    public void pay(Order order) {
        if (environment.acceptsProfiles(Profiles.of("prod"))) {
            realPaymentGateway.charge(order);           // ✗ 生产走真网关
        } else {
            fakePaymentGateway.charge(order);           // ✗ 其他环境走假的
        }
    }
}

三个问题:

问题说明
测试覆盖不到生产代码测试环境永远走 fakePaymentGateway 分支 ✗
分支会越来越多每多一个环境就多一个分支
发布风险上线的瞬间才第一次执行生产分支 ✗

更好的做法(配置驱动,而不是环境驱动):

java
// 让"用哪个支付实现"由配置决定 —— 但分支在"装配期"而不是"业务执行期"
public interface PaymentGateway { void charge(Order order); }

@Configuration
public class PaymentConfig {
    @Bean
    @ConditionalOnProperty(name = "payment.mode", havingValue = "real")
    public PaymentGateway realGateway() { return new RealPaymentGateway(); }

    @Bean
    @ConditionalOnProperty(name = "payment.mode", havingValue = "fake", matchIfMissing = true)
    public PaymentGateway fakeGateway() { return new FakePaymentGateway(); }
}

差别在哪:

profile 分支条件装配
分支位置每次调用都判断启动时决定一次
业务代码有 if-else ✗干净,只依赖接口 ✓
测试要造环境注入一个假的实现就行 ✓

这正是 《Spring Boot 自动配置与项目结构》 讲的"条件注解"在业务配置上的用法 ✓

7. 完整可运行代码 ​

文件清单(labs/spring-boot-web/):

text
src/main/resources/
├── application.yml                    公共配置
├── application-dev.yml                开发环境
└── application-prod.yml               生产环境

src/main/java/cn/kb/learn/web/
├── WebApplication.java                @ConfigurationPropertiesScan
├── config/AppProperties.java          配置绑定
└── ConfigLessonRunner.java            打印生效配置与来源

三个配置文件:

yaml
# application.yml(公共)
server:
  port: 18080
spring:
  application:
    name: spring-boot-web
app:
  name: kb-shop
  page-size-limit: 100
  feature:
    new-checkout: false
logging:
  level:
    cn.kb.learn.web: info
yaml
# application-dev.yml(只写差异)
app:
  feature:
    new-checkout: true
  page-size-limit: 20                # 开发时限制小一点,便于发现分页问题
logging:
  level:
    cn.kb.learn.web: debug
    org.springframework.web: info
yaml
# application-prod.yml
app:
  feature:
    new-checkout: false
  page-size-limit: 100
logging:
  level:
    cn.kb.learn.web: info
    root: warn                        # 生产环境收敛第三方库的日志

运行命令:

powershell
cd labs
./mvnw -pl spring-boot-web package -DskipTests

# 三种环境
java "-Dstdout.encoding=UTF-8" -jar spring-boot-web/target/spring-boot-web.jar
java "-Dstdout.encoding=UTF-8" -jar spring-boot-web/target/spring-boot-web.jar --spring.profiles.active=dev
java "-Dstdout.encoding=UTF-8" -jar spring-boot-web/target/spring-boot-web.jar --spring.profiles.active=prod

# 命令行覆盖配置
java "-Dstdout.encoding=UTF-8" -jar spring-boot-web/target/spring-boot-web.jar --server.port=19000

预期输出(真实运行结果):

text
===== 默认(不指定 profile)=====
激活的 profile:[]
server.port = 18080
  app.page-size-limit = 100
  app.feature.new-checkout = false
日志级别 = info

===== dev profile =====
激活的 profile:[dev]
server.port = 18080
  app.page-size-limit = 20
  app.feature.new-checkout = true
日志级别 = debug

===== prod profile =====
激活的 profile:[prod]
server.port = 18080
  app.page-size-limit = 100
  app.feature.new-checkout = false
日志级别 = info

配置来源清单(dev 下):

text
数据库密码(演示不落盘) = (未设置)
配置来源(按优先级从高到低):
  - commandLineArgs
  - systemEnvironment
  - Config resource 'class path resource [application-dev.yml]' via location 'optional:classpath:/'
  - Config resource 'class path resource [application.yml]' via location 'optional:classpath:/'
  - applicationInfo

这份清单说明了三件事:

观察结论
命令行在第一位临时改配置不用动文件 ✓
环境变量在第二位密码走环境变量的做法是有依据的 ✓
application-dev.yml 在 application.yml 前面profile 文件靠"优先级更高"来覆盖主配置 ✓

8. Debug 观察点 ​

  1. 在 ConfigLessonRunner 的构造器处打断点:观察 AppProperties 是怎么被创建并注入的(绑定发生在启动期);
  2. 在 Environment.getProperty 处断点:求值 environment.getPropertySources(),观察多个来源的合并顺序;
  3. 把 app.page-size-limit 改成 0(配合 @Validated @Min(1))——观察启动直接失败,并读那段报错(这就是"配置错误启动即暴露"的价值);
  4. 故意把 application-dev.yml 里的 key 写错(改成 pageSizeLimits)——观察它被静默忽略(默认绑定不报未知字段)✗ 想让它报错要加 spring.config.import 或开启严格模式;
  5. 在命令行传一个拼错的属性(--app.pageSizeLimit=20 与 --app.page-size-limit=20 都试)——验证松散绑定:两种写法都能生效 ✓。

9. 常见错误 ​

错误 1:把密码写进 application.yml ​

见 5.4 节。这是本项目反复强调的一条(《Spring Boot 自动配置与项目结构》 的 9.5 节也提过)。

自查方法:

powershell
git log --all -p -- "**/application*.yml" | Select-String -Pattern "password|secret|token"

注意:即使后来删掉了,历史里还在 ✗ 真发生了要当作一次安全事件处理(轮换密钥)✓

错误 2:给敏感配置配"看起来方便"的默认值 ​

yaml
password: ${DB_PASSWORD:123456}       # ✗ 没配就用 123456

后果:生产环境漏配环境变量时,应用用默认密码连上了一个错的库(或别人的库) ✗

正确做法:不带默认值——让它启动失败 ✓(失败比"悄悄跑错"好得多)

错误 3:profile 里重复主配置的内容 ​

yaml
# application-dev.yml
app:
  name: kb-shop                # ✗ 与主配置重复
  page-size-limit: 20

后果:主配置改了 app.name,dev 环境不跟着变 ✗ 排查半天。

规则:profile 文件只写"与主配置不同的部分" ✓

错误 4:按 profile 写业务分支 ​

见 6.3 节。

错误 5:把配置项名写错,然后"它能跑" ​

yaml
app:
  pageSizeLimit: 20            # ⚠️ 松散绑定下有时也能绑上,容易混淆
  pageSizeLimits: 20           # ✗ 拼错 → 静默忽略

症状:配置没生效,但不报错 ✗

排查:打印生效值(本节的 ConfigLessonRunner 就是干这个的)✓

更彻底的方案:开启"未知配置项报错"(Spring Boot 的 spring.config.import 不能做这个,但可以用 IDE 的注解处理器 + 自定义 EnvironmentPostProcessor)——实践中最有效的办法还是"启动时打印关键配置" ✓

错误 6:以为改配置文件不用重新打包 ​

text
application.yml 在 jar 里 ✗ → 改它要重新打包

想"不改 jar 改配置",有三种方式:

方式做法
外部配置文件jar 同目录放 application.yml(或 config/ 子目录)——Spring Boot 会自动读 ✓
命令行/环境变量--server.port=9090 / SERVER_PORT=9090
指定配置文件位置--spring.config.location=/etc/app/

优先级(《Spring Boot 自动配置与项目结构》 的 5.5 节):外部文件 > jar 内的文件 ✓

10. 动手实验 ​

目标:把"优先级、profile、敏感配置"三件事亲手验一遍。

步骤:

  1. 运行第 7 节的命令,核对三种 profile 的输出差异;
  2. 验证优先级:同时给环境变量与命令行:
powershell
$env:SERVER_PORT = "19001"
java "-Dstdout.encoding=UTF-8" -jar spring-boot-web/target/spring-boot-web.jar --server.port=19002
# 观察最终端口是 19002(命令行 > 环境变量)
  1. 验证环境变量覆盖配置:只设环境变量(不传命令行),观察端口变成 19001;
  2. 验证 profile 覆盖:启动时同时指定 --app.page-size-limit=50,观察命令行赢了(50,而不是 dev 的 20);
  3. 加一条配置校验:给 AppProperties 加 @Validated 与 @Min(1) @Max(1000),把配置改成 0——观察启动失败与报错内容;
  4. 加一个"外部配置文件":在 jar 同目录放一个 application.yml(只写 app.name: from-external),启动——观察它覆盖了 jar 内的值。

验收方式:能说出第 2 步为什么是 19002、第 6 步为什么外部文件生效。

扩展任务:把"功能开关"做成一个真正的 Feature Flag:

  • 加配置 app.feature.new-checkout(本项目已有);
  • 在 OrderService.create 里根据它切换两种金额计算方式(旧:单价 × 数量;新:满 2 件打 9 折);
  • 要求:代码里判断的是"配置值",不是 profile ✓
  • 分别在 dev 与 prod 下测同一个请求,对比金额 —— 体会"同一份代码 + 不同配置 = 不同行为"。

11. 练习题 ​

基础题

  1. 判断:application-dev.yml 的优先级低于 application.yml。(对 / 错)
  2. 填空:配置的四种来源按优先级从高到低是 、、、。
  3. 简答:为什么"敏感配置"更适合放环境变量,而不是启动参数?

代码阅读题

java
@ConfigurationProperties(prefix = "shop")
public record ShopProperties(String name, int maxItemsPerOrder, boolean allowGuestCheckout) { }
yaml
# application.yml
shop:
  name: 我的商城
  maxItemsPerOrder: 10
  allowGuestCheckout: false

# application-prod.yml
shop:
  maxItemsPerOrder: 3
  1. 用 --spring.profiles.active=prod 启动,ShopProperties 的三个字段各是什么值?
  2. 如果运维想让 maxItemsPerOrder 临时变成 5(且不改任何文件),有哪些办法?请给出至少两种,并说明它们的优先级关系。

编程题

  1. 为一个"文件上传"功能设计完整的配置方案:
    • 需求:上传目录、单文件大小上限、允许的扩展名列表、是否启用病毒扫描;
    • 要求:
      • 用 @ConfigurationProperties 定义配置类(含嵌套结构与默认值);
      • 加上校验(目录非空、大小在 1MB ~ 100MB、扩展名列表非空);
      • 主配置提供默认值,dev 与 prod 各覆盖一部分;
      • 写出"运维不改文件、临时把大小上限改成 5MB"的命令;
    • 回答:allow-virus-scan 这种开关,该放在 profile 里还是主配置里?为什么?

综合任务

  1. 设计一个"多环境配置规范"并落地:
    • 背景:团队有 dev / test / staging / prod 四套环境,之前因为配置混乱出过一次事故(staging 连了生产的数据库);
    • 要求:
      • 写一份至少 8 条的配置规范(覆盖:什么放主配置、什么放 profile、敏感配置怎么给、默认值该不该给、配置项命名、如何确保"改错能发现");
      • 设计"配置自检"机制:写一个启动时的检查,发现危险组合就拒绝启动(例如:profile 是 staging 但数据源地址指向生产库的域名);
      • 说明"为什么'宁可启动失败,不要带着疑似配置跑'";
      • 额外要求:说明"如何把这次事故变成一条能自动拦住它的检查"。
分级提示

提示 1(方向):第 1 题的方向是反的——profile 专属文件优先级更高。

提示 2(关键点):第 5 题的两种办法分别是环境变量与命令行参数,注意它们的优先级顺序。

提示 3(接近答案):第 7 题的"自检机制"就是一段 ApplicationRunner 或 EnvironmentPostProcessor——关键是它要在"应用开始服务之前"执行。

12. 参考答案 ​

基础题
  1. 错。application-dev.yml 的优先级更高 ✓(实测的来源清单里它排在 application.yml 前面)。

机制:profile 专属配置文件是后加载的,后加载的覆盖先加载的 ✓

这也解释了"为什么 profile 文件只写差异":它就是拿来覆盖的 ✓

  1. 命令行参数 > 环境变量 > profile 专属配置文件 > 主配置文件(application.yml)。

补充两条:

位置优先级
jar 同目录的外部配置高于 jar 内的同名文件 ✓
@ConfigurationProperties 里写的默认值最低(相当于"兜底")
  1. 因为启动参数会出现在进程列表里 ✗
powershell
ps -ef | Select-String "java"    # 或者 Windows 的任务管理器 / Get-CimInstance Win32_Process
# 会看到:java -jar app.jar --spring.datasource.password=123456
环境变量启动参数
可见性仅进程自身(/proc/<pid>/environ,需权限)进程列表任何人可见 ✗
泄漏面小大(同事 ps 一下、日志、监控 agent 采集)
适合生产密钥 ✓临时调试(且别带敏感值)

但环境变量也不是绝对安全:容器 inspect、崩溃转储、CI 日志都可能带出它 ✓ 最稳的是密钥管理服务(有审计、能轮换)✓

代码阅读题(第 4、5 题)

第 4 题:

字段值来源
name我的商城主配置
maxItemsPerOrder3application-prod.yml 覆盖了主配置的 10 ✓
allowGuestCheckoutfalse主配置

关键观察:prod 文件里没写的字段,回落到主配置 ✓ 这正是"只写差异"的效果。

⚠️ 一个容易记错的点:有"回落到主配置",但没有"回落到代码默认值"——如果你在 record 里给了默认值:

java
public record ShopProperties(String name, int maxItemsPerOrder, boolean allowGuestCheckout) {
    public ShopProperties {
        if (maxItemsPerOrder == 0) maxItemsPerOrder = 10;      // 手工兜底
    }
}

这会造成"配置没给 → 悄悄用默认值" ⚠️ 有时是好事(有合理默认),有时是陷阱(掩盖了漏配)✗

建议:关键配置不要给默认值(@NotBlank + 启动失败更安全)✓

第 5 题:三种办法。

办法命令优先级
环境变量$env:SHOP_MAXITEMSPERORDER = "5"中
命令行参数--shop.max-items-per-order=5最高 ✓
外部配置文件jar 同目录放 application.yml 写这一项低于上面两者,高于 jar 内文件

同时给时的生效顺序:命令行 > 环境变量 > 外部文件 > jar 内 profile 文件 > jar 内主配置 ✓

⚠️ 环境变量名的坑:shop.max-items-per-order 在环境变量里要写成 SHOP_MAXITEMSPERORDER 或 SHOP_MAX_ITEMS_PER_ORDER ✓

为什么:环境变量名不能含点号和横线(松散绑定帮你做了映射)✓

一个实用建议:在团队里统一"配置项 → 环境变量名"的转换规则(比如"全大写、点变下划线、横线去掉"),并写进部署文档 ✓ 否则运维和开发会对不上。

编程题(第 6 题)
java
package cn.kb.learn.web.config;

import jakarta.validation.constraints.*;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;

import java.util.List;

@Validated
@ConfigurationProperties(prefix = "upload")
public record UploadProperties(
        @NotBlank(message = "上传目录不能为空")
        String directory,

        /** 单文件上限(字节);1MB ~ 100MB */
        @NotNull @Min(1_048_576) @Max(104_857_600)
        Long maxFileSize,

        @NotEmpty(message = "必须至少允许一种扩展名")
        List<String> allowedExtensions,

        /** 是否启用病毒扫描:默认关(本地开发没有扫描服务) */
        boolean virusScanEnabled
) {
    public UploadProperties {
        if (allowedExtensions == null || allowedExtensions.isEmpty()) {
            allowedExtensions = List.of("jpg", "png", "pdf");        // 兜底(也可不兜底,靠校验拦住)
        }
    }
}
yaml
# application.yml(公共默认)
upload:
  directory: ./uploads
  max-file-size: 10485760            # 10MB
  allowed-extensions: [jpg, png, pdf]
  virus-scan-enabled: false

# application-dev.yml
upload:
  directory: ./target/uploads
  allowed-extensions: [jpg, png, pdf, txt, md]     # 开发时放宽

# application-prod.yml
upload:
  directory: /data/app/uploads
  max-file-size: 52428800            # 50MB
  virus-scan-enabled: true           # 生产开扫描

"不改文件、临时把上限改成 5MB":

powershell
# 命令行(优先级最高,推荐用于临时调整)
java "-Dstdout.encoding=UTF-8" -jar app.jar --upload.max-file-size=5242880

# 或环境变量
$env:UPLOAD_MAXFILESIZE = "5242880"
java "-Dstdout.encoding=UTF-8" -jar app.jar

virus-scan-enabled 该放哪:主配置给默认值(false)+ 按环境覆盖 ✓

判断理由
它不是"纯环境差异"它是功能开关——业务代码里会读它 ✓
但它的默认值该保守默认 false(开发不受阻),生产显式打开 ✓
⚠️ 反过来想更安全"安全相关的开关,默认值应该是"开" ✗ 但那样本地开发会因为没有扫描服务而失败

这里有一个真实的取舍:

安全开关的"安全默认值"(默认开启)会阻碍开发;"便利默认值"(默认关闭)会让漏配变成风险。

折中方案:默认关闭 + 生产环境显式打开 + 启动自检:

java
@Component
public class UploadSafetyCheck implements ApplicationRunner {
    private final UploadProperties properties;
    private final Environment environment;

    @Override
    public void run(ApplicationArguments args) {
        boolean isProd = environment.acceptsProfiles(Profiles.of("prod"));
        if (isProd && !properties.virusScanEnabled()) {
            throw new IllegalStateException("生产环境必须开启病毒扫描(upload.virus-scan-enabled=true)");
        }
    }
}

✓ 这才是"把约定变成机制"——与 《Spring Boot 配置管理与多环境》 的 5.4 节"宁可启动失败"是同一条原则。

这条经验可以推广到所有"安全相关的配置":默认值按便利给,但在生产环境用启动检查强制 ✓

综合任务(第 7 题)

《多环境配置规范(8 条)》

#规范为什么
1能跨环境共用的进主配置,只有"环境差异明显的"进 profile避免重复与漂移
2profile 文件只写差异改主配置时不会漏改
3密钥一律走环境变量或密钥服务,严禁进 Git;不给默认值泄漏 + 掩盖漏配
4关键配置必须做启动校验(@Validated)错误在启动暴露,而不是运行时
5配置项命名统一前缀(如 app.* / upload.*),自定义项不与框架的冲突可搜索、可分组
6启动时打印"关键配置的生效值"(敏感项打掩码)排查"配置到底生效没有"
7禁止用 profile 写业务分支;需要开关时用配置项 + 条件装配业务逻辑只有一份
8环境相关的高危组合必须自检(如 staging 连生产库)让事故无法悄悄发生

配置自检机制:

java
package cn.kb.learn.web;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Component;

import java.util.List;

/**
 * 启动自检:发现危险配置组合就拒绝启动。
 *
 * <p>顺序要早(@Order 小)——在开始对外服务之前就要拦住。</p>
 */
@Component
@Order(0)
public class ConfigSafetyCheck implements ApplicationRunner {
    private static final Logger log = LoggerFactory.getLogger(ConfigSafetyCheck.class);

    /** 生产库的特征(域名/库名)——出现这些就说明连错库了 */
    private static final List<String> PROD_DB_MARKERS = List.of("prod-db.internal", "shop_prod");
    /** 非生产环境的名字 */
    private static final List<String> NON_PROD_PROFILES = List.of("dev", "test", "staging");

    private final Environment environment;

    public ConfigSafetyCheck(Environment environment) {
        this.environment = environment;
    }

    @Override
    public void run(ApplicationArguments args) {
        String url = environment.getProperty("spring.datasource.url", "");
        boolean isNonProd = environment.acceptsProfiles(profile -> NON_PROD_PROFILES.contains(profile));

        if (isNonProd && PROD_DB_MARKERS.stream().anyMatch(url::contains)) {
            // 关键:不是"警告",而是"拒绝启动" —— 警告会被忽略,启动失败不会
            throw new IllegalStateException(
                    "配置自检失败:非生产环境(" + String.join(",", environment.getActiveProfiles())
                            + ")的数据源指向了生产库:" + url);
        }

        if (NON_PROD_PROFILES.contains("prod") ) {
            // 占位:其他自检规则(如"生产必须开病毒扫描")也放在这里
        }

        log.info("配置自检通过:profiles={},datasource={}", 
                String.join(",", environment.getActiveProfiles()), mask(url));
    }

    private String mask(String url) {
        int at = url.indexOf('@');
        return at < 0 ? url : "***@" + url.substring(at + 1);
    }
}

为什么"宁可启动失败,不要带着疑似配置跑":

对比带着疑似配置跑启动失败
后果事故已经发生(写脏生产数据、发错消息)服务没起来,但数据是干净的 ✓
发现时机可能几小时后(甚至上线后)立刻 ✓
可比性可能已经造成不可逆影响 ✗可回滚、可修复 ✓

一句话:

配置错误的"代价曲线"是陡峭的——发现的每一分钟都在放大损失。

让它在最早的时刻爆炸,是成本最低的处理方式。

"如何把这次事故变成一条能自动拦住它的检查":

步骤做法
1. 提取事故的"可判定特征""非生产 profile + 生产库地址"——这两个条件在启动时完全可判定 ✓
2. 把它写成检查(上面的 ConfigSafetyCheck)拒绝启动,而不是只打日志
3. 补一个测试用 @SpringBootTest(properties = {"spring.datasource.url=...prod-db..."}) + @ActiveProfiles("staging"),断言上下文启动失败(T1-017)
4. 写进规范与评审清单让"下次有人加环境"时会想到这条检查
5. 复盘时问"还有哪些类似的组合"把同类风险一次性覆盖(如"staging 连生产 Redis""测试环境用生产短信通道")

最后一步最关键:

一次事故的价值,不在于"修好它",而在于"让它这一类都不可能再发生"。

这正是 T1-018 讲的"回归测试"思路:把修好的 bug 变成一个永远跑着的测试。 ✓

13. 掌握标准 ​

  • [ ] 能说出配置的四种来源与优先级(从高到低);
  • [ ] 知道 PropertySources 的迭代顺序是"高优先级在前";
  • [ ] 会用 profile 做多环境隔离,且只写差异;
  • [ ] 能说出 @ConfigurationProperties 相对 @Value 的三个优势;
  • [ ] 知道给 @ConfigurationProperties 加 @Validated 能让配置错误在启动时暴露;
  • [ ] 知道敏感配置要放环境变量且不给默认值;
  • [ ] 会在日志里对敏感配置打掩码;
  • [ ] 知道"按 profile 写业务分支"是反模式,会用配置项 + 条件装配替代;
  • [ ] 知道"外部配置文件"能让改配置不必重新打包。

14. 扩展资源 ​

15. 下一步学习 ​

《日志框架与日志规范》:配置管好了,接下来管"怎么把运行信息记下来"。

进度只保存在本机浏览器,可随时清除

个人学习项目:AI 生成内容均经人工复核,第三方资源仅作索引