@ConfigurationPropertiesで@Valueの代わりに設定を構成する

1. 概要

@Valueは1、2つの設定値を読むだけなら非常に便利です。プロパティの数が増えると、${...}の文字列があらゆるbeanに散在し、見つけにくく、入力ミスが起こりやすく、何もチェックされません。

このレッスンでは、Spring Boot 3.xで@ConfigurationPropertiesに切り替える方法を説明します。このレッスンを読み終わったら、設定を明確な型を持つrecordにまとめる方法、Duration、List、Map、ネストされたオブジェクトなどの型をバインディングする方法を理解できます。また、起動時に設定を検証する方法と、その設定についてのテストを作成する方法も学びます。

2. @Valueの問題

支払いゲートウェイを呼び出す必要があるPaymentServiceを考えてみましょう:

@Service
public class PaymentService {

    @Value("${app.payment.gateway-url}")
    private String gatewayUrl;

    @Value("${app.payment.merchant-id}")
    private String merchantId;

    @Value("${app.payment.timeout:10s}")
    private Duration timeout;

    @Value("${app.payment.max-retries:3}")
    private int maxRetries;

    // ...
}

このコードは動作しますが、いくつかの問題があります:

  • キーは自由な文字列です。 gateway-urlの代わりにgatewayUrlと入力すると、実行時までエラーに気付きません。
  • 検証がありません。 max-retries=-5でも問題なく注入されます。
  • 設定が散在しています。 RefundServiceもmerchant-idが必要な場合、そこでも@Valueを繰り返す必要があります。
  • テストが難しいです。 Springコンテキストを使わずにPaymentServiceをテストしたい場合、リフレクションを使ってフィールドを割り当てる必要があります。
  • 複雑な型が厄介です。 @ValueでMap<String, BigDecimal>を注入するには、かなり複雑なSpELが必要です。

3. @ConfigurationPropertiesで設定クラスを宣言する

3.1. recordを使用する(推奨)

application.ymlファイル:

app:
  payment:
    gateway-url: https://pay.example.com/api
    merchant-id: NONLA-001
    timeout: 5s
    max-retries: 3

対応する設定クラス:

package com.nonla.payment;

import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;

@ConfigurationProperties(prefix = "app.payment")
public record PaymentProperties(
        String gatewayUrl,
        String merchantId,
        @DefaultValue("10s") Duration timeout,
        @DefaultValue("3") int maxRetries) {
}

説明:

  • prefix = "app.payment"は、このrecordがapp.paymentの下にあるすべてのキーを受け取ることをSpringに伝えます。
  • Spring Boot 3は、自動的にrecordに対してconstructor bindingを使用します。setterや@ConstructorBindingは不要です(クラスがコンストラクタを1つだけ持つ場合)。
  • Relaxed bindingはgateway-url、gatewayUrl、GATEWAY_URL(環境変数APP_PAYMENT_GATEWAYURL)を同じパラメータgatewayUrlにマッピングします。
  • @DefaultValueは、キーが設定に存在しない場合のデフォルト値を提供します。
  • 文字列5sは自動的にDuration.ofSeconds(5)に変換されます。

3.2. 登録と使用

上記のrecordはまだbeanではありません。メインクラスで有効にする必要があります:

@SpringBootApplication
@ConfigurationPropertiesScan
public class ShopApplication {
    public static void main(String[] args) {
        SpringApplication.run(ShopApplication.class, args);
    }
}

@ConfigurationPropertiesScanは現在のパッケージとそのサブパッケージをスキャンして、@ConfigurationPropertiesを持つクラスを見つけます。各クラスを明示的に宣言したい場合は、@Configurationクラスで@EnableConfigurationProperties(PaymentProperties.class)を使用します。

現在、PaymentServiceは単純に1つのオブジェクトを注入するだけです:

@Service
public class PaymentService {

    private final PaymentProperties props;

    public PaymentService(PaymentProperties props) {
        this.props = props;
    }

    public String describe() {
        return "%sへの呼び出し、マーチャント%s、タイムアウト%s、再試行%d回"
                .formatted(props.gatewayUrl(), props.merchantId(),
                           props.timeout(), props.maxRetries());
    }
}

describe()を呼び出したときの結果:

https://pay.example.com/apiへの呼び出し、マーチャントNONLA-001、タイムアウトPT5S、再試行3回

PaymentServiceは純粋なJavaクラスになりました。ユニットテストでは、Springなしでnew PaymentService(new PaymentProperties(...))を使用するだけです。

注意: constructor bindingを使用するrecordに@Componentを付けないでください。その場合、Springはそれを通常のbeanとして扱い、別のbeanとしてString gatewayUrlをautowireしようとします。アプリケーションは起動時にエラーになります。@ConfigurationPropertiesScanまたは@EnableConfigurationPropertiesで登録してください。

3.3. setterを持つJavaBean

mutableなオブジェクト(例えば、古いコードとの互換性のため)が必要な場合、getter/setterを持つクラスを使用できます:

