diff --git a/README.md b/README.md index f82b297..20538d2 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,9 @@ files. |---|---|---| | [`actuator-in-production/`](actuator-in-production) | [Spring Boot Actuator in Production](https://ankurm.com/spring-boot-actuator-production-endpoints-security-health-indicators/) | endpoint exposure defaults, securing Actuator, custom health indicators and how they hang | | [`spring-boot-startup-time/`](spring-boot-startup-time) | [Why Your Spring Boot App Takes 8 Seconds to Start](spring-boot-startup-time/post/post.md) | `BufferingApplicationStartup`, JFR startup events, self time vs total time, the classpath-scan tax, the JDK 25 AOT cache | +| [`configuration-properties/`](configuration-properties) | [@ConfigurationProperties vs @Value in Spring Boot 4](https://ankurm.com/) | relaxed binding measured three ways, record constructor binding, validation, IDE metadata generation | +| [`profiles-and-config/`](profiles-and-config) | [Spring Boot Profiles Done Right](https://ankurm.com/) | the precedence stack made visible, config trees and ConfigMaps, why a profile file loses to an environment variable | +| [`spring-aop/`](spring-aop) | [Spring AOP Explained](https://ankurm.com/) | every pointcut designator with real matches, JDK vs CGLIB proxies, six aspects that do not fire | Articles whose text is kept here rather than only on the blog have it under `/post/` — `post.md` for the body and `meta.md` for the title, excerpt and diff --git a/configuration-properties/README.md b/configuration-properties/README.md new file mode 100644 index 0000000..074e896 --- /dev/null +++ b/configuration-properties/README.md @@ -0,0 +1,76 @@ +# @ConfigurationProperties vs @Value + +Companion project for [**@ConfigurationProperties vs @Value in Spring Boot 4**](https://ankurm.com/) +on ankurm.com. + +Every table and transcript quoted in that article was produced by running something here. +Three of them were produced twice, because the first answer was wrong — see +[chapter 2](docs/02-relaxed-binding.md). + +## Versions + +| | | +|---|---| +| Spring Boot | 4.1.1 | +| Spring Framework | 7.0.9 | +| JDK | Eclipse Temurin 25.0.4.1 (LTS) | +| Validation | Hibernate Validator via `spring-boot-starter-validation` | + +## Quickstart + +```bash +export JAVA_HOME=/path/to/jdk-25 +mvn -DskipTests package +./scripts/run-all.sh # regenerate every transcript in docs/output/ +mvn test # 7 contract tests +``` + +## Profiles + +| Profile | What it does | +|---|---| +| *(none)* | binds `demo.*` from `application.yaml` | +| `probe` | runs the relaxed-binding matrix and exits | +| `envprobe` | reports one property as seen by the binder and by `@Value` | +| `badvalidation` | values that violate every constraint, so startup fails | +| `csvlist` | the recipients list as a comma-separated string | + +## Endpoints + +| Endpoint | Purpose | +|---|---| +| `GET /diag/bound` | the bound objects, and the same values via `@Value` | +| `GET /diag/origin?name=` | every source holding a property, in precedence order, with file and line | + +Both are diagnostics. Delete them before shipping. + +## Documentation + +1. [Two mechanisms, not two styles](docs/01-two-mechanisms.md) +2. [Relaxed binding, measured](docs/02-relaxed-binding.md) +3. [Getting the bean registered](docs/03-registration.md) +4. [Records, constructor binding and defaults](docs/04-records-and-defaults.md) +5. [Validation](docs/05-validation.md) +6. [When `@Value` is still the right answer](docs/06-when-value-still-wins.md) +7. [IDE metadata, and the JDK 23 change that silently breaks it](docs/07-ide-metadata.md) +8. [Diagnosing a value](docs/08-diagnosing-a-value.md) + +## Captured output + +| File | Produced by | +|---|---| +| [`00-versions.txt`](docs/output/00-versions.txt) | `scripts/demo-versions.sh` | +| [`01-relaxed-matrix.txt`](docs/output/01-relaxed-matrix.txt) | `scripts/demo-relaxed-matrix.sh` | +| [`02-env-var-binding.txt`](docs/output/02-env-var-binding.txt) | `scripts/demo-env-binding.sh` | +| [`03-value-vs-binding.txt`](docs/output/03-value-vs-binding.txt) | `scripts/demo-value-vs-binding.sh` | +| [`04-validation-failure.txt`](docs/output/04-validation-failure.txt) | `scripts/demo-validation.sh` | +| [`05-metadata-generation.txt`](docs/output/05-metadata-generation.txt) | `scripts/demo-metadata-generation.sh` | + +## Three findings worth the trip + +- **`@Value` gets relaxed binding inside Spring Boot.** The universal claim that it does not is + a statement about the Spring Framework; Boot attaches a property source that changes it. +- **The configuration processor silently stops on JDK 23+** when declared as a plain + dependency, so IDE auto-completion quietly dies while the build stays green. +- **`@Valid` is not what makes nested constraints run.** Boot's `ValidationBindHandler` + validates every object it binds. diff --git a/configuration-properties/docs/01-two-mechanisms.md b/configuration-properties/docs/01-two-mechanisms.md new file mode 100644 index 0000000..878e43f --- /dev/null +++ b/configuration-properties/docs/01-two-mechanisms.md @@ -0,0 +1,49 @@ +[Index](../README.md) · [Relaxed binding →](02-relaxed-binding.md) + +# 1. Two mechanisms, not two styles + +`@Value` and `@ConfigurationProperties` are often presented as a matter of taste. They are not. +They are two different mechanisms that happen to read from the same `Environment`, and almost +every difference in behaviour follows from that. + +## `@Value` is placeholder resolution + +`@Value("${demo.mail.host}")` is a string. Spring's `PropertySourcesPlaceholderConfigurer` +resolves it against the environment during bean post-processing, converts the result to the +field's type, and assigns it. There is no model of what a property *is* — only a name, a +lookup and a conversion. + +Consequences: + +- The property name lives in a string literal. Renaming a property is a text search. +- One field, one lookup. There is no way to express "these six properties belong together". +- Defaulting is `:` inside the placeholder, and nothing else. +- There is no validation step, because there is no object to validate. +- SpEL is available: `#{...}` can compute rather than look up. + +## `@ConfigurationProperties` is binding + +`Binder` walks a target type, works out which properties it needs, asks the +`ConfigurationPropertySource`s for each one, converts, and constructs the object. The target +type is the model. + +Consequences: + +- The property names are derived from the type. Renaming a component renames the property. +- Nested types, lists and maps bind, because the binder knows the shape it is filling. +- Defaults are `@DefaultValue`, applied per component during construction. +- The bound object can be validated as an object, once, at startup. +- No SpEL. The binder maps values; it does not compute them. + +## The one that surprises people + +Because both read from the same environment, the *relaxed* rules ought to differ — and in the +Spring Framework alone they do. Inside Spring Boot they largely do not, because Boot attaches +an extra property source that gives placeholder resolution the binder's name matching. +[Chapter 2](02-relaxed-binding.md) has the measured matrix. + +## Which to use + +Use `@ConfigurationProperties` for anything that is a group of settings — which is most +things. Reach for `@Value` when you need a single value in a place where a type would be +ceremony, or when you need SpEL. [Chapter 6](06-when-value-still-wins.md) is the honest list. diff --git a/configuration-properties/docs/02-relaxed-binding.md b/configuration-properties/docs/02-relaxed-binding.md new file mode 100644 index 0000000..dfae656 --- /dev/null +++ b/configuration-properties/docs/02-relaxed-binding.md @@ -0,0 +1,62 @@ +[← Two mechanisms](01-two-mechanisms.md) · [Index](../README.md) · [Registration →](03-registration.md) + +# 2. Relaxed binding, measured + +Everything here is generated by [`RelaxedBindingProbe`](../src/main/java/com/ankurm/configprops/web/RelaxedBindingProbe.java) +and re-checked against real processes by [`demo-env-binding.sh`](../scripts/demo-env-binding.sh). +Transcripts: [`01-relaxed-matrix.txt`](output/01-relaxed-matrix.txt), +[`02-env-var-binding.txt`](output/02-env-var-binding.txt). + +## The canonical form + +Every property has one canonical name: lower case, words separated by `-`, levels separated by +`.`. For `RelaxedProperties.apiKey` under prefix `demo.relaxed`, that is +`demo.relaxed.api-key`. + +## What matches it + +| Spelling | Source | Binder | `${}` plain Spring | `${}` in Spring Boot | +|---|---|---|---|---| +| `demo.relaxed.api-key` | file / `-D` | bound | bound | bound | +| `demo.relaxed.apiKey` | file / `-D` | bound | MISS | bound | +| `demo.relaxed.api_key` | file / `-D` | bound | MISS | bound | +| `demo.relaxed.APIKEY` | file / `-D` | bound | MISS | bound | +| `DEMO.RELAXED.API-KEY` | file / `-D` | bound | MISS | bound | +| `demo.relaxed.apikey` | file / `-D` | bound | MISS | bound | +| `demo.relaxed.api.key` | file / `-D` | **MISS** | MISS | **MISS** | +| `DEMO_RELAXED_API_KEY` | env var | bound | bound | bound | +| `DEMO_RELAXED_APIKEY` | env var | bound | MISS | bound | +| `demo_relaxed_api_key` | env var | bound | bound | bound | + +## Reading it + +**Dashes, underscores and case are noise.** `ConfigurationPropertyName` compares elements in a +"uniform" form with separators removed and case folded, so `api-key`, `apiKey`, `api_key` and +`apikey` are the same name. + +**A dot is not noise.** `demo.relaxed.api.key` is four name elements; `demo.relaxed.api-key` is +three. Nothing relaxed will ever join them. This is the one spelling that genuinely fails, and +it is the one people write when they are guessing. + +**`@Value` gets relaxed resolution too — but only inside Spring Boot.** The widely repeated +claim that `@Value` does not support relaxed binding is a statement about the Spring Framework. +Spring Boot calls `ConfigurationPropertySources.attach(environment)` while preparing every +environment, which inserts a property source that resolves placeholders through the binder's +name matching. Remove that call and the middle column is what you get. + +That is worth knowing in both directions: you can rely on it in a Boot application, and you +cannot rely on it in a plain `ApplicationContext`, a standalone `Environment` or a test that +builds one by hand. + +## Environment variables + +Both `DEMO_RELAXED_API_KEY` and `DEMO_RELAXED_APIKEY` work. Prefer the first: `_` for every +separator, upper case throughout. It is the form the binder generates when it goes looking, so +it is the one that cannot depend on the reverse mapping. + +A harness note that cost a wrong finding here: Spring Boot decides to apply environment-variable +name mapping by comparing the property source's *name* against +`StandardEnvironment.SYSTEM_ENVIRONMENT_PROPERTY_SOURCE_NAME`, not by checking its type. A +`SystemEnvironmentPropertySource` registered under any other name is mapped with the ordinary +rules. A test that builds one by hand and names it something descriptive will report that +underscore spellings do not bind, and will be wrong. diff --git a/configuration-properties/docs/03-registration.md b/configuration-properties/docs/03-registration.md new file mode 100644 index 0000000..5112226 --- /dev/null +++ b/configuration-properties/docs/03-registration.md @@ -0,0 +1,45 @@ +[← Relaxed binding](02-relaxed-binding.md) · [Index](../README.md) · [Records and defaults →](04-records-and-defaults.md) + +# 3. Getting the bean registered + +`@ConfigurationProperties` on a class does nothing on its own. The class has to become a bean, +and there are three ways to arrange that: + +| How | Where it goes | When to use it | +|---|---|---| +| `@ConfigurationPropertiesScan` | on the application class | most applications; scans a package | +| `@EnableConfigurationProperties(Foo.class)` | on any `@Configuration` class | libraries, or when you want the list explicit | +| `@Component` | on the properties class itself | works, but mixes a stereotype into a value type | + +This project uses `@ConfigurationPropertiesScan`, on +[`ConfigBindingApplication`](../src/main/java/com/ankurm/configprops/ConfigBindingApplication.java). + +## The failure + +If none of the three is present, the class compiles, the application starts, and the bean does +not exist. Injecting it fails with `NoSuchBeanDefinitionException` — which is at least loud. + +The quiet version is worse: with constructor binding, a properties *record* that is never +registered simply never appears, and if the only thing that used it was optional, nothing +complains at all. + +## Constructor binding and `@Autowired` do not mix + +A type bound through its constructor is built by the binder, not by the container. It cannot +have collaborators injected into that constructor, because every parameter is treated as a +property to bind. If a properties type needs a collaborator, it is not a properties type. + +## `@ConfigurationProperties` on an `@Bean` method + +Legal, and useful when the type comes from a library you cannot annotate: + +```java +@Bean +@ConfigurationProperties(prefix = "demo.thirdparty") +ThirdPartyConfig thirdPartyConfig() { + return new ThirdPartyConfig(); +} +``` + +This uses setter binding, not constructor binding — the object already exists by the time the +binder sees it. diff --git a/configuration-properties/docs/04-records-and-defaults.md b/configuration-properties/docs/04-records-and-defaults.md new file mode 100644 index 0000000..a64cbd1 --- /dev/null +++ b/configuration-properties/docs/04-records-and-defaults.md @@ -0,0 +1,62 @@ +[← Registration](03-registration.md) · [Index](../README.md) · [Validation →](05-validation.md) + +# 4. Records, constructor binding and defaults + +See [`MailProperties`](../src/main/java/com/ankurm/configprops/props/MailProperties.java) and +the bound output in [`03-value-vs-binding.txt`](output/03-value-vs-binding.txt). + +## Records need no annotation + +A record has exactly one canonical constructor, so the binder uses it. `@ConstructorBinding` +is only needed to pick between candidates when a type has more than one constructor — and +since Spring Boot 3 it goes on the *constructor*, not the type. + +Constructor binding gives you immutability, which matters more than it sounds: a mutable +`@ConfigurationProperties` bean is a singleton that anything can write to. + +## Defaults + +A record cannot have field initialisers, so the default has to be attached to the component: + +```java +public record MailProperties( + String host, // no default: null if absent + @DefaultValue("587") int port, + @DefaultValue("30s") Duration timeout, + @DefaultValue RetryProperties retries, // nested, with its own defaults + @DefaultValue List recipients, // empty list, not null + @DefaultValue Map headers) { +} +``` + +`@DefaultValue` with no argument on a nested type means "construct it with its own defaults" +rather than "bind it to null". Same for collections: you get an empty one instead of `null`, +which removes a class of `NullPointerException` from startup code. + +A component with neither a value nor a `@DefaultValue` binds to `null` for a reference type. A +`record` component of primitive type with no value and no default fails the bind. + +## The whole-object rule + +If *nothing* under the prefix is present, the binder returns no result at all — not an object +full of defaults. `BindResult.isBound()` is `false` and `get()` throws. Defaults apply to +components of an object that is being constructed; they do not cause one to be constructed. + +For a bean registered through `@ConfigurationPropertiesScan` this is invisible, because Spring +Boot binds with a target that always constructs. It shows up as soon as you call `Binder` +yourself, which is why it is here. + +## Lists + +| Written as | `@ConfigurationProperties` | `@Value` | +|---|---|---| +| YAML block list | binds | does not see it | +| `a,b,c` string | binds | binds | +| `foo[0]`, `foo[1]` | binds | does not see it | + +The middle row is the only shape both understand, and it is why comma-separated lists persist +in configuration long after they stopped being pleasant to read. + +One thing to know about list overriding: a list is bound from the highest-precedence source +that contains the property, *entirely*. Lists do not merge across sources. A source that sets +three elements replaces a lower source's two; it does not append to them. diff --git a/configuration-properties/docs/05-validation.md b/configuration-properties/docs/05-validation.md new file mode 100644 index 0000000..a19bdde --- /dev/null +++ b/configuration-properties/docs/05-validation.md @@ -0,0 +1,55 @@ +[← Records and defaults](04-records-and-defaults.md) · [Index](../README.md) · [When @Value wins →](06-when-value-still-wins.md) + +# 5. Validation + +Transcript: [`04-validation-failure.txt`](output/04-validation-failure.txt). +Type: [`ValidatedProperties`](../src/main/java/com/ankurm/configprops/props/ValidatedProperties.java). + +This is the capability `@Value` does not have at all, and the reason to prefer binding for +anything that can be misconfigured. + +## What it takes + +1. `spring-boot-starter-validation` on the classpath. Without a validator implementation, + `@Validated` is a no-op — nothing is checked and nothing says so. +2. `@Validated` on the properties type. +3. JSR-380 constraints on the components. + +## What you get + +A bad value becomes a startup failure that names the property, the offending value, the file +and line it came from, and the constraint it broke — all of them at once, not one per restart: + +``` +Property: demo.validated.port +Value: "99999" +Origin: class path resource [application-badvalidation.yaml] - 6:11 +Reason: must be less than or equal to 65535 +``` + +The alternative is a `NumberFormatException` in a request handler at 3am. + +## `@Valid` on nested types is not what makes them validate + +The rule "annotate nested properties with `@Valid` or their constraints are ignored" is widely +repeated and does not apply here. It is true of ordinary bean validation, where cascading is +opt-in. Spring Boot's `ValidationBindHandler` validates *every object the binder finishes +constructing*, nested ones included. + +`BindingContractTests.nestedConstraintsFireWithoutValid` pins this: a nested record carrying +`@Max(100)` and no `@Valid` anywhere in the type still fails the bind at 4000. That test was +originally written to assert the opposite and failed, which is how it ended up documented here. + +Keep writing `@Valid` if you like — it is harmless and it is what a reader expects. Just do not +believe it is load-bearing. + +## Ordering + +The violation order in the failure report is not stable between runs. Do not write a test that +asserts on it. + +## Custom validation + +For rules a constraint annotation cannot express — "if `mode` is `remote` then `endpoint` is +required" — implement `Validator` and expose it as a bean named `configurationPropertiesValidator`. +It runs at bind time with the same reporting. diff --git a/configuration-properties/docs/06-when-value-still-wins.md b/configuration-properties/docs/06-when-value-still-wins.md new file mode 100644 index 0000000..fb72e00 --- /dev/null +++ b/configuration-properties/docs/06-when-value-still-wins.md @@ -0,0 +1,42 @@ +[← Validation](05-validation.md) · [Index](../README.md) · [IDE metadata →](07-ide-metadata.md) + +# 6. When `@Value` is still the right answer + +Binding wins most of the time, and this project is largely an argument for it. The exceptions +are real though, and pretending otherwise makes the advice easy to dismiss. + +## SpEL + +`@Value` evaluates SpEL; the binder does not. If the value has to be *computed*, this is the +only one of the two that can do it: + +```java +@Value("#{T(java.lang.Runtime).getRuntime().availableProcessors() * 2}") +private int computedThreads; +``` + +There is no `@ConfigurationProperties` equivalent. The binder maps a value; it does not derive +one. (You can always compute in a compact constructor instead, which is usually clearer.) + +## A single value in a class that is not about configuration + +A `@Component` that needs one feature flag does not benefit from a properties type. The type +would exist to hold one field and would be injected in one place. + +The line is roughly: if you would name the type after the group of settings and the name would +be meaningful, use binding. If you would have to invent a name, use `@Value`. + +## Reading someone else's property + +`@Value("${server.port}")` reads a property owned by Spring Boot. Declaring your own type for +it would imply you own it. + +## What is *not* a good reason + +- **"It's less code."** For one property, marginally. For four, the properties type is + shorter and it is also checked. +- **"I need a default."** Both support defaults. +- **"@Value is faster."** Neither is measurable next to a database call, and both happen once + at startup. +- **"Relaxed binding doesn't work with @Value."** Inside Spring Boot, it does — + [chapter 2](02-relaxed-binding.md). diff --git a/configuration-properties/docs/07-ide-metadata.md b/configuration-properties/docs/07-ide-metadata.md new file mode 100644 index 0000000..51bf87a --- /dev/null +++ b/configuration-properties/docs/07-ide-metadata.md @@ -0,0 +1,88 @@ +[← When @Value wins](06-when-value-still-wins.md) · [Index](../README.md) · [Diagnosing a value →](08-diagnosing-a-value.md) + +# 7. IDE metadata, and the JDK 23 change that silently breaks it + +Transcript: [`05-metadata-generation.txt`](output/05-metadata-generation.txt). + +## What the metadata is + +`spring-boot-configuration-processor` is an annotation processor. At compile time it reads your +`@ConfigurationProperties` types and writes `META-INF/spring-configuration-metadata.json`: + +```json +{ + "groups": [ + { "name": "demo.mail", "type": "com.ankurm.configprops.props.MailProperties" } + ], + "properties": [ + { "name": "demo.mail.port", "type": "java.lang.Integer", "defaultValue": 587 } + ] +} +``` + +That file is what makes property names auto-complete in an IDE, and what shows the Javadoc on +a record component as hover documentation. Nothing at runtime reads it. + +## The failure + +Declaring the processor as a dependency — the way every tutorial written before 2024 shows — +stops working on JDK 23 and later: + +```xml + + org.springframework.boot + spring-boot-configuration-processor + true + +``` + +Absent any processor-related command-line option, `-proc:none` is now javac's default. JDK 21 +began printing an informative message when implicit annotation processing was detected, and +JDK 23 turned the policy off, with the stated goal of making builds robust against processors +landing on the classpath unintentionally. A processor that is only on the classpath is now +simply not run. + +The build still succeeds. The jar is still valid. The application behaves identically. The only +symptom is that property auto-completion quietly stops working, which is the kind of thing +people blame on the IDE. + +Two compilations of identical sources with the identical processor jar: + +``` +A) javac -cp :spring-boot-configuration-processor.jar -d a $SOURCES + spring-configuration-metadata.json files produced: 0 + +B) javac -proc:full -cp :spring-boot-configuration-processor.jar -d b $SOURCES + spring-configuration-metadata.json files produced: 1 +``` + +## The fix + +Declare it as an annotation processor path. That makes Maven pass `--processor-path`, and an +explicit processor option re-enables processing -- the new default only applies when javac is +given nothing to go on: + +```xml + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.springframework.boot + spring-boot-configuration-processor + ${project.parent.version} + + + + +``` + +`-proc:full` also works and is a smaller change, but it restores the old policy for every +processor on the classpath, which is the behaviour that was turned off for a reason. + +## Checking + +`ls target/classes/META-INF/spring-configuration-metadata.json`. If it is not there, the +processor did not run. Add `@ConfigurationProperties` metadata to your build's definition of +done, because nothing else will tell you. diff --git a/configuration-properties/docs/08-diagnosing-a-value.md b/configuration-properties/docs/08-diagnosing-a-value.md new file mode 100644 index 0000000..6dc17d3 --- /dev/null +++ b/configuration-properties/docs/08-diagnosing-a-value.md @@ -0,0 +1,57 @@ +[← IDE metadata](07-ide-metadata.md) · [Index](../README.md) + +# 8. Diagnosing a value + +Endpoint: [`BindingDiagnosticsEndpoint`](../src/main/java/com/ankurm/configprops/web/BindingDiagnosticsEndpoint.java). + +"The property is set but the application does not see it" is the most common configuration bug, +and it is hard for one reason: a value carries no visible provenance. Spring Boot tracks it +anyway — every property loaded from a file or a config tree carries an `Origin` naming the +resource and the line. + +``` +$ curl -s 'localhost:8080/diag/origin?name=demo.mail.host' +{ + "property": "demo.mail.host", + "effectiveValue": "smtp.example.com", + "candidatesInPrecedenceOrder": [ + { + "source": "OriginTrackedMapPropertySource {name='Config resource ... [application.yaml]'}", + "value": "smtp.example.com", + "origin": "class path resource [application.yaml] - 15:11" + } + ] +} +``` + +Line 15, column 11. Not "somewhere in your configuration". + +## How it works + +```java +for (ConfigurationPropertySource source : ConfigurationPropertySources.get(environment)) { + ConfigurationProperty property = + source.getConfigurationProperty(ConfigurationPropertyName.of(name)); + if (property != null) { + // property.getValue(), property.getOrigin(), source.getUnderlyingSource() + } +} +``` + +Iterating `ConfigurationPropertySources.get(...)` gives you the sources in precedence order. +The first hit is the winner; everything after it exists and lost. A property with several +holders is exactly what "my change had no effect" looks like from the inside — and the +[profiles project](../../profiles-and-config) is entirely about that case. + +## Delete it before shipping + +This endpoint prints whatever a mounted secret contains. If you want it permanently, put it +behind the management port and authentication, or use Actuator's `/actuator/env`, which does +the same job with sanitisation built in. + +## Without an endpoint + +- `--debug` prints the auto-configuration report, not property origins. +- Setting `logging.level.org.springframework.boot.context.config=TRACE` logs which config data + resources were loaded, in order — useful when the question is "was my file read at all". +- In a test, inject `Environment` and inspect `getPropertySources()` directly. diff --git a/configuration-properties/docs/output/00-versions.txt b/configuration-properties/docs/output/00-versions.txt new file mode 100644 index 0000000..4f051df --- /dev/null +++ b/configuration-properties/docs/output/00-versions.txt @@ -0,0 +1,7 @@ +== versions == +openjdk version "25.0.4.1" 2026-08-18 LTS +OpenJDK Runtime Environment Temurin-25.0.4.1+1 (build 25.0.4.1+1-LTS) +OpenJDK 64-Bit Server VM Temurin-25.0.4.1+1 (build 25.0.4.1+1-LTS, mixed mode, sharing) + + +spring-boot-starter-parent: 4.1.1 diff --git a/configuration-properties/docs/output/01-relaxed-matrix.txt b/configuration-properties/docs/output/01-relaxed-matrix.txt new file mode 100644 index 0000000..28a23b1 --- /dev/null +++ b/configuration-properties/docs/output/01-relaxed-matrix.txt @@ -0,0 +1,22 @@ +== relaxed binding matrix == +canonical property: demo.relaxed.api-key + +SPELLING SOURCE BINDER ${} plain ${} boot +-------------------------------------------------------------- +demo.relaxed.api-key map bound bound bound +demo.relaxed.apiKey map bound MISS bound +demo.relaxed.api_key map bound MISS bound +demo.relaxed.APIKEY map bound MISS bound +DEMO.RELAXED.API-KEY map bound MISS bound +demo.relaxed.apikey map bound MISS bound +demo.relaxed.api.key map MISS MISS MISS +DEMO_RELAXED_API_KEY env bound bound bound +DEMO_RELAXED_APIKEY env bound MISS bound +demo_relaxed_api_key env bound bound bound +DEMO.RELAXED.API-KEY env bound bound bound + +BINDER = what @ConfigurationProperties sees (Binder) +${} plain = placeholder resolution against a bare StandardEnvironment +${} boot = the same, after ConfigurationPropertySources.attach(env), + which Spring Boot performs on every environment it prepares +bound = the value arrived; MISS = the property was not found diff --git a/configuration-properties/docs/output/02-env-var-binding.txt b/configuration-properties/docs/output/02-env-var-binding.txt new file mode 100644 index 0000000..23e93f6 --- /dev/null +++ b/configuration-properties/docs/output/02-env-var-binding.txt @@ -0,0 +1,43 @@ +== real process, real environment: binding demo.relaxed.api-key == + +canonical property : demo.relaxed.api-key +(MISS) means the property was not found and the declared default was used + +--- as an operating-system environment variable --- + +$ DEMO_RELAXED_API_KEY=secret-value java -jar target/configuration-properties-1.0.0.jar +env-var-set : DEMO_RELAXED_API_KEY=secret-value +@ConfigurationProps: secret-value +@Value : secret-value +Environment.get : secret-value + +$ DEMO_RELAXED_APIKEY=secret-value java -jar target/configuration-properties-1.0.0.jar +env-var-set : DEMO_RELAXED_APIKEY=secret-value +@ConfigurationProps: secret-value +@Value : secret-value +Environment.get : secret-value + +--- as a JVM system property (-D), i.e. the 'map' rows of the matrix --- + +$ java -Ddemo.relaxed.api-key=secret-value -jar target/configuration-properties-1.0.0.jar +@ConfigurationProps: secret-value +@Value : secret-value +Environment.get : secret-value + +$ java -Ddemo.relaxed.apiKey=secret-value -jar target/configuration-properties-1.0.0.jar +@ConfigurationProps: secret-value +@Value : secret-value +Environment.get : secret-value + +$ java -Ddemo.relaxed.apikey=secret-value -jar target/configuration-properties-1.0.0.jar +@ConfigurationProps: secret-value +@Value : secret-value +Environment.get : secret-value + +$ java -Ddemo.relaxed.api.key=secret-value -jar target/configuration-properties-1.0.0.jar +@ConfigurationProps: (unset) +@Value : (MISS) +Environment.get : (MISS) + +The last one is not a spelling of the property. api.key is two name elements; +api-key is one. Nothing relaxed will ever join them. diff --git a/configuration-properties/docs/output/03-value-vs-binding.txt b/configuration-properties/docs/output/03-value-vs-binding.txt new file mode 100644 index 0000000..377f4c2 --- /dev/null +++ b/configuration-properties/docs/output/03-value-vs-binding.txt @@ -0,0 +1,79 @@ +== @ConfigurationProperties vs @Value, same application, same application.yaml == + +$ curl -s localhost:8080/diag/bound +{ + "mail": { + "host": "smtp.example.com", + "port": 587, + "timeout": "PT45S", + "retries": { + "maxAttempts": 5, + "backoff": "PT1.5S" + }, + "recipients": [ + "ops@example.com", + "oncall@example.com" + ], + "headers": { + "X-Env": "demo", + "X-Team": "platform" + } + }, + "validated": { + "name": "demo-service", + "port": 8443, + "endpoint": "https://api.example.com", + "pool": { + "size": 25 + } + }, + "fromValueAnnotation": { + "host": "smtp.example.com", + "port": 587, + "timeout": "PT45S", + "recipients": [], + "poolSize": 25, + "computedThreads": 4 + } +} + +demo.mail.recipients is a YAML block list. The binder produced both elements. +@Value produced an empty list -- placeholder resolution has no concept of a YAML +sequence, so ${demo.mail.recipients:} fell through to its own empty default. + +== the same list written as a comma-separated string, at higher precedence == +$ ./scripts/run.sh "" --demo.mail.recipients=x@e.com,y@e.com,z@e.com + binder : ['x@e.com', 'y@e.com', 'z@e.com'] + @Value : ['x@e.com', 'y@e.com', 'z@e.com'] + +Both see it. A comma-separated string is the one list shape @Value understands, and +the command-line source outranks the YAML file for the binder as well. + +== where did that value come from? == +$ curl -s 'localhost:8080/diag/origin?name=demo.mail.recipients' +{ + "property": "demo.mail.recipients", + "effectiveValue": "x@e.com,y@e.com,z@e.com", + "candidatesInPrecedenceOrder": [ + { + "source": "SimpleCommandLinePropertySource {name='commandLineArgs'}", + "value": "x@e.com,y@e.com,z@e.com", + "origin": "\"demo.mail.recipients\" from property source \"commandLineArgs\"" + } + ], + "shadowedBy": null +} + +$ curl -s 'localhost:8080/diag/origin?name=demo.mail.host' +{ + "property": "demo.mail.host", + "effectiveValue": "smtp.example.com", + "candidatesInPrecedenceOrder": [ + { + "source": "OriginTrackedMapPropertySource {name='Config resource 'class path resource [application.yaml]' via location 'optional:classpath:/''}", + "value": "smtp.example.com", + "origin": "class path resource [application.yaml] from configuration-properties-1.0.0.jar - 15:11" + } + ], + "shadowedBy": null +} diff --git a/configuration-properties/docs/output/04-validation-failure.txt b/configuration-properties/docs/output/04-validation-failure.txt new file mode 100644 index 0000000..a3a5fd6 --- /dev/null +++ b/configuration-properties/docs/output/04-validation-failure.txt @@ -0,0 +1,33 @@ +== startup with demo.validated.* deliberately out of range == +$ java -jar target/configuration-properties-1.0.0.jar --spring.profiles.active=badvalidation + +APPLICATION FAILED TO START +*************************** + +Description: + +Binding to target com.ankurm.configprops.props.ValidatedProperties failed: + + Property: demo.validated.pool.size + Value: "4000" + Reason: must be less than or equal to 100 + + Property: demo.validated.endpoint + Value: "ftp://files.example.com" + Origin: class path resource [application-badvalidation.yaml] from configuration-properties-1.0.0.jar - 8:15 + Reason: must match "https?://.*" + + Property: demo.validated.port + Value: "99999" + Origin: class path resource [application-badvalidation.yaml] from configuration-properties-1.0.0.jar - 6:11 + Reason: must be less than or equal to 65535 + + Property: demo.validated.name + Value: " " + Origin: class path resource [application-badvalidation.yaml] from configuration-properties-1.0.0.jar - 4:11 + Reason: must not be blank + + +Action: + +Update your application's configuration diff --git a/configuration-properties/docs/output/05-metadata-generation.txt b/configuration-properties/docs/output/05-metadata-generation.txt new file mode 100644 index 0000000..a3f6bdf --- /dev/null +++ b/configuration-properties/docs/output/05-metadata-generation.txt @@ -0,0 +1,68 @@ +== is the annotation processor discovered? == + +openjdk version "25.0.4.1" 2026-08-18 LTS +processor jar: spring-boot-configuration-processor-4.1.1.jar + +A) processor on the classpath, javac defaults -- what an dependency gives you +$ javac -cp :spring-boot-configuration-processor.jar -d a $SOURCES + spring-configuration-metadata.json files produced: 0 + +B) identical, plus -proc:full +$ javac -proc:full -cp :spring-boot-configuration-processor.jar -d b $SOURCES + spring-configuration-metadata.json files produced: 1 + +Both compilations succeed. Only one of them has metadata. + +== what this project's pom does instead == +The processor is declared as an on maven-compiler-plugin, +which puts it on javac's --processor-path where discovery is not disabled: + +$ mvn clean package && ls target/classes/META-INF/ +spring-configuration-metadata.json + +== the generated metadata, first entries == +{ + "groups": [ + { + "name": "demo.mail", + "type": "com.ankurm.configprops.props.MailProperties", + "sourceType": "com.ankurm.configprops.props.MailProperties" + }, + { + "name": "demo.mail.retries", + "type": "com.ankurm.configprops.props.MailProperties$RetryProperties", + "sourceType": "com.ankurm.configprops.props.MailProperties", + "sourceMethod": "retries()" + }, + { + "name": "demo.relaxed", + "type": "com.ankurm.configprops.props.RelaxedProperties", + "sourceType": "com.ankurm.configprops.props.RelaxedProperties" + }, + { + "name": "demo.validated", + "type": "com.ankurm.configprops.props.ValidatedProperties", + "sourceType": "com.ankurm.configprops.props.ValidatedProperties" + }, + { + "name": "demo.validated.pool", + "type": "com.ankurm.configprops.props.ValidatedProperties$Pool", + "sourceType": "com.ankurm.configprops.props.ValidatedProperties", + "sourceMethod": "pool()" + } + ], + "properties": [ + { + "name": "demo.mail.headers", + "type": "java.util.Map", + "description": "Bound from arbitrary sub-keys under {@code demo.mail.headers.*}.", + "sourceType": "com.ankurm.configprops.props.MailProperties" + }, + { + "name": "demo.mail.host", + "type": "java.lang.String", + "description": "SMTP host. No default: absent means the application must not start.", + "sourceType": "com.ankurm.configprops.props.MailProperties" + }, + { + "name": "demo.mail.port", diff --git a/configuration-properties/pom.xml b/configuration-properties/pom.xml new file mode 100644 index 0000000..0ae6975 --- /dev/null +++ b/configuration-properties/pom.xml @@ -0,0 +1,75 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 4.1.1 + + + + com.ankurm + configuration-properties + 1.0.0 + configuration-properties + @ConfigurationProperties vs @Value: binding, validation and relaxed rules + + + 25 + + + + + org.springframework.boot + spring-boot-starter-webmvc + + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.springframework.boot + spring-boot-configuration-processor + ${project.parent.version} + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/configuration-properties/scripts/demo-env-binding.sh b/configuration-properties/scripts/demo-env-binding.sh new file mode 100755 index 0000000..04fe424 --- /dev/null +++ b/configuration-properties/scripts/demo-env-binding.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# The matrix re-asked the expensive way: one real JVM per spelling, the value supplied by the +# operating system or by -D, and read back through both @ConfigurationProperties and @Value. +# +# This script exists because the in-process matrix was wrong twice before it was right. A +# synthesised property source is not a running Spring Boot application, and when the two +# disagree the running application wins. +set -euo pipefail +cd "$(dirname "$0")/.." +source scripts/env.sh + +RUN=(java -jar "$JAR" --spring.profiles.active=envprobe + --spring.main.web-application-type=none --spring.main.banner-mode=off + --logging.level.root=OFF) + +run_env() { + echo "\$ $1=secret-value java -jar $JAR" + env -u DEMO_RELAXED_API_KEY -u DEMO_RELAXED_APIKEY "$1=secret-value" "${RUN[@]}" 2>&1 \ + | clean | grep -E "^(env-var-set|@Config|@Value|Environment)" + echo +} + +run_sysprop() { + echo "\$ java -D$1=secret-value -jar $JAR" + env -u DEMO_RELAXED_API_KEY -u DEMO_RELAXED_APIKEY \ + java "-D$1=secret-value" -jar "$JAR" --spring.profiles.active=envprobe \ + --spring.main.web-application-type=none --spring.main.banner-mode=off \ + --logging.level.root=OFF 2>&1 \ + | clean | grep -E "^(@Config|@Value|Environment)" + echo +} + +{ + echo "== real process, real environment: binding demo.relaxed.api-key ==" + echo + echo "canonical property : demo.relaxed.api-key" + echo "(MISS) means the property was not found and the declared default was used" + echo + echo "--- as an operating-system environment variable ---" + echo + run_env DEMO_RELAXED_API_KEY + run_env DEMO_RELAXED_APIKEY + echo "--- as a JVM system property (-D), i.e. the 'map' rows of the matrix ---" + echo + run_sysprop demo.relaxed.api-key + run_sysprop demo.relaxed.apiKey + run_sysprop demo.relaxed.apikey + run_sysprop demo.relaxed.api.key + echo "The last one is not a spelling of the property. api.key is two name elements;" + echo "api-key is one. Nothing relaxed will ever join them." +} > docs/output/02-env-var-binding.txt 2>&1 +cat docs/output/02-env-var-binding.txt diff --git a/configuration-properties/scripts/demo-metadata-generation.sh b/configuration-properties/scripts/demo-metadata-generation.sh new file mode 100755 index 0000000..3aee1cf --- /dev/null +++ b/configuration-properties/scripts/demo-metadata-generation.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# Does spring-boot-configuration-processor actually run? +# +# Two builds of the SAME sources with the SAME processor jar, differing only in how the +# processor is declared to the compiler. On JDK 23+ that difference decides whether +# META-INF/spring-configuration-metadata.json exists at all -- and neither build fails. +set -euo pipefail +cd "$(dirname "$0")/.." +source scripts/env.sh + +WORK="${TMPDIR:-/tmp}/configprops-metadata-ab" +rm -rf "$WORK"; mkdir -p "$WORK/a" "$WORK/b" + +PROC_JAR=$(find ~/.m2/repository/org/springframework/boot/spring-boot-configuration-processor \ + -name 'spring-boot-configuration-processor-*.jar' | sort | tail -1) +CP=$("$MVN" -B -o -q dependency:build-classpath -Dmdep.outputFile=/dev/stdout 2>/dev/null | tail -1) + +SOURCES=$(find src/main/java -name '*.java') + +{ + echo "== is the annotation processor discovered? ==" + echo + java -version 2>&1 | clean | head -1 + echo "processor jar: $(basename "$PROC_JAR")" + echo + echo "A) processor on the classpath, javac defaults -- what an dependency gives you" + echo "\$ javac -cp :spring-boot-configuration-processor.jar -d a \$SOURCES" + javac -nowarn -cp "$PROC_JAR:$CP" -d "$WORK/a" $SOURCES 2>&1 | clean | head -3 || true + echo " spring-configuration-metadata.json files produced: \ +$(find "$WORK/a" -name 'spring-configuration-metadata.json' | wc -l)" + echo + echo "B) identical, plus -proc:full" + echo "\$ javac -proc:full -cp :spring-boot-configuration-processor.jar -d b \$SOURCES" + javac -nowarn -proc:full -cp "$PROC_JAR:$CP" -d "$WORK/b" $SOURCES 2>&1 | clean | head -3 || true + echo " spring-configuration-metadata.json files produced: \ +$(find "$WORK/b" -name 'spring-configuration-metadata.json' | wc -l)" + echo + echo "Both compilations succeed. Only one of them has metadata." + echo + echo "== what this project's pom does instead ==" + echo "The processor is declared as an on maven-compiler-plugin," + echo "which puts it on javac's --processor-path where discovery is not disabled:" + echo + echo "\$ mvn clean package && ls target/classes/META-INF/" + ls target/classes/META-INF/ 2>/dev/null || echo "(run mvn package first)" + echo + echo "== the generated metadata, first entries ==" + python3 -m json.tool target/classes/META-INF/spring-configuration-metadata.json 2>/dev/null \ + | head -45 +} > docs/output/05-metadata-generation.txt 2>&1 +cat docs/output/05-metadata-generation.txt diff --git a/configuration-properties/scripts/demo-relaxed-matrix.sh b/configuration-properties/scripts/demo-relaxed-matrix.sh new file mode 100755 index 0000000..bfd900e --- /dev/null +++ b/configuration-properties/scripts/demo-relaxed-matrix.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# The relaxed-binding matrix, generated by binding each spelling rather than by hand. +set -euo pipefail +cd "$(dirname "$0")/.." +source scripts/env.sh +java -jar "$JAR" --spring.profiles.active=probe --spring.main.web-application-type=none \ + 2>&1 | clean | sed -n '/== relaxed binding matrix ==/,/MISS = the property/p' \ + > docs/output/01-relaxed-matrix.txt +cat docs/output/01-relaxed-matrix.txt diff --git a/configuration-properties/scripts/demo-validation.sh b/configuration-properties/scripts/demo-validation.sh new file mode 100755 index 0000000..4c43628 --- /dev/null +++ b/configuration-properties/scripts/demo-validation.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# What a failing @Validated @ConfigurationProperties actually prints at startup. +set -euo pipefail +cd "$(dirname "$0")/.." +source scripts/env.sh +{ + echo "== startup with demo.validated.* deliberately out of range ==" + echo "\$ java -jar $JAR --spring.profiles.active=badvalidation" + echo + java -jar "$JAR" --spring.profiles.active=badvalidation \ + --spring.main.web-application-type=none 2>&1 | clean \ + | sed -n '/APPLICATION FAILED TO START/,/^Update your application/p' + echo + echo + echo "All four violations are reported at once, each with the file and line that supplied" + echo "the value. The process refused to start rather than serving traffic with a pool size" + echo "of 4000." + echo + echo "Note demo.validated.pool.size has no Origin line. It is a nested record reached" + echo "through @Valid, and the binder tracks origins per bound property, not per constraint." +} > docs/output/04-validation-failure.txt 2>&1 +cat docs/output/04-validation-failure.txt diff --git a/configuration-properties/scripts/demo-value-vs-binding.sh b/configuration-properties/scripts/demo-value-vs-binding.sh new file mode 100755 index 0000000..24fe5e6 --- /dev/null +++ b/configuration-properties/scripts/demo-value-vs-binding.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Side by side: what the binder produced and what @Value produced, from one running process. +set -euo pipefail +cd "$(dirname "$0")/.." +source scripts/env.sh +{ + echo "== @ConfigurationProperties vs @Value, same application, same application.yaml ==" + echo + scripts/run.sh > /dev/null + echo "\$ curl -s localhost:8080/diag/bound" + curl -s "http://127.0.0.1:${APP_PORT}/diag/bound" | python3 -m json.tool + echo + echo "demo.mail.recipients is a YAML block list. The binder produced both elements." + echo "@Value produced an empty list -- placeholder resolution has no concept of a YAML" + echo "sequence, so \${demo.mail.recipients:} fell through to its own empty default." + echo + echo "== the same list written as a comma-separated string, at higher precedence ==" + scripts/run.sh "" --demo.mail.recipients=x@e.com,y@e.com,z@e.com > /dev/null + echo "\$ ./scripts/run.sh \"\" --demo.mail.recipients=x@e.com,y@e.com,z@e.com" + curl -s "http://127.0.0.1:${APP_PORT}/diag/bound" \ + | python3 -c 'import json,sys; d=json.load(sys.stdin); print(" binder :", d["mail"]["recipients"]); print(" @Value :", d["fromValueAnnotation"]["recipients"])' + echo + echo "Both see it. A comma-separated string is the one list shape @Value understands, and" + echo "the command-line source outranks the YAML file for the binder as well." + echo + echo "== where did that value come from? ==" + echo "\$ curl -s 'localhost:8080/diag/origin?name=demo.mail.recipients'" + curl -s "http://127.0.0.1:${APP_PORT}/diag/origin?name=demo.mail.recipients" | python3 -m json.tool + echo + echo "\$ curl -s 'localhost:8080/diag/origin?name=demo.mail.host'" + curl -s "http://127.0.0.1:${APP_PORT}/diag/origin?name=demo.mail.host" | python3 -m json.tool + scripts/stop.sh +} > docs/output/03-value-vs-binding.txt 2>&1 +cat docs/output/03-value-vs-binding.txt diff --git a/configuration-properties/scripts/demo-versions.sh b/configuration-properties/scripts/demo-versions.sh new file mode 100755 index 0000000..699d701 --- /dev/null +++ b/configuration-properties/scripts/demo-versions.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Exact versions every other transcript in this directory was produced against. +set -euo pipefail +cd "$(dirname "$0")/.." +source scripts/env.sh +{ + echo "== versions ==" + java -version 2>&1 | clean + echo + "$MVN" -B -o -q dependency:tree 2>/dev/null \ + | grep -E "spring-boot:jar|spring-core:jar|spring-context:jar|hibernate-validator:jar|jakarta.validation-api:jar" \ + | sed 's/^\[INFO\] //' || true + echo + echo "spring-boot-starter-parent: $(grep -A2 'spring-boot-starter-parent' pom.xml | grep '' | sed 's/.*\(.*\)<\/version>.*/\1/')" +} > docs/output/00-versions.txt 2>&1 +cat docs/output/00-versions.txt diff --git a/configuration-properties/scripts/env.sh b/configuration-properties/scripts/env.sh new file mode 100755 index 0000000..8862a12 --- /dev/null +++ b/configuration-properties/scripts/env.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Shared environment. Point JAVA_HOME at a JDK 25 (or newer) installation. +: "${JAVA_HOME:?set JAVA_HOME to a JDK 25+ installation}" +export PATH="$JAVA_HOME/bin:$PATH" +MVN="${MVN:-mvn}" +JAR="target/configuration-properties-1.0.0.jar" +APP_MAIN="com.ankurm.configprops.ConfigBindingApplication" +APP_PORT="${APP_PORT:-8080}" + +# Strip environment noise that is an artefact of the machine, not of Spring: +# the JVM prints a JAVA_TOOL_OPTIONS banner to stderr on every launch when a proxy +# truststore is configured, and it would otherwise end up in every committed transcript. +clean() { grep -v "Picked up JAVA_TOOL_OPTIONS" | grep -v "^OpenJDK 64-Bit Server VM warning"; } diff --git a/configuration-properties/scripts/run-all.sh b/configuration-properties/scripts/run-all.sh new file mode 100755 index 0000000..492a625 --- /dev/null +++ b/configuration-properties/scripts/run-all.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Regenerate every transcript under docs/output/. +set -euo pipefail +cd "$(dirname "$0")/.." +source scripts/env.sh + +"$MVN" -B -q package -DskipTests + +for demo in versions relaxed-matrix env-binding value-vs-binding validation metadata-generation; do + echo "=== $demo ===" + "scripts/demo-$demo.sh" > /dev/null +done +scripts/stop.sh +echo +echo "regenerated:" +ls -1 docs/output/ diff --git a/configuration-properties/scripts/run.sh b/configuration-properties/scripts/run.sh new file mode 100755 index 0000000..360dd71 --- /dev/null +++ b/configuration-properties/scripts/run.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Start the application and block until it answers. Extra arguments are passed to the app, +# so a scenario can add --demo.mail.recipients=a,b,c without a new profile. +# ./scripts/run.sh # defaults +# ./scripts/run.sh csvlist # a profile +# ./scripts/run.sh "" --demo.x=y # no profile, one override +set -euo pipefail +cd "$(dirname "$0")/.." +source scripts/env.sh +PROFILES="${1:-}"; shift || true +LOG="${LOG:-/tmp/configprops-demo.log}" +PIDFILE="${PIDFILE:-target/app.pid}" + +scripts/stop.sh + +ARGS=(-jar "$JAR") +[ -n "$PROFILES" ] && ARGS+=("--spring.profiles.active=$PROFILES") +ARGS+=("$@") + +setsid nohup java "${ARGS[@]}" > "$LOG" 2>&1 < /dev/null & +echo $! > "$PIDFILE" + +for _ in $(seq 1 60); do + code=$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:${APP_PORT}/diag/bound" || true) + [ "$code" = "200" ] && exit 0 + # If the JVM died -- most often because the port was still held -- fail fast and loudly + # instead of letting curl answer from a process started by an earlier scenario. + kill -0 "$(cat "$PIDFILE")" 2>/dev/null || { echo "JVM exited during startup:" >&2 + tail -25 "$LOG" >&2; exit 1; } + sleep 1 +done +echo "application did not answer; tail of $LOG:" >&2 +tail -40 "$LOG" >&2 +exit 1 diff --git a/configuration-properties/scripts/stop.sh b/configuration-properties/scripts/stop.sh new file mode 100755 index 0000000..4e4ccf4 --- /dev/null +++ b/configuration-properties/scripts/stop.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Stop the demo application. +# +# This uses a PID file rather than a pattern match, deliberately. `pkill -f spring-boot` +# matches the shell that is running the script and takes the terminal with it. Even a +# careful-looking `ps | grep '[c]onfiguration-properties'` matches the shell's own command +# line whenever that string appears in the command you just typed -- which it does, because +# you typed the jar name. Killing a recorded PID cannot misfire. +set -u +cd "$(dirname "$0")/.." +PIDFILE="${PIDFILE:-target/app.pid}" + +if [ -f "$PIDFILE" ]; then + pid=$(cat "$PIDFILE") + # Confirm the PID is still ours before signalling it: PIDs are reused. + if [ -n "$pid" ] && grep -qa "configuration-properties" "/proc/$pid/cmdline" 2>/dev/null; then + kill -9 "$pid" 2>/dev/null || true + fi + rm -f "$PIDFILE" +fi + +# Killing the process is not the same as the socket closing, and a stale listener looks +# exactly like your configuration change having had no effect. +for _ in $(seq 1 40); do + if ! (exec 3<>/dev/tcp/127.0.0.1/"${APP_PORT:-8080}") 2>/dev/null; then break; fi + sleep 0.25 +done +exec 3<&- 2>/dev/null || true diff --git a/configuration-properties/src/main/java/com/ankurm/configprops/ConfigBindingApplication.java b/configuration-properties/src/main/java/com/ankurm/configprops/ConfigBindingApplication.java new file mode 100644 index 0000000..660245c --- /dev/null +++ b/configuration-properties/src/main/java/com/ankurm/configprops/ConfigBindingApplication.java @@ -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". + * + *

{@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); + } +} diff --git a/configuration-properties/src/main/java/com/ankurm/configprops/props/MailProperties.java b/configuration-properties/src/main/java/com/ankurm/configprops/props/MailProperties.java new file mode 100644 index 0000000..fd13ae4 --- /dev/null +++ b/configuration-properties/src/main/java/com/ankurm/configprops/props/MailProperties.java @@ -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. + * + *

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. + * + *

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 recipients, + @DefaultValue Map 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) { + } +} diff --git a/configuration-properties/src/main/java/com/ankurm/configprops/props/RelaxedProperties.java b/configuration-properties/src/main/java/com/ankurm/configprops/props/RelaxedProperties.java new file mode 100644 index 0000000..f40dca9 --- /dev/null +++ b/configuration-properties/src/main/java/com/ankurm/configprops/props/RelaxedProperties.java @@ -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. + * + *

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) { +} diff --git a/configuration-properties/src/main/java/com/ankurm/configprops/props/ValidatedProperties.java b/configuration-properties/src/main/java/com/ankurm/configprops/props/ValidatedProperties.java new file mode 100644 index 0000000..1b0c0a4 --- /dev/null +++ b/configuration-properties/src/main/java/com/ankurm/configprops/props/ValidatedProperties.java @@ -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. + * + *

