Spring Boot 中的 Profile:开发、测试、生产配置

1. 概述

应用程序通常在多个环境中运行。你的本地机器使用 H2 数据库。测试服务器使用独立的 PostgreSQL。生产环境使用真实的集群和真实的 SMTP。如果每次部署都手工修改配置文件,迟早你会不小心把开发配置推送到生产环境。

Spring Boot 通过 profile 解决这个问题。读完本文,你将学会如何按 profile 分离配置文件,使用 @Profile 开启/关闭 bean,通过多种方式激活 profile,用 @ActiveProfiles 编写测试,并避免常见错误。

2. 什么是 Profile?

Profile 是一个有名字的标签,例如 dev、test、prod。Spring 用这个标签来决定两件事:

  • 哪些配置会被加载:application-dev.yml 只有在 profile dev 激活时才会被读取。
  • 哪些 bean 会被创建:标注 @Profile("prod") 的 bean 只有在 profile prod 激活时才会被注册。

如果没有激活任何 profile,Spring 会使用一个名为 default 的默认 profile。不标注 @Profile 的 bean 始终被创建,无论哪个 profile 激活。

你只需要基本的 web 依赖来完成本文中的示例:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

3. 按 Profile 分离配置文件

3.1. 分离成多个文件

命名约定是 application-{profile}.yml(或 .properties)。文件 application.yml 包含公共配置,总是被加载:

# application.yml
spring:
  application:
    name: shop-service
  profiles:
    default: dev

app:
  mail:
    from: no-reply@shop.vn
    host: localhost

按 profile 的文件只包含不同的部分:

# application-dev.yml
server:
  port: 8080
logging:
  level:
    root: DEBUG
# application-test.yml
server:
  port: 0
app:
  mail:
    host: fake-smtp
# application-prod.yml
server:
  port: 80
logging:
  level:
    root: WARN
app:
  mail:
    host: ${MAIL_HOST}

说明:

  • spring.profiles.default: dev 表示当你没有激活任何 profile 时,Spring 使用 dev 而不是 default。开发机可以直接运行而无需传递参数。
  • 当 profile prod 激活时,Spring 先加载 application.yml,然后是 application-prod.yml。profile 文件中的值会覆盖公共配置中的值。因此 app.mail.host 变成环境变量 MAIL_HOST 的值,而 app.mail.from 仍然保持 no-reply@shop.vn。
  • test 中的 server.port: 0 让 Spring 选择一个随机端口,避免并行运行时的端口冲突。

3.2. 合并到一个 YAML 文件

如果你想要更少的文件,可以使用 YAML 多文档格式,用 --- 分隔:

# application.yml
app:
  mail:
    host: localhost
---
spring:
  config:
    activate:
      on-profile: prod
app:
  mail:
    host: smtp.shop.vn

第二个文档仅在 prod 激活时生效。当每个 profile 差异较小时,这种方式很简洁。当配置很长时,分离成文件仍然更易读和易于审查。

注意:在 .properties 文件中,使用 #--- 行来分隔文档,而不是 ---。

4. 激活 Profile

有多种方式。优先级遵循 Spring Boot 的通用规则:命令行参数 > 环境变量 > 配置文件。

命令行参数:

java -jar shop-service.jar --spring.profiles.active=prod

JVM 系统属性:

java -Dspring.profiles.active=prod -jar shop-service.jar

环境变量,这是 Docker 和 Kubernetes 中最常见的方式:

export SPRING_PROFILES_ACTIVE=prod
java -jar shop-service.jar

使用 Maven 插件运行时:

./mvnw spring-boot:run -Dspring-boot.run.profiles=dev

通过代码,当你想始终添加某个特定 profile 时很有用:

@SpringBootApplication
public class ShopApplication {

    public static void main(String[] args) {
        SpringApplication app = new SpringApplication(ShopApplication.class);
        app.setAdditionalProfiles("metrics");
        app.run(args);
    }
}

setAdditionalProfiles 是添加 profile 到列表中,不会替换从外部激活的 profile。用 --spring.profiles.active=prod 运行时,prod 和 metrics 都会激活。

你可以用逗号同时激活多个 profile:--spring.profiles.active=prod,eu。如果两个文件都定义同一个 key,后面的 profile 胜出。在这个例子中,application-eu.yml 覆盖 application-prod.yml。