@ConfigurationProperties(prefix = "app.mail")
public class MailProperties {
    private String host = "localhost";
    private int port = 25;

    public String getHost() { return host; }
    public void setHost(String host) { this.host = host; }
    public int getPort() { return port; }
    public void setPort(int port) { this.port = port; }
}

Springはデフォルトコンストラクタを呼び出し、見つかったキーごとにsetterを呼び出します。デフォルト値はフィールドの初期値です。このアプローチはうまく機能しますが、オブジェクトはランタイムのどこからでも変更される可能性があります。したがって、recordがより安全な選択です。

4. 複雑なデータ型のバインディング

支払い設定をリスト、マップ、およびネストされたオブジェクトで拡張します:

app:
  payment:
    gateway-url: https://pay.example.com/api
    merchant-id: NONLA-001
    timeout: 5s
    max-retries: 3
    max-payload: 2MB
    supported-currencies:
      - VND
      - USD
    fees:
      card: 1.5
      wallet: 0.8
    webhook:
      enabled: true
      secret: s3cr3t
@ConfigurationProperties(prefix = "app.payment")
public record PaymentProperties(
        String gatewayUrl,
        String merchantId,
        @DefaultValue("10s") Duration timeout,
        @DefaultValue("3") int maxRetries,
        @DefaultValue("1MB") DataSize maxPayload,
        @DefaultValue({"VND"}) List<String> supportedCurrencies,
        Map<String, BigDecimal> fees,
        @DefaultValue Webhook webhook) {

    public record Webhook(
            @DefaultValue("false") boolean enabled,
            String secret) {
    }
}

各パラメータはバインディングされます:

  • DataSizeはB、KB、MB、GBなどの単位を理解します。2MBはDataSize.ofMegabytes(2)になります。
  • List<String>はYAML形式のリストと、コンマで区切られた文字列(VND,USD)の両方を受け入れます。
  • Map<String, BigDecimal>は子キー(card、wallet)をマップのキーとして取ります。
  • Webhookはネストされたrecordで、プレフィックスapp.payment.webhookに従って再帰的にバインドされます。
  • webhookに値なしで@DefaultValueがあると、Springはプレフィックス全体が欠落している場合、デフォルト値を使用してWebhookを作成します。このアノテーションがない場合、webhook()はnullを返します。

注意: Durationでは、単位なしの数値はミリ秒として解釈されます。timeout: 30は30秒ではなく30msを意味します。常に単位を記入する(30s、500ms)か、パラメータに@DurationUnit(ChronoUnit.SECONDS)を付けてください。

5. 起動時に設定を検証する

validation starterを追加します:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

次に、@ValidatedでマークしてJakarta Bean Validationの制約を追加します:

@Validated
@ConfigurationProperties(prefix = "app.payment")
public record PaymentProperties(
        @NotBlank String gatewayUrl,
        @NotBlank String merchantId,
        @DefaultValue("10s") Duration timeout,
        @Min(0) @Max(10) @DefaultValue("3") int maxRetries,
        @DefaultValue("1MB") DataSize maxPayload,
        @NotEmpty @DefaultValue({"VND"}) List<String> supportedCurrencies,
        Map<String, BigDecimal> fees,
        @Valid @DefaultValue Webhook webhook) {

    public record Webhook(
            @DefaultValue("false") boolean enabled,
            String secret) {
    }
}
  • @Validatedは、バインディングが完了した直後に検証をアクティブにします。
  • webhookの@Validは、Webhook内の制約も確認されるようにします。@Validがない場合、Springはネストされたオブジェクトをスキップします。

誰かがmerchant-idを空にする場合、アプリケーションは起動時に停止して、明確なメッセージが表示されます:

***************************
APPLICATION FAILED TO START
***************************

Description:

Binding to target com.nonla.payment.PaymentProperties failed:

    Property: app.payment.merchantId
    Value: ""
    Origin: class path resource [application.yml] - 4:17
    Reason: must not be blank

Action:

Update your application's configuration

システムはデプロイ時にエラーを報告し、最初の支払いリクエストで破損するのを待つ代わりに。これは@Valueに対する最大のメリットです。

6. IDEでの設定プロセッサを使用したヒント

annotation processorを追加します:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-configuration-processor</artifactId>
    <optional>true</optional>
</dependency>

コンパイル時に、processorはapp.paymentの下のすべてのキーを説明するMETA-INF/spring-configuration-metadata.jsonファイルを生成します:型、デフォルト値、説明。IntelliJ IDEAとVS Code(Spring Toolsを使用)はこのファイルを使用してキーの提案を行い、存在しないキーについて警告し、application.ymlでキーにマウスを置いたときにドキュメントを表示します。

各プロパティの説明はJavadocから取得されます。recordの場合、@paramを使用してJavadocを記述します:

/**
 * 支払いゲートウェイの設定。
 *
 * @param gatewayUrl 支払いゲートウェイのベースURL
 * @param maxRetries ゲートウェイの呼び出しに失敗した場合の最大再試行回数
 */