{@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}. + * + *

{@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) { + } +} diff --git a/configuration-properties/src/main/java/com/ankurm/configprops/web/BindingDiagnosticsEndpoint.java b/configuration-properties/src/main/java/com/ankurm/configprops/web/BindingDiagnosticsEndpoint.java new file mode 100644 index 0000000..1a0ca0d --- /dev/null +++ b/configuration-properties/src/main/java/com/ankurm/configprops/web/BindingDiagnosticsEndpoint.java @@ -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. + * + *

"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. + * + *

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. + * + *

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 origin( + @RequestParam(defaultValue = "demo.mail.host") String name) { + + ConfigurationPropertyName propertyName = ConfigurationPropertyName.of(name); + List> candidates = new ArrayList<>(); + + for (ConfigurationPropertySource source : ConfigurationPropertySources.get(environment)) { + var property = source.getConfigurationProperty(propertyName); + if (property == null) { + continue; + } + Map 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 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 bound() { + Map result = new LinkedHashMap<>(); + result.put("mail", mail); + result.put("validated", validated); + + Map 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; + } +} diff --git a/configuration-properties/src/main/java/com/ankurm/configprops/web/RealEnvironmentProbe.java b/configuration-properties/src/main/java/com/ankurm/configprops/web/RealEnvironmentProbe.java new file mode 100644 index 0000000..b9f0029 --- /dev/null +++ b/configuration-properties/src/main/java/com/ankurm/configprops/web/RealEnvironmentProbe.java @@ -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. + * + *

{@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. + * + *

{@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(); + } +} diff --git a/configuration-properties/src/main/java/com/ankurm/configprops/web/RelaxedBindingProbe.java b/configuration-properties/src/main/java/com/ankurm/configprops/web/RelaxedBindingProbe.java new file mode 100644 index 0000000..3bf1c6d --- /dev/null +++ b/configuration-properties/src/main/java/com/ankurm/configprops/web/RelaxedBindingProbe.java @@ -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. + * + *

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: + * + *

    + *
  1. BINDER — does {@link Binder}, the engine behind + * {@code @ConfigurationProperties}, resolve it?
  2. + *
  3. ${} plain — does {@code ${demo.relaxed.api-key}} resolve against + * a bare {@link StandardEnvironment}? This is placeholder resolution as the Spring + * Framework alone defines it.
  4. + *
  5. ${} boot — 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.
  6. + *
+ * + *

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. + * + *

Two harness details were paid for in wrong answers and are worth stating: + * the system-environment source must be named + * {@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}. + * + *

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 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 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 factory) { + + StandardEnvironment forBinder = factory.get(); + BindResult 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}. + * + *

