Three new article modules: configuration binding, profiles and config data, Spring AOP
configuration-properties/ @ConfigurationProperties vs @Value on Spring Boot 4.1.1. The relaxed-binding matrix is generated by binding each spelling rather than transcribed, and re-checked against real processes -- the in-process probe was wrong twice before it was right. Records the three findings that came out of it: @Value does get relaxed resolution inside Spring Boot (Boot attaches ConfigurationPropertySources), the configuration processor silently stops generating metadata on JDK 23+ when declared as a plain dependency, and @Valid is not what makes nested constraints run. profiles-and-config/ Precedence, profiles, spring.config.import and config trees. /precedence reports every source holding a property in rank order with file and line, which turns "my profile file had no effect" into a two-line answer. Also pins the counterintuitive one: an imported file outranks the file that imported it. spring-aop/ Designators, proxy types, and aspects that do not fire. One advice per supported designator so the reference table is generated from real matches; all fourteen unsupported designators fed to the parser. Two corrections to the reference documentation: unsupported designators throw UnsupportedPointcutPrimitiveException (extends RuntimeException, not IllegalArgumentException), and spring-boot-starter-aop was renamed to spring-boot-starter-aspectj in Boot 4. 19 contract tests across the three modules, 15 captured transcripts, all regenerated by scripts/run-all.sh. Verified on Spring Boot 4.1.1, Spring Framework 7.0.9, JDK 25.0.4.1. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Gip4srpzMwjgoba6uEfbr5
This commit is contained in:
+23
@@ -0,0 +1,23 @@
|
||||
package com.ankurm.configprops;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
|
||||
|
||||
/**
|
||||
* Companion application for the ankurm.com article
|
||||
* "@ConfigurationProperties vs @Value in Spring Boot 4".
|
||||
*
|
||||
* <p>{@code @ConfigurationPropertiesScan} is what registers the {@code @ConfigurationProperties}
|
||||
* types in {@code com.ankurm.configprops.props} as beans. Without it — and without
|
||||
* {@code @EnableConfigurationProperties} or a stereotype annotation on each type — the
|
||||
* classes compile, the application starts, and the beans simply do not exist. That is the first
|
||||
* entry in the failure gallery: see {@code docs/03-registration.md}.
|
||||
*/
|
||||
@SpringBootApplication
|
||||
@ConfigurationPropertiesScan
|
||||
public class ConfigBindingApplication {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(ConfigBindingApplication.class, args);
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package com.ankurm.configprops.props;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.boot.context.properties.bind.DefaultValue;
|
||||
|
||||
/**
|
||||
* Constructor binding with a record — the shape this article recommends.
|
||||
*
|
||||
* <p>A record has exactly one canonical constructor, so Spring Boot uses constructor binding
|
||||
* without any annotation. {@code @ConstructorBinding} is only needed to disambiguate when a
|
||||
* type has more than one candidate constructor.
|
||||
*
|
||||
* <p>A record cannot declare field initialisers, so "default value" has to be expressed with
|
||||
* {@link DefaultValue}. Leaving it off does not give you {@code null} for a primitive-like
|
||||
* type — it gives you a binding failure. See {@code docs/04-records-and-defaults.md}.
|
||||
*
|
||||
* @param host SMTP host. No default: absent means the application must not start.
|
||||
* @param port SMTP port, defaulted rather than left null.
|
||||
* @param timeout Bound from an ISO-8601 duration or a suffixed form such as {@code 30s}.
|
||||
* @param retries Nested record, bound from {@code demo.mail.retries.*}.
|
||||
* @param recipients Bound from a YAML list, an indexed property list, or a comma-separated string.
|
||||
* @param headers Bound from arbitrary sub-keys under {@code demo.mail.headers.*}.
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "demo.mail")
|
||||
public record MailProperties(
|
||||
String host,
|
||||
@DefaultValue("587") int port,
|
||||
@DefaultValue("30s") Duration timeout,
|
||||
@DefaultValue RetryProperties retries,
|
||||
@DefaultValue List<String> recipients,
|
||||
@DefaultValue Map<String, String> headers) {
|
||||
|
||||
/**
|
||||
* Nested record. {@code @DefaultValue} on the enclosing component means this is
|
||||
* instantiated with its own defaults when {@code demo.mail.retries.*} is absent entirely,
|
||||
* rather than binding to {@code null}.
|
||||
*/
|
||||
public record RetryProperties(
|
||||
@DefaultValue("3") int maxAttempts,
|
||||
@DefaultValue("2s") Duration backoff) {
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package com.ankurm.configprops.props;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.boot.context.properties.bind.DefaultValue;
|
||||
|
||||
/**
|
||||
* One property, bound by the relaxed binder, used to prove which spellings actually work.
|
||||
*
|
||||
* <p>The canonical form of this property is {@code demo.relaxed.api-key}. The relaxed binder
|
||||
* accepts several spellings of that name; {@code @Value} accepts exactly one. The matrix in
|
||||
* {@code docs/02-relaxed-binding.md} is generated by
|
||||
* {@code com.ankurm.configprops.web.RelaxedBindingProbe}, not written by hand.
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "demo.relaxed")
|
||||
public record RelaxedProperties(@DefaultValue("(unset)") String apiKey) {
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package com.ankurm.configprops.props;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.boot.context.properties.bind.DefaultValue;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
|
||||
/**
|
||||
* Validation is the capability {@code @Value} does not have at all.
|
||||
*
|
||||
* <p>{@code @Validated} plus JSR-380 constraints turns a bad value into a startup failure with
|
||||
* a readable report, instead of a {@code NumberFormatException} somewhere in a request three
|
||||
* hours later. The failure text this produces is captured in
|
||||
* {@code docs/output/04-validation-failure.txt}.
|
||||
*
|
||||
* <p>{@code @Valid} on the nested component is written here because it is conventional, but
|
||||
* on this path it is not what makes the nested constraints run. Spring Boot's
|
||||
* {@code ValidationBindHandler} validates every object the binder finishes constructing, so
|
||||
* {@code demo.validated.pool.size} is checked with or without it. The rule that nested types
|
||||
* need {@code @Valid} comes from ordinary bean validation, where cascading is opt-in, and it
|
||||
* gets repeated about {@code @ConfigurationProperties} where it does not apply.
|
||||
* {@code BindingContractTests.nestedConstraintsFireWithoutValid} pins this. See
|
||||
* {@code docs/05-validation.md}.
|
||||
*/
|
||||
@Validated
|
||||
@ConfigurationProperties(prefix = "demo.validated")
|
||||
public record ValidatedProperties(
|
||||
@NotBlank String name,
|
||||
@Min(1) @Max(65535) int port,
|
||||
@Pattern(regexp = "https?://.*") @DefaultValue("http://localhost") String endpoint,
|
||||
@Valid @DefaultValue Pool pool) {
|
||||
|
||||
public record Pool(@Min(1) @Max(100) @DefaultValue("10") int size) {
|
||||
}
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
package com.ankurm.configprops.web;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import com.ankurm.configprops.props.MailProperties;
|
||||
import com.ankurm.configprops.props.ValidatedProperties;
|
||||
|
||||
import org.springframework.boot.context.properties.source.ConfigurationPropertyName;
|
||||
import org.springframework.boot.context.properties.source.ConfigurationPropertySource;
|
||||
import org.springframework.boot.context.properties.source.ConfigurationPropertySources;
|
||||
import org.springframework.boot.origin.Origin;
|
||||
import org.springframework.core.env.ConfigurableEnvironment;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* Prints where a value actually came from, instead of only what it is.
|
||||
*
|
||||
* <p>"The property is set but the application does not see it" is the single most common
|
||||
* configuration bug, and the reason it is hard is that a value carries no visible provenance.
|
||||
* Spring Boot tracks it anyway: every property loaded from a file or a config tree carries an
|
||||
* {@link Origin} naming the file and the line. This endpoint surfaces it.
|
||||
*
|
||||
* <p>Documented in {@code docs/08-diagnosing-a-value.md}. Delete it before shipping — it
|
||||
* will happily print a password that came from a mounted secret.
|
||||
*/
|
||||
@RestController
|
||||
public class BindingDiagnosticsEndpoint {
|
||||
|
||||
private final ConfigurableEnvironment environment;
|
||||
private final MailProperties mail;
|
||||
private final ValidatedProperties validated;
|
||||
private final ValueHolder valueHolder;
|
||||
|
||||
public BindingDiagnosticsEndpoint(ConfigurableEnvironment environment, MailProperties mail,
|
||||
ValidatedProperties validated, ValueHolder valueHolder) {
|
||||
this.environment = environment;
|
||||
this.mail = mail;
|
||||
this.validated = validated;
|
||||
this.valueHolder = valueHolder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every property source that can supply the given name, in precedence order, with the
|
||||
* value each one holds and where that value was written.
|
||||
*
|
||||
* <p>The first row is the winner. Rows below it are values that exist and lose —
|
||||
* which is what a "my change had no effect" bug looks like from the inside.
|
||||
*/
|
||||
@GetMapping("/diag/origin")
|
||||
public Map<String, Object> origin(
|
||||
@RequestParam(defaultValue = "demo.mail.host") String name) {
|
||||
|
||||
ConfigurationPropertyName propertyName = ConfigurationPropertyName.of(name);
|
||||
List<Map<String, Object>> candidates = new ArrayList<>();
|
||||
|
||||
for (ConfigurationPropertySource source : ConfigurationPropertySources.get(environment)) {
|
||||
var property = source.getConfigurationProperty(propertyName);
|
||||
if (property == null) {
|
||||
continue;
|
||||
}
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("source", String.valueOf(source.getUnderlyingSource()));
|
||||
row.put("value", String.valueOf(property.getValue()));
|
||||
row.put("origin", String.valueOf(property.getOrigin()));
|
||||
candidates.add(row);
|
||||
}
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("property", name);
|
||||
result.put("effectiveValue", environment.getProperty(name));
|
||||
result.put("candidatesInPrecedenceOrder", candidates);
|
||||
result.put("shadowedBy", candidates.size() > 1 ? candidates.getFirst().get("source") : null);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** The bound objects, so the article can show a record's contents rather than describe them. */
|
||||
@GetMapping("/diag/bound")
|
||||
public Map<String, Object> bound() {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("mail", mail);
|
||||
result.put("validated", validated);
|
||||
|
||||
Map<String, Object> fromValue = new LinkedHashMap<>();
|
||||
fromValue.put("host", valueHolder.host());
|
||||
fromValue.put("port", valueHolder.port());
|
||||
fromValue.put("timeout", valueHolder.timeout().toString());
|
||||
fromValue.put("recipients", valueHolder.recipients());
|
||||
fromValue.put("poolSize", valueHolder.poolSize());
|
||||
fromValue.put("computedThreads", valueHolder.computedThreads());
|
||||
result.put("fromValueAnnotation", fromValue);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
package com.ankurm.configprops.web;
|
||||
|
||||
import com.ankurm.configprops.props.RelaxedProperties;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* The same question as {@link RelaxedBindingProbe}, but asked of a real process with a real
|
||||
* operating-system environment variable rather than a synthesised property source.
|
||||
*
|
||||
* <p>{@code scripts/demo-env-binding.sh} runs this once per spelling. It exists because the
|
||||
* synthesised answer was surprising enough to be worth re-checking against reality: the
|
||||
* spelling most articles recommend does not bind.
|
||||
*
|
||||
* <p>{@code @Value} here carries a default so that a miss is reported rather than crashing
|
||||
* the process — the difference between the two columns is the point.
|
||||
*/
|
||||
@Component
|
||||
@Profile("envprobe")
|
||||
public class RealEnvironmentProbe implements CommandLineRunner {
|
||||
|
||||
private final RelaxedProperties bound;
|
||||
private final Environment environment;
|
||||
|
||||
@Value("${demo.relaxed.api-key:(MISS)}")
|
||||
private String viaValue;
|
||||
|
||||
public RealEnvironmentProbe(RelaxedProperties bound, Environment environment) {
|
||||
this.bound = bound;
|
||||
this.environment = environment;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run(String... args) {
|
||||
System.out.println("env-var-set : " + describeEnv());
|
||||
System.out.println("@ConfigurationProps: " + bound.apiKey());
|
||||
System.out.println("@Value : " + viaValue);
|
||||
System.out.println("Environment.get : "
|
||||
+ environment.getProperty("demo.relaxed.api-key", "(MISS)"));
|
||||
}
|
||||
|
||||
private String describeEnv() {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
System.getenv().forEach((k, v) -> {
|
||||
if (k.toUpperCase().startsWith("DEMO_RELAXED") || k.toUpperCase().startsWith("DEMO.")) {
|
||||
sb.append(k).append('=').append(v).append(' ');
|
||||
}
|
||||
});
|
||||
return sb.isEmpty() ? "(none)" : sb.toString().trim();
|
||||
}
|
||||
}
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
package com.ankurm.configprops.web;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import com.ankurm.configprops.props.RelaxedProperties;
|
||||
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.boot.context.properties.bind.BindResult;
|
||||
import org.springframework.boot.context.properties.bind.Binder;
|
||||
import org.springframework.boot.context.properties.source.ConfigurationPropertySources;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.core.env.MapPropertySource;
|
||||
import org.springframework.core.env.StandardEnvironment;
|
||||
import org.springframework.core.env.SystemEnvironmentPropertySource;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Generates the relaxed-binding matrix in {@code docs/02-relaxed-binding.md} by actually
|
||||
* binding each spelling, rather than by quoting the reference documentation.
|
||||
*
|
||||
* <p>For every spelling of the canonical property {@code demo.relaxed.api-key} the probe asks
|
||||
* three questions, and the three answers are not the same:
|
||||
*
|
||||
* <ol>
|
||||
* <li><strong>BINDER</strong> — does {@link Binder}, the engine behind
|
||||
* {@code @ConfigurationProperties}, resolve it?</li>
|
||||
* <li><strong>${} plain</strong> — does {@code ${demo.relaxed.api-key}} resolve against
|
||||
* a bare {@link StandardEnvironment}? This is placeholder resolution as the Spring
|
||||
* Framework alone defines it.</li>
|
||||
* <li><strong>${} boot</strong> — does the same placeholder resolve once
|
||||
* {@link ConfigurationPropertySources#attach} has been called? Spring Boot calls it on
|
||||
* every environment it prepares, which quietly gives {@code @Value} relaxed resolution
|
||||
* that plain Spring does not have.</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>That third column is the one worth knowing about. "{@code @Value} does not support relaxed
|
||||
* binding" is repeated everywhere and is true of the Spring Framework; inside a Spring Boot
|
||||
* application it is not, and the difference is one method call made on your behalf during
|
||||
* environment preparation.
|
||||
*
|
||||
* <p>Two harness details were paid for in wrong answers and are worth stating:
|
||||
* the system-environment source must be <em>named</em>
|
||||
* {@link StandardEnvironment#SYSTEM_ENVIRONMENT_PROPERTY_SOURCE_NAME} or Boot maps it with the
|
||||
* ordinary rules, and a bare {@code StandardEnvironment} is not what a Boot application runs
|
||||
* with. Both produced plausible, publishable, incorrect results before being checked against a
|
||||
* real process by {@code scripts/demo-env-binding.sh}.
|
||||
*
|
||||
* <p>Activated by the {@code probe} profile so it does not run during the other demos.
|
||||
*/
|
||||
@Component
|
||||
@Profile("probe")
|
||||
public class RelaxedBindingProbe implements CommandLineRunner {
|
||||
|
||||
/** The one canonical name. Everything else in the matrix is a spelling of this. */
|
||||
private static final String CANONICAL = "demo.relaxed.api-key";
|
||||
|
||||
private static final List<String> MAP_SPELLINGS = List.of(
|
||||
"demo.relaxed.api-key", // kebab-case, the canonical form
|
||||
"demo.relaxed.apiKey", // camelCase
|
||||
"demo.relaxed.api_key", // underscore notation
|
||||
"demo.relaxed.APIKEY", // upper case, no separator
|
||||
"DEMO.RELAXED.API-KEY", // upper case with separators
|
||||
"demo.relaxed.apikey", // no separator at all
|
||||
"demo.relaxed.api.key"); // dot as separator -- a DIFFERENT property
|
||||
|
||||
private static final List<String> ENV_SPELLINGS = List.of(
|
||||
"DEMO_RELAXED_API_KEY", // underscore for every separator -- the safe form
|
||||
"DEMO_RELAXED_APIKEY", // word joined up
|
||||
"demo_relaxed_api_key", // lower case underscores
|
||||
"DEMO.RELAXED.API-KEY"); // dots and dashes, which most shells reject anyway
|
||||
|
||||
@Override
|
||||
public void run(String... args) {
|
||||
System.out.println("== relaxed binding matrix ==");
|
||||
System.out.println("canonical property: " + CANONICAL);
|
||||
System.out.println();
|
||||
System.out.printf("%-24s %-6s %-8s %-10s %-10s%n",
|
||||
"SPELLING", "SOURCE", "BINDER", "${} plain", "${} boot");
|
||||
System.out.println("-".repeat(62));
|
||||
|
||||
for (String spelling : MAP_SPELLINGS) {
|
||||
report(spelling, "map", () -> mapEnvironment(spelling));
|
||||
}
|
||||
for (String spelling : ENV_SPELLINGS) {
|
||||
report(spelling, "env", () -> envEnvironment(spelling));
|
||||
}
|
||||
|
||||
System.out.println();
|
||||
System.out.println("BINDER = what @ConfigurationProperties sees (Binder)");
|
||||
System.out.println("${} plain = placeholder resolution against a bare StandardEnvironment");
|
||||
System.out.println("${} boot = the same, after ConfigurationPropertySources.attach(env),");
|
||||
System.out.println(" which Spring Boot performs on every environment it prepares");
|
||||
System.out.println("bound = the value arrived; MISS = the property was not found");
|
||||
}
|
||||
|
||||
private void report(String spelling, String kind,
|
||||
java.util.function.Supplier<StandardEnvironment> factory) {
|
||||
|
||||
StandardEnvironment forBinder = factory.get();
|
||||
BindResult<RelaxedProperties> result =
|
||||
Binder.get(forBinder).bind("demo.relaxed", RelaxedProperties.class);
|
||||
String binder = result.isBound() && !"(unset)".equals(result.get().apiKey())
|
||||
? "bound" : "MISS";
|
||||
|
||||
String plain = placeholder(factory.get());
|
||||
|
||||
StandardEnvironment attached = factory.get();
|
||||
ConfigurationPropertySources.attach(attached);
|
||||
String boot = placeholder(attached);
|
||||
|
||||
System.out.printf("%-24s %-6s %-8s %-10s %-10s%n", spelling, kind, binder, plain, boot);
|
||||
}
|
||||
|
||||
private String placeholder(StandardEnvironment environment) {
|
||||
try {
|
||||
environment.resolveRequiredPlaceholders("${" + CANONICAL + "}");
|
||||
return "bound";
|
||||
} catch (IllegalArgumentException ex) {
|
||||
return "MISS";
|
||||
}
|
||||
}
|
||||
|
||||
/** An environment whose only property source behaves like a properties or YAML file. */
|
||||
private StandardEnvironment mapEnvironment(String spelling) {
|
||||
StandardEnvironment environment = new StandardEnvironment();
|
||||
environment.getPropertySources().addFirst(
|
||||
new MapPropertySource("probe", Map.of(spelling, "from-" + spelling)));
|
||||
return environment;
|
||||
}
|
||||
|
||||
/**
|
||||
* An environment whose only property source is a {@link SystemEnvironmentPropertySource}.
|
||||
*
|
||||
* <p>The source <strong>must</strong> carry the name
|
||||
* {@link StandardEnvironment#SYSTEM_ENVIRONMENT_PROPERTY_SOURCE_NAME}: Spring Boot decides
|
||||
* which name mapper applies by comparing the source's name against that constant, not by
|
||||
* checking its type. A {@code SystemEnvironmentPropertySource} called anything else is
|
||||
* mapped with the ordinary rules and the underscore spellings stop resolving.
|
||||
*/
|
||||
private StandardEnvironment envEnvironment(String spelling) {
|
||||
StandardEnvironment environment = new StandardEnvironment();
|
||||
Map<String, Object> entries = new LinkedHashMap<>();
|
||||
entries.put(spelling, "from-" + spelling);
|
||||
environment.getPropertySources().addFirst(new SystemEnvironmentPropertySource(
|
||||
StandardEnvironment.SYSTEM_ENVIRONMENT_PROPERTY_SOURCE_NAME, entries));
|
||||
return environment;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.ankurm.configprops.web;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* The {@code @Value} side of the comparison, written the way people actually write it.
|
||||
*
|
||||
* <p>Everything here works. The point of the class is what it costs to make it work, and what
|
||||
* it still cannot do:
|
||||
*
|
||||
* <ul>
|
||||
* <li>Every field repeats the property name as a string literal. Rename the property and the
|
||||
* compiler says nothing.</li>
|
||||
* <li>The name has to be the exact canonical form. {@code ${demo.mail.apiKey}} does not find
|
||||
* {@code demo.mail.api-key} — proved by
|
||||
* {@link RelaxedBindingProbe}.</li>
|
||||
* <li>There is no validation. {@code :-1} below is accepted silently.</li>
|
||||
* <li>A missing property without a {@code :default} is a startup failure whose message names
|
||||
* the field, not the property's purpose.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>What {@code @Value} does have that binding does not: SpEL. {@code #{...}} can compute a
|
||||
* value; {@code @ConfigurationProperties} only maps one. That is the honest reason to keep it.
|
||||
* See {@code docs/06-when-value-still-wins.md}.
|
||||
*/
|
||||
@Component
|
||||
public class ValueHolder {
|
||||
|
||||
/** Exact canonical name required -- no relaxed spelling is accepted here. */
|
||||
@Value("${demo.mail.host}")
|
||||
private String host;
|
||||
|
||||
/** The ":" default is the only defaulting mechanism @Value has. */
|
||||
@Value("${demo.mail.port:587}")
|
||||
private int port;
|
||||
|
||||
/** Conversion works: the ConversionService is shared with the binder. */
|
||||
@Value("${demo.mail.timeout:30s}")
|
||||
private Duration timeout;
|
||||
|
||||
/**
|
||||
* A comma-separated string splits into a List. A YAML block list does NOT --
|
||||
* that is one of the concrete gaps captured in docs/output/03-value-vs-binding.txt.
|
||||
*/
|
||||
@Value("${demo.mail.recipients:}")
|
||||
private List<String> recipients;
|
||||
|
||||
/** No constraint is applied. A negative pool size is simply a negative pool size. */
|
||||
@Value("${demo.validated.pool.size:-1}")
|
||||
private int poolSize;
|
||||
|
||||
/** SpEL -- the capability @ConfigurationProperties genuinely does not have. */
|
||||
@Value("#{T(java.lang.Runtime).getRuntime().availableProcessors() * 2}")
|
||||
private int computedThreads;
|
||||
|
||||
public String host() { return host; }
|
||||
public int port() { return port; }
|
||||
public Duration timeout() { return timeout; }
|
||||
public List<String> recipients() { return recipients; }
|
||||
public int poolSize() { return poolSize; }
|
||||
public int computedThreads() { return computedThreads; }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
demo:
|
||||
validated:
|
||||
# @NotBlank -- blank is exactly what it rejects.
|
||||
name: " "
|
||||
# @Min(1) @Max(65535)
|
||||
port: 99999
|
||||
# @Pattern(regexp = "https?://.*")
|
||||
endpoint: "ftp://files.example.com"
|
||||
pool:
|
||||
# @Min(1) @Max(100) on a nested record. Only checked because the component
|
||||
# carries @Valid; without it this passes.
|
||||
size: 4000
|
||||
@@ -0,0 +1,7 @@
|
||||
# Overrides the YAML block list in application.yaml with a comma-separated string.
|
||||
# demo.csvlist.loaded is a marker so the transcript can prove this file was read.
|
||||
demo:
|
||||
csvlist:
|
||||
loaded: "yes"
|
||||
mail:
|
||||
recipients: "[email protected],[email protected],[email protected]"
|
||||
@@ -0,0 +1,37 @@
|
||||
spring:
|
||||
application:
|
||||
name: configuration-properties
|
||||
|
||||
server:
|
||||
port: 8080
|
||||
|
||||
logging:
|
||||
level:
|
||||
root: WARN
|
||||
com.ankurm: INFO
|
||||
|
||||
demo:
|
||||
mail:
|
||||
host: smtp.example.com
|
||||
# port omitted on purpose: @DefaultValue("587") supplies it.
|
||||
timeout: 45s
|
||||
retries:
|
||||
max-attempts: 5
|
||||
backoff: 1500ms
|
||||
# A YAML block list. @ConfigurationProperties binds this; @Value("${demo.mail.recipients}")
|
||||
# does not -- see docs/output/03-value-vs-binding.txt.
|
||||
recipients:
|
||||
- [email protected]
|
||||
- [email protected]
|
||||
headers:
|
||||
X-Env: demo
|
||||
X-Team: platform
|
||||
# demo.relaxed.api-key is deliberately NOT set here. scripts/demo-env-binding.sh supplies
|
||||
# it as an operating-system environment variable, one spelling at a time, and a value in
|
||||
# this file would mask the env var and make every row of the matrix say "bound".
|
||||
validated:
|
||||
name: demo-service
|
||||
port: 8443
|
||||
endpoint: https://api.example.com
|
||||
pool:
|
||||
size: 25
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
package com.ankurm.configprops;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
|
||||
import com.ankurm.configprops.props.MailProperties;
|
||||
import com.ankurm.configprops.props.RelaxedProperties;
|
||||
import com.ankurm.configprops.props.ValidatedProperties;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.boot.context.properties.bind.BindException;
|
||||
import org.springframework.boot.context.properties.bind.Binder;
|
||||
import org.springframework.boot.context.properties.bind.DefaultValue;
|
||||
import org.springframework.boot.context.properties.bind.validation.BindValidationException;
|
||||
import org.springframework.boot.context.properties.source.ConfigurationPropertySources;
|
||||
import org.springframework.core.env.MapPropertySource;
|
||||
import org.springframework.core.env.StandardEnvironment;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
|
||||
|
||||
/**
|
||||
* Contract tests for the claims the article makes. Each one pins a behaviour that would
|
||||
* otherwise be an assertion in prose.
|
||||
*/
|
||||
class BindingContractTests {
|
||||
|
||||
private static Binder binderFor(Map<String, Object> properties) {
|
||||
StandardEnvironment environment = new StandardEnvironment();
|
||||
environment.getPropertySources().addFirst(new MapPropertySource("test", properties));
|
||||
return Binder.get(environment);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("kebab-case, camelCase, underscores and no separator are all the same property")
|
||||
void relaxedSpellingsAreEquivalent() {
|
||||
for (String spelling : new String[] {
|
||||
"demo.relaxed.api-key", "demo.relaxed.apiKey",
|
||||
"demo.relaxed.api_key", "demo.relaxed.apikey", "demo.relaxed.APIKEY" }) {
|
||||
RelaxedProperties bound = binderFor(Map.of(spelling, "v"))
|
||||
.bind("demo.relaxed", RelaxedProperties.class).get();
|
||||
assertThat(bound.apiKey()).as(spelling).isEqualTo("v");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a dot is a name separator, so api.key is a different property from api-key")
|
||||
void dotIsNotAWordSeparator() {
|
||||
// Nothing under the prefix matches, so the bind produces no result at all. Note this
|
||||
// is *not* an all-defaults object: @DefaultValue applies to a component of an object
|
||||
// that is being constructed, and here no object is constructed.
|
||||
assertThat(binderFor(Map.of("demo.relaxed.api.key", "v"))
|
||||
.bind("demo.relaxed", RelaxedProperties.class).isBound()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a record binds through its canonical constructor with no annotation")
|
||||
void recordConstructorBinding() {
|
||||
MailProperties bound = binderFor(Map.of(
|
||||
"demo.mail.host", "smtp.test",
|
||||
"demo.mail.retries.max-attempts", "9"))
|
||||
.bind("demo.mail", MailProperties.class).get();
|
||||
|
||||
assertThat(bound.host()).isEqualTo("smtp.test");
|
||||
assertThat(bound.port()).as("@DefaultValue supplied it").isEqualTo(587);
|
||||
assertThat(bound.timeout()).isEqualTo(Duration.ofSeconds(30));
|
||||
assertThat(bound.retries().maxAttempts()).isEqualTo(9);
|
||||
assertThat(bound.retries().backoff()).as("nested default").isEqualTo(Duration.ofSeconds(2));
|
||||
assertThat(bound.recipients()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a component with no value and no @DefaultValue binds to null, not to a failure")
|
||||
void absentPropertyWithoutDefault() {
|
||||
MailProperties bound = binderFor(Map.of("demo.mail.port", "25"))
|
||||
.bind("demo.mail", MailProperties.class).get();
|
||||
assertThat(bound.host()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("@Validated turns a bad value into a BindValidationException")
|
||||
void validationRejectsBadValues() {
|
||||
assertThatExceptionOfType(BindException.class)
|
||||
.isThrownBy(() -> binderFor(Map.of(
|
||||
"demo.validated.name", "ok",
|
||||
"demo.validated.port", "99999"))
|
||||
.bind("demo.validated", org.springframework.boot.context.properties.bind
|
||||
.Bindable.of(ValidatedProperties.class),
|
||||
new org.springframework.boot.context.properties.bind.validation
|
||||
.ValidationBindHandler(validator()))
|
||||
.get())
|
||||
.withCauseInstanceOf(BindValidationException.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Contradicts a widely repeated rule.
|
||||
*
|
||||
* <p>The advice "put {@code @Valid} on nested {@code @ConfigurationProperties} types or
|
||||
* their constraints are ignored" is true of ordinary bean validation, where cascading is
|
||||
* opt-in. It is not true of the binder. {@code ValidationBindHandler} validates every
|
||||
* object it finishes binding, nested ones included, so the constraint below fires with no
|
||||
* {@code @Valid} anywhere in the type.
|
||||
*
|
||||
* <p>This test was originally written to assert the opposite and failed, which is how the
|
||||
* article came to say the opposite of what most of its neighbours say.
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("nested constraints are evaluated even without @Valid")
|
||||
void nestedConstraintsFireWithoutValid() {
|
||||
assertThatExceptionOfType(BindException.class)
|
||||
.isThrownBy(() -> binderFor(Map.of("demo.unchecked.pool.size", "4000"))
|
||||
.bind("demo.unchecked", org.springframework.boot.context.properties.bind
|
||||
.Bindable.of(Unchecked.class),
|
||||
new org.springframework.boot.context.properties.bind.validation
|
||||
.ValidationBindHandler(validator()))
|
||||
.get())
|
||||
.withCauseInstanceOf(BindValidationException.class)
|
||||
.withMessageContaining("demo.unchecked");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Boot's attached property source gives ${} the same relaxed rules as the binder")
|
||||
void attachGivesPlaceholdersRelaxedResolution() {
|
||||
StandardEnvironment plain = new StandardEnvironment();
|
||||
plain.getPropertySources()
|
||||
.addFirst(new MapPropertySource("test", Map.of("demo.relaxed.apiKey", "v")));
|
||||
|
||||
assertThat(plain.resolvePlaceholders("${demo.relaxed.api-key:MISS}"))
|
||||
.as("plain Spring: no relaxed placeholder resolution")
|
||||
.isEqualTo("MISS");
|
||||
|
||||
ConfigurationPropertySources.attach(plain);
|
||||
assertThat(plain.resolvePlaceholders("${demo.relaxed.api-key:MISS}"))
|
||||
.as("Spring Boot attaches this source on every environment it prepares")
|
||||
.isEqualTo("v");
|
||||
}
|
||||
|
||||
private static LocalValidatorFactoryBean validator() {
|
||||
LocalValidatorFactoryBean factory = new LocalValidatorFactoryBean();
|
||||
factory.afterPropertiesSet();
|
||||
return factory;
|
||||
}
|
||||
|
||||
/** Deliberately missing {@code @Valid} on the nested component. */
|
||||
@Validated
|
||||
@ConfigurationProperties(prefix = "demo.unchecked")
|
||||
record Unchecked(@DefaultValue Pool pool) {
|
||||
record Pool(@Min(1) @Max(100) @DefaultValue("10") int size) {
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user