应用启动时,日志会告诉你哪个 profile 正在运行:

INFO  c.e.shop.ShopApplication : The following 1 profile is active: "prod"

如果没有激活任何 profile,也没有设置 spring.profiles.default,你会看到:

INFO  c.e.shop.ShopApplication : No active profile set, falling back to 1 default profile: "default"

每次部署时都要查看这行日志。这是快速发现服务器运行了错误 profile 的最好方法。

5. 使用 @Profile 开启/关闭 Bean

不同的配置只是问题的一部分。有时你需要完全不同的实现。例如:dev 只是将电子邮件打印到控制台,test 将其保存到内存以便检查,prod 通过 SMTP 发送。

定义一个公共接口:

public interface NotificationService {
    void send(String to, String message);
}

dev 的实现:

@Service
@Profile("dev")
public class ConsoleNotificationService implements NotificationService {

    @Override
    public void send(String to, String message) {
        System.out.println("[DEV] 发送给 " + to + ": " + message);
    }
}

test 的实现,保存消息以进行断言:

@Service
@Profile("test")
public class InMemoryNotificationService implements NotificationService {

    private final List<String> sentMessages = new CopyOnWriteArrayList<>();

    @Override
    public void send(String to, String message) {
        sentMessages.add(to + "|" + message);
    }

    public List<String> getSentMessages() {
        return List.copyOf(sentMessages);
    }
}

prod 的实现:

@Service
@Profile("prod")
public class SmtpNotificationService implements NotificationService {

    private final String host;

    public SmtpNotificationService(@Value("${app.mail.host}") String host) {
        this.host = host;
    }

    @Override
    public void send(String to, String message) {
        // 实际上会使用 JavaMailSender;这里只是为了演示
        System.out.println("[SMTP " + host + "] 发送给 " + to);
    }
}

使用这个类的代码不需要知道它在哪个环境中:

@Service
public class OrderService {

    private final NotificationService notificationService;

    public OrderService(NotificationService notificationService) {
        this.notificationService = notificationService;
    }

    public void placeOrder(String customerEmail, long orderId) {
        notificationService.send(customerEmail, "订单 #" + orderId + " 已创建");
    }
}

在任何时刻,只有三个 NotificationService bean 中的一个被创建。Spring 会把正确的 bean 注入到 OrderService 中。用 profile dev 运行并调用 placeOrder("an@shop.vn", 42),你会得到:

[DEV] 发送给 an@shop.vn: 订单 #42 已创建

5.1. Profile 表达式

@Profile 支持逻辑表达式:

@Configuration
public class SecurityConfig {

    @Bean
    @Profile("!prod")
    public CommandLineRunner seedDemoData() {
        return args -> System.out.println("加载演示数据...");
    }

    @Bean
    @Profile("dev | test")
    public CommandLineRunner relaxedCors() {
        return args -> System.out.println("对所有源开放 CORS");
    }
}
  • !prod:在除了 prod 之外的所有 profile 中创建 bean。
  • dev | test:当 dev 或 test 激活时创建。也支持 & 表示“且”,例如 prod & eu。

@Profile 可以放在 @Component/@Configuration 类上,或者放在单个 @Bean 方法上。放在 @Configuration 上时,其中的所有 bean 都受该条件限制。

5.2. 在代码中检查 Profile

有时你需要在逻辑中进行分支。使用 Environment:

@Component
public class StartupBanner implements CommandLineRunner {

    private final Environment environment;

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

    @Override
    public void run(String... args) {
        if (environment.acceptsProfiles(Profiles.of("prod"))) {
            System.out.println("正在运行生产环境");
        }
    }
}

尽量少用这种方式。如果代码中到处是 if (prod),你应该把它们分离成带 @Profile 的独立 bean。

6. Profile 组

随着系统增长,你可能想把配置分成更小的部分,比如 proddb 用于数据库,prodmq 用于消息代理。Profile 组允许你用一个名字激活整个集群:

# application.yml
spring:
  profiles:
    group:
      prod: proddb, prodmq
      dev: devdb

