Spring Bootのプロフィール:dev、test、prodの設定

1. 概要

アプリケーションは通常、複数の環境で実行されます。開発マシンはH2データベースを使用します。テストサーバーは独自のPostgreSQLを使用します。本番環境は実際のクラスタと実際のSMTPを使用します。デプロイのたびに設定ファイルを手動で編集していると、やがて開発設定を誤って本番環境にプッシュしてしまうでしょう。

Spring Bootはこの問題をプロフィールで解決します。この記事を読み終わったら、プロフィールに応じて設定ファイルを分離する方法、@Profileでbeanのオン/オフを切り替える方法、複数の方法でプロフィールを有効化する方法、@ActiveProfilesでテストを書く方法、そしてよくあるエラーを回避する方法を知ることができます。

2. プロフィールとは?

プロフィールは名前付きのラベルで、例えばdev、test、prodです。Springはこのラベルを使って2つのことを決定します:

  • どの設定が読み込まれるか: application-dev.ymlはプロフィールdevがアクティブな場合にのみ読み込まれます。
  • どのbeanが作成されるか: @Profile("prod")が付いたbeanはプロフィールprodがアクティブな場合にのみ登録されます。

プロフィールが有効化されていない場合、Springはデフォルトという名前のプロフィールを使用します。@Profileが付いていないbeanは、どのプロフィールがアクティブであるかに関わらず、常に作成されます。

この記事の例では、基本的な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. プロフィール別の設定ファイル

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

プロフィール別のファイルは異なる部分のみを記述します:

# 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とは、プロフィールを有効化しない場合、Springはdefaultではなくdevを使用することを意味します。開発マシンはパラメータなしで直ぐに実行できます。
  • プロフィールprodがアクティブな場合、Springは最初にapplication.ymlを読み込み、次にapplication-prod.ymlを読み込みます。プロフィールファイル内の値は共通の値をオーバーライドします。したがって、app.mail.hostは環境変数MAIL_HOSTの値になり、app.mail.fromはno-reply@shop.vnのままです。
  • testのserver.port: 0は、Springがランダムなポートを選択するようにして、並列実行時の競合を回避します。

3.2. 1つのYAMLファイルに統合する

ファイルを減らしたい場合は、---で区切られたYAML複数ドキュメントを使用できます:

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

2番目のドキュメントはprodがアクティブな場合にのみ有効です。この方法は各プロフィールの違いが少ない場合にコンパクトです。設定が長い場合、ファイルを分割する方が読みやすくレビューしやすいです。

注意: .propertiesファイルでは、#---行を使って---の代わりにドキュメントを分割します。

4. プロフィールの有効化

多くの方法があります。優先順位は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

コードで指定する、特定のプロフィールを常に追加したい場合に便利です:

@SpringBootApplication
public class ShopApplication {

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

setAdditionalProfilesはプロフィールをリストに追加し、外部から有効化されたプロフィールを置き換えません。--spring.profiles.active=prodで実行すると、prodとmetricsの両方がアクティブになります。

カンマで複数のプロフィールを同時に有効化できます:--spring.profiles.active=prod,eu。2つのファイルが同じキーを定義している場合、後ろのプロフィールが優先されます。この例では、application-eu.ymlがapplication-prod.ymlをオーバーライドします。

アプリケーションが起動すると、ログにどのプロフィールが実行されているかが表示されます:

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

プロフィールがなく、spring.profiles.defaultも設定されていない場合、以下が表示されます:

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

デプロイ時には常にこのログを確認してください。これはサーバーが誤ったプロフィールで実行されていることを検出する最速の方法です。

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用の実装で、メッセージをassertするために保存します:

@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 + " が作成されました");
    }
}

同時に、3つのNotificationService beanのうち1つだけが作成されます。Springは適切なbeanをOrderServiceにインジェクトします。プロフィールdevで実行してplaceOrder("an@shop.vn", 42)を呼び出すと、以下が得られます:

[DEV] 送信先 an@shop.vn: ご注文 #42 が作成されました