The source must 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 entries = new LinkedHashMap<>(); + entries.put(spelling, "from-" + spelling); + environment.getPropertySources().addFirst(new SystemEnvironmentPropertySource( + StandardEnvironment.SYSTEM_ENVIRONMENT_PROPERTY_SOURCE_NAME, entries)); + return environment; + } +} diff --git a/configuration-properties/src/main/java/com/ankurm/configprops/web/ValueHolder.java b/configuration-properties/src/main/java/com/ankurm/configprops/web/ValueHolder.java new file mode 100644 index 0000000..ac320c9 --- /dev/null +++ b/configuration-properties/src/main/java/com/ankurm/configprops/web/ValueHolder.java @@ -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. + * + *

Everything here works. The point of the class is what it costs to make it work, and what + * it still cannot do: + * + *

    + *
  • Every field repeats the property name as a string literal. Rename the property and the + * compiler says nothing.
  • + *
  • 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}.
  • + *
  • There is no validation. {@code :-1} below is accepted silently.
  • + *
  • A missing property without a {@code :default} is a startup failure whose message names + * the field, not the property's purpose.
  • + *
+ * + *

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 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 recipients() { return recipients; } + public int poolSize() { return poolSize; } + public int computedThreads() { return computedThreads; } +} diff --git a/configuration-properties/src/main/resources/application-badvalidation.yaml b/configuration-properties/src/main/resources/application-badvalidation.yaml new file mode 100644 index 0000000..fbf9095 --- /dev/null +++ b/configuration-properties/src/main/resources/application-badvalidation.yaml @@ -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 diff --git a/configuration-properties/src/main/resources/application-csvlist.yaml b/configuration-properties/src/main/resources/application-csvlist.yaml new file mode 100644 index 0000000..6c881fd --- /dev/null +++ b/configuration-properties/src/main/resources/application-csvlist.yaml @@ -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: "ops@example.com,oncall@example.com,sre@example.com" diff --git a/configuration-properties/src/main/resources/application.yaml b/configuration-properties/src/main/resources/application.yaml new file mode 100644 index 0000000..23588e0 --- /dev/null +++ b/configuration-properties/src/main/resources/application.yaml @@ -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: + - ops@example.com + - oncall@example.com + 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 diff --git a/configuration-properties/src/test/java/com/ankurm/configprops/BindingContractTests.java b/configuration-properties/src/test/java/com/ankurm/configprops/BindingContractTests.java new file mode 100644 index 0000000..272226f --- /dev/null +++ b/configuration-properties/src/test/java/com/ankurm/configprops/BindingContractTests.java @@ -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 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. + * + *

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. + * + *

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) { + } + } +} diff --git a/profiles-and-config/README.md b/profiles-and-config/README.md new file mode 100644 index 0000000..40fc7a0 --- /dev/null +++ b/profiles-and-config/README.md @@ -0,0 +1,74 @@ +# Spring Boot profiles, config import and config trees + +Companion project for [**Spring Boot Profiles Done Right**](https://ankurm.com/) on ankurm.com. + +The question the project answers: you set a value in `application-prod.yaml`, deployed with +`prod` active, and the old value is still in effect. Why? + +## Versions + +| | | +|---|---| +| Spring Boot | 4.1.1 | +| Spring Framework | 7.0.9 | +| JDK | Eclipse Temurin 25.0.4.1 (LTS) | + +## Quickstart + +```bash +export JAVA_HOME=/path/to/jdk-25 +mvn -DskipTests package +./scripts/run-all.sh # regenerate every transcript in docs/output/ +mvn test # 6 contract tests +``` + +## Profiles + +| Profile | What it demonstrates | +|---|---| +| `prod` | a profile group expanding to `prod`, `prod-db`, `prod-metrics` | +| `import` | `spring.config.import`, and the imported file winning | +| `multidoc` | one file, three documents, activated by condition | +| `badactivation` | `spring.profiles.active` set from a profile-specific document — refused | +| `staging` | the conditional document inside `application-multidoc.yaml` | + +## Endpoints + +| Endpoint | Purpose | +|---|---| +| `GET /precedence?name=` | every source holding a property, ranked, with file and line | +| `GET /sources` | the live property-source stack in precedence order | + +Diagnostics. Delete before shipping, or use Actuator's `/actuator/env`, which sanitises. + +## Documentation + +1. [The precedence list](docs/01-the-precedence-list.md) +2. [Profiles: files, documents and groups](docs/02-profiles.md) +3. [Seeing precedence instead of reasoning about it](docs/03-seeing-precedence.md) +4. [Why your profile-specific file lost](docs/04-why-your-profile-file-lost.md) +5. [`spring.config.import`](docs/05-config-import.md) +6. [Config trees and Kubernetes ConfigMaps](docs/06-config-trees-and-configmaps.md) + +## Captured output + +| File | Produced by | +|---|---| +| [`00-versions.txt`](docs/output/00-versions.txt) | `scripts/demo-versions.sh` | +| [`01-precedence.txt`](docs/output/01-precedence.txt) | `scripts/demo-precedence.sh` | +| [`02-profile-file-loses.txt`](docs/output/02-profile-file-loses.txt) | `scripts/demo-profile-file-loses.sh` | +| [`03-config-tree.txt`](docs/output/03-config-tree.txt) | `scripts/demo-config-tree.sh` | +| [`04-import-and-multidoc.txt`](docs/output/04-import-and-multidoc.txt) | `scripts/demo-import-and-multidoc.sh` | + +## The short answer + +Config data — every file you write, profile-specific ones included — is item 3 in Spring Boot's +documented precedence list. OS environment variables are item 5. Later items win. A +profile-specific file beats other files and loses to the weakest environment variable on the +host. + +## The one that surprises people + +`spring.config.import` does not behave like `#include`. The **imported** file is processed after +the file that declared the import, so it **wins**. Import a shared baseline expecting to +override it and every key it sets will quietly beat yours. diff --git a/profiles-and-config/docs/01-the-precedence-list.md b/profiles-and-config/docs/01-the-precedence-list.md new file mode 100644 index 0000000..31c68f2 --- /dev/null +++ b/profiles-and-config/docs/01-the-precedence-list.md @@ -0,0 +1,56 @@ +[Index](../README.md) · [Profiles →](02-profiles.md) + +# 1. The precedence list + +Spring Boot's documented order, lowest precedence first. Later entries win. + +| # | Source | +|---|---| +| 1 | Default properties (`SpringApplication.setDefaultProperties`) | +| 2 | `@PropertySource` on `@Configuration` classes | +| 3 | **Config data** — `application.properties`, `application.yaml`, profile-specific files, `spring.config.import` | +| 4 | `RandomValuePropertySource` (`random.*`) | +| 5 | **OS environment variables** | +| 6 | Java system properties (`-D`) | +| 7 | JNDI attributes from `java:comp/env` | +| 8 | `ServletContext` init parameters | +| 9 | `ServletConfig` init parameters | +| 10 | `SPRING_APPLICATION_JSON` | +| 11 | Command-line arguments | +| 12 | `properties` on `@SpringBootTest` | +| 13 | `@DynamicPropertySource` | +| 14 | `@TestPropertySource` | +| 15 | Devtools global settings | + +## The two rows that matter + +**Item 3 covers every file you write.** `application.yaml`, `application-prod.yaml`, an +imported config tree, a mounted ConfigMap — all of it is config data, all of it at rank 3. + +**Item 5 is above it.** Every environment variable outranks every file. + +Profile-specific files beat non-profile files, and later imports beat earlier ones, but those +are orderings *within* item 3. Nothing inside item 3 can reach item 5. + +That single fact explains the bug this project exists for, and +[chapter 4](04-why-your-profile-file-lost.md) walks through it with a transcript. + +## Seeing it for real + +`/sources` prints the live stack, which is more useful than the table because it shows exactly +which files were loaded: + +``` + 3. SimpleCommandLinePropertySource commandLineArgs + 6. PropertiesPropertySource systemProperties + 7. OriginAwareSystemEnvironmentPropertySource systemEnvironment + 9. OriginTrackedMapPropertySource application-prod-metrics.yaml + 10. OriginTrackedMapPropertySource application-prod-db.yaml + 11. OriginTrackedMapPropertySource application-prod.yaml + 12. OriginTrackedMapPropertySource application.yaml +``` + +Note rank 2 in the real stack, which the table does not mention: +`ConfigurationPropertySourcesPropertySource`, named `configurationProperties`. That is the +source Spring Boot attaches to give `${...}` placeholders the binder's relaxed name matching — +see the [binding project's chapter 2](../../configuration-properties/docs/02-relaxed-binding.md). diff --git a/profiles-and-config/docs/02-profiles.md b/profiles-and-config/docs/02-profiles.md new file mode 100644 index 0000000..ac3df7f --- /dev/null +++ b/profiles-and-config/docs/02-profiles.md @@ -0,0 +1,70 @@ +[← Precedence list](01-the-precedence-list.md) · [Index](../README.md) · [Seeing precedence →](03-seeing-precedence.md) + +# 2. Profiles: files, documents and groups + +## Profile-specific files + +`application-.yaml`, loaded from the same locations as `application.yaml`, and always +overriding it. With several profiles active, last one wins: +`--spring.profiles.active=prod,live` means `application-live.yaml` beats +`application-prod.yaml`. + +## Multi-document files + +The same effect without multiplying files. Documents are separated by `---` and activated by +condition: + +```yaml +demo: + greeting: from-multidoc-default-document +--- +spring: + config: + activate: + on-profile: staging +demo: + greeting: from-multidoc-staging-document +``` + +Later documents win over earlier ones, so an unconditional first document acts as the default +and each conditional document overrides it. Measured in +[`04-import-and-multidoc.txt`](output/04-import-and-multidoc.txt). + +`spring.config.activate.on-cloud-platform` and `spring.config.activate.on-profile` can be +combined; both must match. + +## Profile groups + +One profile that activates several: + +```yaml +spring: + profiles: + group: + prod: prod-db,prod-metrics +``` + +`--spring.profiles.active=prod` reports all three as active, and all three +`application-.yaml` files are loaded. Groups are resolved before config data is +processed, which is why declaring a group in `application.yaml` can still affect which files +get loaded. + +## The activation Spring Boot refuses + +`spring.profiles.active` cannot be set from a document that is itself profile-specific: + +``` +InvalidConfigDataPropertyException: Property 'spring.profiles.active' imported from location +'class path resource [application-badactivation.yaml]' is invalid in a profile specific +resource [origin: ... - 12:13] +``` + +A profile that activates itself would change which files are loaded after the set of files had +already been decided. Boot refuses rather than half-applying it. `spring.profiles.include` has +the same restriction; `spring.config.activate.on-profile` is how you express the condition. + +## `@Profile` is a different mechanism + +`@Profile("prod")` on a bean is evaluated when the context is built, long after config data is +resolved. It decides which *beans* exist, not which *properties* are set. The two use the same +profile names and nothing else. diff --git a/profiles-and-config/docs/03-seeing-precedence.md b/profiles-and-config/docs/03-seeing-precedence.md new file mode 100644 index 0000000..f7692de --- /dev/null +++ b/profiles-and-config/docs/03-seeing-precedence.md @@ -0,0 +1,59 @@ +[← Profiles](02-profiles.md) · [Index](../README.md) · [Why your profile file lost →](04-why-your-profile-file-lost.md) + +# 3. Seeing precedence instead of reasoning about it + +Endpoint: [`PrecedenceEndpoint`](../src/main/java/com/ankurm/profiles/web/PrecedenceEndpoint.java). +Transcript: [`01-precedence.txt`](output/01-precedence.txt). + +Set `demo.greeting` from five places at once and ask which won: + +``` +$ DEMO_GREETING=from-environment-variable \ + java -Ddemo.greeting=from-system-property \ + -jar profiles-and-config-1.0.0.jar --spring.profiles.active=prod \ + --demo.greeting=from-command-line-argument +``` + +``` +"effectiveValue": "from-command-line-argument", +"activeProfiles": ["prod", "prod-db", "prod-metrics"], +"holders": [ + { "rank": 1, "value": "from-command-line-argument", "source": "commandLineArgs" }, + { "rank": 2, "value": "from-system-property", "source": "systemProperties" }, + { "rank": 3, "value": "from-environment-variable", "source": "systemEnvironment" }, + { "rank": 4, "value": "from-application-prod-yaml", "origin": "application-prod.yaml - 4:13" }, + { "rank": 5, "value": "from-application-yaml", "origin": "application.yaml - 21:13" } +], +"shadowedCount": 4 +``` + +Five sources hold the property. Four of them lose. Each one that came from a file names its +line. + +## The whole implementation + +```java +for (ConfigurationPropertySource source : ConfigurationPropertySources.get(environment)) { + ConfigurationProperty property = + source.getConfigurationProperty(ConfigurationPropertyName.of(name)); + if (property != null) { + // rank = position, property.getValue(), property.getOrigin() + } +} +``` + +`ConfigurationPropertySources.get(...)` returns the sources in precedence order. Iterate, +collect every hit, and the first is the winner. That is the entire diagnostic. + +## Why this beats reading the list + +The documented order is correct but abstract. It does not tell you that a `DEMO_GREETING` left +over from a shell three weeks ago is sitting at rank 3, and that is the actual question. + +## Alternatives if you would rather not add an endpoint + +- Actuator's `/actuator/env` gives the same information with sanitisation, and + `/actuator/env/{name}` narrows to one property. Prefer it in anything real. +- `logging.level.org.springframework.boot.context.config=TRACE` logs which config data + resources were loaded and in what order. +- `--debug` does *not* show this. It prints the auto-configuration report. diff --git a/profiles-and-config/docs/04-why-your-profile-file-lost.md b/profiles-and-config/docs/04-why-your-profile-file-lost.md new file mode 100644 index 0000000..0b6cf93 --- /dev/null +++ b/profiles-and-config/docs/04-why-your-profile-file-lost.md @@ -0,0 +1,73 @@ +[← Seeing precedence](03-seeing-precedence.md) · [Index](../README.md) · [Config import →](05-config-import.md) + +# 4. Why your profile-specific file lost + +Transcript: [`02-profile-file-loses.txt`](output/02-profile-file-loses.txt). + +The bug: you set a value in `application-prod.yaml`, deploy with `prod` active, and the old +value is still in effect. + +## Two runs, one difference + +``` +--- 1. prod profile active, no environment variable --- + effective value : jdbc:postgresql://prod-db:5432/orders + 1. jdbc:postgresql://prod-db:5432/orders <- application-prod.yaml + 2. jdbc:h2:mem:default <- application.yaml +``` + +Working as intended. Now with one leftover variable in the environment: + +``` +--- 2. identical, plus one leftover environment variable --- + effective value : jdbc:postgresql://leftover:5432/orders + 1. jdbc:postgresql://leftover:5432/orders <- systemEnvironment + 2. jdbc:postgresql://prod-db:5432/orders <- application-prod.yaml + 3. jdbc:h2:mem:default <- application.yaml +``` + +The profile file was still loaded. It still holds the right value. It is at rank 2. + +## Why it feels wrong + +Profile-specific files *do* override — the mental model is not wrong, it is incomplete. They +override other config data. Config data as a whole sits at item 3 in the precedence list and +environment variables at item 5, so the strongest file loses to the weakest variable. + +## Where the leftover variables come from + +Every one of these is real: + +- A Kubernetes `Deployment` with an `env:` block that predates the ConfigMap and was never + removed. `envFrom` a `ConfigMap` produces environment variables, not config data. +- A `docker-compose.yml` `environment:` entry copied from a colleague. +- Spring Cloud Kubernetes or a service mesh injecting `SPRING_DATASOURCE_URL`. +- A CI runner exporting variables for a different service. +- `SPRING_APPLICATION_JSON`, which is item 10 and beats almost everything. + +## Diagnosing it in one step + +If a property is not what the file says, look for a variable: + +```bash +kubectl exec deploy/my-app -- env | grep -i datasource +``` + +or ask the running application, which reports every holder including the one you did not know +about. + +## Living with it + +**Prefer environment variables in containers, files for defaults.** The precedence order is +designed for exactly this: the image carries defaults, the deployment overrides them. Fighting +it means fighting the design. + +**Do not set the same key in both places.** If a value is per-environment, keep it out of the +profile files entirely so there is only ever one source. + +**Name environment variables specifically.** `DEMO_DATASOURCE_URL` collides with nothing; +`SPRING_DATASOURCE_URL` collides with every Spring application on the host. + +**Mount configuration as a config tree instead.** Still config data, still below environment +variables, but at least it is one mechanism rather than two — +[chapter 6](06-config-trees-and-configmaps.md). diff --git a/profiles-and-config/docs/05-config-import.md b/profiles-and-config/docs/05-config-import.md new file mode 100644 index 0000000..f795be7 --- /dev/null +++ b/profiles-and-config/docs/05-config-import.md @@ -0,0 +1,58 @@ +[← Why your profile file lost](04-why-your-profile-file-lost.md) · [Index](../README.md) · [Config trees →](06-config-trees-and-configmaps.md) + +# 5. `spring.config.import` + +Transcript: [`04-import-and-multidoc.txt`](output/04-import-and-multidoc.txt). + +```yaml +spring: + config: + import: "optional:classpath:/imported.yaml" +``` + +## The imported file wins + +This is the part that catches people, and it catches them in the direction opposite to the one +they brace for: + +``` + effective value : from-imported-yaml + 1. from-imported-yaml <- imported.yaml + 2. from-application-import-yaml <- application-import.yaml (declared the import) + 3. from-application-yaml <- application.yaml +``` + +The importing file declared the import and then lost to it. An imported document is processed +*after* the document that declared it, and later documents win. + +`#include` semantics would give the opposite. So would treating the import as a set of +defaults, which is what people usually intend when they import a shared baseline. If you import +a company-wide `common.yaml` expecting your own file to override it, every key `common.yaml` +sets will quietly beat yours. + +To get defaults-style behaviour, put your overrides somewhere that outranks config data — an +environment variable or a command-line argument — or import from a *later* document in your own +file so the ordering is explicit. + +## Prefixes + +| Prefix | Meaning | +|---|---| +| `optional:` | do not fail if it is missing | +| `file:` | a filesystem path | +| `classpath:` | a classpath resource | +| `configtree:` | a directory of value-per-file entries | + +They compose: `optional:configtree:/etc/config/`. + +Without `optional:`, a missing location is `ConfigDataLocationNotFoundException` at startup. +That is usually what you want for a secret mount and never what you want for a developer +machine. + +## Where imports are legal + +`spring.config.import` is only honoured in config data — `application.yaml` and friends. Setting +it as an environment variable or a command-line argument works too, because those are processed +before config data is loaded. Setting it anywhere else does nothing. + +Imports are processed depth-first, and a cycle is detected and reported rather than looping. diff --git a/profiles-and-config/docs/06-config-trees-and-configmaps.md b/profiles-and-config/docs/06-config-trees-and-configmaps.md new file mode 100644 index 0000000..a8423bc --- /dev/null +++ b/profiles-and-config/docs/06-config-trees-and-configmaps.md @@ -0,0 +1,85 @@ +[← Config import](05-config-import.md) · [Index](../README.md) + +# 6. Config trees and Kubernetes ConfigMaps + +Transcript: [`03-config-tree.txt`](output/03-config-tree.txt). + +## What Kubernetes actually mounts + +A ConfigMap mounted as a volume is not a properties file. Kubernetes writes **one file per +key**, named after the key, containing only the value with no trailing newline: + +``` +/demo.datasource-url +/demo.greeting +/demo.pool-size +/demo/nested/value +/demo.api-key +``` + +``` +$ cat /demo.greeting +from-configmap-volume +``` + +There is no syntax to parse. The filename is the key. + +## Reading it + +``` +--spring.config.import=configtree:/etc/config/,configtree:/etc/secrets/ +``` + +A trailing `/` is required — the location is a directory. Values arrive as properties: + +``` + demo.pool-size = 25 + demo.nested.value = from-nested-directory + demo.api-key = sk_live_not_a_real_key +``` + +A directory under the mount becomes a nested property, so `demo/nested/value` is +`demo.nested.value`. That is how a ConfigMap whose keys contain slashes arrives. + +Secrets mount identically. The only difference is file permissions, which is why the same +mechanism reads both and why nothing in your application needs to know which it got. + +## Why this beats mounting a properties file + +- **Per-key updates.** Changing one key rewrites one file. Kubernetes propagates it to the + volume without a restart, and `spring.config.import` supports `configtree` reloading through + Spring Cloud Kubernetes if you want to act on it. +- **No parse step**, so no chance of one malformed line taking out the whole file. +- **Secrets and config read the same way.** +- **Values can contain anything.** No escaping, no quoting, no YAML surprises — a value of + `yes` stays the string `yes`. + +## Wildcards + +``` +--spring.config.import=optional:configtree:/etc/config/*/ +``` + +Reads every immediate subdirectory, which is the shape you get when several ConfigMaps are +mounted under one parent. Useful for "one ConfigMap per component" layouts. + +## It is still config data + +An imported config tree outranks `application.yaml` — and still loses to an environment +variable: + +``` + effective value : from-environment-variable + 1. from-environment-variable <- systemEnvironment + 2. from-configmap-volume <- ConfigTreePropertySource + 3. from-application-yaml <- application.yaml +``` + +If you mount a ConfigMap *and* set `envFrom` on the same Deployment — which is a common way to +migrate from one to the other — the environment variables win and the ConfigMap looks broken. + +## There is no profile-specific config tree + +No `-prod` convention exists. Per-environment configuration is a different ConfigMap +chosen by the Deployment, not by `spring.profiles.active`. This is a feature: the environment +is decided by what you deploy, not by a string inside the image. diff --git a/profiles-and-config/docs/output/01-precedence.txt b/profiles-and-config/docs/output/01-precedence.txt new file mode 100644 index 0000000..0916cad --- /dev/null +++ b/profiles-and-config/docs/output/01-precedence.txt @@ -0,0 +1,83 @@ +== every source sets demo.greeting at once == + +$ DEMO_GREETING=from-environment-variable \ + java -Ddemo.greeting=from-system-property \ + -jar target/profiles-and-config-1.0.0.jar --spring.profiles.active=prod \ + --demo.greeting=from-command-line-argument + +{ + "property": "demo.greeting", + "effectiveValue": "from-command-line-argument", + "activeProfiles": [ + "prod", + "prod-db", + "prod-metrics" + ], + "holders": [ + { + "rank": 1, + "source": "SimpleCommandLinePropertySource {name='commandLineArgs'}", + "value": "from-command-line-argument", + "origin": "\"demo.greeting\" from property source \"commandLineArgs\"" + }, + { + "rank": 2, + "source": "PropertiesPropertySource {name='systemProperties'}", + "value": "from-system-property", + "origin": "\"demo.greeting\" from property source \"systemProperties\"" + }, + { + "rank": 3, + "source": "OriginAwareSystemEnvironmentPropertySource {name='systemEnvironment'}", + "value": "from-environment-variable", + "origin": "System Environment Property \"DEMO_GREETING\"" + }, + { + "rank": 4, + "source": "OriginTrackedMapPropertySource {name='Config resource 'class path resource [application-prod.yaml]' via location 'optional:classpath:/''}", + "value": "from-application-prod-yaml", + "origin": "class path resource [application-prod.yaml] from profiles-and-config-1.0.0.jar - 4:13" + }, + { + "rank": 5, + "source": "OriginTrackedMapPropertySource {name='Config resource 'class path resource [application.yaml]' via location 'optional:classpath:/''}", + "value": "from-application-yaml", + "origin": "class path resource [application.yaml] from profiles-and-config-1.0.0.jar - 21:13" + } + ], + "shadowedCount": 4 +} + +== and with the environment variable removed, nothing else changed == +./scripts/demo-precedence.sh: line 41: 5735 Killed DEMO_GREETING=from-environment-variable setsid nohup java -Ddemo.greeting=from-system-property -jar "$JAR" --spring.profiles.active=prod --demo.greeting=from-command-line-argument > /tmp/profiles-precedence.log 2>&1 < /dev/null +{ + "property": "demo.greeting", + "effectiveValue": "from-system-property", + "activeProfiles": [ + "prod", + "prod-db", + "prod-metrics" + ], + "holders": [ + { + "rank": 1, + "source": "PropertiesPropertySource {name='systemProperties'}", + "value": "from-system-property", + "origin": "\"demo.greeting\" from property source \"systemProperties\"" + }, + { + "rank": 2, + "source": "OriginTrackedMapPropertySource {name='Config resource 'class path resource [application-prod.yaml]' via location 'optional:classpath:/''}", + "value": "from-application-prod-yaml", + "origin": "class path resource [application-prod.yaml] from profiles-and-config-1.0.0.jar - 4:13" + }, + { + "rank": 3, + "source": "OriginTrackedMapPropertySource {name='Config resource 'class path resource [application.yaml]' via location 'optional:classpath:/''}", + "value": "from-application-yaml", + "origin": "class path resource [application.yaml] from profiles-and-config-1.0.0.jar - 21:13" + } + ], + "shadowedCount": 2 +} +./scripts/demo-precedence.sh: line 41: 5796 Killed setsid nohup java -Ddemo.greeting=from-system-property -jar "$JAR" --spring.profiles.active=prod > /tmp/profiles-precedence2.log 2>&1 < /dev/null diff --git a/profiles-and-config/docs/output/02-profile-file-loses.txt b/profiles-and-config/docs/output/02-profile-file-loses.txt new file mode 100644 index 0000000..4f745c5 --- /dev/null +++ b/profiles-and-config/docs/output/02-profile-file-loses.txt @@ -0,0 +1,41 @@ +== does application-prod.yaml win? == + +demo.datasource-url is set in application.yaml and again in application-prod.yaml. + +--- 1. prod profile active, no environment variable --- +$ java -jar target/profiles-and-config-1.0.0.jar --spring.profiles.active=prod + active profiles : prod, prod-db, prod-metrics + effective value : jdbc:postgresql://prod-db:5432/orders + 1. jdbc:postgresql://prod-db:5432/orders <- 'file application-prod.yaml' via location 'optional:classpath:/''} + 2. jdbc:h2:mem:default <- 'file application.yaml' via location 'optional:classpath:/''} + holders that lost: 1 + +--- 2. identical, plus one leftover environment variable --- +$ DEMO_DATASOURCE_URL=jdbc:postgresql://leftover:5432/orders \ + java -jar target/profiles-and-config-1.0.0.jar --spring.profiles.active=prod + active profiles : prod, prod-db, prod-metrics + effective value : jdbc:postgresql://leftover:5432/orders + 1. jdbc:postgresql://leftover:5432/orders <- OriginAwareSystemEnvironmentPropertySource {name='systemEnvironment'} + 2. jdbc:postgresql://prod-db:5432/orders <- 'file application-prod.yaml' via location 'optional:classpath:/''} + 3. jdbc:h2:mem:default <- 'file application.yaml' via location 'optional:classpath:/''} + holders that lost: 2 + +The profile-specific file is still loaded and still holds its value -- it is listed, +and it lost. Config data is item 3 in the documented precedence list; OS environment +variables are item 5, and later items win. + +== the full property-source stack, in order == +$ curl -s localhost:8080/sources + 1. MapPropertySource server.ports + 2. ConfigurationPropertySourcesPropertySource configurationProperties + 3. SimpleCommandLinePropertySource commandLineArgs + 4. StubPropertySource servletConfigInitParams + 5. ServletContextPropertySource servletContextInitParams + 6. PropertiesPropertySource systemProperties + 7. OriginAwareSystemEnvironmentPropertySource systemEnvironment + 8. RandomValuePropertySource random + 9. OriginTrackedMapPropertySource Config resource 'class path resource [application-prod-metrics.yaml]' via location 'optional:classpath:/' + 10. OriginTrackedMapPropertySource Config resource 'class path resource [application-prod-db.yaml]' via location 'optional:classpath:/' + 11. OriginTrackedMapPropertySource Config resource 'class path resource [application-prod.yaml]' via location 'optional:classpath:/' + 12. OriginTrackedMapPropertySource Config resource 'class path resource [application.yaml]' via location 'optional:classpath:/' + 13. ApplicationInfoPropertySource applicationInfo diff --git a/profiles-and-config/docs/output/03-config-tree.txt b/profiles-and-config/docs/output/03-config-tree.txt new file mode 100644 index 0000000..25301eb --- /dev/null +++ b/profiles-and-config/docs/output/03-config-tree.txt @@ -0,0 +1,45 @@ +== what Kubernetes actually mounts == +$ find /tmp/demo-configmap /tmp/demo-secret -type f | sort +/demo.datasource-url +/demo.greeting +/demo.pool-size +/demo/nested/value +/demo.api-key + +$ cat /demo.greeting; echo +from-configmap-volume + +Each file holds a bare value with no trailing newline and no key. There is no +properties syntax to parse -- the filename is the key. + +== importing it == +$ java -jar target/profiles-and-config-1.0.0.jar \ + --spring.config.import=configtree:/tmp/demo-configmap/,configtree:/tmp/demo-secret/ + + active profiles : (none) + effective value : from-configmap-volume + 1. from-configmap-volume <- ConfigTreePropertySource {name='Config tree '/tmp/demo-configmap''} + 2. from-application-yaml <- 'file application.yaml' via location 'optional:classpath:/''} + holders that lost: 1 + + demo.pool-size = 25 + demo.nested.value = from-nested-directory + demo.api-key = sk_live_not_a_real_key + +A directory under the mount becomes a nested property: demo/nested/value is +demo.nested.value. That is how a ConfigMap with slashes in its keys arrives. + +== the part that surprises people == +An imported config tree outranks application.yaml, but it is still config data, +so it still loses to an environment variable: + + active profiles : (none) + effective value : from-environment-variable + 1. from-environment-variable <- OriginAwareSystemEnvironmentPropertySource {name='systemEnvironment'} + 2. from-configmap-volume <- ConfigTreePropertySource {name='Config tree '/tmp/demo-configmap''} + 3. from-application-yaml <- 'file application.yaml' via location 'optional:classpath:/''} + holders that lost: 2 + +There is also no such thing as a profile-specific config tree. There is no +-prod directory convention; a per-environment ConfigMap is a different mount +chosen by the deployment, not by spring.profiles.active. diff --git a/profiles-and-config/docs/output/04-import-and-multidoc.txt b/profiles-and-config/docs/output/04-import-and-multidoc.txt new file mode 100644 index 0000000..bb553a8 --- /dev/null +++ b/profiles-and-config/docs/output/04-import-and-multidoc.txt @@ -0,0 +1,60 @@ +== spring.config.import: which document wins? == + +application-import.yaml imports imported.yaml. Both set demo.greeting. +$ java -jar target/profiles-and-config-1.0.0.jar --spring.profiles.active=import + + active profiles : import + effective value : from-imported-yaml + 1. from-imported-yaml <- 'file imported.yaml' via location 'optional:classpath:/imported.yaml''} + 2. from-application-import-yaml <- 'file application-import.yaml' via location 'optional:classpath:/''} + 3. from-application-yaml <- 'file application.yaml' via location 'optional:classpath:/''} + holders that lost: 2 + + demo.imported-only = yes-this-file-was-read + +The imported file WON. spring.config.import does not behave like #include, and it +does not behave like a default either: the imported document is processed AFTER the +document that declared the import, so it outranks the file that pulled it in. +If you import a shared baseline expecting your own file to override it, every key +the baseline sets will quietly beat yours. + +== one file, several documents, activated by condition == +--- spring.profiles.active= (with the multidoc profile) --- + active profiles : multidoc + effective value : from-multidoc-default-document + 1. from-multidoc-default-document <- 'file application-multidoc.yaml' via location 'optional:classpath:/' (document ... + 2. from-application-yaml <- 'file application.yaml' via location 'optional:classpath:/''} + holders that lost: 1 + +--- spring.profiles.active=staging (with the multidoc profile) --- + active profiles : multidoc, staging + effective value : from-multidoc-staging-document + 1. from-multidoc-staging-document <- 'file application-multidoc.yaml' via location 'optional:classpath:/' (document ... + 2. from-multidoc-default-document <- 'file application-multidoc.yaml' via location 'optional:classpath:/' (document ... + 3. from-application-yaml <- 'file application.yaml' via location 'optional:classpath:/''} + holders that lost: 2 + +--- spring.profiles.active=prod (with the multidoc profile) --- + active profiles : multidoc, prod, prod-db, prod-metrics + effective value : from-application-prod-yaml + 1. from-application-prod-yaml <- 'file application-prod.yaml' via location 'optional:classpath:/''} + 2. from-multidoc-prod-document <- 'file application-multidoc.yaml' via location 'optional:classpath:/' (document ... + 3. from-multidoc-default-document <- 'file application-multidoc.yaml' via location 'optional:classpath:/' (document ... + 4. from-application-yaml <- 'file application.yaml' via location 'optional:classpath:/''} + holders that lost: 3 + +Later documents in the same file win over earlier ones, so the unconditional first +document acts as the default and each conditional document overrides it. + +== the activation Spring Boot refuses == +application-badactivation.yaml tries to set spring.profiles.active from a document +that is itself conditional on a profile. +$ java -jar target/profiles-and-config-1.0.0.jar --spring.profiles.active=badactivation,staging + +org.springframework.boot.context.config.InvalidConfigDataPropertyException: Property +'spring.profiles.active' imported from location 'class path resource +[application-badactivation.yaml]' is invalid in a profile specific resource [origin: class path +resource [application-badactivation.yaml] from profiles-and-config-1.0.0.jar - 12:13] + at +org.springframework.boot.context.config.InvalidConfigDataPropertyException.lambda$throwIfPropert +yFound$1(InvalidConfigDataPropertyException.java:123) diff --git a/profiles-and-config/pom.xml b/profiles-and-config/pom.xml new file mode 100644 index 0000000..e4befaa --- /dev/null +++ b/profiles-and-config/pom.xml @@ -0,0 +1,44 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 4.1.1 + + + + com.ankurm + profiles-and-config + 1.0.0 + profiles-and-config + Spring Boot profiles, config import, config trees and ConfigMaps + + + 25 + + + + + org.springframework.boot + spring-boot-starter-webmvc + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/profiles-and-config/scripts/demo-config-tree.sh b/profiles-and-config/scripts/demo-config-tree.sh new file mode 100755 index 0000000..93e41e8 --- /dev/null +++ b/profiles-and-config/scripts/demo-config-tree.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# Config trees: what a Kubernetes ConfigMap or Secret actually looks like to Spring Boot. +# +# A ConfigMap mounted as a volume is not a properties file. Kubernetes writes one file per +# key, named after the key, containing only the value. `configtree:` is the loader that reads +# that shape. This script builds the same directory layout on disk, so the demonstration is +# the real mechanism rather than a description of it. +set -euo pipefail +set +m +cd "$(dirname "$0")/.." +source scripts/env.sh + +TREE="${TMPDIR:-/tmp}/demo-configmap" +SECRET="${TMPDIR:-/tmp}/demo-secret" +rm -rf "$TREE" "$SECRET"; mkdir -p "$TREE" "$SECRET" + +# Exactly what `kubectl create configmap demo --from-literal=demo.greeting=...` produces +# once mounted: one file per key, the filename IS the property name. +printf 'from-configmap-volume' > "$TREE/demo.greeting" +printf 'jdbc:postgresql://configmap-db:5432/o' > "$TREE/demo.datasource-url" +printf '25' > "$TREE/demo.pool-size" +# Nested keys use a directory per level, or a dotted filename. Both work. +mkdir -p "$TREE/demo/nested" +printf 'from-nested-directory' > "$TREE/demo/nested/value" +# A Secret mount looks identical; only the permissions differ. +printf 'sk_live_not_a_real_key' > "$SECRET/demo.api-key" + +{ + echo "== what Kubernetes actually mounts ==" + echo "\$ find $TREE $SECRET -type f | sort" + find "$TREE" "$SECRET" -type f | sort | sed "s|$TREE||;s|$SECRET||" + echo + echo "\$ cat /demo.greeting; echo" + cat "$TREE/demo.greeting"; echo + echo + echo "Each file holds a bare value with no trailing newline and no key. There is no" + echo "properties syntax to parse -- the filename is the key." + echo + echo "== importing it ==" + echo "\$ java -jar $JAR \\" + echo " --spring.config.import=configtree:$TREE/,configtree:$SECRET/" + echo + start_app "--spring.config.import=configtree:$TREE/,configtree:$SECRET/" > /dev/null + report demo.greeting + echo + echo " demo.pool-size = $(curl -s "http://127.0.0.1:${APP_PORT}/precedence?name=demo.pool-size" | python3 -c 'import json,sys; print(json.load(sys.stdin)["effectiveValue"])')" + echo " demo.nested.value = $(curl -s "http://127.0.0.1:${APP_PORT}/precedence?name=demo.nested.value" | python3 -c 'import json,sys; print(json.load(sys.stdin)["effectiveValue"])')" + echo " demo.api-key = $(curl -s "http://127.0.0.1:${APP_PORT}/precedence?name=demo.api-key" | python3 -c 'import json,sys; print(json.load(sys.stdin)["effectiveValue"])')" + echo + echo "A directory under the mount becomes a nested property: demo/nested/value is" + echo "demo.nested.value. That is how a ConfigMap with slashes in its keys arrives." + echo + echo "== the part that surprises people ==" + echo "An imported config tree outranks application.yaml, but it is still config data," + echo "so it still loses to an environment variable:" + echo + APP_ENV="DEMO_GREETING=from-environment-variable" \ + start_app "--spring.config.import=configtree:$TREE/" > /dev/null + report demo.greeting + echo + echo "There is also no such thing as a profile-specific config tree. There is no" + echo "-prod directory convention; a per-environment ConfigMap is a different mount" + echo "chosen by the deployment, not by spring.profiles.active." + stop_app +} > docs/output/03-config-tree.txt 2>&1 +cat docs/output/03-config-tree.txt diff --git a/profiles-and-config/scripts/demo-import-and-multidoc.sh b/profiles-and-config/scripts/demo-import-and-multidoc.sh new file mode 100755 index 0000000..7e3e1c9 --- /dev/null +++ b/profiles-and-config/scripts/demo-import-and-multidoc.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# spring.config.import ordering, multi-document activation, and the activation Boot refuses. +set -euo pipefail +set +m +cd "$(dirname "$0")/.." +source scripts/env.sh + +{ + echo "== spring.config.import: which document wins? ==" + echo + echo "application-import.yaml imports imported.yaml. Both set demo.greeting." + echo "\$ java -jar $JAR --spring.profiles.active=import" + echo + start_app --spring.profiles.active=import > /dev/null + report demo.greeting + echo + echo " demo.imported-only = $(curl -s "http://127.0.0.1:${APP_PORT}/precedence?name=demo.imported-only" | python3 -c 'import json,sys; print(json.load(sys.stdin)["effectiveValue"])')" + echo + echo "The imported file WON. spring.config.import does not behave like #include, and it" + echo "does not behave like a default either: the imported document is processed AFTER the" + echo "document that declared the import, so it outranks the file that pulled it in." + echo "If you import a shared baseline expecting your own file to override it, every key" + echo "the baseline sets will quietly beat yours." + echo + + echo "== one file, several documents, activated by condition ==" + for profile in "" staging prod; do + label="${profile:-}" + echo "--- spring.profiles.active=$label (with the multidoc profile) ---" + if [ -z "$profile" ]; then + start_app --spring.profiles.active=multidoc > /dev/null + else + start_app --spring.profiles.active="multidoc,$profile" > /dev/null + fi + report demo.greeting + echo + done + echo "Later documents in the same file win over earlier ones, so the unconditional first" + echo "document acts as the default and each conditional document overrides it." + echo + + echo "== the activation Spring Boot refuses ==" + echo "application-badactivation.yaml tries to set spring.profiles.active from a document" + echo "that is itself conditional on a profile." + echo "\$ java -jar $JAR --spring.profiles.active=badactivation,staging" + echo + stop_app + java -jar "$JAR" --spring.profiles.active=badactivation,staging 2>&1 | clean \ + | grep -E 'InvalidConfigDataPropertyException' | head -2 | fold -s -w 96 + echo + echo + echo "InvalidConfigDataPropertyException, naming the file and the line. Boot refuses" + echo "rather than half-applying it: a profile that activates itself would change which" + echo "files are loaded after those files had already been chosen." +} > docs/output/04-import-and-multidoc.txt 2>&1 +cat docs/output/04-import-and-multidoc.txt diff --git a/profiles-and-config/scripts/demo-precedence.sh b/profiles-and-config/scripts/demo-precedence.sh new file mode 100755 index 0000000..fd9166a --- /dev/null +++ b/profiles-and-config/scripts/demo-precedence.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# The whole precedence question, asked once with every source set at the same time. +# +# demo.greeting is set by application.yaml, by application-prod.yaml, by an environment +# variable, by a system property and by a command-line argument -- simultaneously. The +# endpoint reports all of them in order, so the winner is not a matter of opinion. +set -euo pipefail +set +m # no job-control notices ("Killed") in the captured transcript +cd "$(dirname "$0")/.." +source scripts/env.sh + +{ + echo "== every source sets demo.greeting at once ==" + echo + echo "\$ DEMO_GREETING=from-environment-variable \\" + echo " java -Ddemo.greeting=from-system-property \\" + echo " -jar $JAR --spring.profiles.active=prod \\" + echo " --demo.greeting=from-command-line-argument" + echo + + scripts/stop.sh + DEMO_GREETING=from-environment-variable setsid nohup java \ + -Ddemo.greeting=from-system-property -jar "$JAR" \ + --spring.profiles.active=prod --demo.greeting=from-command-line-argument \ + > /tmp/profiles-precedence.log 2>&1 < /dev/null & + echo $! > target/app.pid + for _ in $(seq 1 60); do + curl -s -o /dev/null "http://127.0.0.1:${APP_PORT}/precedence" && break; sleep 1; done + + curl -s "http://127.0.0.1:${APP_PORT}/precedence?name=demo.greeting" | python3 -m json.tool + echo + echo "== and with the environment variable removed, nothing else changed ==" + scripts/stop.sh + setsid nohup java -Ddemo.greeting=from-system-property -jar "$JAR" \ + --spring.profiles.active=prod > /tmp/profiles-precedence2.log 2>&1 < /dev/null & + echo $! > target/app.pid + for _ in $(seq 1 60); do + curl -s -o /dev/null "http://127.0.0.1:${APP_PORT}/precedence" && break; sleep 1; done + curl -s "http://127.0.0.1:${APP_PORT}/precedence?name=demo.greeting" | python3 -m json.tool + scripts/stop.sh +} > docs/output/01-precedence.txt 2>&1 +cat docs/output/01-precedence.txt diff --git a/profiles-and-config/scripts/demo-profile-file-loses.sh b/profiles-and-config/scripts/demo-profile-file-loses.sh new file mode 100755 index 0000000..caf0d48 --- /dev/null +++ b/profiles-and-config/scripts/demo-profile-file-loses.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# The article's title question: why did application-prod.yaml have no effect? +# +# Because an environment variable was set. Profile-specific files beat non-profile files, +# but the whole config-data group sits BELOW environment variables in the documented +# precedence list, so a profile file never outranks one. +set -euo pipefail +set +m +cd "$(dirname "$0")/.." +source scripts/env.sh + +{ + echo "== does application-prod.yaml win? ==" + echo + echo "demo.datasource-url is set in application.yaml and again in application-prod.yaml." + echo + + echo "--- 1. prod profile active, no environment variable ---" + echo "\$ java -jar $JAR --spring.profiles.active=prod" + start_app --spring.profiles.active=prod > /dev/null + report demo.datasource-url + echo + + echo "--- 2. identical, plus one leftover environment variable ---" + echo "\$ DEMO_DATASOURCE_URL=jdbc:postgresql://leftover:5432/orders \\" + echo " java -jar $JAR --spring.profiles.active=prod" + APP_ENV="DEMO_DATASOURCE_URL=jdbc:postgresql://leftover:5432/orders" \ + start_app --spring.profiles.active=prod > /dev/null + report demo.datasource-url + echo + + echo "The profile-specific file is still loaded and still holds its value -- it is listed," + echo "and it lost. Config data is item 3 in the documented precedence list; OS environment" + echo "variables are item 5, and later items win." + echo + echo "== the full property-source stack, in order ==" + echo "\$ curl -s localhost:8080/sources" + curl -s "http://127.0.0.1:${APP_PORT}/sources" | python3 -c ' +import json,sys +for r in json.load(sys.stdin): + print(" %2d. %-34s %s" % (r["rank"], r["type"], r["name"][:110]))' + stop_app +} > docs/output/02-profile-file-loses.txt 2>&1 +cat docs/output/02-profile-file-loses.txt diff --git a/profiles-and-config/scripts/demo-versions.sh b/profiles-and-config/scripts/demo-versions.sh new file mode 100755 index 0000000..1e8c800 --- /dev/null +++ b/profiles-and-config/scripts/demo-versions.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail +set +m # no job-control notices ("Killed") in the captured transcript +cd "$(dirname "$0")/.." +source scripts/env.sh +{ + echo "== versions ==" + java -version 2>&1 | clean + echo + echo "spring-boot-starter-parent: $(grep -A2 'spring-boot-starter-parent' pom.xml | grep '' | sed 's/.*\(.*\)<\/version>.*/\1/')" +} > docs/output/00-versions.txt 2>&1 +cat docs/output/00-versions.txt diff --git a/profiles-and-config/scripts/env.sh b/profiles-and-config/scripts/env.sh new file mode 100755 index 0000000..5085ec3 --- /dev/null +++ b/profiles-and-config/scripts/env.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# Shared environment. Point JAVA_HOME at a JDK 25 (or newer) installation. +: "${JAVA_HOME:?set JAVA_HOME to a JDK 25+ installation}" +export PATH="$JAVA_HOME/bin:$PATH" +MVN="${MVN:-mvn}" +JAR="target/profiles-and-config-1.0.0.jar" +APP_MAIN="com.ankurm.profiles.ProfilesApplication" +APP_PORT="${APP_PORT:-8080}" + +# Strip environment noise that is an artefact of the machine, not of Spring: +# the JVM prints a JAVA_TOOL_OPTIONS banner to stderr on every launch when a proxy +# truststore is configured, and it would otherwise end up in every committed transcript. +clean() { grep -v "Picked up JAVA_TOOL_OPTIONS" | grep -v "^OpenJDK 64-Bit Server VM warning"; } + +# Start the demo jar detached, record its PID, and block until it answers. +# Extra arguments are passed to the application. Environment variables for the run are +# passed by setting them on the call: `APP_ENV="A=1 B=2" start_app --spring.profiles.active=x` +start_app() { + stop_app + mkdir -p target + # Deliberately NOT setsid: setsid forks when it is not already a process-group leader, + # so $! would be the PID of a process that exits immediately and the JVM would survive + # every later stop_app. A surviving JVM keeps the port, the next scenario fails to bind, + # and curl answers from the previous scenario -- which reads exactly like the + # configuration change under test having had no effect. Three wrong findings in this + # repository came from that before it was tracked down. + if [ -n "${APP_ENV:-}" ]; then + # shellcheck disable=SC2086 + env $APP_ENV nohup java -jar "$JAR" "$@" > /tmp/profiles-demo.log 2>&1 < /dev/null & + else + nohup java -jar "$JAR" "$@" > /tmp/profiles-demo.log 2>&1 < /dev/null & + fi + echo $! > target/app.pid + for _ in $(seq 1 60); do + curl -s -o /dev/null "http://127.0.0.1:${APP_PORT}/precedence" 2>/dev/null && return 0 + kill -0 "$(cat target/app.pid)" 2>/dev/null || { echo "JVM exited during startup:" + tail -20 /tmp/profiles-demo.log; return 1; } + sleep 1 + done + echo "application did not answer"; tail -20 /tmp/profiles-demo.log; return 1 +} + +# Stop it by recorded PID. Never by pattern: `ps | grep ` also matches the shell +# running the script, because the jar name is on that shell's own command line. +stop_app() { + if [ -f target/app.pid ]; then + pid=$(cat target/app.pid) + if [ -n "$pid" ] && grep -qa "profiles-and-config" "/proc/$pid/cmdline" 2>/dev/null; then + kill -9 "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true # reap, so bash prints no "Killed" notice + fi + rm -f target/app.pid + fi + for _ in $(seq 1 40); do + if ! (exec 3<>/dev/tcp/127.0.0.1/"${APP_PORT:-8080}") 2>/dev/null; then break; fi + sleep 0.25 + done + exec 3<&- 2>/dev/null || true +} + +# Print the precedence report for one property, compactly. +report() { + curl -s "http://127.0.0.1:${APP_PORT}/precedence?name=$1" | python3 -c ' +import json,sys +d=json.load(sys.stdin) +print(" active profiles :", ", ".join(d["activeProfiles"]) or "(none)") +print(" effective value :", d["effectiveValue"]) +for h in d["holders"]: + src=h["source"] + for noisy,short in (("Config resource \x27class path resource [","file "), + ("\x27 via location \x27optional:classpath:/\x27}","")): + src=src.replace(noisy,short) + src=src.replace("OriginTrackedMapPropertySource {name=","").replace("]","") + print(" %d. %-34s <- %s" % (h["rank"], h["value"], src.strip())) +print(" holders that lost:", d["shadowedCount"])' +} diff --git a/profiles-and-config/scripts/run-all.sh b/profiles-and-config/scripts/run-all.sh new file mode 100755 index 0000000..a7aa86f --- /dev/null +++ b/profiles-and-config/scripts/run-all.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Regenerate every transcript under docs/output/. +set -euo pipefail +cd "$(dirname "$0")/.." +source scripts/env.sh + +"$MVN" -B -q package -DskipTests + +for demo in versions precedence profile-file-loses config-tree import-and-multidoc; do + echo "=== $demo ===" + "scripts/demo-$demo.sh" > /dev/null +done +stop_app +echo +echo "regenerated:" +ls -1 docs/output/ diff --git a/profiles-and-config/scripts/run.sh b/profiles-and-config/scripts/run.sh new file mode 100755 index 0000000..0a1b0e5 --- /dev/null +++ b/profiles-and-config/scripts/run.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Start the application and block until it answers. Extra arguments are passed to the app, +# so a scenario can add --demo.mail.recipients=a,b,c without a new profile. +# ./scripts/run.sh # defaults +# ./scripts/run.sh csvlist # a profile +# ./scripts/run.sh "" --demo.x=y # no profile, one override +set -euo pipefail +cd "$(dirname "$0")/.." +source scripts/env.sh +PROFILES="${1:-}"; shift || true +LOG="${LOG:-/tmp/configprops-demo.log}" +PIDFILE="${PIDFILE:-target/app.pid}" + +scripts/stop.sh + +ARGS=(-jar "$JAR") +[ -n "$PROFILES" ] && ARGS+=("--spring.profiles.active=$PROFILES") +ARGS+=("$@") + +setsid nohup java "${ARGS[@]}" > "$LOG" 2>&1 < /dev/null & +echo $! > "$PIDFILE" + +for _ in $(seq 1 60); do + code=$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:${APP_PORT}/precedence" || true) + [ "$code" = "200" ] && exit 0 + # If the JVM died -- most often because the port was still held -- fail fast and loudly + # instead of letting curl answer from a process started by an earlier scenario. + kill -0 "$(cat "$PIDFILE")" 2>/dev/null || { echo "JVM exited during startup:" >&2 + tail -25 "$LOG" >&2; exit 1; } + sleep 1 +done +echo "application did not answer; tail of $LOG:" >&2 +tail -40 "$LOG" >&2 +exit 1 diff --git a/profiles-and-config/scripts/stop.sh b/profiles-and-config/scripts/stop.sh new file mode 100755 index 0000000..3467ad5 --- /dev/null +++ b/profiles-and-config/scripts/stop.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Stop the demo application. +# +# This uses a PID file rather than a pattern match, deliberately. `pkill -f spring-boot` +# matches the shell that is running the script and takes the terminal with it. Even a +# careful-looking `ps | grep '[c]onfiguration-properties'` matches the shell's own command +# line whenever that string appears in the command you just typed -- which it does, because +# you typed the jar name. Killing a recorded PID cannot misfire. +set -u +cd "$(dirname "$0")/.." +PIDFILE="${PIDFILE:-target/app.pid}" + +if [ -f "$PIDFILE" ]; then + pid=$(cat "$PIDFILE") + # Confirm the PID is still ours before signalling it: PIDs are reused. + if [ -n "$pid" ] && grep -qa "profiles-and-config" "/proc/$pid/cmdline" 2>/dev/null; then + kill -9 "$pid" 2>/dev/null || true + fi + rm -f "$PIDFILE" +fi + +# Killing the process is not the same as the socket closing, and a stale listener looks +# exactly like your configuration change having had no effect. +for _ in $(seq 1 40); do + if ! (exec 3<>/dev/tcp/127.0.0.1/"${APP_PORT:-8080}") 2>/dev/null; then break; fi + sleep 0.25 +done +exec 3<&- 2>/dev/null || true diff --git a/profiles-and-config/src/main/java/com/ankurm/profiles/ProfilesApplication.java b/profiles-and-config/src/main/java/com/ankurm/profiles/ProfilesApplication.java new file mode 100644 index 0000000..3f9d474 --- /dev/null +++ b/profiles-and-config/src/main/java/com/ankurm/profiles/ProfilesApplication.java @@ -0,0 +1,19 @@ +package com.ankurm.profiles; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * Companion application for the ankurm.com article + * "Spring Boot Profiles Done Right: Config Import, Config Trees and Kubernetes ConfigMaps". + * + *

