Konfiguration mit @ConfigurationProperties statt @Value
1. Übersicht
@Value ist sehr praktisch, wenn Sie nur ein oder zwei Konfigurationswerte auslesen müssen. Wenn die Anzahl der Properties wächst, beginnen die Strings ${...} überall in den Beans zu verstreuen, sind schwer zu finden, leicht zu verwechseln und werden überhaupt nicht validiert.
Diese Anleitung zeigt Ihnen, wie Sie in Spring Boot 3.x zu @ConfigurationProperties wechseln. Danach wissen Sie, wie Sie Konfiguration in einem record mit klaren Typen zusammenfassen und Binding für Typen wie Duration, List, Map und verschachtelte Objekte durchführen. Sie werden auch lernen, wie Sie Konfiguration beim Starten validieren und Tests dafür schreiben.
2. Probleme mit @Value
Betrachten Sie einen PaymentService, der einen Payment-Gateway aufrufen muss:
@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;
// ...
}
Der Code läuft, hat aber mehrere Probleme:
- Keys sind Freitext. Wenn Sie
app.payment.gatewayUrlstattgateway-urlschreiben, erfahren Sie vom Fehler erst zur Laufzeit. - Keine Validierung.
max-retries=-5wird ohne Probleme injiziert. - Konfiguration ist verstreut. Wenn auch
RefundServicedenmerchant-idbenötigt, wiederholen Sie die@Value-Annotation dort. - Schwer zu testen. Um
PaymentServiceohne Spring Context zu testen, müssen Sie Reflection verwenden, um die Fields zu setzen. - Komplexe Typen sind umständlich. Eine
Map<String, BigDecimal>über@Valuezu injizieren erfordert komplexes SpEL.
3. Konfigurationsklasse mit @ConfigurationProperties deklarieren
3.1. Einen record verwenden (empfohlen)
Datei application.yml:
app:
payment:
gateway-url: https://pay.example.com/api
merchant-id: NONLA-001
timeout: 5s
max-retries: 3
Entsprechende Konfigurationsklasse:
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) {
}
Erklärung:
prefix = "app.payment"teilt Spring mit, dass dieser record alle Keys unterapp.paymenterhält.- Spring Boot 3 nutzt automatisch constructor binding für records. Sie benötigen keine Setter oder
@ConstructorBinding(wenn die Klasse nur einen Constructor hat). - Relaxed binding bildet
gateway-url,gatewayUrl,GATEWAY_URL(UmgebungsvariableAPP_PAYMENT_GATEWAYURL) auf denselben ParametergatewayUrlab. @DefaultValuestellt einen Standardwert zur Verfügung, wenn der Key in der Konfiguration fehlt.- Der String
5swird automatisch inDuration.ofSeconds(5)umgewandelt.
3.2. Registrierung und Verwendung
Der record oben ist noch kein Bean. Sie müssen ihn in Ihrer Hauptklasse aktivieren:
@SpringBootApplication
@ConfigurationPropertiesScan
public class ShopApplication {
public static void main(String[] args) {
SpringApplication.run(ShopApplication.class, args);
}
}
@ConfigurationPropertiesScan sucht das aktuelle Package und seine Subpackages nach Klassen mit @ConfigurationProperties. Wenn Sie jede Klasse explizit deklarieren möchten, verwenden Sie @EnableConfigurationProperties(PaymentProperties.class) auf einer @Configuration-Klasse.
Jetzt kann PaymentService einfach ein Objekt injizieren:
@Service
public class PaymentService {
private final PaymentProperties props;
public PaymentService(PaymentProperties props) {
this.props = props;
}
public String describe() {
return "Rufe %s mit merchant %s auf, timeout %s, retry %d mal"
.formatted(props.gatewayUrl(), props.merchantId(),
props.timeout(), props.maxRetries());
}
}
Das Ergebnis beim Aufrufen von describe():
Rufe https://pay.example.com/api mit merchant NONLA-001 auf, timeout PT5S, retry 3 mal
PaymentService ist jetzt eine reine Java-Klasse. Im Unit-Test benötigen Sie nur new PaymentService(new PaymentProperties(...)), keinen Spring.
Hinweis: Annotieren Sie den record, der constructor binding verwendet, nicht mit
@Component. Spring würde ihn dann als normales Bean behandeln und versuchen,String gatewayUrlwie ein anderes Bean zu autowiren. Die Anwendung startet mit einem Fehler. Registrieren Sie ihn stattdessen über@ConfigurationPropertiesScanoder@EnableConfigurationProperties.
3.3. JavaBean-Klasse mit Settern
Wenn Sie ein änderbares Objekt benötigen, zum Beispiel für Kompatibilität mit altem Code, können Sie eine Klasse mit Gettern und Settern verwenden:
@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 ruft den Standardconstructor auf, dann Setter für jeden gefundenen Key. Standardwerte sind die Initialisierungswerte der Fields. Dies funktioniert gut, aber das Objekt kann zur Laufzeit überall geändert werden. Daher ist ein record die sicherere Wahl.
4. Binding komplexer Datentypen
Erweitern Sie die Payment-Konfiguration mit Listen, Maps und verschachtelte Objekten:
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) {
}
}
Jeder Parameter wird wie folgt gebunden:
DataSizeversteht Einheiten wieB,KB,MB,GB.2MBergibtDataSize.ofMegabytes(2).List<String>akzeptiert sowohl YAML-Listen als auch durch Kommas getrennte Strings (VND,USD).Map<String, BigDecimal>nimmt die untergeordneten Keys (card,wallet) als Map-Keys.Webhookist ein verschachtelter record, der rekursiv mit dem Prefixapp.payment.webhookgebunden wird.@DefaultValueohne Wert aufwebhookbewirkt, dass Spring einWebhookmit Standardwerten erstellt, wenn der gesamtewebhook-Block fehlt. Ohne diese Annotation würdewebhook()nullzurückgeben.
Hinweis: Bei
Durationwird eine Zahl ohne Einheit als Millisekunden interpretiert.timeout: 30bedeutet 30ms, nicht 30 Sekunden. Sie sollten immer die Einheit angeben (30s,500ms), oder@DurationUnit(ChronoUnit.SECONDS)auf dem Parameter setzen.
5. Validierung von Konfiguration beim Starten
Fügen Sie das Validation-Starter hinzu:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
Markieren Sie dann @Validated und fügen Sie Jakarta Bean Validation Constraints hinzu:
@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) {
}
}
@Validatedaktiviert die Validierung unmittelbar nach dem Binding.@Validaufwebhookstellt sicher, dass auch die Constraints innerhalb vonWebhookgeprüft werden. Ohne@Validüberspringt Spring verschachtelte Objekte.
Wenn jemand merchant-id leer lässt, stoppt die Anwendung beim Start mit einer klaren Fehlermeldung:
***************************
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
Das System meldet den Fehler sofort beim Deploy, nicht erst nach einigen Stunden, wenn die erste Payment-Anfrage fehlschlägt. Das ist ein großer Vorteil gegenüber @Value.
6. IDE-Hinweise mit Configuration Processor
Fügen Sie den Annotation Processor hinzu:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
Beim Kompilieren erzeugt der Processor die Datei META-INF/spring-configuration-metadata.json, die alle Keys unter app.payment beschreibt: Typ, Standardwert und Beschreibung. IntelliJ IDEA und VS Code (mit Spring Tools) nutzen diese Datei, um Keys vorzuschlagen, nicht existierende Keys zu warnen und Dokumentation anzuzeigen, wenn Sie mit der Maus über einen Key in application.yml fahren.
Die Beschreibung jeder Property wird aus der Javadoc übernommen. Bei records schreiben Sie Javadoc mit @param:
/**
* Konfiguration des Payment Gateways.
*
* @param gatewayUrl Basis-URL des Payment Gateways
* @param maxRetries maximale Anzahl von Wiederholungsversuchen, wenn der Gateway-Aufruf fehlschlägt
*/
Hinweis: Wenn Ihr Projekt bereits
annotationProcessorPathsimmaven-compiler-plugindeklariert hat (zum Beispiel für Lombok oder MapStruct), müssen Siespring-boot-configuration-processorzu dieser Liste hinzufügen. Sonst läuft der Processor nicht.
7. Testen von Konfigurationsklassen
Sie benötigen keinen schweren @SpringBootTest. ApplicationContextRunner erstellt einen minimalen Context, der nur das Nötigste enthält:
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));
}
}
Diese beiden Tests prüfen folgende Punkte:
- Der erste Test bestätigt, dass Werte korrekt gebunden werden (
2szuDuration,1.5zuBigDecimal). Er bestätigt auch, dass@DefaultValuefürmaxRetries,supportedCurrenciesundwebhookangewendet wird. - Der zweite Test bestätigt, dass
max-retries=50gegen@Max(10)verstößt und den Context-Start mitBindValidationExceptionfehlschlagen lässt.
Jeder Test läuft in wenigen hundert Millisekunden, weil kein Webserver oder andere Auto-Konfiguration geladen wird.
8. Wann Sie @Value noch verwenden
@ConfigurationProperties ersetzt @Value nicht vollständig. Sie können @Value noch verwenden, wenn:
- Sie SpEL benötigen. Zum Beispiel
@Value("#{${app.payment.timeout-seconds} * 1000}").@ConfigurationPropertiesunterstützt SpEL-Ausdrücke nicht. - Sie nur einen einzelnen Wert benötigen, der nur an einer Stelle verwendet wird. Zum Beispiel
@Value("${spring.application.name}")in einem Log-Bean. Einen ganzen record dafür zu erstellen ist Overkill.
Einige häufige Fehler beim Wechsel zu @ConfigurationProperties:
- Vergessen der Registrierung. Ohne
@ConfigurationPropertiesScanoder@EnableConfigurationPropertieserhalten Sie den Fehler “No qualifying bean of type PaymentProperties”. - Prefix in falscher Schreibweise. Das Prefix muss in kebab-case in Kleinbuchstaben sein (
app.payment, nichtapp.Payment). Wenn es falsch ist, meldet Spring den Fehler beim Start. - Mehrere Constructors. Wenn ein record oder eine Klasse mehr als einen Constructor hat, müssen Sie den Constructor für das Binding mit
@ConstructorBindingmarkieren. - Eine Klasse für alles. Passen Sie nicht die gesamte Anwendungskonfiguration in ein riesiges
AppProperties. Teilen Sie nach Geschäftsdomäne auf:PaymentProperties,MailProperties,StorageProperties…
Hinweis: Konfiguration-records sind unveränderlich (immutable). Wenn Sie Spring Cloud zur Laufzeit-Aktualisierung von Konfiguration verwenden, muss das Bean neu erstellt werden (über
@RefreshScopeoder einen Rebind-Mechanismus), damit neue Werte gelten. Erwarten Sie nicht, dass der record sich automatisch ändert.
9. Fazit
In dieser Anleitung haben Sie die verstreuten @Value-Annotationen durch einen PaymentProperties-record mit klaren Typen ersetzt. Konkret haben Sie:
- Binding für
Duration,DataSize,List,Mapund verschachtelte records mit Standardwerten über@DefaultValuedurchgeführt; - Konfiguration beim Start mit
@Validatedund Jakarta Constraints validiert; - IDE-Hinweise für Keys durch
spring-boot-configuration-processorerhalten; - Konfigurationsklassen schnell und einfach mit
ApplicationContextRunnergetestet.
Für jede Gruppe von Konfiguration mit zwei oder mehr Keys sollte @ConfigurationProperties die Standardwahl sein. Behalten Sie @Value für einzelne Werte oder wenn Sie wirklich SpEL benötigen.