Springの小話 - ConfigurationPropertiesにrecordクラスを使ってみた

| 8 min read
Author: toshio-ogiwara toshio-ogiwaraの画像

Java16でrecordクラスが正式化されてからそれなりに時間が経ち、今ではすっかり普通に使われるようになりました。筆者はデータクラスの実装にLombokを使っていますが、そろそろ若干の後ろめたさを感じてきました。そんなこともあり重い腰をやっと上げ、Spring Bootの@ConfigurationPropertiesのバインドクラスをLombokからrecordクラスにまずは変えてみました。今回はそこで得たrecordクラスの利用法や使用感を紹介したいと思います。最初に結論をいうと、recordクラス、Lombokの@Dataと遜色なく使えて便利でお勧めです。

Information

この記事はSpring Boot 3.3.4で動作を確認しています。また記事で説明したコードはGitHubのこちらにアップしています。

Lombokの@Dataを使った例

#

実装法や使用感は従来のLombokのクラスをrecordクラスに変えてみるのがわかりやすいと思います。ということで、今回は次の設定とクラスをrecordクラスにしたらどうなるかを例に説明していきます。

  • @ConfigurationPropertiesでバインドする設定
test:
  jwt-issuer:
    enable: true
    private-key: classpath:/jwt.key
    clock:
      type: FIXED
      fixed-datetime: 2024-02-01T12:30 # typeがSYSTEMの場合は無視される
    claim:
      issuer: JwtIssuerProperties
      exp: 30 # 有効期限(分単位)
  • @ConfigurationPropertiesで設定をバインドするクラス
@ConfigurationProperties(prefix = "test.jwt-issuer")
@Validated // (1)
@Data // Lombokでgetter/setterを生成
public class JwtIssuerDataProperties {

    private boolean enable = false; // (2)
    @NotNull // (3)
    private RSAPrivateKey privateKey;
    private ClockProperties clock = new ClockProperties(); // (4)
    private Claim claim = new Claim(); // (5)

    @Data
    public static class ClockProperties {
        enum Type {
            SYSTEM,
            FIXED
        }
        private Type type = Type.SYSTEM; // (6)
        private LocalDateTime fixedDatetime;
        public Clock getClock() { // (7)
            return switch (type) {
                case SYSTEM -> Clock.systemDefaultZone();
                case FIXED -> Clock.fixed(getFixedInstant(), ZoneId.systemDefault());
            };
        }
        public Instant getFixedInstant() { // (8)
            return fixedDatetime.atZone(ZoneId.systemDefault()).toInstant();
        }
    }

    @Data
    public static class Claim {
        private String issuer;
        @PositiveOrZero // (9)
        private int exp = 60;  // (10)
        public Instant getExpirationTime(Instant creationTime) { // (11)
            return creationTime.plusSeconds(exp * 60);
        }
    }
}

@ConfigurationPropertiesの機能をある程度網羅した例を使いたかったため、実際利用しているJWTの設定クラスを例にしていることもあり若干難しく見えますが、@ConfigurationPropertiesの利用における注目ポイントは次のとおりです。(逆にいうと他はあまり気にしなくてもOKです)

  1. バインドする設定が存在しなかった場合の初期値を定義している → (2)(6)(10)
  2. ネストしたオブジェクトを持っている → (4)(5)
  3. バインドされた値をもとにした導出メソッドを持っている → (7)(8)(11)
  4. BeanValidationによる設定値の検証を行っている → (1)(3)(9)
  5. Stringの設定値からRSAPrivateKey[1]やenum型、LocalDateTime型などへの型変換が自動で行われる
設定値のバインドはフィールドアクセスで行われる

@ConfigurationPropertiesは設定キーにマッチするフィールドにキーに対する設定値をバインドする機能ですが、この設定値のバインドはプロパティアクセス(setter呼び出し)ではなくフィールドアクセスで行われます。このため、バインドクラスにgetter/setterがなくても実は設定値にフィールドはバインドされます。

こう考えるとバインドクラスに用いるLombokのアノテーションはgetter/setterの両方を生成する@Dataではなく、getterしか生成しないイミュータブルな@Valueがよい気がしますが1点だけ難点があります。それは初期値を持つフィールドに@NonFinalが必要になることです。