Every scenario in {@code scripts/} starts this same application with a different + * combination of profiles, imported locations and environment variables, and asks it one + * question: which source won, and which sources were present and lost. + */ +@SpringBootApplication +public class ProfilesApplication { + public static void main(String[] args) { + SpringApplication.run(ProfilesApplication.class, args); + } +} diff --git a/profiles-and-config/src/main/java/com/ankurm/profiles/web/PrecedenceEndpoint.java b/profiles-and-config/src/main/java/com/ankurm/profiles/web/PrecedenceEndpoint.java new file mode 100644 index 0000000..af2e469 --- /dev/null +++ b/profiles-and-config/src/main/java/com/ankurm/profiles/web/PrecedenceEndpoint.java @@ -0,0 +1,90 @@ +package com.ankurm.profiles.web; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +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.core.env.ConfigurableEnvironment; +import org.springframework.core.env.EnumerablePropertySource; +import org.springframework.core.env.PropertySource; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * The endpoint the whole article is built on: for one property, every source that holds a + * value for it, in precedence order, with the winner first. + * + *

"I set it in {@code application-prod.yaml} and it had no effect" is not a mystery once + * you can see that four sources hold the property and yours is third. Spring Boot knows the + * answer; it just never volunteers it. + * + *

Documented in {@code docs/03-seeing-precedence.md}. Delete it before shipping: it will + * print whatever a mounted secret contains. + */ +@RestController +public class PrecedenceEndpoint { + + private final ConfigurableEnvironment environment; + + public PrecedenceEndpoint(ConfigurableEnvironment environment) { + this.environment = environment; + } + + /** Every source holding {@code name}, highest precedence first. */ + @GetMapping("/precedence") + public Map precedence( + @RequestParam(defaultValue = "demo.greeting") String name) { + + ConfigurationPropertyName propertyName = ConfigurationPropertyName.of(name); + List> holders = new ArrayList<>(); + + for (ConfigurationPropertySource source : ConfigurationPropertySources.get(environment)) { + var property = source.getConfigurationProperty(propertyName); + if (property == null) { + continue; + } + Map row = new LinkedHashMap<>(); + row.put("rank", holders.size() + 1); + row.put("source", shortName(source.getUnderlyingSource())); + row.put("value", String.valueOf(property.getValue())); + row.put("origin", String.valueOf(property.getOrigin())); + holders.add(row); + } + + Map result = new LinkedHashMap<>(); + result.put("property", name); + result.put("effectiveValue", environment.getProperty(name)); + result.put("activeProfiles", List.of(environment.getActiveProfiles())); + result.put("holders", holders); + result.put("shadowedCount", Math.max(0, holders.size() - 1)); + return result; + } + + /** The environment's property sources in order, so the article can show the real stack. */ + @GetMapping("/sources") + public List> sources() { + List> rows = new ArrayList<>(); + int rank = 1; + for (PropertySource source : environment.getPropertySources()) { + Map row = new LinkedHashMap<>(); + row.put("rank", rank++); + row.put("name", source.getName()); + row.put("type", source.getClass().getSimpleName()); + if (source instanceof EnumerablePropertySource enumerable) { + row.put("propertyCount", enumerable.getPropertyNames().length); + } + rows.add(row); + } + return rows; + } + + private String shortName(Object underlying) { + String text = String.valueOf(underlying); + return text.length() > 150 ? text.substring(0, 150) + "..." : text; + } +} diff --git a/profiles-and-config/src/main/resources/application-badactivation.yaml b/profiles-and-config/src/main/resources/application-badactivation.yaml new file mode 100644 index 0000000..597ca21 --- /dev/null +++ b/profiles-and-config/src/main/resources/application-badactivation.yaml @@ -0,0 +1,12 @@ +# spring.profiles.active cannot be set from a document that is itself profile-specific. +# Spring Boot refuses this rather than silently half-applying it. The exact exception is +# captured in docs/output/05-invalid-activation.txt. +demo: + greeting: from-badactivation +--- +spring: + config: + activate: + on-profile: staging + profiles: + active: sneaky diff --git a/profiles-and-config/src/main/resources/application-import.yaml b/profiles-and-config/src/main/resources/application-import.yaml new file mode 100644 index 0000000..e9ae2eb --- /dev/null +++ b/profiles-and-config/src/main/resources/application-import.yaml @@ -0,0 +1,11 @@ +# Demonstrates spring.config.import ordering. +# +# The imported document is processed as though it appeared immediately AFTER this one, which +# means the importing file wins on any key both of them set. That is the opposite of the +# intuition most people bring from #include, and it is the subject of docs/05-config-import.md. +spring: + config: + import: "optional:classpath:/imported.yaml" + +demo: + greeting: from-application-import-yaml diff --git a/profiles-and-config/src/main/resources/application-multidoc.yaml b/profiles-and-config/src/main/resources/application-multidoc.yaml new file mode 100644 index 0000000..45c8079 --- /dev/null +++ b/profiles-and-config/src/main/resources/application-multidoc.yaml @@ -0,0 +1,20 @@ +# One file, three documents, activated by condition rather than by filename. +# +# spring.config.activate.on-profile is the mechanism behind profile-specific behaviour when +# you would rather keep everything in one file. Later documents win over earlier ones. +demo: + greeting: from-multidoc-default-document +--- +spring: + config: + activate: + on-profile: staging +demo: + greeting: from-multidoc-staging-document +--- +spring: + config: + activate: + on-profile: prod +demo: + greeting: from-multidoc-prod-document diff --git a/profiles-and-config/src/main/resources/application-prod-db.yaml b/profiles-and-config/src/main/resources/application-prod-db.yaml new file mode 100644 index 0000000..4a6ac42 --- /dev/null +++ b/profiles-and-config/src/main/resources/application-prod-db.yaml @@ -0,0 +1,3 @@ +# Activated as part of the "prod" profile group declared in application.yaml. +demo: + pool-size: 40 diff --git a/profiles-and-config/src/main/resources/application-prod-metrics.yaml b/profiles-and-config/src/main/resources/application-prod-metrics.yaml new file mode 100644 index 0000000..a59e4d3 --- /dev/null +++ b/profiles-and-config/src/main/resources/application-prod-metrics.yaml @@ -0,0 +1,3 @@ +# The second member of the "prod" group. +demo: + metrics-enabled: true diff --git a/profiles-and-config/src/main/resources/application-prod.yaml b/profiles-and-config/src/main/resources/application-prod.yaml new file mode 100644 index 0000000..b29bae1 --- /dev/null +++ b/profiles-and-config/src/main/resources/application-prod.yaml @@ -0,0 +1,5 @@ +# Profile-specific configuration. This file always beats application.yaml -- and still loses +# to an environment variable, which is the point of docs/04-why-your-profile-file-lost.md. +demo: + greeting: from-application-prod-yaml + datasource-url: jdbc:postgresql://prod-db:5432/orders diff --git a/profiles-and-config/src/main/resources/application.yaml b/profiles-and-config/src/main/resources/application.yaml new file mode 100644 index 0000000..9d22514 --- /dev/null +++ b/profiles-and-config/src/main/resources/application.yaml @@ -0,0 +1,22 @@ +spring: + application: + name: profiles-and-config + profiles: + # A profile group: activating "prod" activates all three. Groups are resolved before + # config data is processed, which is why a group can be declared here and still affect + # which application-.yaml files are loaded. + group: + prod: prod-db,prod-metrics + +server: + port: 8080 + +logging: + level: + root: WARN + +demo: + # The property every scenario asks about. Each source below sets it to a string naming + # itself, so the winner is self-identifying in the transcript. + greeting: from-application-yaml + datasource-url: jdbc:h2:mem:default diff --git a/profiles-and-config/src/main/resources/imported.yaml b/profiles-and-config/src/main/resources/imported.yaml new file mode 100644 index 0000000..64a71ff --- /dev/null +++ b/profiles-and-config/src/main/resources/imported.yaml @@ -0,0 +1,4 @@ +# Imported by application-import.yaml. Sets the same key, and loses. +demo: + greeting: from-imported-yaml + imported-only: yes-this-file-was-read diff --git a/profiles-and-config/src/test/java/com/ankurm/profiles/PrecedenceContractTests.java b/profiles-and-config/src/test/java/com/ankurm/profiles/PrecedenceContractTests.java new file mode 100644 index 0000000..657086f --- /dev/null +++ b/profiles-and-config/src/test/java/com/ankurm/profiles/PrecedenceContractTests.java @@ -0,0 +1,102 @@ +package com.ankurm.profiles; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.WebApplicationType; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.core.env.ConfigurableEnvironment; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Pins the precedence claims the article makes, so a future Spring Boot upgrade that changes + * any of them fails the build rather than quietly making the article wrong. + */ +class PrecedenceContractTests { + + private ConfigurableApplicationContext run(String... args) { + SpringApplication application = new SpringApplication(ProfilesApplication.class); + application.setWebApplicationType(WebApplicationType.NONE); + return application.run(args); + } + + @Test + @DisplayName("a profile-specific file beats application.yaml") + void profileFileBeatsBaseFile() { + try (var context = run("--spring.profiles.active=prod")) { + assertThat(context.getEnvironment().getProperty("demo.greeting")) + .isEqualTo("from-application-prod-yaml"); + } + } + + @Test + @DisplayName("a profile group activates every profile it names") + void profileGroupExpands() { + try (var context = run("--spring.profiles.active=prod")) { + assertThat(context.getEnvironment().getActiveProfiles()) + .containsExactlyInAnyOrder("prod", "prod-db", "prod-metrics"); + assertThat(context.getEnvironment().getProperty("demo.pool-size")).isEqualTo("40"); + } + } + + @Test + @DisplayName("a command-line argument beats every config file") + void commandLineBeatsConfigData() { + try (var context = run("--spring.profiles.active=prod", + "--demo.greeting=from-command-line")) { + assertThat(context.getEnvironment().getProperty("demo.greeting")) + .isEqualTo("from-command-line"); + } + } + + /** + * The counterintuitive one, and the reason the article has a callout about it: an + * imported document outranks the document that imported it. + */ + @Test + @DisplayName("spring.config.import: the IMPORTED file wins over the importing file") + void importedFileWins() { + try (var context = run("--spring.profiles.active=import")) { + ConfigurableEnvironment environment = context.getEnvironment(); + assertThat(environment.getProperty("demo.imported-only")) + .as("the import was processed at all") + .isEqualTo("yes-this-file-was-read"); + assertThat(environment.getProperty("demo.greeting")) + .as("and it beat application-import.yaml, which declared the import") + .isEqualTo("from-imported-yaml"); + } + } + + @Test + @DisplayName("later documents in a multi-document file win over earlier ones") + void multiDocumentOrdering() { + try (var context = run("--spring.profiles.active=multidoc")) { + assertThat(context.getEnvironment().getProperty("demo.greeting")) + .isEqualTo("from-multidoc-default-document"); + } + try (var context = run("--spring.profiles.active=multidoc,staging")) { + assertThat(context.getEnvironment().getProperty("demo.greeting")) + .isEqualTo("from-multidoc-staging-document"); + } + } + + @Test + @DisplayName("a config tree beats application.yaml but still loses to nothing above it") + void configTreeIsConfigData(@org.junit.jupiter.api.io.TempDir java.nio.file.Path mount) + throws Exception { + java.nio.file.Files.writeString(mount.resolve("demo.greeting"), "from-config-tree"); + + try (var context = run("--spring.config.import=configtree:" + mount + "/")) { + assertThat(context.getEnvironment().getProperty("demo.greeting")) + .isEqualTo("from-config-tree"); + } + // A command-line argument still outranks it: a config tree is config data. + try (var context = run("--spring.config.import=configtree:" + mount + "/", + "--demo.greeting=from-command-line")) { + assertThat(context.getEnvironment().getProperty("demo.greeting")) + .isEqualTo("from-command-line"); + } + } +} diff --git a/spring-aop/README.md b/spring-aop/README.md new file mode 100644 index 0000000..5e67111 --- /dev/null +++ b/spring-aop/README.md @@ -0,0 +1,67 @@ +# Spring AOP: designators, proxies, and aspects that do not fire + +Companion project for [**Spring AOP Explained**](https://ankurm.com/) on ankurm.com. + +Two halves. One set of aspects that work — one advice per pointcut designator, so the reference +table is generated from real matches. One set that does not fire, each for a different reason, +each paired with its fix. + +## Versions + +| | | +|---|---| +| Spring Boot | 4.1.1 | +| Spring Framework | 7.0.9 | +| AspectJ weaver | 1.9.25.1 | +| JDK | Eclipse Temurin 25.0.4.1 (LTS) | + +## Read this before copying a dependency block + +In Spring Boot 3 the starter was `spring-boot-starter-aop`. In Spring Boot 4 it is +**`spring-boot-starter-aspectj`**. The old artifact's last publication is `4.0.0-M2` and its +last GA is `3.5.16`, so a pre-4 dependency block fails resolution with a missing-version error. + +## Quickstart + +```bash +export JAVA_HOME=/path/to/jdk-25 +mvn -DskipTests package +./scripts/run-all.sh # regenerate every transcript in docs/output/ +mvn test # 6 contract tests +``` + +## Endpoints + +| Endpoint | Purpose | +|---|---| +| `GET /aop/proxies` | per bean: proxied or not, which kind, and the advisor list | +| `GET /aop/designators` | exercises every advised method, reports what each designator matched | +| `GET /aop/parser` | feeds supported and unsupported expressions to the pointcut parser | +| `GET /aop/broken` | runs the failure gallery and reports that nothing fired | + +## Documentation + +1. [What Spring AOP actually is](docs/01-what-spring-aop-is.md) +2. [The designator reference](docs/02-designators.md) +3. [JDK dynamic proxies and CGLIB](docs/03-proxy-types.md) +4. [Advice types and ordering](docs/04-advice-types.md) +5. [Six aspects that do not fire](docs/05-broken-aspect-gallery.md) +6. [Diagnosing a silent aspect](docs/06-diagnosing-a-silent-aspect.md) + +## Captured output + +| File | Produced by | +|---|---| +| [`00-versions.txt`](docs/output/00-versions.txt) | `scripts/demo-versions.sh` | +| [`01-designators.txt`](docs/output/01-designators.txt) | `scripts/demo-designators.sh` | +| [`02-proxy-types.txt`](docs/output/02-proxy-types.txt) | `scripts/demo-proxy-types.sh` | +| [`03-broken-gallery.txt`](docs/output/03-broken-gallery.txt) | `scripts/demo-broken-gallery.sh` | + +## Two corrections to the reference documentation + +- Unsupported designators throw **`UnsupportedPointcutPrimitiveException`**, which extends + `RuntimeException` directly — not `IllegalArgumentException` as the docs state. Catching the + documented type will not catch it. +- The framework default is interface-based proxying, but **Spring Boot sets + `proxy-target-class=true`**, so you get CGLIB even for beans that implement interfaces. That + changes which designators match; `scripts/demo-proxy-types.sh` shows both. diff --git a/spring-aop/docs/01-what-spring-aop-is.md b/spring-aop/docs/01-what-spring-aop-is.md new file mode 100644 index 0000000..bcd9ff7 --- /dev/null +++ b/spring-aop/docs/01-what-spring-aop-is.md @@ -0,0 +1,51 @@ +[Index](../README.md) · [Designators →](02-designators.md) + +# 1. What Spring AOP actually is + +Spring AOP is a **proxy** mechanism that borrows AspectJ's **pointcut language**. Both halves +of that sentence explain a failure mode. + +## It is proxies + +Spring does not modify your bytecode. When a bean matches a pointcut, the container puts a +proxy in the bean registry in its place. Callers get the proxy; the proxy runs advice and then +forwards to the real object. + +Everything that follows from this: + +- Only **beans** can be advised. An object created with `new` has no proxy. +- Only calls **through the proxy** are intercepted. A call the object makes to itself is not. +- Only **overridable** methods can be advised: not private, not final, not static. +- Join points are **method executions**. Field access, constructor calls and exception handlers + are not available, no matter what the pointcut language allows you to write. + +## It borrows the pointcut language + +`aspectjweaver` on the classpath supplies the pointcut *parser*. Spring uses it to decide which +methods match and then does its own weaving with proxies. AspectJ's own weaver is not involved. + +This is why the designator list is a subset: the language can express `call()` and `cflow()`, +and a proxy cannot implement them. See [chapter 2](02-designators.md). + +## The Spring Boot 4 starter rename + +In Spring Boot 3 the dependency was `spring-boot-starter-aop`. In Spring Boot 4 it is +**`spring-boot-starter-aspectj`**. + +``` +spring-boot-starter-aop last published 4.0.0-M2 (last GA: 3.5.16) +spring-boot-starter-aspectj first published 4.0.0-M3 +``` + +The contents are unchanged: `spring-boot-starter`, `spring-aop`, `aspectjweaver`. But the old +artifact is no longer in the Boot BOM, so copying a dependency block out of any pre-4 tutorial +fails resolution with a missing-version error rather than a helpful message. + +Without the starter, `@Aspect` classes are ordinary beans, no pointcut is ever parsed, and +every aspect in the application silently matches nothing. + +## When to use something else + +If you need to advise field access, constructors, or calls between objects you do not own, you +need real AspectJ weaving (compile-time or load-time), not Spring AOP. If you need to advise +one internal call, you need to refactor — see [chapter 5](05-broken-aspect-gallery.md). diff --git a/spring-aop/docs/02-designators.md b/spring-aop/docs/02-designators.md new file mode 100644 index 0000000..9ef9a82 --- /dev/null +++ b/spring-aop/docs/02-designators.md @@ -0,0 +1,86 @@ +[← What Spring AOP is](01-what-spring-aop-is.md) · [Index](../README.md) · [Proxy types →](03-proxy-types.md) + +# 2. The designator reference + +Generated by [`DesignatorAspect`](../src/main/java/com/ankurm/aop/aspect/DesignatorAspect.java) +and [`PointcutParserEndpoint`](../src/main/java/com/ankurm/aop/web/PointcutParserEndpoint.java). +Transcript: [`01-designators.txt`](output/01-designators.txt). + +## Supported + +| Designator | Matches on | Evaluated | Cost | +|---|---|---|---| +| `execution(...)` | method signature | statically | cheap | +| `within(Type)` | the declaring type | statically | cheap | +| `this(Type)` | the **proxy**'s type | at runtime | per call | +| `target(Type)` | the **target object**'s type | at runtime | per call | +| `args(Types)` | argument runtime types; can bind | at runtime | per call | +| `@target(Ann)` | annotation on the executing object's class | at runtime | per call | +| `@args(Ann)` | annotation on argument runtime types | at runtime | per call | +| `@within(Ann)` | annotation on the declaring type | statically | cheap | +| `@annotation(Ann)` | annotation on the method | statically | cheap | +| `bean(name)` | Spring bean name, wildcards allowed | — | cheap | + +`bean(...)` is Spring's own; it does not exist in AspectJ. + +The runtime group cannot be decided from the signature alone, so Spring must check on every +candidate invocation. Prefer the static equivalent where one exists: `@within` instead of +`@target`, `within` instead of `this`, when the distinction does not matter. + +## What each one actually matched here + +``` + execution (full signature) -> DefaultOrderService.place(..) + execution (wildcards) -> DefaultOrderService.cancel(..) + within -> InventoryService.reserve(..) + this(OrderService) -> place, cancel, interfaceless + target(DefaultOrderService) -> place, cancel, interfaceless + args (bound: SKU-1/2) -> DefaultOrderService.place(..) + @target(Audited) -> DefaultOrderService.cancel(..) + @args(Trackable) -> DefaultOrderService.interfaceless(..) + @within(Audited) -> DefaultOrderService.place(..) + @annotation(Marker) -> DefaultOrderService.place(..) + bean(inventoryService) -> InventoryService.reserve(..) + bean(*OrderService) -> place, cancel, interfaceless +``` + +`InventoryService.finalCheck` was called and appears nowhere: it is `final`, so no proxy could +override it. That is [failure 4](05-broken-aspect-gallery.md). + +## Not supported + +`call`, `get`, `set`, `preinitialization`, `staticinitialization`, `initialization`, `handler`, +`adviceexecution`, `withincode`, `cflow`, `cflowbelow`, `if`, `@this`, `@withincode`. + +All fourteen were fed to the parser. All fourteen were rejected, with: + +``` +org.aspectj.weaver.tools.UnsupportedPointcutPrimitiveException +Pointcut expression 'call(* ...place(..))' contains unsupported pointcut primitive 'call' +``` + +**The reference documentation says these throw `IllegalArgumentException`. They do not.** +`UnsupportedPointcutPrimitiveException extends RuntimeException` directly — checked with +`javap`, pinned by `AopContractTests.unsupportedDesignatorExceptionType`. A +`catch (IllegalArgumentException)` will not catch it. + +## When the failure happens + +`setExpression(...)` only stores the string. The expression is not parsed or validated until +something asks it to match. An unsupported designator therefore fails at the first candidate +invocation, not at startup — so a rarely-exercised aspect can ship broken. + +## Combining and naming + +`&&`, `||` and `!` compose designators. Name the result rather than repeating it: + +```java +@Pointcut("within(com.ankurm.aop.service..*)") +public void inServiceLayer() {} + +@Before("this(OrderService) && inServiceLayer()") +public void advice(JoinPoint jp) { } +``` + +A named pointcut is referenced by its method name and is the single biggest readability win +available here. diff --git a/spring-aop/docs/03-proxy-types.md b/spring-aop/docs/03-proxy-types.md new file mode 100644 index 0000000..69c30af --- /dev/null +++ b/spring-aop/docs/03-proxy-types.md @@ -0,0 +1,78 @@ +[← Designators](02-designators.md) · [Index](../README.md) · [Advice types →](04-advice-types.md) + +# 3. JDK dynamic proxies and CGLIB + +Transcript: [`02-proxy-types.txt`](output/02-proxy-types.txt). + +| | JDK dynamic proxy | CGLIB | +|---|---|---| +| Built by | `java.lang.reflect.Proxy` | subclassing the target | +| Requires | at least one interface | a non-final class | +| Proxy is an instance of | the interfaces only | the target class | +| Can advise | public interface methods | public, protected, package-private | +| Cannot advise | anything not on the interface | `final`, `private`, `static` | +| Class name | `$Proxy62` | `Foo$$SpringCGLIB$$0` | + +## Spring Boot chooses CGLIB + +The Spring Framework's own default is interface-based when an interface exists. **Spring Boot +sets `spring.aop.proxy-target-class=true`**, so you get CGLIB either way unless you change it. + +Measured, on a bean that *does* implement an interface: + +``` + defaultOrderService CGLIB subclass class: DefaultOrderService$$SpringCGLIB$$0 + interfaces : (none) + proxy is an instance of DefaultOrderService : True +``` + +And with `--spring.aop.proxy-target-class=false`: + +``` + defaultOrderService JDK dynamic proxy class: $Proxy62 + interfaces : OrderService + proxy is an instance of DefaultOrderService : False +``` + +## What changes when you switch + +Same aspects, same beans: + +``` +CGLIB: this(OrderService) -> place, cancel, interfaceless +JDK: this(OrderService) -> place, cancel +``` + +`interfaceless(..)` is not on `OrderService`, so under a JDK proxy it is invisible to advice — +and casting the bean to `DefaultOrderService` throws `ClassCastException`. + +This is why `this()` and `target()` exist as separate designators. `this()` tests the proxy; +`target()` tests the object behind it. Under CGLIB they nearly always agree, which is exactly +why the distinction only ever bites *after* somebody changes the proxy type. + +## CGLIB constraints worth knowing + +- A **final class** cannot be proxied at all — this one fails loudly. +- A **final method** is silently not advised. The bean is still a proxy; the method just is not + overridden. This is the quiet one. +- **Private** methods are never advised by either strategy. +- The target's constructor is **not** called twice: Spring creates the proxy instance through + Objenesis. On a JVM that forbids constructor bypassing you may see double invocation and a + debug log line about it. +- On the module path, classes in `java.lang` cannot be proxied without + `--add-opens=java.base/java.lang=ALL-UNNAMED`. + +## `@Proxyable`, new in Spring 7.0 + +Per-bean control, rather than one global switch: + +```java +@Proxyable(ProxyType.INTERFACES) +@Bean +MyService myService() { ... } +``` + +Verified against the class file rather than the docs: +`org.springframework.context.annotation.Proxyable`, targets `TYPE` and `METHOD`, with +`ProxyType value()` (`DEFAULT`, `INTERFACES`, `TARGET_CLASS`) and `Class[] interfaces()`. +Note the package — it is in `spring-context`, not `spring-aop`. diff --git a/spring-aop/docs/04-advice-types.md b/spring-aop/docs/04-advice-types.md new file mode 100644 index 0000000..6eba5db --- /dev/null +++ b/spring-aop/docs/04-advice-types.md @@ -0,0 +1,56 @@ +[← Proxy types](03-proxy-types.md) · [Index](../README.md) · [Broken aspect gallery →](05-broken-aspect-gallery.md) + +# 4. Advice types and ordering + +## The five + +| Annotation | Runs | Can it stop the call? | Can it change the result? | +|---|---|---|---| +| `@Before` | before | only by throwing | no | +| `@AfterReturning` | after a normal return | no | no (can read it) | +| `@AfterThrowing` | after an exception | no | no (can read it) | +| `@After` | after either | no | no | +| `@Around` | wraps | yes | yes | + +`@Around` is the only one that receives a `ProceedingJoinPoint` and therefore the only one that +can decide whether the target runs at all, retry it, cache around it, or replace its result. + +```java +@Around("@annotation(Timed)") +public Object time(ProceedingJoinPoint pjp) throws Throwable { + long start = System.nanoTime(); + try { + return pjp.proceed(); + } finally { + record(pjp.getSignature(), System.nanoTime() - start); + } +} +``` + +Two rules for `@Around`: it must return `Object` (or a compatible type) and it must actually +call `proceed()`. Forgetting `proceed()` silently turns every advised method into a method that +returns `null` and never runs — a failure that looks like the target method being broken. + +## Ordering + +Within one aspect, advice order for the same join point is **not** guaranteed and is not the +source order. If two pieces of advice in one aspect must be ordered, split them into two +aspects. + +Between aspects, `@Order` or `Ordered` decides. Lower value = higher precedence = outermost. +Advice nests: the outermost aspect's `@Before` runs first and its `@After` runs last. + +``` +@Order(1) Tx : before ... [ @Order(2) Logging : before ... target ... after ] ... after +``` + +This matters when combining transactions with anything that must be inside or outside them. +`@Transactional` is ordered at `Ordered.LOWEST_PRECEDENCE` by default, so almost everything +else wraps *outside* it — meaning your logging advice sees the method return before the +transaction commits, and therefore before a commit failure has happened. + +## `ExposeInvocationInterceptor` + +You will see this at the head of every advisor chain in +[`02-proxy-types.txt`](output/02-proxy-types.txt). Spring adds it automatically so that +`AopContext.currentProxy()` and argument binding work. It is not something you configured. diff --git a/spring-aop/docs/05-broken-aspect-gallery.md b/spring-aop/docs/05-broken-aspect-gallery.md new file mode 100644 index 0000000..fdc1f7a --- /dev/null +++ b/spring-aop/docs/05-broken-aspect-gallery.md @@ -0,0 +1,84 @@ +[← Advice types](04-advice-types.md) · [Index](../README.md) · [Diagnosing →](06-diagnosing-a-silent-aspect.md) + +# 5. Six aspects that do not fire + +Sources: [`BrokenAspects`](../src/main/java/com/ankurm/aop/broken/BrokenAspects.java), +[`SelfInvokingService`](../src/main/java/com/ankurm/aop/broken/SelfInvokingService.java), +[`NewedUpService`](../src/main/java/com/ankurm/aop/broken/NewedUpService.java). +Transcript: [`03-broken-gallery.txt`](output/03-broken-gallery.txt). + +None of these warn. None fail at startup. All of them look correct in review. + +## 1. `@Aspect` without `@Component` + +`@Aspect` is an AspectJ annotation. It tells Spring how to interpret a bean it already has; it +does not create one. Without a stereotype or an `@Bean` method, the class is never instantiated +and the pointcut is never registered. + +The most common cause, and the most invisible — from the container's point of view nothing was +ever requested, so there is nothing to warn about. + +**Fix:** add `@Component`. + +## 2. A pointcut that matches nothing + +```java +@Before("execution(* com.ankurm.aop.services.*.*(..))") // "services", plural +``` + +A package name matching no type is not an error; it is an empty match set. At runtime this is +indistinguishable from an aspect that was never registered. + +**Fix:** assert on it. `AspectJExpressionPointcut#matches(Method, Class)` in a unit test is two +lines and catches every typo permanently. + +## 3. A private method + +Neither proxy strategy can override a private method, so neither can intercept it. The +annotation is legal and inert. + +**Fix:** make it at least package-private *and* call it from outside the object — visibility +alone is not enough if the call is internal, which brings you to number 5. + +## 4. A final method + +CGLIB proxies by subclassing. A final method is inherited rather than overridden, so calls go +straight to the original. A final *class* fails loudly; a final *method* is silent. + +Note `beanIsProxied: true` in the transcript. The bean is proxied. This one method is not. + +**Fix:** remove `final`, or proxy by interface. + +## 5. Self-invocation + +The expensive one, because the code looks right and the annotation is visible. + +```json +"5-self-invocation": { + "beanIsProxied": true, + "innerAdvisedWhenCalledFromOuter": false, + "innerAdvisedWhenCalledDirectly": true +} +``` + +Same method, same advice. Called through the proxy it is advised; reached by `this.inner()` +from another method of the same object it is not, because the proxy is not in that call path. + +This is the same mechanism that makes `@Transactional` and `@Cacheable` silently do nothing on +internal calls. Learning it once here saves learning it three times. + +**Fixes, best first:** + +1. Move the method to another bean. This is almost always the right answer, and the resulting + design is usually better anyway. +2. Inject the bean into itself and call through that reference. +3. `AopContext.currentProxy()` with `exposeProxy = true`. Works; couples your code to Spring + AOP and makes the class aware it is proxied. + +## 6. An object created with `new` + +Spring AOP advises beans. An instance built by a factory, a helper or a test has no proxy and +never will. + +**Fix:** get it from the container. If it genuinely must be constructed by hand and still +advised, that is what AspectJ load-time weaving is for. diff --git a/spring-aop/docs/06-diagnosing-a-silent-aspect.md b/spring-aop/docs/06-diagnosing-a-silent-aspect.md new file mode 100644 index 0000000..cf7ea27 --- /dev/null +++ b/spring-aop/docs/06-diagnosing-a-silent-aspect.md @@ -0,0 +1,75 @@ +[← Broken aspect gallery](05-broken-aspect-gallery.md) · [Index](../README.md) + +# 6. Diagnosing a silent aspect + +Endpoint: [`AopDiagnosticsEndpoint`](../src/main/java/com/ankurm/aop/web/AopDiagnosticsEndpoint.java). + +Almost every non-firing aspect is one of three things, and all three are visible in one place. + +## The three questions, in order + +**1. Is the bean proxied at all?** + +```java +AopUtils.isAopProxy(bean) +``` + +`false` means no pointcut matched this bean, or it is not a bean. Stop here and check the +pointcut and the registration. + +**2. Which kind of proxy?** + +```java +AopUtils.isJdkDynamicProxy(bean) // interfaces only +AopUtils.isCglibProxy(bean) // subclass +``` + +If it is a JDK proxy and your method is not on an interface, that is your answer. + +**3. Is your advice in the advisor list?** + +```java +if (bean instanceof Advised advised) { + Arrays.stream(advised.getAdvisors()) + .map(a -> a.getAdvice().getClass().getSimpleName()) + .forEach(System.out::println); +} +``` + +Proxied, right kind, and your advice missing means the pointcut matched the *bean* but not the +*method*. + +## Output + +``` + defaultOrderService CGLIB subclass target=DefaultOrderService + class : DefaultOrderService$$SpringCGLIB$$0 + interfaces : (none) + advisors : 11 +``` + +## Testing a pointcut without an application + +The fastest check of all, and it belongs in a test rather than in a diagnostic endpoint: + +```java +AspectJExpressionPointcut pointcut = new AspectJExpressionPointcut(); +pointcut.setExpression("execution(* com.example.service.*.*(..))"); +assertThat(pointcut.matches(Foo.class.getMethod("bar"), Foo.class)).isTrue(); +``` + +Remember that `setExpression` does not parse; the parse happens on first `matches`. So this is +also how you find out that a designator is unsupported before production does. + +## Logging + +``` +logging.level.org.springframework.aop=DEBUG +``` + +reports proxy creation per bean. Verbose, but it answers question 1 for every bean at once. + +## Delete the endpoint before shipping + +It reports internal wiring. If you want it permanently, put it behind the management port and +authentication. diff --git a/spring-aop/docs/output/00-versions.txt b/spring-aop/docs/output/00-versions.txt new file mode 100644 index 0000000..07aba63 --- /dev/null +++ b/spring-aop/docs/output/00-versions.txt @@ -0,0 +1,11 @@ +== versions == +openjdk version "25.0.4.1" 2026-08-18 LTS +OpenJDK Runtime Environment Temurin-25.0.4.1+1 (build 25.0.4.1+1-LTS) +OpenJDK 64-Bit Server VM Temurin-25.0.4.1+1 (build 25.0.4.1+1-LTS, mixed mode, sharing) + +spring-boot-starter-parent: 4.1.1 +aspectjweaver: 1.9.25.1 + +== the Boot 4 starter rename == +spring-boot-starter-aop last published: 4.0.0-M2 (last GA 3.5.16) +spring-boot-starter-aspectj first published: 4.0.0-M3 diff --git a/spring-aop/docs/output/01-designators.txt b/spring-aop/docs/output/01-designators.txt new file mode 100644 index 0000000..82066ba --- /dev/null +++ b/spring-aop/docs/output/01-designators.txt @@ -0,0 +1,59 @@ +== which designator matched which join point == + +Five methods are called once each: OrderService.place, OrderService.cancel, +InventoryService.reserve, InventoryService.finalCheck and +DefaultOrderService.interfaceless. + + orderService proxy kind : CGLIB subclass + proxy is an instance of DefaultOrderService : True + + args (bound: SKU-1/2) -> DefaultOrderService.place(..) + bean(*OrderService) -> DefaultOrderService.place(..), DefaultOrderService.cancel(..), DefaultOrderService.interfaceless(..) + @within(Audited) -> DefaultOrderService.place(..) + execution (full signature) -> DefaultOrderService.place(..) + @annotation(Marker) -> DefaultOrderService.place(..) + target(DefaultOrderService) -> DefaultOrderService.place(..), DefaultOrderService.cancel(..), DefaultOrderService.interfaceless(..) + this(OrderService) -> DefaultOrderService.place(..), DefaultOrderService.cancel(..), DefaultOrderService.interfaceless(..) + execution (wildcards) -> DefaultOrderService.cancel(..) + @target(Audited) -> DefaultOrderService.cancel(..) + bean(inventoryService) -> InventoryService.reserve(..) + within -> InventoryService.reserve(..) + @args(Trackable) -> DefaultOrderService.interfaceless(..) + +== what the parser accepts and refuses == + + supported (all parsed and evaluated): + execution(* com.ankurm.aop.service.OrderService.place(..)) OK + within(com.ankurm.aop.service..*) OK + this(com.ankurm.aop.service.OrderService) OK + target(com.ankurm.aop.service.DefaultOrderService) OK + args(String, int) OK + @target(com.ankurm.aop.service.Audited) OK + @args(com.ankurm.aop.service.Trackable) OK + @within(com.ankurm.aop.service.Audited) OK + @annotation(com.ankurm.aop.service.Marker) OK + bean(defaultOrderService) OK + + unsupported in Spring AOP: + call(* com.ankurm.aop.service.OrderService.place(. rejected + get(* com.ankurm.aop.service.*.*) rejected + set(* com.ankurm.aop.service.*.*) rejected + initialization(com.ankurm.aop.service.*.new(..)) rejected + staticinitialization(com.ankurm.aop.service.*) rejected + preinitialization(com.ankurm.aop.service.*.new(..) rejected + handler(java.lang.Exception) rejected + adviceexecution() rejected + withincode(* com.ankurm.aop.service.*.*(..)) rejected + cflow(execution(* com.ankurm.aop.service.*.*(..))) rejected + cflowbelow(execution(* com.ankurm.aop.service.*.*( rejected + if() rejected + @this(com.ankurm.aop.service.Audited) rejected + @withincode(com.ankurm.aop.service.Marker) rejected + + the exception, in full: + org.aspectj.weaver.tools.UnsupportedPointcutPrimitiveException + Pointcut expression 'call(* com.ankurm.aop.service.OrderService.place(..))' contains unsupported pointcut primitive 'call' + +The reference documentation says these produce an IllegalArgumentException. They do +not: UnsupportedPointcutPrimitiveException extends RuntimeException directly, so a +catch of IllegalArgumentException will not catch it. diff --git a/spring-aop/docs/output/02-proxy-types.txt b/spring-aop/docs/output/02-proxy-types.txt new file mode 100644 index 0000000..c657c2c --- /dev/null +++ b/spring-aop/docs/output/02-proxy-types.txt @@ -0,0 +1,53 @@ +== Spring Boot default: spring.aop.proxy-target-class=true == +$ java -jar target/spring-aop-demo-1.0.0.jar + + defaultOrderService CGLIB subclass target=DefaultOrderService + class : DefaultOrderService$$SpringCGLIB$$0 + interfaces : (none) + advisors : 11 + inventoryService CGLIB subclass target=InventoryService + class : InventoryService$$SpringCGLIB$$0 + interfaces : (none) + advisors : 6 + selfInvokingService CGLIB subclass target=SelfInvokingService + class : SelfInvokingService$$SpringCGLIB$$0 + interfaces : (none) + advisors : 2 + + proxy is an instance of DefaultOrderService : True + this(OrderService) -> DefaultOrderService.place(..), DefaultOrderService.cancel(..), DefaultOrderService.interfaceless(..) + target(DefaultOrderService) -> DefaultOrderService.place(..), DefaultOrderService.cancel(..), DefaultOrderService.interfaceless(..) + bean(*OrderService) -> DefaultOrderService.place(..), DefaultOrderService.cancel(..), DefaultOrderService.interfaceless(..) + +== framework default restored: spring.aop.proxy-target-class=false == +$ java -jar target/spring-aop-demo-1.0.0.jar --spring.aop.proxy-target-class=false + + defaultOrderService JDK dynamic proxy target=DefaultOrderService + class : $Proxy62 + interfaces : OrderService + advisors : 11 + inventoryService CGLIB subclass target=InventoryService + class : InventoryService$$SpringCGLIB$$0 + interfaces : (none) + advisors : 6 + selfInvokingService CGLIB subclass target=SelfInvokingService + class : SelfInvokingService$$SpringCGLIB$$0 + interfaces : (none) + advisors : 2 + + proxy is an instance of DefaultOrderService : False + this(OrderService) -> OrderService.place(..), OrderService.cancel(..) + target(DefaultOrderService) -> OrderService.place(..), OrderService.cancel(..) + bean(*OrderService) -> OrderService.place(..), OrderService.cancel(..) + +Same aspects, same beans, different proxy strategy: + + - With CGLIB the proxy is a SUBCLASS of DefaultOrderService, so it is an instance of + the implementation class and methods that are not on the interface are advised. + - With a JDK proxy the proxy implements OrderService only. It is NOT an instance of + DefaultOrderService, casting to that class throws ClassCastException, and any + method absent from the interface is invisible to advice. + +This is why this() and target() differ. this() tests the proxy; target() tests the +object behind it. Under CGLIB they usually agree, which is exactly why the +distinction only bites after somebody switches the proxy type. diff --git a/spring-aop/docs/output/03-broken-gallery.txt b/spring-aop/docs/output/03-broken-gallery.txt new file mode 100644 index 0000000..92fd3da --- /dev/null +++ b/spring-aop/docs/output/03-broken-gallery.txt @@ -0,0 +1,37 @@ +== aspects that do not fire == + +{ + "5-self-invocation": { + "beanIsProxied": true, + "result": "outer -> inner", + "innerAdvisedWhenCalledFromOuter": false, + "innerAdvisedWhenCalledDirectly": true + }, + "6-created-with-new": { + "isProxy": false, + "adviceFired": false + }, + "4-final-method": { + "beanIsProxied": true, + "proxyKind": "CGLIB subclass", + "adviceFired": false + }, + "1-aspect-without-component-fired": false, + "2-pointcut-typo-fired": false, + "3-private-method-fired": false, + "note": "every value above should be false except the two that prove the method is advisable when reached through the proxy" +} + +Reading it: + + 1 @Aspect without @Component - the class is never instantiated, so the pointcut + is never registered. No warning is produced. + 2 pointcut typo - 'com.ankurm.aop.services' (plural) parses fine and + matches nothing. An empty match set is not an error. + 3 private method - cannot be overridden, so cannot be intercepted. + 4 final method - CGLIB subclasses; a final method is inherited, not + overridden. Note beanIsProxied is still true. + 5 self-invocation - innerAdvisedWhenCalledFromOuter is false and + innerAdvisedWhenCalledDirectly is true. Same method, + same advice: only the call path differs. + 6 created with new - no container, no proxy, no advice. diff --git a/spring-aop/pom.xml b/spring-aop/pom.xml new file mode 100644 index 0000000..99bfab6 --- /dev/null +++ b/spring-aop/pom.xml @@ -0,0 +1,60 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 4.1.1 + + + + com.ankurm + spring-aop-demo + 1.0.0 + spring-aop-demo + Spring AOP: pointcut designators, proxy types, and why aspects do not fire + + + 25 + + + + + org.springframework.boot + spring-boot-starter-webmvc + + + + org.springframework.boot + spring-boot-starter-aspectj + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/spring-aop/scripts/demo-broken-gallery.sh b/spring-aop/scripts/demo-broken-gallery.sh new file mode 100755 index 0000000..a81c11d --- /dev/null +++ b/spring-aop/scripts/demo-broken-gallery.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# The broken-aspect gallery: six aspects that do not fire, and why. +set -euo pipefail +set +m +cd "$(dirname "$0")/.." +source scripts/env.sh +{ + echo "== aspects that do not fire ==" + echo + start_app > /dev/null + curl -s "http://127.0.0.1:${APP_PORT}/aop/broken" | python3 -m json.tool + echo + echo "Reading it:" + echo + echo " 1 @Aspect without @Component - the class is never instantiated, so the pointcut" + echo " is never registered. No warning is produced." + echo " 2 pointcut typo - 'com.ankurm.aop.services' (plural) parses fine and" + echo " matches nothing. An empty match set is not an error." + echo " 3 private method - cannot be overridden, so cannot be intercepted." + echo " 4 final method - CGLIB subclasses; a final method is inherited, not" + echo " overridden. Note beanIsProxied is still true." + echo " 5 self-invocation - innerAdvisedWhenCalledFromOuter is false and" + echo " innerAdvisedWhenCalledDirectly is true. Same method," + echo " same advice: only the call path differs." + echo " 6 created with new - no container, no proxy, no advice." + stop_app +} > docs/output/03-broken-gallery.txt 2>&1 +cat docs/output/03-broken-gallery.txt diff --git a/spring-aop/scripts/demo-designators.sh b/spring-aop/scripts/demo-designators.sh new file mode 100755 index 0000000..d7bd052 --- /dev/null +++ b/spring-aop/scripts/demo-designators.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# Every pointcut designator Spring AOP supports, with what it actually matched. +set -euo pipefail +set +m +cd "$(dirname "$0")/.." +source scripts/env.sh +{ + echo "== which designator matched which join point ==" + echo + echo "Five methods are called once each: OrderService.place, OrderService.cancel," + echo "InventoryService.reserve, InventoryService.finalCheck and" + echo "DefaultOrderService.interfaceless." + echo + start_app > /dev/null + curl -s "http://127.0.0.1:${APP_PORT}/aop/designators" | python3 -c ' +import json,sys +d=json.load(sys.stdin) +print(" orderService proxy kind :", d["orderServiceProxyKind"]) +print(" proxy is an instance of DefaultOrderService :", + d["orderServiceIsDefaultOrderServiceInstance"]) +print() +for k,v in d["matches"].items(): + print(" %-32s -> %s" % (k, ", ".join(v)))' + echo + echo "== what the parser accepts and refuses ==" + echo + curl -s "http://127.0.0.1:${APP_PORT}/aop/parser" | python3 -c ' +import json,sys +d=json.load(sys.stdin) +print(" supported (all parsed and evaluated):") +for r in d["supported"]: + print(" %-52s %s" % (r["expression"], "OK" if r["accepted"] else "REJECTED")) +print() +print(" unsupported in Spring AOP:") +for r in d["unsupported"]: + print(" %-52s %s" % (r["expression"][:50], "accepted!" if r["accepted"] else "rejected")) +print() +first=[r for r in d["unsupported"] if not r["accepted"]][0] +print(" the exception, in full:") +print(" " + first["exception"]) +print(" " + first["message"])' + echo + echo "The reference documentation says these produce an IllegalArgumentException. They do" + echo "not: UnsupportedPointcutPrimitiveException extends RuntimeException directly, so a" + echo "catch of IllegalArgumentException will not catch it." + stop_app +} > docs/output/01-designators.txt 2>&1 +cat docs/output/01-designators.txt diff --git a/spring-aop/scripts/demo-proxy-types.sh b/spring-aop/scripts/demo-proxy-types.sh new file mode 100755 index 0000000..a829762 --- /dev/null +++ b/spring-aop/scripts/demo-proxy-types.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# JDK dynamic proxies versus CGLIB, and what changes when you switch. +# +# Spring Boot sets spring.aop.proxy-target-class=true by default, so beans are proxied by +# CGLIB even when they implement an interface. Setting it to false restores the framework's +# own default and changes which designators match -- the same aspects, different results. +set -euo pipefail +set +m +cd "$(dirname "$0")/.." +source scripts/env.sh + +snapshot() { + curl -s "http://127.0.0.1:${APP_PORT}/aop/proxies" | python3 -c ' +import json,sys +for r in json.load(sys.stdin): + print(" %-22s %-18s target=%s" % (r["bean"], r["proxyKind"], r["targetClass"].split(".")[-1])) + print(" class : %s" % r["class"].split(".")[-1]) + print(" interfaces : %s" % (", ".join(r.get("proxiedInterfaces") or []) or "(none)")) + print(" advisors : %d" % r.get("advisorCount", 0))' + echo + curl -s "http://127.0.0.1:${APP_PORT}/aop/designators" | python3 -c ' +import json,sys +d=json.load(sys.stdin) +print(" proxy is an instance of DefaultOrderService :", + d["orderServiceIsDefaultOrderServiceInstance"]) +for k in ("this(OrderService)","target(DefaultOrderService)","bean(*OrderService)"): + print(" %-30s -> %s" % (k, ", ".join(d["matches"].get(k, ["(no match)"]))))' +} + +{ + echo "== Spring Boot default: spring.aop.proxy-target-class=true ==" + echo "\$ java -jar $JAR" + echo + start_app > /dev/null + snapshot + echo + echo "== framework default restored: spring.aop.proxy-target-class=false ==" + echo "\$ java -jar $JAR --spring.aop.proxy-target-class=false" + echo + start_app --spring.aop.proxy-target-class=false > /dev/null + snapshot + echo + echo "Same aspects, same beans, different proxy strategy:" + echo + echo " - With CGLIB the proxy is a SUBCLASS of DefaultOrderService, so it is an instance of" + echo " the implementation class and methods that are not on the interface are advised." + echo " - With a JDK proxy the proxy implements OrderService only. It is NOT an instance of" + echo " DefaultOrderService, casting to that class throws ClassCastException, and any" + echo " method absent from the interface is invisible to advice." + echo + echo "This is why this() and target() differ. this() tests the proxy; target() tests the" + echo "object behind it. Under CGLIB they usually agree, which is exactly why the" + echo "distinction only bites after somebody switches the proxy type." + stop_app +} > docs/output/02-proxy-types.txt 2>&1 +cat docs/output/02-proxy-types.txt diff --git a/spring-aop/scripts/demo-versions.sh b/spring-aop/scripts/demo-versions.sh new file mode 100755 index 0000000..e97eeb6 --- /dev/null +++ b/spring-aop/scripts/demo-versions.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." +source scripts/env.sh +{ + echo "== versions ==" + java -version 2>&1 | clean + echo + echo "spring-boot-starter-parent: $(grep -A2 'spring-boot-starter-parent' pom.xml | grep '' | sed 's/.*\(.*\)<\/version>.*/\1/')" + echo "aspectjweaver: $(find ~/.m2/repository -name 'aspectjweaver-*.jar' | sed 's/.*aspectjweaver-//;s/\.jar//' | sort | tail -1)" + echo + echo "== the Boot 4 starter rename ==" + echo "spring-boot-starter-aop last published: 4.0.0-M2 (last GA 3.5.16)" + echo "spring-boot-starter-aspectj first published: 4.0.0-M3" +} > docs/output/00-versions.txt 2>&1 +cat docs/output/00-versions.txt diff --git a/spring-aop/scripts/env.sh b/spring-aop/scripts/env.sh new file mode 100755 index 0000000..4541fc7 --- /dev/null +++ b/spring-aop/scripts/env.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Shared environment. Point JAVA_HOME at a JDK 25 (or newer) installation. +: "${JAVA_HOME:?set JAVA_HOME to a JDK 25+ installation}" +export PATH="$JAVA_HOME/bin:$PATH" +MVN="${MVN:-mvn}" +JAR="target/spring-aop-demo-1.0.0.jar" +APP_MAIN="com.ankurm.aop.AopApplication" +APP_PORT="${APP_PORT:-8080}" + +# Strip environment noise that is an artefact of the machine, not of Spring: +# the JVM prints a JAVA_TOOL_OPTIONS banner to stderr on every launch when a proxy +# truststore is configured, and it would otherwise end up in every committed transcript. +clean() { grep -v "Picked up JAVA_TOOL_OPTIONS" | grep -v "^OpenJDK 64-Bit Server VM warning"; } + +# Start the demo jar detached, record its PID, and block until it answers. +# Extra arguments are passed to the application. Environment variables for the run are +# passed by setting them on the call: `APP_ENV="A=1 B=2" start_app --spring.profiles.active=x` +start_app() { + stop_app + mkdir -p target + # Deliberately NOT setsid: setsid forks when it is not already a process-group leader, + # so $! would be the PID of a process that exits immediately and the JVM would survive + # every later stop_app. A surviving JVM keeps the port, the next scenario fails to bind, + # and curl answers from the previous scenario -- which reads exactly like the + # configuration change under test having had no effect. Three wrong findings in this + # repository came from that before it was tracked down. + if [ -n "${APP_ENV:-}" ]; then + # shellcheck disable=SC2086 + env $APP_ENV nohup java -jar "$JAR" "$@" > /tmp/aop-demo.log 2>&1 < /dev/null & + else + nohup java -jar "$JAR" "$@" > /tmp/aop-demo.log 2>&1 < /dev/null & + fi + echo $! > target/app.pid + for _ in $(seq 1 60); do + curl -s -o /dev/null "http://127.0.0.1:${APP_PORT}/aop/proxies" 2>/dev/null && return 0 + kill -0 "$(cat target/app.pid)" 2>/dev/null || { echo "JVM exited during startup:" + tail -20 /tmp/aop-demo.log; return 1; } + sleep 1 + done + echo "application did not answer"; tail -20 /tmp/aop-demo.log; return 1 +} + +# Stop it by recorded PID. Never by pattern: `ps | grep ` also matches the shell +# running the script, because the jar name is on that shell's own command line. +stop_app() { + if [ -f target/app.pid ]; then + pid=$(cat target/app.pid) + if [ -n "$pid" ] && grep -qa "spring-aop-demo" "/proc/$pid/cmdline" 2>/dev/null; then + kill -9 "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true # reap, so bash prints no "Killed" notice + fi + rm -f target/app.pid + fi + for _ in $(seq 1 40); do + if ! (exec 3<>/dev/tcp/127.0.0.1/"${APP_PORT:-8080}") 2>/dev/null; then break; fi + sleep 0.25 + done + exec 3<&- 2>/dev/null || true +} + +# Print the precedence report for one property, compactly. diff --git a/spring-aop/scripts/run-all.sh b/spring-aop/scripts/run-all.sh new file mode 100755 index 0000000..ae5f8c4 --- /dev/null +++ b/spring-aop/scripts/run-all.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Regenerate every transcript under docs/output/. +set -euo pipefail +cd "$(dirname "$0")/.." +source scripts/env.sh + +"$MVN" -B -q package -DskipTests + +for demo in versions designators proxy-types broken-gallery; do + echo "=== $demo ===" + "scripts/demo-$demo.sh" > /dev/null +done +stop_app +echo +echo "regenerated:" +ls -1 docs/output/ diff --git a/spring-aop/src/main/java/com/ankurm/aop/AopApplication.java b/spring-aop/src/main/java/com/ankurm/aop/AopApplication.java new file mode 100644 index 0000000..a7215e6 --- /dev/null +++ b/spring-aop/src/main/java/com/ankurm/aop/AopApplication.java @@ -0,0 +1,20 @@ +package com.ankurm.aop; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * Companion application for the ankurm.com article + * "Spring AOP Explained: Pointcuts, Advice Types, and Why Your Aspect Isn't Firing". + * + *

