本节目标:把「随环境变化的东西」从代码里搬进配置文件,为图书管理服务建立 dev / test / prod 三套配置,并搞清每一行最终落在哪里。
适用版本:Spring Boot 4.1.x(Java 21)
6.1 application.yml 与 Profile
上一章我们让 BookApplication 顺利启动,也把图书相关的 Bean 交给了容器管理。但参数还是写死在代码里的——数据库地址、端口、借阅上限。只要换一个环境,就得改代码重新打包。本节要解决的正是这件事:把配置从代码里抽出来,再用 Profile 按环境分堆。
我们继续用贯穿本章的「图书管理服务」(book service)作为例子:dev 用内存数据库、test 用独立的测试库、prod 连生产库。
为什么配置要外部化
把配置写死在代码里有三个后果:同一份代码要发布到三个环境就得打三个包,构建产物和源码版本对不上,线上出问题无法回溯;改一个端口要重新编译、重新走发布流程;密钥和连接串一旦进了 Git 历史,清理成本极高。
外部化配置的核心思路只有一句话:代码只描述「怎么算」,配置描述「对谁算」。Spring Boot 把配置抽象成一组有序的 PropertySource,绑定阶段按优先级取第一个命中的值——这正是 6.3 节要展开的机制。
properties 还是 yml
Spring Boot 同时支持 application.properties 和 application.yml(以及 .yaml)。两者能力等价,差别在表达方式。
| 维度 | application.properties | application.yml |
|---|---|---|
| 层级表达 | 扁平,靠 . 串起来 | 缩进表达嵌套 |
| 重复前缀 | 每行都要写全 | 父键只写一次 |
| 多文档 | 不支持 | --- 分隔 |
| 列表 | book.categories[0]=小说 | 缩进 - 小说 |
| 解析器 | java.util.Properties | SnakeYAML |
| 出错方式 | 拼错键名不报错,静默失效 | 缩进或语法错直接启动失败 |
| 适合 | 零散几个键、极简场景 | 结构化、成块的配置 |
同样的配置,两种写法:
book.name=图书管理服务
book.contact.email=ops@example.com
book.contact.phone=010-00000000
book:
name: 图书管理服务
contact:
email: ops@example.com
phone: 010-00000000
属性少时 properties 更直白;一旦出现 book.contact.*、spring.datasource.hikari.* 这类深层前缀,yml 的层级优势就很明显。建议团队统一选一种,混用会让「哪个文件覆盖了哪个」变得难以推理。本书示例统一用 YAML。
YAML 的六个常见坑
YAML 好看,但对格式极其敏感。下面六个坑出现频率最高。
坑一:用 Tab 缩进。 YAML 规范禁止用 Tab 缩进,只允许空格。编辑器里看着对齐,实际存的是 \t,启动时报 found character '\t' that cannot start any token。开启「显示空白字符」并让 Tab 自动转空格即可。
坑二:冒号后面忘了空格。 key: value 里的空格是语法的一部分。
book:
name:图书管理服务 # 错误:整行被当成一个字符串标量,key 根本不存在
正确写法是 name: 图书管理服务。
坑三:隐式类型转换。 YAML 1.1 会自动推断类型,很多「字符串」会被吃掉:
book:
code: no # 布尔 false,不是字符串 "no"
version: 1.0 # 浮点数
zip: 0755 # 前导 0 → 八进制整数 493
open-time: 12:30 # 六十进制 → 整数 750
publish-date: 2026-09-15 # 日期对象
凡是「像数字、布尔或日期、但语义上是字符串」的值,加引号即可:code: "no"、zip: "0755"、open-time: "12:30"。12:30 变 750 这条尤其阴——绑定到 String openTime 时你会看到 "750",排查半天想不到是 YAML 干的。
坑四:特殊字符与 #。 # 是注释起点,出现在值中间会截断:token: abc#123 的实际值只有 abc,必须写成 token: "abc#123"。另外 @ 和反引号不能作为纯量的开头,owner: @admin 会解析失败,要写成 owner: "@admin"。中文、空格,以及后面不跟空格的冒号(如 url: http://x)都不需要引号。
坑五:--- 多文档。 一个文件里可以用 --- 分隔多个文档,每个文档各自成块:
book:
name: 图书管理服务
---
spring:
config:
activate:
on-profile: prod
book:
name: 图书管理服务(生产)
--- 必须顶格写;文档级 profile 要用 spring.config.activate.on-profile,老的 spring.profiles 已被移除。profile 专属文档里不允许再出现 spring.profiles.active 或 spring.profiles.include,否则启动抛 InvalidConfigDataPropertyException。
坑六:列表与空值。
book:
categories:
- 小说
- 技术
tags: [文学, 编程] # 行内写法等价
aliases: [] # 空列表
description: # 值为 null
列表项前面的 - 后必须有一个空格;写成 -小说 会被当成字符串 -小说。
为图书服务建立三套配置
约定优于配置:application.yml 是所有环境共享的基线,application-{profile}.yml 只放差异项。
src/main/resources/application.yml:
book:
name: 图书管理服务
max-borrow-days: 30
page-size: 20
spring:
application:
name: book-service
src/main/resources/application-dev.yml:
book:
max-borrow-days: 90
spring:
datasource:
url: jdbc:h2:mem:books
username: sa
password: ""
h2:
console:
enabled: true
src/main/resources/application-test.yml:
spring:
datasource:
url: jdbc:postgresql://localhost:5432/books_test
username: books
password: books
src/main/resources/application-prod.yml:
spring:
datasource:
url: ${DB_URL}
username: ${DB_USER}
password: ${DB_PASSWORD}
book:
page-size: 50
book.name 只在基线里出现一次,三个环境都不用重复;dev 把借阅上限放宽到 90 天,prod 把分页调大。没有出现在 profile 文件里的键,自动继承基线值。
激活 Profile 的五种方式
| 方式 | 写法 | 典型场景 |
|---|---|---|
| 配置文件 | spring.profiles.active: dev | 本机默认开发 |
| 命令行参数 | --spring.profiles.active=prod | 部署脚本、java -jar |
| 环境变量 | SPRING_PROFILES_ACTIVE=prod | 容器 / Kubernetes |
| JVM 系统属性 | -Dspring.profiles.active=prod | 启动脚本 |
| 代码 | SpringApplication.setAdditionalProfiles("dev") | 追加而非替换 |
java -jar book-service.jar --spring.profiles.active=prod
容器里则用环境变量 SPRING_PROFILES_ACTIVE=prod,因为镜像不该为环境而变。
几个容易踩的点:
spring.profiles.active只能写在非 profile 专属的文档里,写进application-prod.yml会直接报错。- 可以同时激活多个:
--spring.profiles.active=dev,local。冲突的键后写的赢,所以dev,prod里prod覆盖dev。 - 没有激活任何 profile 时会落到名为
default的默认 profile,启动日志可见No active profile set, falling back to 1 default profile: "default"。想改名用spring.profiles.default: dev。 - 想「打包」一组 profile,用
spring.profiles.group:写成prod: prod,monitoring后,--spring.profiles.active=prod会同时激活prod与monitoring。
用 @Profile 标注 Bean
profile 不只影响配置值,还能决定「哪些 Bean 存在」。这对「开发用假实现、生产用真实现」特别有用。
import java.util.concurrent.atomic.AtomicLong;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import org.springframework.stereotype.Repository;
public interface IsbnService {
String nextIsbn();
}
@Repository
@Profile("prod")
class DatabaseIsbnService implements IsbnService {
public String nextIsbn() {
return "978-7-" + System.currentTimeMillis(); // 从数据库序列取号
}
}
@Component
@Profile({"dev", "test"})
class InMemoryIsbnService implements IsbnService {
private final AtomicLong seq = new AtomicLong(1000);
public String nextIsbn() {
return "TEST-" + seq.incrementAndGet();
}
}
@Profile 的值是一个数组,数组内是「或」的关系:{"dev", "test"} 表示 dev 或 test 时注册。! 表示取反,例如 @Profile("!prod") 让 MockNotificationSender 只在非生产环境生效。
profile 组合表达式
条件不止「某个 profile 在不在」时,可以用表达式。Spring 的 Profiles 支持 !(非)、&(与)、|(或)和括号。下面两个类需要 @Component 与 @Profile 的 import,与上一段相同。
@Component
@Profile("prod & !test")
class ProdOnlyMetricsExporter {
}
@Component
@Profile("(dev | test) & !cloud")
class LocalOnlyCacheManager {
}
表达式同样能用在配置文件的 on-profile 上:
---
spring:
config:
activate:
on-profile: "prod & !test"
book:
audit-enabled: true
优先级规则是 ! 最高、& 次之、| 最低;拿不准就加括号,别依赖记忆。
默认配置与覆盖关系
把「谁覆盖谁」画成一张从下往上的表(越靠下越晚加载、优先级越高):
| 层级 | 文件 / 来源 |
|---|---|
| 基线 | classpath:/application.yml |
| 基线(配置目录) | classpath:/config/application.yml |
| 外部基线 | ./application.yml |
| 外部基线(配置目录) | ./config/application.yml |
| profile 专属 | classpath:/application-{profile}.yml |
| 外部 profile | ./application-{profile}.yml |
| 外部 profile(配置目录) | ./config/application-{profile}.yml |
规则可以归纳成三句:
- 基线永远先加载,profile 文件在其上做增量覆盖,没写的键继承基线。
- profile 文件之间后加载的赢,顺序由
spring.profiles.active列表决定。 - jar 包外的文件赢过 jar 包内的同名文件,所以运维改配置不必重新打包。
.env 与 spring.config.import
很多团队习惯把本地密钥放在 .env 里并写进 .gitignore。Spring Boot 默认不读 .env,但可以用 spring.config.import 把它当 properties 文件导入:
spring:
config:
import: optional:file:./.env[.properties]
optional:前缀表示文件不存在时不报错,这对「本地有、CI 没有」的场景很关键。[.properties]是位置提示:.env没有扩展名,不提示的话 Spring 猜不出格式。- 导入的键值优先级高于导入它的那个文档,即
.env里的book.page-size会盖掉application.yml里的同名项。
.env 与 .properties 语法大部分重合(都是 KEY=VALUE),但不完全兼容:.env 允许 export KEY=VALUE,也允许不加引号的值里有空格。最稳妥的用法是只放简单的 KEY=VALUE。
Spring Boot 4.1 给 spring.config.import 增加了指定编码的能力:当被导入的文件不是 UTF-8(例如 Windows 导出的 GBK 文件)时,可以在位置提示里追加 encoding 参数(语法见官方 4.1 Release Notes),避免中文乱码:
spring:
config:
import: "optional:file:./config/legacy.properties[encoding=GBK]"
以前只能靠 JVM 的 -Dfile.encoding 全局兜底,现在可以只对某个文件生效,对从老系统迁移配置的团队很实用。
本节常见坑速查
| 现象 | 原因 | 处理 |
|---|---|---|
启动报 cannot start any token | 缩进用了 Tab | 换成空格 |
| 某个键「写了没生效」 | 冒号后缺空格,整行成标量 | 补空格 |
值变成 false / 750 / 493 | YAML 隐式类型转换 | 加引号 |
| 值被截断 | # 被当成注释 | 加引号 |
InvalidConfigDataPropertyException | profile 文档里写了 spring.profiles.active | 移到基线文档 |
| 中文乱码 | 导入文件不是 UTF-8 | 4.1 用 encoding 指定 |
小结
- 配置外部化的目标是同一份产物跑多个环境;Spring Boot 用有序的
PropertySource实现「按优先级取第一个命中」。 - properties 与 yml 能力等价,深层结构选 yml;一个项目只选一种。
- YAML 的六个坑集中在缩进、冒号空格、隐式类型、特殊字符、多文档和列表空值上,凡「像数字、布尔或日期」的字符串一律加引号。
application.yml是基线,application-{profile}.yml做增量覆盖;激活方式有配置文件、命令行、环境变量、系统属性、代码五种,冲突时后激活的赢。@Profile决定 Bean 是否注册,支持!、&、|与括号的组合表达式。spring.config.import可以读.env,4.1 起还能为被导入文件单独指定编码。
到这里,配置文件已经能按环境切换了。但用 @Value("${book.max-borrow-days}") 一个个取值既啰嗦又不安全——下一节我们用 @ConfigurationProperties 把一整块配置绑成类型安全的对象。
阅读导航:上一节:5.3 生命周期回调 · 下一节:6.2 @ConfigurationProperties 类型安全配置 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。