主题
界面
主题
《Spring Boot 自动配置与项目结构》 讲了"自动配置怎么生效",本节讲**"配置从哪来、怎么改、怎么按环境切换"**。
一句话概括本节的取舍:
同一份构建产物(jar),不重新打包,换一套配置就能在开发 / 测试 / 生产运行。这也是"配置外置"这件事的全部价值。
完成本节后,你应该能够:
@ConfigurationProperties 相对 @Value 的三个优势;application.yml,以及正确的做法;Environment 读出"合并后的最终值"(而不是去读原始文件);application.yml 与自动配置的关系吗?(《Spring Boot 自动配置与项目结构》 的 5.5 节)先看"把配置写死在代码里"会发生什么:
private static final String DB_URL = "jdbc:mysql://localhost:3306/shop"; // ✗
private static final String DB_PASSWORD = "123456"; // ✗ 更糟| 问题 | 后果 |
|---|---|
| 换环境要改代码 | 开发/测试/生产的地址不同 → 要改代码、重新构建、重新测试 ✗ |
| 密码进 Git | 一次提交,永久留痕——即使后来删掉,历史里还在 ✗ |
| 改一个值要重新打包 | 运维无法自己调整,必须等发版 |
| 没有"同构建"保证 | 生产跑的不是测试过的那份产物 ✗ |
正确做法的一句话:
代码只描述"逻辑",配置描述"环境差异"。
打包一次,用不同的配置启动——这样"测试过的产物"和"上线的产物"是同一个。 ✓
按优先级从高到低(实测输出的顺序):
1. 命令行参数 --server.port=19000
2. 环境变量 SERVER_PORT=19000(大写下划线)
3. profile 专属配置文件 application-dev.yml / application-prod.yml
4. 主配置文件 application.yml
5. 代码里的默认值 (最低)本节实测:把"来源清单"打印出来(真实运行输出):
配置来源(按优先级从高到低):
- 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() 的迭代顺序是**"高优先级在前"**(不是"低在前")✗
怎么记住:"越靠后加进来的来源,优先级越高"——命令行参数是启动时最后解析的,所以它最高 ✓
验证优先级的三种方式(本项目都实测过):
# ① 配置文件里写的是 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 # 生效 19002Spring 的"松散绑定"(relaxed binding):同一个配置项有多种写法:
| 写法 | 用在哪 |
|---|---|
server.port | yml / properties / 命令行前缀 |
SERVER_PORT | 环境变量(大写下划线) |
serverPort | 某些场景(如 @Value 里) |
这条规则的实际价值:app.page-size-limit 这个配置,在环境变量里要写成 APP_PAGESIZELIMIT 或 APP_PAGE_SIZE_LIMIT ✓(去掉点号和横线,全大写)
三个文件的分工:
application.yml 公共配置(所有环境都一样的部分)
application-dev.yml 开发环境(日志详细、功能开关打开)
application-prod.yml 生产环境(日志收敛、开关保守)实测:同一份 jar,三种启动方式的差异
| 启动方式 | page-size-limit | new-checkout | 日志级别 |
|---|---|---|---|
| 默认(不指定 profile) | 100 | false | info |
--spring.profiles.active=dev | 20 | true | debug |
--spring.profiles.active=prod | 100 | false | info |
读这张表要看出三件事:
| 观察 | 含义 |
|---|---|
| 默认与 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 走不同业务逻辑"是反模式:
if (environment.acceptsProfiles(Profiles.of("prod"))) {
doProductionLogic(); // ✗
} else {
doTestLogic();
}后果:测试环境走的分支与生产不同——"测试通过"对生产没有任何保证 ✗ ✗
业务逻辑只应该有一份;环境差异只体现在"配置"上。
功能开关(Feature Flag)不是"环境分支"——它是"同一套逻辑的可配置参数"(本节的
new-checkout就是这么用的:代码里判断的是配置值,不是 profile)✓
@ConfigurationProperties:类型安全的配置绑定 三种读配置的方式对比:
// ① @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) { }
}| 维度 | @Value | Environment | @ConfigurationProperties |
|---|---|---|---|
| 类型安全 | ⚠️ 弱 | ❌ 全是 String | ✅ 强 |
| 集中管理 | ❌ 散落在各处 | ⚠️ 字符串 key | ✅ 一个类 |
| 嵌套结构 | ❌ 难 | ❌ 难 | ✅ record 嵌套 |
| IDE 支持 | ⚠️ | ❌ | ✅ 能补全、能跳转 |
| 校验 | ❌ | ❌ | ✅ 配合 @Validated |
| 配置写错时 | 用到才炸 | 用到才炸 | ✅ 启动就失败 |
最后一行是它的核心竞争力:
配置错误应该在启动时暴露,而不是在第一个用户访问时暴露。
启用方式(本项目用的):
@SpringBootApplication
@ConfigurationPropertiesScan // 扫描 @ConfigurationProperties 类
public class WebApplication { ... }另一种方式:在配置类上 @EnableConfigurationProperties(AppProperties.class) ✓
加校验(配置写错了直接启动失败):
@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 会让应用启动失败并给出清晰提示 ✓ 而不是等到某个分页请求时算出奇怪的结果 ✗
application.yml 先明确一件事:application.yml 会被打进 jar ✓ 而且在 Git 里 ✗
spring:
datasource:
password: 123456 # ✗ 三重暴露:代码库、构建产物、镜像正确做法:
spring:
datasource:
password: ${DB_PASSWORD} # 从环境变量读;没有就启动失败(而不是用默认值悄悄跑起来)三种来源的选择:
| 方式 | 适用 | 说明 |
|---|---|---|
| 环境变量 | 绝大多数场景 | 容器/K8s 的原生方式 ✓ |
启动参数(--spring.datasource.password=...) | 临时调试 | ⚠️ 会出现在进程列表里(ps -ef 能看到)✗ |
| 密钥管理服务(Vault、KMS、云厂商的 Secrets Manager) | 生产推荐 | 有审计、能轮换 ✓ |
⚠️ 两个容易被忽略的点:
| 点 | 说明 |
|---|---|
| 环境变量也可能泄漏 | 崩溃转储(heap dump)、容器 inspect、CI 日志——别在日志里打印它 ✗ |
| 默认值是个陷阱 | ${DB_PASSWORD:123456} ⚠️ 配错了会用默认密码悄悄跑起来——生产环境宁可"启动失败" ✓ |
本项目的一个实践(ConfigLessonRunner 里):
System.out.println("数据库密码(演示不落盘) = " + mask(environment.getProperty("DB_PASSWORD")));
private String mask(String value) {
if (value == null || value.isBlank()) {
return "(未设置)";
}
return "***已设置(长度 " + value.length() + ")***"; // 只证明"有值",不打印值
}实测输出:
数据库密码(演示不落盘) = (未设置)这条规则值得写进团队规范:
日志里永远不打印敏感配置的明文——需要确认"配没配上"时,打印长度或掩码后的形式 ✓
Environment 读"最终值" Environment 已经帮你做完了"多来源合并" ✓
// ✓ 读到的就是"应用真正在用的值"(命令行、环境变量、profile 全都算进去了)
environment.getProperty("server.port");
// ✗ 直接读 application.yml —— 只能拿到文件里写的值,看不到命令行覆盖实测的差异(同一个 server.port):
| 启动方式 | application.yml 里的值 | environment.getProperty 读到的 |
|---|---|---|
| 默认 | 18080 | 18080 |
--server.port=19000 | 18080 | 19000 ✓ |
这也是 《Spring Boot 自动配置与项目结构》 里那条建议的展开:
不要绕过框架去读原始数据源,用它提供的抽象。
一个实用技巧:想排查"某个配置到底从哪来的",可以遍历 PropertySources(本节的 ConfigLessonRunner 就是这么做的)✓
environment.getPropertySources().forEach(source -> System.out.println(source.getName()));注意顺序是"高优先级在前"(5.1 节)✓
# application.yml
app:
name: kb-shop@ConfigurationProperties(prefix = "app")
public record AppProperties(String name) { }System.out.println(appProperties.name());见第 7 节。
@Service
public class PaymentService {
public void pay(Order order) {
if (environment.acceptsProfiles(Profiles.of("prod"))) {
realPaymentGateway.charge(order); // ✗ 生产走真网关
} else {
fakePaymentGateway.charge(order); // ✗ 其他环境走假的
}
}
}三个问题:
| 问题 | 说明 |
|---|---|
| 测试覆盖不到生产代码 | 测试环境永远走 fakePaymentGateway 分支 ✗ |
| 分支会越来越多 | 每多一个环境就多一个分支 |
| 发布风险 | 上线的瞬间才第一次执行生产分支 ✗ |
更好的做法(配置驱动,而不是环境驱动):
// 让"用哪个支付实现"由配置决定 —— 但分支在"装配期"而不是"业务执行期"
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 自动配置与项目结构》 讲的"条件注解"在业务配置上的用法 ✓
文件清单(labs/spring-boot-web/):
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 打印生效配置与来源三个配置文件:
# 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# application-dev.yml(只写差异)
app:
feature:
new-checkout: true
page-size-limit: 20 # 开发时限制小一点,便于发现分页问题
logging:
level:
cn.kb.learn.web: debug
org.springframework.web: info# application-prod.yml
app:
feature:
new-checkout: false
page-size-limit: 100
logging:
level:
cn.kb.learn.web: info
root: warn # 生产环境收敛第三方库的日志运行命令:
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预期输出(真实运行结果):
===== 默认(不指定 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 下):
数据库密码(演示不落盘) = (未设置)
配置来源(按优先级从高到低):
- 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 文件靠"优先级更高"来覆盖主配置 ✓ |
ConfigLessonRunner 的构造器处打断点:观察 AppProperties 是怎么被创建并注入的(绑定发生在启动期);Environment.getProperty 处断点:求值 environment.getPropertySources(),观察多个来源的合并顺序;app.page-size-limit 改成 0(配合 @Validated @Min(1))——观察启动直接失败,并读那段报错(这就是"配置错误启动即暴露"的价值);application-dev.yml 里的 key 写错(改成 pageSizeLimits)——观察它被静默忽略(默认绑定不报未知字段)✗ 想让它报错要加 spring.config.import 或开启严格模式;--app.pageSizeLimit=20 与 --app.page-size-limit=20 都试)——验证松散绑定:两种写法都能生效 ✓。application.yml 见 5.4 节。这是本项目反复强调的一条(《Spring Boot 自动配置与项目结构》 的 9.5 节也提过)。
自查方法:
git log --all -p -- "**/application*.yml" | Select-String -Pattern "password|secret|token"注意:即使后来删掉了,历史里还在 ✗ 真发生了要当作一次安全事件处理(轮换密钥)✓
password: ${DB_PASSWORD:123456} # ✗ 没配就用 123456后果:生产环境漏配环境变量时,应用用默认密码连上了一个错的库(或别人的库) ✗
正确做法:不带默认值——让它启动失败 ✓(失败比"悄悄跑错"好得多)
# application-dev.yml
app:
name: kb-shop # ✗ 与主配置重复
page-size-limit: 20后果:主配置改了 app.name,dev 环境不跟着变 ✗ 排查半天。
规则:profile 文件只写"与主配置不同的部分" ✓
见 6.3 节。
app:
pageSizeLimit: 20 # ⚠️ 松散绑定下有时也能绑上,容易混淆
pageSizeLimits: 20 # ✗ 拼错 → 静默忽略症状:配置没生效,但不报错 ✗
排查:打印生效值(本节的 ConfigLessonRunner 就是干这个的)✓
更彻底的方案:开启"未知配置项报错"(Spring Boot 的 spring.config.import 不能做这个,但可以用 IDE 的注解处理器 + 自定义 EnvironmentPostProcessor)——实践中最有效的办法还是"启动时打印关键配置" ✓
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 内的文件 ✓
目标:把"优先级、profile、敏感配置"三件事亲手验一遍。
步骤:
$env:SERVER_PORT = "19001"
java "-Dstdout.encoding=UTF-8" -jar spring-boot-web/target/spring-boot-web.jar --server.port=19002
# 观察最终端口是 19002(命令行 > 环境变量)--app.page-size-limit=50,观察命令行赢了(50,而不是 dev 的 20);AppProperties 加 @Validated 与 @Min(1) @Max(1000),把配置改成 0——观察启动失败与报错内容;application.yml(只写 app.name: from-external),启动——观察它覆盖了 jar 内的值。验收方式:能说出第 2 步为什么是 19002、第 6 步为什么外部文件生效。
扩展任务:把"功能开关"做成一个真正的 Feature Flag:
app.feature.new-checkout(本项目已有);OrderService.create 里根据它切换两种金额计算方式(旧:单价 × 数量;新:满 2 件打 9 折);基础题
application-dev.yml 的优先级低于 application.yml。(对 / 错)代码阅读题
@ConfigurationProperties(prefix = "shop")
public record ShopProperties(String name, int maxItemsPerOrder, boolean allowGuestCheckout) { }# application.yml
shop:
name: 我的商城
maxItemsPerOrder: 10
allowGuestCheckout: false
# application-prod.yml
shop:
maxItemsPerOrder: 3--spring.profiles.active=prod 启动,ShopProperties 的三个字段各是什么值?maxItemsPerOrder 临时变成 5(且不改任何文件),有哪些办法?请给出至少两种,并说明它们的优先级关系。编程题
@ConfigurationProperties 定义配置类(含嵌套结构与默认值);allow-virus-scan 这种开关,该放在 profile 里还是主配置里?为什么?综合任务
提示 1(方向):第 1 题的方向是反的——profile 专属文件优先级更高。
提示 2(关键点):第 5 题的两种办法分别是环境变量与命令行参数,注意它们的优先级顺序。
提示 3(接近答案):第 7 题的"自检机制"就是一段 ApplicationRunner 或 EnvironmentPostProcessor——关键是它要在"应用开始服务之前"执行。
application-dev.yml 的优先级更高 ✓(实测的来源清单里它排在 application.yml 前面)。机制:profile 专属配置文件是后加载的,后加载的覆盖先加载的 ✓
这也解释了"为什么 profile 文件只写差异":它就是拿来覆盖的 ✓
application.yml)。补充两条:
| 位置 | 优先级 |
|---|---|
| jar 同目录的外部配置 | 高于 jar 内的同名文件 ✓ |
@ConfigurationProperties 里写的默认值 | 最低(相当于"兜底") |
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 题:
| 字段 | 值 | 来源 |
|---|---|---|
name | 我的商城 | 主配置 |
maxItemsPerOrder | 3 | application-prod.yml 覆盖了主配置的 10 ✓ |
allowGuestCheckout | false | 主配置 |
关键观察:prod 文件里没写的字段,回落到主配置 ✓ 这正是"只写差异"的效果。
⚠️ 一个容易记错的点:有"回落到主配置",但没有"回落到代码默认值"——如果你在 record 里给了默认值:
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 ✓
为什么:环境变量名不能含点号和横线(松散绑定帮你做了映射)✓
一个实用建议:在团队里统一"配置项 → 环境变量名"的转换规则(比如"全大写、点变下划线、横线去掉"),并写进部署文档 ✓ 否则运维和开发会对不上。
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"); // 兜底(也可不兜底,靠校验拦住)
}
}
}# 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":
# 命令行(优先级最高,推荐用于临时调整)
java "-Dstdout.encoding=UTF-8" -jar app.jar --upload.max-file-size=5242880
# 或环境变量
$env:UPLOAD_MAXFILESIZE = "5242880"
java "-Dstdout.encoding=UTF-8" -jar app.jarvirus-scan-enabled 该放哪:主配置给默认值(false)+ 按环境覆盖 ✓
| 判断 | 理由 |
|---|---|
| 它不是"纯环境差异" | 它是功能开关——业务代码里会读它 ✓ |
| 但它的默认值该保守 | 默认 false(开发不受阻),生产显式打开 ✓ |
| ⚠️ 反过来想更安全 | "安全相关的开关,默认值应该是"开" ✗ 但那样本地开发会因为没有扫描服务而失败 |
这里有一个真实的取舍:
安全开关的"安全默认值"(默认开启)会阻碍开发;"便利默认值"(默认关闭)会让漏配变成风险。
折中方案:默认关闭 + 生产环境显式打开 + 启动自检:
@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 节"宁可启动失败"是同一条原则。
这条经验可以推广到所有"安全相关的配置":默认值按便利给,但在生产环境用启动检查强制 ✓
《多环境配置规范(8 条)》
| # | 规范 | 为什么 |
|---|---|---|
| 1 | 能跨环境共用的进主配置,只有"环境差异明显的"进 profile | 避免重复与漂移 |
| 2 | profile 文件只写差异 | 改主配置时不会漏改 |
| 3 | 密钥一律走环境变量或密钥服务,严禁进 Git;不给默认值 | 泄漏 + 掩盖漏配 |
| 4 | 关键配置必须做启动校验(@Validated) | 错误在启动暴露,而不是运行时 |
| 5 | 配置项命名统一前缀(如 app.* / upload.*),自定义项不与框架的冲突 | 可搜索、可分组 |
| 6 | 启动时打印"关键配置的生效值"(敏感项打掩码) | 排查"配置到底生效没有" |
| 7 | 禁止用 profile 写业务分支;需要开关时用配置项 + 条件装配 | 业务逻辑只有一份 |
| 8 | 环境相关的高危组合必须自检(如 staging 连生产库) | 让事故无法悄悄发生 |
配置自检机制:
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 变成一个永远跑着的测试。 ✓
PropertySources 的迭代顺序是"高优先级在前";@ConfigurationProperties 相对 @Value 的三个优势;@ConfigurationProperties 加 @Validated 能让配置错误在启动时暴露;《日志框架与日志规范》:配置管好了,接下来管"怎么把运行信息记下来"。
暂无