@ConfigurationProperties(prefix = "test.jwt-issuer")
@Validated
@Value
public class JwtIssuerDataProperties {
    private @NonFinal boolean enable = false; // ← @NonFinalが必要
    ...

@Valueはデフォルトですべてのフィールドをfinalにするため、@NonFinalを付けないとインスタンス生成時にフィールドが初期値でfixされてしまいます。筆者はこの点が好みではないため、作法的にはイミュータブルな@Valueがよいと思いつつ、ミュータブルな@Dataを使っています。(賛否両論あると思いますが)

recordクラスを使う場合のキモ

#

では、先ほどのLombokのバインドクラスをrecordクラスの実装に変えてみたいと思いますが、ここでキモとなるのが初期値の設定です。

recordクラスはフィールドを宣言しないため「普通のクラス」のようにフィールド宣言時に初期値を設定することはできません。また、recordクラスで初期値を定義する場合は次のように外から指定する値だけを引数に持つコンストラクタを新たに定義し、それを呼び出すことが定石ですが@ConfigurationPropertiesで使うことができるコンストラクタは1つだけです。よって、この定石も使えません。

@ConfigurationProperties(prefix = "test.jwt-issuer")
@Validated
public record JwtIssuerRecordProperties2(
        boolean enable,
        RSAPrivateKey privateKey,
        ClockProperties clock,
        Claim claim) {

    public JwtIssuerRecordProperties2 (
            RSAPrivateKey privateKey,
            ClockProperties clock,
            Claim claim) {
        // コンストラクタをオーバーロードして初期値を設定
        this(false, privateKey, clock, claim); 
    }
    ...

recordクラスを使った例

#

そこで登場するのが@DefaultValueです。Spring Bootにはrecordクラスの初期値の設定として@DefaultValueが用意されています。この@DefaultValueを使ったrecordクラスの実装は次のようになります。

@ConfigurationProperties(prefix = "test.jwt-issuer")
@Validated
public record JwtIssuerRecordProperties(

        @DefaultValue("false") // (1)
        boolean enable,
        @NotNull
        RSAPrivateKey privateKey,
        @DefaultValue // (2)
        ClockProperties clock,
        @DefaultValue // (3)
        Claim claim) {

    public static record ClockProperties(
            @DefaultValue("SYSTEM") // (4)
            Type type,
            LocalDateTime fixedDatetime) {
        enum Type {
            SYSTEM, FIXED
        }
        public Clock clock() {
            return switch (type) {
                case SYSTEM -> Clock.systemDefaultZone();
                case FIXED -> Clock.fixed(getFixedInstant(), ZoneId.systemDefault());
            };
        }
        public Instant getFixedInstant() {
            return fixedDatetime.atZone(ZoneId.systemDefault()).toInstant();
        }
    }

    public static record Claim(
            String issuer,
            @DefaultValue("60") // (5)
            @PositiveOrZero
            int exp) {
        public Instant expirationTime(Instant creationTime) {
            return creationTime.plusSeconds(exp * 60);
        }
    }
}

値フィールドに対する@DefaultValueの指定

#

recordクラスでは(1)(4)(5)のように初期値を設定したいコンストラクタ引数に@DefaultValueで初期値を指定します。

Spring Bootはrecordクラスのコンストラクタを呼び出す際、引数にバインドする設定がない場合はnullを設定しますが、引数に@DefaultValueが付けられている場合は、アノテーションに指定されている文字列をSpring Frameworkの型変換サービス(ConversionService)で型変換した値を設定します。recordクラスの初期値はこの仕組みを使って設定します。

ネストしたオブジェクトに対する@DefaultValueの指定

#

同じ@DefaultValueの指定でも(2)と(3)は上述の初期値の設定とは少し意味合いが異なります。

(2)と(3)の@DefaultValueの指定はデフォルトでネストしたクラスの空のインスタンスを生成する意味となります。この空のインスタンスが生成される際も(4)と(5)の初期値の設定は有効となります。逆に(2)と(3)の@DefaultValue指定がなく、かつそれぞれが持つフィールドに対する設定が1つも存在しなかった場合はclockフィールドとclaimフィールドはnullとなります。

導出メソッドの定義や型変換など

#

導出メソッドの定義や型変換、BeanValidationによる検証はLombokの@Dataクラスや「普通のクラス」と全く同じように行うことができます。

recordクラスではできないこと

#

ここまでの内容を見るとrecordクラスは「普通のクラス」と遜色なく@ConfigurationPropertiesのバインドクラスとして使うことができるように見えますが、筆者が知る限り1つだけrecordクラスにできないことがあります。

それはJavaConfigによるバインドプロパティの指定です。Lombokの@Dataクラスなど「普通のクラス」の場合、次のようにすることでバインドプロパティを実行時に決定することができます。

  • JavaConfigを使いバインドプロパティを実行時に決定する例
@Bean
@ConfigurationProperties(prefix = "test.jwt-issuer")
JwtIssuerDataProperties jwtIssuerDataProperties() {
    // JwtIssuerDataPropertiesには@ConfigurationPropertiesを付けない
    return new JwtIssuerDataProperties(); 
}

この使い方はプレフィックスが異なる複数の設定がある場合に使ったりしますが、これはrecordクラスではできません。

上述のコラム欄にも書きましたが「普通のクラス」ではバインドクラスのインスタンスが生成された後にフィールドアクセスによる値の設定が行われますが、recordクラスのフィールドは内部的にfinalであるため、インスタンス生成後にフィールドの値を変更することはできません。これがrecordクラスではできない理由となります。

さいごに

#

最後に説明したJavaConfigによるバインドプロパティの指定はrecordクラスではできませんが、このケースが必要となるのは筆者の経験上、かなり稀です。

recordクラスはJavaの言語仕様に含まれる標準機能でかつ、Lombokの@Dataに比べても機能的に大きな遜色はなく、イミュータブルなデータクラスを簡潔に記述できることから、@ConfigurationPropertiesには原則recordクラスにし、JavaConfigによるバインドプロパティの指定など、recordクラスではできないケースがある場合にのみLombokを使うようにするのがよいのではないかと思います。


  1. RsaKeyConversionServicePostProcessorのBeanが登録されていればパス(String)からRSAPrivateKeyの型変換を自動で行うことができます。通常このPostProcessorは@EnableWebSecurityで登録されます。なおサンプルでは秘密鍵をクラスパス上に置いていますが、非常に重要なデータなので、本番環境ではセキュリティが確保された安全な場所に配置しましょう。 ↩︎

豆蔵では共に高め合う仲間を募集しています!

recruit

具体的な採用情報はこちらからご覧いただけます。