现在运行 --spring.profiles.active=prod 会激活 prod、proddb 和 prodmq。Spring 会依次加载 application-prod.yml、application-proddb.yml、application-prodmq.yml。运维人员只需要记住一个名字。

7. 使用 @ActiveProfiles 编写测试

在测试中,使用 @ActiveProfiles 选择 profile。下面的测试检查两件事:正确的 bean 被注入,以及来自 application-test.yml 的配置生效。

@SpringBootTest
@ActiveProfiles("test")
class OrderServiceProfileTest {

    @Autowired
    private OrderService orderService;

    @Autowired
    private NotificationService notificationService;

    @Value("${app.mail.host}")
    private String mailHost;

    @Test
    void shouldUseInMemoryNotificationInTestProfile() {
        assertThat(notificationService).isInstanceOf(InMemoryNotificationService.class);
    }

    @Test
    void shouldRecordNotificationWhenOrderPlaced() {
        orderService.placeOrder("binh@shop.vn", 7);

        var inMemory = (InMemoryNotificationService) notificationService;
        assertThat(inMemory.getSentMessages())
                .contains("binh@shop.vn|订单 #7 已创建");
    }

    @Test
    void shouldOverrideMailHostFromTestProfile() {
        assertThat(mailHost).isEqualTo("fake-smtp");
    }
}

说明:

  • @ActiveProfiles("test") 为这个类激活 profile test,无论 spring.profiles.default 是什么。
  • 第一个测试确认 Spring 选择了 InMemoryNotificationService 而不是真正发送邮件。
  • 第二个测试使用内存中的 bean 来检查消息内容,无需 mock。
  • 第三个测试证明来自 application-test.yml 的 app.mail.host 已覆盖 application.yml 中的 localhost。

注意:每个不同的 profile 组合都会创建一个单独的 Spring context。如果每个测试类使用不同的 profile 集合,context 缓存就无法重用,整个测试套件会变得很慢。应该将测试中的 profile 组合数量保持在最小值。

8. 常见错误

在 profile 文件中设置 spring.profiles.active。 从 Spring Boot 2.4 开始,在 application-dev.yml 中声明 spring.profiles.active 或 spring.profiles.include(或在带有 on-profile 的文档中)会导致应用无法启动:

InvalidConfigDataPropertyException: Property 'spring.profiles.active' imported from
location 'class path resource [application-dev.yml]' is invalid in a profile specific resource

应该使用 profile 组(第 6 节)来替代。

在 application.yml 中硬编码 spring.profiles.active=dev。 这个文件被打包到 jar 中。如果有人忘记在服务器上传递环境变量,生产环境会用开发配置运行。应该在本地环境使用 spring.profiles.default,并在服务器上始终显式激活 profile。

在 application-prod.yml 中放置密钥。 数据库密码或 API 密钥不应该在 Git 中。应该使用像 ${DB_PASSWORD} 这样的占位符,并通过环境变量或 secret 管理器提供值。

没有 bean 匹配 profile。 如果你用 staging profile 运行,但只有 dev、test、prod 的实现,应用会报错:

Parameter 0 of constructor in OrderService required a bean of type
'NotificationService' that could not be found.

考虑使用 @Profile("!prod") 添加一个回退 bean,或为新 profile 添加实现。

将 profile 滥用于功能开关。 Profile 适合用来描述环境。如果你想独立于环境来开启/关闭某个功能,使用 @ConditionalOnProperty 通常更清楚:

@Bean
@ConditionalOnProperty(name = "app.feature.loyalty-points", havingValue = "true")
public LoyaltyService loyaltyService() {
    return new LoyaltyService();
}

当你有像 prod-with-loyalty 或 dev-no-cache 这样的 profile 时,这表明你应该转向使用属性。

9. 总结

Profile 帮助你按环境隔离配置和行为,而无需修改代码。你已经看到了如何使用 application-{profile}.yml 覆盖配置、使用 @Profile 选择实现、使用 profile 组来组合多个 profile,以及使用 @ActiveProfiles 进行测试。

几个要记住的原则:在 application.yml 中放置公共配置,仅在 profile 文件中记录差异。在服务器上通过环境变量或命令行参数激活 profile。不要把密钥提交到 Git。在每次部署后始终检查日志行 The following profiles are active。

spring-bootprofileconfigurationtesting