注意: プロジェクトがmaven-compiler-pluginでannotationProcessorPathsを既に宣言している場合(例えば、LombokやMapStructの場合)、spring-boot-configuration-processorをそのリストに追加する必要があります。そうしないと、processorは実行されません。

7. 設定クラスのテスト

重い@SpringBootTestは必要ありません。ApplicationContextRunnerは必要なものだけを含む小さなコンテキストを構築します:

package com.nonla.payment;

import static org.assertj.core.api.Assertions.assertThat;

import java.math.BigDecimal;
import java.time.Duration;
import org.junit.jupiter.api.Test;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.context.properties.bind.validation.BindValidationException;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.context.annotation.Configuration;

class PaymentPropertiesTest {

    @Configuration
    @EnableConfigurationProperties(PaymentProperties.class)
    static class TestConfig {
    }

    private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
            .withUserConfiguration(TestConfig.class);

    @Test
    void shouldBindValuesAndApplyDefaults() {
        contextRunner
                .withPropertyValues(
                        "app.payment.gateway-url=https://pay.example.com/api",
                        "app.payment.merchant-id=NONLA-001",
                        "app.payment.timeout=2s",
                        "app.payment.fees.card=1.5")
                .run(context -> {
                    PaymentProperties props = context.getBean(PaymentProperties.class);

                    assertThat(props.timeout()).isEqualTo(Duration.ofSeconds(2));
                    assertThat(props.maxRetries()).isEqualTo(3);
                    assertThat(props.supportedCurrencies()).containsExactly("VND");
                    assertThat(props.fees()).containsEntry("card", new BigDecimal("1.5"));
                    assertThat(props.webhook().enabled()).isFalse();
                });
    }

    @Test
    void shouldFailWhenMaxRetriesOutOfRange() {
        contextRunner
                .withPropertyValues(
                        "app.payment.gateway-url=https://pay.example.com/api",
                        "app.payment.merchant-id=NONLA-001",
                        "app.payment.max-retries=50")
                .run(context -> assertThat(context)
                        .hasFailed()
                        .getFailure()
                        .hasRootCauseInstanceOf(BindValidationException.class));
    }
}

これら2つのテストは次の点をチェックします:

  • 最初のテストは、値が正しく型にバインディングされていることを確認します(2sがDurationに、1.5がBigDecimalに)。また、maxRetries、supportedCurrencies、webhookに@DefaultValueが適用されていることも確認します。
  • 2番目のテストは、max-retries=50が@Max(10)に違反し、BindValidationExceptionでコンテキストの起動に失敗することを確認します。

各テストは数百ミリ秒で実行されます。Webサーバーや他のauto-configurationがないためです。

8. @Valueをいつ使い続けるか

@ConfigurationPropertiesは@Valueを完全に置き換えません。以下の場合、@Valueを使用できます:

  • SpELが必要です。 例えば@Value("#{${app.payment.timeout-seconds} * 1000}")。@ConfigurationPropertiesはSpEL式をサポートしていません。
  • 1つの値だけが必要で、1つの場所でのみ使用されます。 例えば、ログbeanの@Value("${spring.application.name}")。この場合、record全体を作成するのは余分です。

@ConfigurationPropertiesに切り替えるときに発生する一般的なエラー:

  • クラスの登録を忘れる。 @ConfigurationPropertiesScanまたは@EnableConfigurationPropertiesがないと、「No qualifying bean of type PaymentProperties」エラーが発生します。
  • プレフィックスが規約に合わない。 プレフィックスはkebab-caseの小文字である必要があります(app.payment、app.Paymentではなく)。間違っている場合、Springは起動時にエラーを報告します。
  • 複数のコンストラクタを持つ。 recordまたはクラスが複数のコンストラクタを持つ場合、バインディングに使用するコンストラクタに@ConstructorBindingでマークする必要があります。
  • すべてに1つのクラスを使用する。 アプリケーション全体の設定を巨大なAppPropertiesに詰め込まないでください。ビジネスドメイン別に分割してください:PaymentProperties、MailProperties、StorageProperties…

注意: 設定recordは変更不可です。Spring Cloudを使用してランタイムに設定をリフレッシュする場合、beanは(@RefreshScopeまたは再バインドメカニズムを介して)再作成される必要があります。そうしないと、新しい値は有効になりません。recordが自動的に変わることを期待しないでください。

9. 結論

このレッスンでは、バラバラの@Valueを明確な型を持つPaymentProperties recordに置き換えました。具体的には、以下のことができました:

  • @DefaultValueを使用してDuration、DataSize、List、Map、およびネストされたrecordをバインディングする
  • @ValidatedとJakarta制約を使用して起動時に設定を検証する
  • spring-boot-configuration-processorのおかげでIDEでキーの提案を取得する
  • ApplicationContextRunnerを使用して設定部分を迅速にテストする

3つ以上のキーを持つ設定のグループでは、@ConfigurationPropertiesがデフォルトの選択肢であるべきです。@Valueは、個別の値またはSpELが本当に必要な場合のために残してください。

spring-bootconfigurationconfigurationpropertiesvalidationjava