5.1. プロフィール式

@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を除くすべてのプロフィールでbeanを作成します。
  • dev | test:devまたはtestがアクティブな場合に作成します。「かつ」を表す&もあります、例えばprod & eu。

@Profileは@Component/@Configurationクラスまたは個々の@Beanメソッドに付けることができます。@Configurationに付けると、内部のすべてのbeanが同じ条件の対象になります。

5.2. コードでプロフィールを確認する

時には、ロジックで分岐する必要があります。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. プロフィールグループ

システムが大きくなるにつれて、設定を細かく分割したい場合があります。例えば、proddbはデータベース用、prodmqはメッセージブローカー用です。プロフィールグループにより、1つの名前でクラスタ全体を有効化できます:

# 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を読み込みます。オペレータは1つの名前を覚えるだけで済みます。

7. @ActiveProfilesでテストを書く

テストでは、@ActiveProfilesを使ってプロフィールを選択します。以下のテストは2つのことを確認します:正しい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")は、spring.profiles.defaultが何であれ、このクラスのコンテキストに対してプロフィールtestをアクティブ化します。
  • 最初のテストは、Springが実際のメール送信ではなくInMemoryNotificationServiceを選択することを確認します。
  • 2番目のテストは、メモリ内beanを使用してメッセージの内容を確認し、モックは必要ありません。
  • 3番目のテストは、application-test.ymlからのapp.mail.hostがapplication.ymlのlocalhost値をオーバーライドしていることを示しています。

注意: プロフィールの組み合わせが異なるたびに、別々のSpringコンテキストが作成されます。各テストクラスが異なるプロフィール群を使用する場合、コンテキストキャッシュが再利用されず、テストスイートが大幅に低下します。テスト内のプロフィール組み合わせの数を最小限に保ってください。

8. よくあるエラー

プロフィール別ファイルにspring.profiles.activeを設定する。 Spring Boot 2.4以降、application-dev.yml内(またはプロフィール固有のドキュメント内)にspring.profiles.activeまたはspring.profiles.includeを宣言するとアプリケーションが起動しなくなります:

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

代わりにプロフィールグループ(セクション6)を使用してください。

application.ymlにspring.profiles.active=devをハードコードする。 このファイルはjarにパッケージ化されます。サーバーで誰かが環境変数の設定を忘れると、prodが開発設定で実行されます。ローカル環境にはspring.profiles.defaultを使用し、サーバーではプロフィールを明示的に有効化してください。

application-prod.ymlに秘密を置く。 データベースのパスワードやAPIキーはGitに置くべきではありません。${DB_PASSWORD}のようなプレースホルダーを使用し、環境変数またはシークレットマネージャーで値を提供してください。

プロフィールに一致するbeanがない。 stagingプロフィールで実行しているが、dev、test、prodの実装しかない場合、アプリケーションはエラーを報告します:

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

フォールバックbeanを@Profile("!prod")で検討するか、新しいプロフィールの実装を追加してください。

フィーチャーフラグにプロフィールを乱用する。 プロフィールは環境を説明するのに適しています。環境に関係なく機能をオン/オフにしたい場合、@ConditionalOnPropertyの方が通常明確です:

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

prod-with-loyaltyやdev-no-cacheのようなプロフィールがある場合、それはプロパティに切り替える時期のサインです。

9. まとめ

プロフィールは、コードを変更せずに環境ごとに設定と動作を分離するのに役立ちます。application-{profile}.ymlを使って設定をオーバーライドする方法、@Profileで実装を選択する方法、プロフィールグループで複数のプロフィールをグループ化する方法、@ActiveProfilesでテストする方法を学びました。

覚えておくべき原則がいくつかあります:共通設定をapplication.ymlに置き、プロフィールファイルには異なる部分のみ記述してください。サーバーでプロフィールを環境変数またはコマンドライン引数で有効化してください。Gitに秘密を入れないでください。そして、デプロイのたびにThe following profiles are activeログを常に確認してください。

spring-bootprofileconfigurationtesting