Spring Boot: @ConfigurationProperties を付けたクラスには setter が必要

Spring Boot には、設定ファイルや環境変数から値を取得して、Bean にセットしてくれる便利機能がある。
が、マニュアル通りにやったつもりなのに値がセットされなくて、しばらくハマった。
原因は、setter を定義していなかったこと、だった。

ヌルヌルしてるよ!

Bean のクラスに @ConfigurationProperties アノテーションを付けておくと、インスタンス化するときに設定ファイルや環境変数から値が取得され、プロパティにセットされる。
……というのをマニュアルで読んで、試してみようと思って書いたクラスがこちら。

@Component
@ConfigurationProperties(prefix = "settings")
public class AdventurerSettings {
	private String job;
	private String level;

	public String getJob() {
		return job;
	}

	public String getLevel() {
		return level;
	}
}

で、下記のような内容の設定ファイル application.yml を作って、クラスパスのルートに置いた。

settings:
  job: DRG
  level: 99+

そして、上記の AdventurerSettings を他のクラスに注入して、値を出力してみようとした。

@RestController
public class Status {
	@Autowired
	private AdventurerSettings settings;
	
	@RequestMapping("/")
	String status() {
		return String.format("%s %s", settings.getJob(), settings.getLevel());
	}
}

期待していた結果はこう。

DRG 99+

ところが実際には、

null null

となってしまった。NullPointerException が発生していないことからもわかるように、AdventurerSettings の注入自体は行われているが、値が取れていない。

あせったー。

何が悪いのかわからなくて 1 時間ほど悩んだ末に、みんな大好き Stackoverflow にそのものズバリの回答があった。setter を定義しないといけないというのだ。

そこで早速 setter を追加して以下のようにした。

@Component
@ConfigurationProperties(prefix = "settings")
public class AdventurerSettings {
	private String job;
	private String level;

	public void setJob(String job) {
		this.job = job;
	}

	public String getJob() {
		return job;
	}

	public void setLevel(String level) {
		this.level = level;
	}

	public String getLevel() {
		return level;
	}
}

すると、期待通りに

DRG 99+

と出力されるようになった。

違う人だ

そもそも最初に setter を書かなかったのは、設定情報を immutable にするためなんだけど、Spring が依存性を注入する時には private フィールドへの注入もできるので、@ConfigurationProperties についても同じだろうという思い込みがあった。
つまり、

	@Autowired
	private AdventurerSettings settings;

というのが合法なんだから、設定値についても private フィールドと getter だけあれば大丈夫だろうと思っていたのだった。
実は、Spring Boot の @ConfigurationProperties を使う代わりに、Spring Framework 本体に含まれている @Value アノテーションを使った場合には、普通に private フィールドに値をセットさせることができる。

	@Value("${settings.job}")
	private String job;

だったら @ConfigurationProperties も private フィールドに値をセットしてくれてもいいような気がしてしまう。実際、要望として上がってもいるようだが、下記の Issue に付けられたコメントを見ると、少し難しそうだ。

すなわち、フィールド単位で明示的に設定対象を指定する @Value や @Autowired と違って、@ConfigurationProperties はクラスに対するアノテーションなので、単純に private フィールドへのセットができるようにしてしまうと、プログラマーが設定してほしくないフィールドに対しても、設定ファイルを書くだけでセットできるようになってしまう。今年の春に Struts で「リクエストパラメータ名に OGNL を書くことでクラスローダのソースを書き換えることが可能」という脆弱性が見つかって大騒ぎになっていたけど、あれと全く同じことが起きてしまうわけだ。
かといって、例えば「セットしてよいフィールド」を明示的に指定するとしたら、結局 @Value で書くのと大差なくなり、@ConfigurationProperties が持つ「アノテーションを 1 つ付けるだけでいいから楽チン」というメリットはスポイルされてしまう。
最初は同じようなものだと思った @Value や @Autowired と @ConfigurationProperties も、こうして見ると事情が結構違っていることがわかった。
うーん。個人的には、「設定ファイルで指定できるかどうかが、setter があるかどうかで決まる」というルールはわかりづらいと思うんだけど……。