Two halves. {@code aspect/} and {@code service/} contain aspects that work, one per + * pointcut designator, so the designator reference in the article is generated from real + * matches. {@code broken/} contains aspects that do not fire, each for a different reason, + * each paired with the fix. + */ +@SpringBootApplication +public class AopApplication { + public static void main(String[] args) { + SpringApplication.run(AopApplication.class, args); + } +} diff --git a/spring-aop/src/main/java/com/ankurm/aop/aspect/DesignatorAspect.java b/spring-aop/src/main/java/com/ankurm/aop/aspect/DesignatorAspect.java new file mode 100644 index 0000000..684e4f7 --- /dev/null +++ b/spring-aop/src/main/java/com/ankurm/aop/aspect/DesignatorAspect.java @@ -0,0 +1,141 @@ +package com.ankurm.aop.aspect; + +import org.aspectj.lang.JoinPoint; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Before; +import org.aspectj.lang.annotation.Pointcut; + +import com.ankurm.aop.service.Audited; +import com.ankurm.aop.service.Marker; +import com.ankurm.aop.service.OrderService; +import com.ankurm.aop.service.Payload; + +import org.springframework.stereotype.Component; + +/** + * One advice per pointcut designator Spring AOP supports. Ten designators, ten pieces of + * advice, all on the same small set of beans, so the article's reference table can be + * generated from {@code /aop/designators} rather than transcribed. + * + *

The designators fall into three groups worth keeping straight: + *

    + *
  • Signature matching — {@code execution}, {@code within}. + * Evaluated statically against the method signature.
  • + *
  • Runtime type matching — {@code this}, {@code target}, + * {@code args}, {@code @target}, {@code @args}. These force a runtime check on every + * candidate call, which is why they cost more than they look like they should.
  • + *
  • Annotation and bean matching — {@code @within}, + * {@code @annotation}, {@code bean}. The last is Spring's own, not AspectJ's.
  • + *
+ */ +@Aspect +@Component +public class DesignatorAspect { + + private final MatchRecorder recorder; + + public DesignatorAspect(MatchRecorder recorder) { + this.recorder = recorder; + } + + /** + * A named pointcut, so the same expression is not repeated in six places. Naming + * pointcuts is the single biggest readability win available in Spring AOP. + */ + @Pointcut("within(com.ankurm.aop.service..*)") + public void inServiceLayer() { + } + + // -- signature matching ------------------------------------------------------------ + + /** Matches method executions by signature. The workhorse; everything else is a filter. */ + @Before("execution(public String com.ankurm.aop.service.OrderService.place(String, int))") + public void executionByFullSignature(JoinPoint jp) { + record("execution (full signature)", jp); + } + + /** The same designator with wildcards, which is how it is normally written. */ + @Before("execution(* com.ankurm.aop.service.*Service.cancel(..))") + public void executionWithWildcards(JoinPoint jp) { + record("execution (wildcards)", jp); + } + + /** Limits matching to join points inside a type. Static: no runtime test. */ + @Before("within(com.ankurm.aop.service.InventoryService)") + public void withinType(JoinPoint jp) { + record("within", jp); + } + + // -- runtime type matching --------------------------------------------------------- + + /** + * {@code this} tests the PROXY. With a JDK proxy the proxy implements the interface but + * is not an instance of the implementation class, so {@code this(DefaultOrderService)} + * does not match. With a CGLIB proxy it does, because the proxy is a subclass. + * That difference is measured in {@code docs/output/03-proxy-types.txt}. + */ + @Before("this(com.ankurm.aop.service.OrderService) && inServiceLayer()") + public void thisProxyIsOrderService(JoinPoint jp) { + record("this(OrderService)", jp); + } + + /** {@code target} tests the object BEHIND the proxy, so the proxy type is irrelevant. */ + @Before("target(com.ankurm.aop.service.DefaultOrderService)") + public void targetIsImplementation(JoinPoint jp) { + record("target(DefaultOrderService)", jp); + } + + /** Matches on the runtime types of the arguments, and can bind them. */ + @Before("args(sku, quantity) && inServiceLayer()") + public void argsByType(JoinPoint jp, String sku, int quantity) { + record("args (bound: " + sku + "/" + quantity + ")", jp); + } + + /** The class of the executing object carries the annotation. Runtime test. */ + @Before("@target(com.ankurm.aop.service.Audited) && execution(* *.cancel(..))") + public void targetClassAnnotated(JoinPoint jp) { + record("@target(Audited)", jp); + } + + /** The runtime types of the arguments carry the annotation. */ + @Before("@args(com.ankurm.aop.service.Trackable) && inServiceLayer()") + public void argumentTypesAnnotated(JoinPoint jp) { + record("@args(Trackable)", jp); + } + + // -- annotation and bean matching -------------------------------------------------- + + /** Declaring type carries the annotation. Static, so cheaper than {@code @target}. */ + @Before("@within(com.ankurm.aop.service.Audited) && execution(* *.place(..))") + public void declaringTypeAnnotated(JoinPoint jp) { + record("@within(Audited)", jp); + } + + /** The method itself carries the annotation. The one most people reach for first. */ + @Before("@annotation(com.ankurm.aop.service.Marker)") + public void methodAnnotated(JoinPoint jp) { + record("@annotation(Marker)", jp); + } + + /** Spring's own designator: match by bean name, wildcards allowed. Not AspectJ. */ + @Before("bean(inventoryService)") + public void byBeanName(JoinPoint jp) { + record("bean(inventoryService)", jp); + } + + /** Wildcards work on bean names too, which is the usual reason to use this designator. */ + @Before("bean(*OrderService)") + public void byBeanNameWildcard(JoinPoint jp) { + record("bean(*OrderService)", jp); + } + + private void record(String designator, JoinPoint jp) { + recorder.record(designator, jp.getSignature().toShortString()); + } + + /** Referenced by {@code @args} so the argument type is used; keeps the compiler honest. */ + @SuppressWarnings("unused") + private static Payload unused(OrderService service, Marker marker, Audited audited) { + return null; + } +} diff --git a/spring-aop/src/main/java/com/ankurm/aop/aspect/MatchRecorder.java b/spring-aop/src/main/java/com/ankurm/aop/aspect/MatchRecorder.java new file mode 100644 index 0000000..1565b5a --- /dev/null +++ b/spring-aop/src/main/java/com/ankurm/aop/aspect/MatchRecorder.java @@ -0,0 +1,30 @@ +package com.ankurm.aop.aspect; + +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; + +import org.springframework.stereotype.Component; + +/** + * Collects which designator matched which join point, so the designator table in the article + * is a record of what actually fired rather than a restatement of the reference documentation. + */ +@Component +public class MatchRecorder { + + private final Map> matches = new LinkedHashMap<>(); + + public void record(String designator, String joinPoint) { + matches.computeIfAbsent(designator, key -> new LinkedHashSet<>()).add(joinPoint); + } + + public Map> matches() { + return matches; + } + + public void clear() { + matches.clear(); + } +} diff --git a/spring-aop/src/main/java/com/ankurm/aop/broken/BrokenAspects.java b/spring-aop/src/main/java/com/ankurm/aop/broken/BrokenAspects.java new file mode 100644 index 0000000..273a0e7 --- /dev/null +++ b/spring-aop/src/main/java/com/ankurm/aop/broken/BrokenAspects.java @@ -0,0 +1,118 @@ +package com.ankurm.aop.broken; + +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Before; + +import com.ankurm.aop.aspect.MatchRecorder; + +import org.springframework.stereotype.Component; + +/** + * The gallery. Each aspect here is written the way it is commonly written and does not fire, + * for a different reason. {@code /aop/broken} exercises them all and reports which ones + * recorded a match, so the article's table of failure modes is generated from real misses. + * + *

The working versions live in {@link com.ankurm.aop.aspect.DesignatorAspect}; each entry + * below names the fix. + */ +public final class BrokenAspects { + + private BrokenAspects() { + } + + /** + * 1. The aspect is not a Spring bean. + * + *

{@code @Aspect} is an AspectJ annotation. It tells Spring how to interpret a bean it + * already has; it does not make the class into a bean. Without {@code @Component} (or an + * {@code @Bean} method, or a component-scan stereotype) the class is never instantiated + * and the pointcut is never registered. + * + *

This is the single most common cause, and the most invisible: there is no warning, + * because from Spring's point of view nothing was ever asked for. + * + *

Fix: add {@code @Component}. + */ + @Aspect + public static class NotABean { + @Before("execution(* com.ankurm.aop.service.*.*(..))") + public void neverRuns() { + Recorder.record("1. @Aspect without @Component"); + } + } + + /** + * 2. The pointcut expression does not match anything. + * + *

A pointcut that matches nothing is indistinguishable at runtime from an aspect that + * was never registered. Here the package is {@code com.ankurm.aop.services} — note + * the plural — which does not exist. AspectJ parses it happily: a package name that + * matches no type is not an error, it is an empty match set. + * + *

Fix: check the expression against + * {@code AspectJExpressionPointcut#matches} in a test, or start from + * {@code within(com.example..*)} and narrow. + */ + @Aspect + @Component + public static class PackageTypo { + @Before("execution(* com.ankurm.aop.services.*.*(..))") + public void neverRuns() { + Recorder.record("2. pointcut matches nothing (package typo)"); + } + } + + /** + * 3. Advising a private method. + * + *

Both proxy strategies work by dispatching through something that wraps the target: a + * JDK proxy implements the interface, a CGLIB proxy extends the class. Neither can + * intercept a private method, because neither can override one. The pointcut is legal and + * simply never matches. + * + *

Fix: make the method at least package-visible and call it from + * outside the object, or move it to a collaborator. + */ + @Aspect + @Component + public static class PrivateMethod { + @Before("execution(private * com.ankurm.aop.service.InventoryService.hidden(..))") + public void neverRuns() { + Recorder.record("3. advice on a private method"); + } + } + + /** + * 4. Advising a final method. + * + *

Spring Boot proxies with CGLIB by default, which subclasses the target. A final + * method cannot be overridden, so the subclass inherits the original and calls go + * straight to it. No error is raised for a final method — only a final class + * fails loudly, because then the subclass itself is impossible. + * + *

Fix: remove {@code final}, or proxy by interface instead. + */ + @Aspect + @Component + public static class FinalMethod { + @Before("execution(* com.ankurm.aop.service.InventoryService.finalCheck(..))") + public void neverRuns() { + Recorder.record("4. advice on a final method"); + } + } + + /** Static hand-off so the broken aspects can report without each taking a constructor. */ + static final class Recorder { + private static MatchRecorder delegate; + + static void bind(MatchRecorder recorder) { + delegate = recorder; + } + + static void record(String what) { + if (delegate != null) { + delegate.record("FIRED " + what, "unexpected"); + } + } + } +} diff --git a/spring-aop/src/main/java/com/ankurm/aop/broken/NewedUpService.java b/spring-aop/src/main/java/com/ankurm/aop/broken/NewedUpService.java new file mode 100644 index 0000000..9b72c5f --- /dev/null +++ b/spring-aop/src/main/java/com/ankurm/aop/broken/NewedUpService.java @@ -0,0 +1,22 @@ +package com.ankurm.aop.broken; + +import com.ankurm.aop.service.Marker; + +/** + * 6. The object was never a bean. + * + *

Spring AOP advises beans. An instance created with {@code new} — in a helper, in a + * factory, in a test — has no proxy around it and never will, no matter how many + * annotations it carries. The annotation is a request to the container, and the container + * was not involved. + * + *

Fix: get the instance from the container. If it genuinely must be + * constructed by hand and still advised, that is what AspectJ load-time weaving is for. + */ +public class NewedUpService { + + @Marker + public String work() { + return "worked"; + } +} diff --git a/spring-aop/src/main/java/com/ankurm/aop/broken/SelfInvokingService.java b/spring-aop/src/main/java/com/ankurm/aop/broken/SelfInvokingService.java new file mode 100644 index 0000000..ec8583c --- /dev/null +++ b/spring-aop/src/main/java/com/ankurm/aop/broken/SelfInvokingService.java @@ -0,0 +1,37 @@ +package com.ankurm.aop.broken; + +import com.ankurm.aop.service.Marker; + +import org.springframework.stereotype.Service; + +/** + * 5. Self-invocation. + * + *

The most expensive failure mode in the gallery, because the code looks correct and the + * annotation is right there. {@link #outer()} is called through the proxy, so advice on it + * runs. The call it then makes to {@link #inner()} is a plain {@code this.inner()} on the + * target object — the proxy is not involved, so advice on {@code inner()} never runs. + * + *

This is the same mechanism that makes {@code @Transactional} and {@code @Cacheable} + * silently do nothing on internal calls, which is why it is worth understanding once rather + * than three times. + * + *

Fix: move {@code inner()} to another bean. Failing that, inject the + * bean into itself and call through that reference, or use + * {@code AopContext.currentProxy()} with {@code exposeProxy = true} — both work and + * both are worse. + */ +@Service +public class SelfInvokingService { + + @Marker + public String outer() { + return "outer -> " + inner(); + } + + /** Advised in principle. Never advised in practice when reached from {@link #outer()}. */ + @Marker + public String inner() { + return "inner"; + } +} diff --git a/spring-aop/src/main/java/com/ankurm/aop/service/Audited.java b/spring-aop/src/main/java/com/ankurm/aop/service/Audited.java new file mode 100644 index 0000000..c7a3109 --- /dev/null +++ b/spring-aop/src/main/java/com/ankurm/aop/service/Audited.java @@ -0,0 +1,12 @@ +package com.ankurm.aop.service; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** Type-level annotation, matched by {@code @within(..)} and {@code @target(..)}. */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface Audited { +} diff --git a/spring-aop/src/main/java/com/ankurm/aop/service/DefaultOrderService.java b/spring-aop/src/main/java/com/ankurm/aop/service/DefaultOrderService.java new file mode 100644 index 0000000..2e0363b --- /dev/null +++ b/spring-aop/src/main/java/com/ankurm/aop/service/DefaultOrderService.java @@ -0,0 +1,40 @@ +package com.ankurm.aop.service; + +import org.springframework.stereotype.Service; + +/** + * Implements an interface, so by default Spring AOP proxies it with a JDK dynamic proxy + * — except that Spring Boot flips the global default to class-based proxies. Which + * one you actually get is measured, not assumed: see + * {@code docs/output/03-proxy-types.txt}. + */ +@Service +@Audited +public class DefaultOrderService implements OrderService { + + @Override + @Marker + public String place(String sku, int quantity) { + return "placed " + quantity + " x " + sku; + } + + @Override + public String cancel(String id) { + return "cancelled " + id; + } + + @Override + public String slow() { + try { + Thread.sleep(25); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } + return "slept"; + } + + /** Not on the interface. A JDK proxy cannot intercept this; a CGLIB proxy can. */ + public String interfaceless(Payload payload) { + return "handled " + payload.value(); + } +} diff --git a/spring-aop/src/main/java/com/ankurm/aop/service/InventoryService.java b/spring-aop/src/main/java/com/ankurm/aop/service/InventoryService.java new file mode 100644 index 0000000..10fd580 --- /dev/null +++ b/spring-aop/src/main/java/com/ankurm/aop/service/InventoryService.java @@ -0,0 +1,26 @@ +package com.ankurm.aop.service; + +import org.springframework.stereotype.Service; + +/** + * No interface, so this bean can only be proxied by CGLIB, and only its non-final, + * non-private methods can be advised. + */ +@Service +public class InventoryService { + + public String reserve(String sku) { + return "reserved " + sku; + } + + /** CGLIB proxies by subclassing. A final method cannot be overridden, so it cannot be advised. */ + public final String finalCheck(String sku) { + return "checked " + sku; + } + + /** Neither can a private one. Included so the article can show both misses in one class. */ + @SuppressWarnings("unused") + private String hidden() { + return "hidden"; + } +} diff --git a/spring-aop/src/main/java/com/ankurm/aop/service/Marker.java b/spring-aop/src/main/java/com/ankurm/aop/service/Marker.java new file mode 100644 index 0000000..bd12dab --- /dev/null +++ b/spring-aop/src/main/java/com/ankurm/aop/service/Marker.java @@ -0,0 +1,12 @@ +package com.ankurm.aop.service; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** Method-level annotation, matched by {@code @annotation(..)}. */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface Marker { +} diff --git a/spring-aop/src/main/java/com/ankurm/aop/service/OrderService.java b/spring-aop/src/main/java/com/ankurm/aop/service/OrderService.java new file mode 100644 index 0000000..f004b08 --- /dev/null +++ b/spring-aop/src/main/java/com/ankurm/aop/service/OrderService.java @@ -0,0 +1,8 @@ +package com.ankurm.aop.service; + +/** Interface, so this bean can be proxied by a JDK dynamic proxy. */ +public interface OrderService { + String place(String sku, int quantity); + String cancel(String id); + String slow(); +} diff --git a/spring-aop/src/main/java/com/ankurm/aop/service/Payload.java b/spring-aop/src/main/java/com/ankurm/aop/service/Payload.java new file mode 100644 index 0000000..16ba58d --- /dev/null +++ b/spring-aop/src/main/java/com/ankurm/aop/service/Payload.java @@ -0,0 +1,6 @@ +package com.ankurm.aop.service; + +/** Argument type carrying a type annotation, so {@code @args(..)} has something to match. */ +@Trackable +public record Payload(String value) { +} diff --git a/spring-aop/src/main/java/com/ankurm/aop/service/Trackable.java b/spring-aop/src/main/java/com/ankurm/aop/service/Trackable.java new file mode 100644 index 0000000..708aabd --- /dev/null +++ b/spring-aop/src/main/java/com/ankurm/aop/service/Trackable.java @@ -0,0 +1,12 @@ +package com.ankurm.aop.service; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** Parameter-type annotation, matched by {@code @args(..)}. */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface Trackable { +} diff --git a/spring-aop/src/main/java/com/ankurm/aop/web/AopDiagnosticsEndpoint.java b/spring-aop/src/main/java/com/ankurm/aop/web/AopDiagnosticsEndpoint.java new file mode 100644 index 0000000..fbaa864 --- /dev/null +++ b/spring-aop/src/main/java/com/ankurm/aop/web/AopDiagnosticsEndpoint.java @@ -0,0 +1,98 @@ +package com.ankurm.aop.web; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import com.ankurm.aop.aspect.MatchRecorder; +import com.ankurm.aop.service.DefaultOrderService; +import com.ankurm.aop.service.InventoryService; +import com.ankurm.aop.service.OrderService; +import com.ankurm.aop.service.Payload; + +import org.springframework.aop.framework.Advised; +import org.springframework.aop.support.AopUtils; +import org.springframework.context.ApplicationContext; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Prints what the container actually built, which is the fastest way to answer "why isn't my + * aspect firing". + * + *

Almost every non-firing aspect is one of three things, and all three are visible here: + * the bean is not proxied at all, the bean is proxied by the wrong kind of proxy, or the + * advisor list attached to the proxy does not contain your advice. Guessing between them + * wastes an afternoon; reading them takes a second. + * + *

Documented in {@code docs/06-diagnosing-a-silent-aspect.md}. Delete before shipping. + */ +@RestController +public class AopDiagnosticsEndpoint { + + private final ApplicationContext context; + private final OrderService orderService; + private final InventoryService inventoryService; + private final MatchRecorder recorder; + + public AopDiagnosticsEndpoint(ApplicationContext context, OrderService orderService, + InventoryService inventoryService, MatchRecorder recorder) { + this.context = context; + this.orderService = orderService; + this.inventoryService = inventoryService; + this.recorder = recorder; + } + + /** For each interesting bean: is it a proxy, which kind, and what advice is attached. */ + @GetMapping("/aop/proxies") + public List> proxies() { + List> rows = new ArrayList<>(); + for (String name : List.of("defaultOrderService", "inventoryService", + "selfInvokingService", "newedUpService")) { + if (!context.containsBean(name)) { + continue; + } + Object bean = context.getBean(name); + Map row = new LinkedHashMap<>(); + row.put("bean", name); + row.put("class", bean.getClass().getName()); + row.put("isProxy", AopUtils.isAopProxy(bean)); + row.put("proxyKind", AopUtils.isJdkDynamicProxy(bean) ? "JDK dynamic proxy" + : AopUtils.isCglibProxy(bean) ? "CGLIB subclass" : "not proxied"); + row.put("targetClass", AopUtils.getTargetClass(bean).getName()); + if (bean instanceof Advised advised) { + row.put("advisorCount", advised.getAdvisors().length); + row.put("advisors", java.util.Arrays.stream(advised.getAdvisors()) + .map(a -> a.getAdvice().getClass().getSimpleName()).toList()); + row.put("proxiedInterfaces", java.util.Arrays.stream(advised.getProxiedInterfaces()) + .map(Class::getSimpleName).toList()); + } + rows.add(row); + } + return rows; + } + + /** Exercise every advised method, then report which designator matched what. */ + @GetMapping("/aop/designators") + public Map designators() { + recorder.clear(); + + orderService.place("SKU-1", 2); + orderService.cancel("ORD-9"); + inventoryService.reserve("SKU-1"); + inventoryService.finalCheck("SKU-1"); + if (orderService instanceof DefaultOrderService concrete) { + concrete.interfaceless(new Payload("p")); + } + + Map result = new LinkedHashMap<>(); + result.put("orderServiceProxyKind", + AopUtils.isJdkDynamicProxy(orderService) ? "JDK dynamic proxy" + : AopUtils.isCglibProxy(orderService) ? "CGLIB subclass" : "not proxied"); + result.put("orderServiceIsDefaultOrderServiceInstance", + orderService instanceof DefaultOrderService); + result.put("matches", recorder.matches()); + return result; + } +} diff --git a/spring-aop/src/main/java/com/ankurm/aop/web/BrokenGalleryEndpoint.java b/spring-aop/src/main/java/com/ankurm/aop/web/BrokenGalleryEndpoint.java new file mode 100644 index 0000000..0f7e540 --- /dev/null +++ b/spring-aop/src/main/java/com/ankurm/aop/web/BrokenGalleryEndpoint.java @@ -0,0 +1,90 @@ +package com.ankurm.aop.web; + +import java.util.LinkedHashMap; +import java.util.Map; + +import com.ankurm.aop.aspect.MatchRecorder; +import com.ankurm.aop.broken.NewedUpService; +import com.ankurm.aop.broken.SelfInvokingService; +import com.ankurm.aop.service.InventoryService; + +import org.springframework.aop.support.AopUtils; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Exercises every entry in the broken-aspect gallery and reports whether its advice fired. + * + *

Every row should read {@code advice fired: false}. A row that reads {@code true} means + * the failure mode it documents no longer exists in this Spring version, which would be worth + * knowing about. + */ +@RestController +public class BrokenGalleryEndpoint { + + private final SelfInvokingService selfInvoking; + private final InventoryService inventory; + private final MatchRecorder recorder; + + public BrokenGalleryEndpoint(SelfInvokingService selfInvoking, InventoryService inventory, + MatchRecorder recorder) { + this.selfInvoking = selfInvoking; + this.inventory = inventory; + this.recorder = recorder; + } + + @GetMapping("/aop/broken") + public Map broken() { + Map result = new LinkedHashMap<>(); + + // 5. Self-invocation. outer() is advised; the inner() it calls is not. + recorder.clear(); + String viaOuter = selfInvoking.outer(); + boolean innerAdvisedViaOuter = recorder.matches().values().stream() + .flatMap(java.util.Set::stream).anyMatch(s -> s.contains("inner")); + + // The same method called directly through the proxy IS advised -- proof that the + // method is advisable and only the call path was the problem. + recorder.clear(); + selfInvoking.inner(); + boolean innerAdvisedDirectly = recorder.matches().values().stream() + .flatMap(java.util.Set::stream).anyMatch(s -> s.contains("inner")); + + Map selfInvocation = new LinkedHashMap<>(); + selfInvocation.put("beanIsProxied", AopUtils.isAopProxy(selfInvoking)); + selfInvocation.put("result", viaOuter); + selfInvocation.put("innerAdvisedWhenCalledFromOuter", innerAdvisedViaOuter); + selfInvocation.put("innerAdvisedWhenCalledDirectly", innerAdvisedDirectly); + result.put("5-self-invocation", selfInvocation); + + // 6. An instance created with new is not a bean and is not proxied. + NewedUpService newed = new NewedUpService(); + recorder.clear(); + newed.work(); + Map newedUp = new LinkedHashMap<>(); + newedUp.put("isProxy", AopUtils.isAopProxy(newed)); + newedUp.put("adviceFired", !recorder.matches().isEmpty()); + result.put("6-created-with-new", newedUp); + + // 3 and 4. Private and final methods on a proxied bean. + recorder.clear(); + inventory.finalCheck("SKU-1"); + Map finalMethod = new LinkedHashMap<>(); + finalMethod.put("beanIsProxied", AopUtils.isAopProxy(inventory)); + finalMethod.put("proxyKind", AopUtils.isCglibProxy(inventory) ? "CGLIB subclass" : "other"); + finalMethod.put("adviceFired", !recorder.matches().isEmpty()); + result.put("4-final-method", finalMethod); + + // 1 and 2 cannot fire by construction; report that nothing recorded a FIRED marker. + result.put("1-aspect-without-component-fired", recorder.matches().keySet().stream() + .anyMatch(k -> k.startsWith("FIRED 1"))); + result.put("2-pointcut-typo-fired", recorder.matches().keySet().stream() + .anyMatch(k -> k.startsWith("FIRED 2"))); + result.put("3-private-method-fired", recorder.matches().keySet().stream() + .anyMatch(k -> k.startsWith("FIRED 3"))); + + result.put("note", "every value above should be false except the two that prove the " + + "method is advisable when reached through the proxy"); + return result; + } +} diff --git a/spring-aop/src/main/java/com/ankurm/aop/web/PointcutParserEndpoint.java b/spring-aop/src/main/java/com/ankurm/aop/web/PointcutParserEndpoint.java new file mode 100644 index 0000000..d0bee1c --- /dev/null +++ b/spring-aop/src/main/java/com/ankurm/aop/web/PointcutParserEndpoint.java @@ -0,0 +1,83 @@ +package com.ankurm.aop.web; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import com.ankurm.aop.service.DefaultOrderService; + +import org.springframework.aop.aspectj.AspectJExpressionPointcut; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Asks the pointcut parser directly which designators it accepts, and what it says when it + * refuses one. + * + *

The list of unsupported designators is documented, but the error you get is not, and the + * error is what you will actually be looking at. Being able to recognise it saves the ten + * minutes it otherwise takes to work out that {@code call()} is an AspectJ designator Spring + * AOP has never supported. + */ +@RestController +public class PointcutParserEndpoint { + + /** Designators the reference documentation lists as unsupported in Spring AOP. */ + private static final List UNSUPPORTED = List.of( + "call(* com.ankurm.aop.service.OrderService.place(..))", + "get(* com.ankurm.aop.service.*.*)", + "set(* com.ankurm.aop.service.*.*)", + "initialization(com.ankurm.aop.service.*.new(..))", + "staticinitialization(com.ankurm.aop.service.*)", + "preinitialization(com.ankurm.aop.service.*.new(..))", + "handler(java.lang.Exception)", + "adviceexecution()", + "withincode(* com.ankurm.aop.service.*.*(..))", + "cflow(execution(* com.ankurm.aop.service.*.*(..)))", + "cflowbelow(execution(* com.ankurm.aop.service.*.*(..)))", + "if()", + "@this(com.ankurm.aop.service.Audited)", + "@withincode(com.ankurm.aop.service.Marker)"); + + private static final List SUPPORTED = List.of( + "execution(* com.ankurm.aop.service.OrderService.place(..))", + "within(com.ankurm.aop.service..*)", + "this(com.ankurm.aop.service.OrderService)", + "target(com.ankurm.aop.service.DefaultOrderService)", + "args(String, int)", + "@target(com.ankurm.aop.service.Audited)", + "@args(com.ankurm.aop.service.Trackable)", + "@within(com.ankurm.aop.service.Audited)", + "@annotation(com.ankurm.aop.service.Marker)", + "bean(defaultOrderService)"); + + @GetMapping("/aop/parser") + public Map parser() throws Exception { + Map result = new LinkedHashMap<>(); + result.put("supported", SUPPORTED.stream().map(this::probe).toList()); + result.put("unsupported", UNSUPPORTED.stream().map(this::probe).toList()); + return result; + } + + /** Parse and evaluate one expression, reporting what the parser did with it. */ + private Map probe(String expression) { + Map row = new LinkedHashMap<>(); + row.put("expression", expression); + try { + AspectJExpressionPointcut pointcut = new AspectJExpressionPointcut(); + pointcut.setExpression(expression); + // Force evaluation: setExpression only stores the string, so an expression is not + // rejected until something asks it to match. + pointcut.matches(DefaultOrderService.class.getMethod("place", String.class, int.class), + DefaultOrderService.class); + row.put("accepted", true); + } catch (Exception ex) { + row.put("accepted", false); + row.put("exception", ex.getClass().getName()); + String message = ex.getMessage(); + row.put("message", message == null ? null + : message.length() > 220 ? message.substring(0, 220) + "..." : message); + } + return row; + } +} diff --git a/spring-aop/src/main/resources/application.yaml b/spring-aop/src/main/resources/application.yaml new file mode 100644 index 0000000..e40b887 --- /dev/null +++ b/spring-aop/src/main/resources/application.yaml @@ -0,0 +1,10 @@ +spring: + application: + name: spring-aop-demo + +server: + port: 8080 + +logging: + level: + root: WARN diff --git a/spring-aop/src/test/java/com/ankurm/aop/AopContractTests.java b/spring-aop/src/test/java/com/ankurm/aop/AopContractTests.java new file mode 100644 index 0000000..a353f8d --- /dev/null +++ b/spring-aop/src/test/java/com/ankurm/aop/AopContractTests.java @@ -0,0 +1,104 @@ +package com.ankurm.aop; + +import com.ankurm.aop.aspect.MatchRecorder; +import com.ankurm.aop.broken.NewedUpService; +import com.ankurm.aop.broken.SelfInvokingService; +import com.ankurm.aop.service.DefaultOrderService; +import com.ankurm.aop.service.InventoryService; +import com.ankurm.aop.service.OrderService; + +import org.aspectj.weaver.tools.UnsupportedPointcutPrimitiveException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import org.springframework.aop.aspectj.AspectJExpressionPointcut; +import org.springframework.aop.support.AopUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +/** Pins the claims the AOP article makes about proxies, designators and failure modes. */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) +class AopContractTests { + + @Autowired OrderService orderService; + @Autowired InventoryService inventoryService; + @Autowired SelfInvokingService selfInvoking; + @Autowired MatchRecorder recorder; + + @Test + @DisplayName("Spring Boot proxies with CGLIB even when the bean implements an interface") + void bootDefaultsToCglib() { + assertThat(AopUtils.isCglibProxy(orderService)).isTrue(); + assertThat(orderService).isInstanceOf(DefaultOrderService.class); + } + + @Test + @DisplayName("self-invocation: the inner call is not advised, the direct call is") + void selfInvocationSkipsAdvice() { + recorder.clear(); + selfInvoking.outer(); + boolean viaOuter = recorder.matches().values().stream() + .flatMap(java.util.Set::stream).anyMatch(s -> s.contains("inner")); + + recorder.clear(); + selfInvoking.inner(); + boolean direct = recorder.matches().values().stream() + .flatMap(java.util.Set::stream).anyMatch(s -> s.contains("inner")); + + assertThat(viaOuter).as("reached through this.inner() -- proxy not involved").isFalse(); + assertThat(direct).as("reached through the proxy -- advice runs").isTrue(); + } + + @Test + @DisplayName("an object created with new is never advised") + void newedUpObjectIsNotAdvised() { + NewedUpService service = new NewedUpService(); + recorder.clear(); + service.work(); + assertThat(AopUtils.isAopProxy(service)).isFalse(); + assertThat(recorder.matches()).isEmpty(); + } + + @Test + @DisplayName("a final method on a proxied bean is not advised, and no error is raised") + void finalMethodIsNotAdvised() { + assertThat(AopUtils.isAopProxy(inventoryService)).isTrue(); + recorder.clear(); + inventoryService.finalCheck("SKU-1"); + assertThat(recorder.matches()).isEmpty(); + } + + /** + * The reference documentation states that an unsupported designator produces an + * {@code IllegalArgumentException}. It does not. Catching that type will not catch this. + */ + @Test + @DisplayName("unsupported designators throw UnsupportedPointcutPrimitiveException, not IAE") + void unsupportedDesignatorExceptionType() throws Exception { + AspectJExpressionPointcut pointcut = new AspectJExpressionPointcut(); + pointcut.setExpression("call(* com.ankurm.aop.service.OrderService.place(..))"); + + assertThatExceptionOfType(UnsupportedPointcutPrimitiveException.class) + .isThrownBy(() -> pointcut.matches( + DefaultOrderService.class.getMethod("place", String.class, int.class), + DefaultOrderService.class)) + .withMessageContaining("unsupported pointcut primitive 'call'"); + + assertThat(UnsupportedPointcutPrimitiveException.class) + .as("it extends RuntimeException directly, not IllegalArgumentException") + .hasSuperclass(RuntimeException.class); + } + + @Test + @DisplayName("a pointcut naming a package that does not exist parses and matches nothing") + void pointcutTypoIsSilent() throws Exception { + AspectJExpressionPointcut pointcut = new AspectJExpressionPointcut(); + pointcut.setExpression("execution(* com.ankurm.aop.services.*.*(..))"); // plural + assertThat(pointcut.matches( + DefaultOrderService.class.getMethod("place", String.class, int.class), + DefaultOrderService.class)).isFalse(); + } +}