Three new article modules: configuration binding, profiles and config data, Spring AOP
configuration-properties/ @ConfigurationProperties vs @Value on Spring Boot 4.1.1. The relaxed-binding matrix is generated by binding each spelling rather than transcribed, and re-checked against real processes -- the in-process probe was wrong twice before it was right. Records the three findings that came out of it: @Value does get relaxed resolution inside Spring Boot (Boot attaches ConfigurationPropertySources), the configuration processor silently stops generating metadata on JDK 23+ when declared as a plain dependency, and @Valid is not what makes nested constraints run. profiles-and-config/ Precedence, profiles, spring.config.import and config trees. /precedence reports every source holding a property in rank order with file and line, which turns "my profile file had no effect" into a two-line answer. Also pins the counterintuitive one: an imported file outranks the file that imported it. spring-aop/ Designators, proxy types, and aspects that do not fire. One advice per supported designator so the reference table is generated from real matches; all fourteen unsupported designators fed to the parser. Two corrections to the reference documentation: unsupported designators throw UnsupportedPointcutPrimitiveException (extends RuntimeException, not IllegalArgumentException), and spring-boot-starter-aop was renamed to spring-boot-starter-aspectj in Boot 4. 19 contract tests across the three modules, 15 captured transcripts, all regenerated by scripts/run-all.sh. Verified on Spring Boot 4.1.1, Spring Framework 7.0.9, JDK 25.0.4.1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gip4srpzMwjgoba6uEfbr5
This commit is contained in:
@@ -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 |
|
| [`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 |
|
| [`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
|
Articles whose text is kept here rather than only on the blog have it under
|
||||||
`<directory>/post/` — `post.md` for the body and `meta.md` for the title, excerpt and
|
`<directory>/post/` — `post.md` for the body and `meta.md` for the title, excerpt and
|
||||||
|
|||||||
76
configuration-properties/README.md
Normal file
76
configuration-properties/README.md
Normal file
@@ -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.
|
||||||
49
configuration-properties/docs/01-two-mechanisms.md
Normal file
49
configuration-properties/docs/01-two-mechanisms.md
Normal file
@@ -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.
|
||||||
62
configuration-properties/docs/02-relaxed-binding.md
Normal file
62
configuration-properties/docs/02-relaxed-binding.md
Normal file
@@ -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.
|
||||||
45
configuration-properties/docs/03-registration.md
Normal file
45
configuration-properties/docs/03-registration.md
Normal file
@@ -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.
|
||||||
62
configuration-properties/docs/04-records-and-defaults.md
Normal file
62
configuration-properties/docs/04-records-and-defaults.md
Normal file
@@ -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<String> recipients, // empty list, not null
|
||||||
|
@DefaultValue Map<String, String> 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.
|
||||||
55
configuration-properties/docs/05-validation.md
Normal file
55
configuration-properties/docs/05-validation.md
Normal file
@@ -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.
|
||||||
42
configuration-properties/docs/06-when-value-still-wins.md
Normal file
42
configuration-properties/docs/06-when-value-still-wins.md
Normal file
@@ -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).
|
||||||
88
configuration-properties/docs/07-ide-metadata.md
Normal file
88
configuration-properties/docs/07-ide-metadata.md
Normal file
@@ -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
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-configuration-processor</artifactId>
|
||||||
|
<optional>true</optional>
|
||||||
|
</dependency>
|
||||||
|
```
|
||||||
|
|
||||||
|
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 <deps>:spring-boot-configuration-processor.jar -d a $SOURCES
|
||||||
|
spring-configuration-metadata.json files produced: 0
|
||||||
|
|
||||||
|
B) javac -proc:full -cp <deps>: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
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-compiler-plugin</artifactId>
|
||||||
|
<configuration>
|
||||||
|
<annotationProcessorPaths>
|
||||||
|
<path>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-configuration-processor</artifactId>
|
||||||
|
<version>${project.parent.version}</version>
|
||||||
|
</path>
|
||||||
|
</annotationProcessorPaths>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
```
|
||||||
|
|
||||||
|
`-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.
|
||||||
57
configuration-properties/docs/08-diagnosing-a-value.md
Normal file
57
configuration-properties/docs/08-diagnosing-a-value.md
Normal file
@@ -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.
|
||||||
7
configuration-properties/docs/output/00-versions.txt
Normal file
7
configuration-properties/docs/output/00-versions.txt
Normal file
@@ -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
|
||||||
22
configuration-properties/docs/output/01-relaxed-matrix.txt
Normal file
22
configuration-properties/docs/output/01-relaxed-matrix.txt
Normal file
@@ -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
|
||||||
43
configuration-properties/docs/output/02-env-var-binding.txt
Normal file
43
configuration-properties/docs/output/02-env-var-binding.txt
Normal file
@@ -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.
|
||||||
79
configuration-properties/docs/output/03-value-vs-binding.txt
Normal file
79
configuration-properties/docs/output/03-value-vs-binding.txt
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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 <optional> dependency gives you
|
||||||
|
$ javac -cp <deps>:spring-boot-configuration-processor.jar -d a $SOURCES
|
||||||
|
spring-configuration-metadata.json files produced: 0
|
||||||
|
|
||||||
|
B) identical, plus -proc:full
|
||||||
|
$ javac -proc:full -cp <deps>: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 <annotationProcessorPath> 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<java.lang.String,java.lang.String>",
|
||||||
|
"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",
|
||||||
75
configuration-properties/pom.xml
Normal file
75
configuration-properties/pom.xml
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-parent</artifactId>
|
||||||
|
<version>4.1.1</version>
|
||||||
|
<relativePath/>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<groupId>com.ankurm</groupId>
|
||||||
|
<artifactId>configuration-properties</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<name>configuration-properties</name>
|
||||||
|
<description>@ConfigurationProperties vs @Value: binding, validation and relaxed rules</description>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<java.version>25</java.version>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-webmvc</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<!-- Supplies the jakarta.validation API plus Hibernate Validator. Without it,
|
||||||
|
@Validated on a @ConfigurationProperties class is silently a no-op:
|
||||||
|
no validator is present, so nothing is checked. See docs/05-validation.md. -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-validation</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<!-- The configuration processor is declared HERE, as an annotationProcessorPath,
|
||||||
|
and deliberately NOT as an <optional> dependency.
|
||||||
|
|
||||||
|
On JDK 23 and later javac no longer discovers annotation processors on the
|
||||||
|
classpath (that discovery was deprecated in JDK 21 and switched off in 23).
|
||||||
|
A processor declared only as a dependency is therefore never run: the build
|
||||||
|
still succeeds, the jar is still valid, and META-INF/spring-configuration-
|
||||||
|
metadata.json is silently absent. The only visible symptom is that property
|
||||||
|
auto-completion stops working in the IDE.
|
||||||
|
|
||||||
|
docs/output/05-metadata-generation.txt is the A/B that proves it. -->
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-compiler-plugin</artifactId>
|
||||||
|
<configuration>
|
||||||
|
<annotationProcessorPaths>
|
||||||
|
<path>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-configuration-processor</artifactId>
|
||||||
|
<version>${project.parent.version}</version>
|
||||||
|
</path>
|
||||||
|
</annotationProcessorPaths>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
52
configuration-properties/scripts/demo-env-binding.sh
Executable file
52
configuration-properties/scripts/demo-env-binding.sh
Executable file
@@ -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
|
||||||
51
configuration-properties/scripts/demo-metadata-generation.sh
Executable file
51
configuration-properties/scripts/demo-metadata-generation.sh
Executable file
@@ -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 <optional> dependency gives you"
|
||||||
|
echo "\$ javac -cp <deps>: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 <deps>: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 <annotationProcessorPath> 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
|
||||||
9
configuration-properties/scripts/demo-relaxed-matrix.sh
Executable file
9
configuration-properties/scripts/demo-relaxed-matrix.sh
Executable file
@@ -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
|
||||||
22
configuration-properties/scripts/demo-validation.sh
Executable file
22
configuration-properties/scripts/demo-validation.sh
Executable file
@@ -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
|
||||||
34
configuration-properties/scripts/demo-value-vs-binding.sh
Executable file
34
configuration-properties/scripts/demo-value-vs-binding.sh
Executable file
@@ -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
|
||||||
16
configuration-properties/scripts/demo-versions.sh
Executable file
16
configuration-properties/scripts/demo-versions.sh
Executable file
@@ -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 '<artifactId>spring-boot-starter-parent' pom.xml | grep '<version>' | sed 's/.*<version>\(.*\)<\/version>.*/\1/')"
|
||||||
|
} > docs/output/00-versions.txt 2>&1
|
||||||
|
cat docs/output/00-versions.txt
|
||||||
13
configuration-properties/scripts/env.sh
Executable file
13
configuration-properties/scripts/env.sh
Executable file
@@ -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"; }
|
||||||
16
configuration-properties/scripts/run-all.sh
Executable file
16
configuration-properties/scripts/run-all.sh
Executable file
@@ -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/
|
||||||
34
configuration-properties/scripts/run.sh
Executable file
34
configuration-properties/scripts/run.sh
Executable file
@@ -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
|
||||||
28
configuration-properties/scripts/stop.sh
Executable file
28
configuration-properties/scripts/stop.sh
Executable file
@@ -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
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.ankurm.configprops;
|
||||||
|
|
||||||
|
import org.springframework.boot.SpringApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Companion application for the ankurm.com article
|
||||||
|
* "@ConfigurationProperties vs @Value in Spring Boot 4".
|
||||||
|
*
|
||||||
|
* <p>{@code @ConfigurationPropertiesScan} is what registers the {@code @ConfigurationProperties}
|
||||||
|
* types in {@code com.ankurm.configprops.props} as beans. Without it — and without
|
||||||
|
* {@code @EnableConfigurationProperties} or a stereotype annotation on each type — the
|
||||||
|
* classes compile, the application starts, and the beans simply do not exist. That is the first
|
||||||
|
* entry in the failure gallery: see {@code docs/03-registration.md}.
|
||||||
|
*/
|
||||||
|
@SpringBootApplication
|
||||||
|
@ConfigurationPropertiesScan
|
||||||
|
public class ConfigBindingApplication {
|
||||||
|
public static void main(String[] args) {
|
||||||
|
SpringApplication.run(ConfigBindingApplication.class, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package com.ankurm.configprops.props;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.boot.context.properties.bind.DefaultValue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor binding with a record — the shape this article recommends.
|
||||||
|
*
|
||||||
|
* <p>A record has exactly one canonical constructor, so Spring Boot uses constructor binding
|
||||||
|
* without any annotation. {@code @ConstructorBinding} is only needed to disambiguate when a
|
||||||
|
* type has more than one candidate constructor.
|
||||||
|
*
|
||||||
|
* <p>A record cannot declare field initialisers, so "default value" has to be expressed with
|
||||||
|
* {@link DefaultValue}. Leaving it off does not give you {@code null} for a primitive-like
|
||||||
|
* type — it gives you a binding failure. See {@code docs/04-records-and-defaults.md}.
|
||||||
|
*
|
||||||
|
* @param host SMTP host. No default: absent means the application must not start.
|
||||||
|
* @param port SMTP port, defaulted rather than left null.
|
||||||
|
* @param timeout Bound from an ISO-8601 duration or a suffixed form such as {@code 30s}.
|
||||||
|
* @param retries Nested record, bound from {@code demo.mail.retries.*}.
|
||||||
|
* @param recipients Bound from a YAML list, an indexed property list, or a comma-separated string.
|
||||||
|
* @param headers Bound from arbitrary sub-keys under {@code demo.mail.headers.*}.
|
||||||
|
*/
|
||||||
|
@ConfigurationProperties(prefix = "demo.mail")
|
||||||
|
public record MailProperties(
|
||||||
|
String host,
|
||||||
|
@DefaultValue("587") int port,
|
||||||
|
@DefaultValue("30s") Duration timeout,
|
||||||
|
@DefaultValue RetryProperties retries,
|
||||||
|
@DefaultValue List<String> recipients,
|
||||||
|
@DefaultValue Map<String, String> headers) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nested record. {@code @DefaultValue} on the enclosing component means this is
|
||||||
|
* instantiated with its own defaults when {@code demo.mail.retries.*} is absent entirely,
|
||||||
|
* rather than binding to {@code null}.
|
||||||
|
*/
|
||||||
|
public record RetryProperties(
|
||||||
|
@DefaultValue("3") int maxAttempts,
|
||||||
|
@DefaultValue("2s") Duration backoff) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.ankurm.configprops.props;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.boot.context.properties.bind.DefaultValue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One property, bound by the relaxed binder, used to prove which spellings actually work.
|
||||||
|
*
|
||||||
|
* <p>The canonical form of this property is {@code demo.relaxed.api-key}. The relaxed binder
|
||||||
|
* accepts several spellings of that name; {@code @Value} accepts exactly one. The matrix in
|
||||||
|
* {@code docs/02-relaxed-binding.md} is generated by
|
||||||
|
* {@code com.ankurm.configprops.web.RelaxedBindingProbe}, not written by hand.
|
||||||
|
*/
|
||||||
|
@ConfigurationProperties(prefix = "demo.relaxed")
|
||||||
|
public record RelaxedProperties(@DefaultValue("(unset)") String apiKey) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package com.ankurm.configprops.props;
|
||||||
|
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import jakarta.validation.constraints.Max;
|
||||||
|
import jakarta.validation.constraints.Min;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Pattern;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.boot.context.properties.bind.DefaultValue;
|
||||||
|
import org.springframework.validation.annotation.Validated;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation is the capability {@code @Value} does not have at all.
|
||||||
|
*
|
||||||
|
* <p>{@code @Validated} plus JSR-380 constraints turns a bad value into a startup failure with
|
||||||
|
* a readable report, instead of a {@code NumberFormatException} somewhere in a request three
|
||||||
|
* hours later. The failure text this produces is captured in
|
||||||
|
* {@code docs/output/04-validation-failure.txt}.
|
||||||
|
*
|
||||||
|
* <p>{@code @Valid} on the nested component is written here because it is conventional, but
|
||||||
|
* on this path it is not what makes the nested constraints run. Spring Boot's
|
||||||
|
* {@code ValidationBindHandler} validates every object the binder finishes constructing, so
|
||||||
|
* {@code demo.validated.pool.size} is checked with or without it. The rule that nested types
|
||||||
|
* need {@code @Valid} comes from ordinary bean validation, where cascading is opt-in, and it
|
||||||
|
* gets repeated about {@code @ConfigurationProperties} where it does not apply.
|
||||||
|
* {@code BindingContractTests.nestedConstraintsFireWithoutValid} pins this. See
|
||||||
|
* {@code docs/05-validation.md}.
|
||||||
|
*/
|
||||||
|
@Validated
|
||||||
|
@ConfigurationProperties(prefix = "demo.validated")
|
||||||
|
public record ValidatedProperties(
|
||||||
|
@NotBlank String name,
|
||||||
|
@Min(1) @Max(65535) int port,
|
||||||
|
@Pattern(regexp = "https?://.*") @DefaultValue("http://localhost") String endpoint,
|
||||||
|
@Valid @DefaultValue Pool pool) {
|
||||||
|
|
||||||
|
public record Pool(@Min(1) @Max(100) @DefaultValue("10") int size) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package com.ankurm.configprops.web;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import com.ankurm.configprops.props.MailProperties;
|
||||||
|
import com.ankurm.configprops.props.ValidatedProperties;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.source.ConfigurationPropertyName;
|
||||||
|
import org.springframework.boot.context.properties.source.ConfigurationPropertySource;
|
||||||
|
import org.springframework.boot.context.properties.source.ConfigurationPropertySources;
|
||||||
|
import org.springframework.boot.origin.Origin;
|
||||||
|
import org.springframework.core.env.ConfigurableEnvironment;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prints where a value actually came from, instead of only what it is.
|
||||||
|
*
|
||||||
|
* <p>"The property is set but the application does not see it" is the single most common
|
||||||
|
* configuration bug, and the reason it is hard is that a value carries no visible provenance.
|
||||||
|
* Spring Boot tracks it anyway: every property loaded from a file or a config tree carries an
|
||||||
|
* {@link Origin} naming the file and the line. This endpoint surfaces it.
|
||||||
|
*
|
||||||
|
* <p>Documented in {@code docs/08-diagnosing-a-value.md}. Delete it before shipping — it
|
||||||
|
* will happily print a password that came from a mounted secret.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
public class BindingDiagnosticsEndpoint {
|
||||||
|
|
||||||
|
private final ConfigurableEnvironment environment;
|
||||||
|
private final MailProperties mail;
|
||||||
|
private final ValidatedProperties validated;
|
||||||
|
private final ValueHolder valueHolder;
|
||||||
|
|
||||||
|
public BindingDiagnosticsEndpoint(ConfigurableEnvironment environment, MailProperties mail,
|
||||||
|
ValidatedProperties validated, ValueHolder valueHolder) {
|
||||||
|
this.environment = environment;
|
||||||
|
this.mail = mail;
|
||||||
|
this.validated = validated;
|
||||||
|
this.valueHolder = valueHolder;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every property source that can supply the given name, in precedence order, with the
|
||||||
|
* value each one holds and where that value was written.
|
||||||
|
*
|
||||||
|
* <p>The first row is the winner. Rows below it are values that exist and lose —
|
||||||
|
* which is what a "my change had no effect" bug looks like from the inside.
|
||||||
|
*/
|
||||||
|
@GetMapping("/diag/origin")
|
||||||
|
public Map<String, Object> origin(
|
||||||
|
@RequestParam(defaultValue = "demo.mail.host") String name) {
|
||||||
|
|
||||||
|
ConfigurationPropertyName propertyName = ConfigurationPropertyName.of(name);
|
||||||
|
List<Map<String, Object>> candidates = new ArrayList<>();
|
||||||
|
|
||||||
|
for (ConfigurationPropertySource source : ConfigurationPropertySources.get(environment)) {
|
||||||
|
var property = source.getConfigurationProperty(propertyName);
|
||||||
|
if (property == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Map<String, Object> row = new LinkedHashMap<>();
|
||||||
|
row.put("source", String.valueOf(source.getUnderlyingSource()));
|
||||||
|
row.put("value", String.valueOf(property.getValue()));
|
||||||
|
row.put("origin", String.valueOf(property.getOrigin()));
|
||||||
|
candidates.add(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
result.put("property", name);
|
||||||
|
result.put("effectiveValue", environment.getProperty(name));
|
||||||
|
result.put("candidatesInPrecedenceOrder", candidates);
|
||||||
|
result.put("shadowedBy", candidates.size() > 1 ? candidates.getFirst().get("source") : null);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The bound objects, so the article can show a record's contents rather than describe them. */
|
||||||
|
@GetMapping("/diag/bound")
|
||||||
|
public Map<String, Object> bound() {
|
||||||
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
result.put("mail", mail);
|
||||||
|
result.put("validated", validated);
|
||||||
|
|
||||||
|
Map<String, Object> fromValue = new LinkedHashMap<>();
|
||||||
|
fromValue.put("host", valueHolder.host());
|
||||||
|
fromValue.put("port", valueHolder.port());
|
||||||
|
fromValue.put("timeout", valueHolder.timeout().toString());
|
||||||
|
fromValue.put("recipients", valueHolder.recipients());
|
||||||
|
fromValue.put("poolSize", valueHolder.poolSize());
|
||||||
|
fromValue.put("computedThreads", valueHolder.computedThreads());
|
||||||
|
result.put("fromValueAnnotation", fromValue);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package com.ankurm.configprops.web;
|
||||||
|
|
||||||
|
import com.ankurm.configprops.props.RelaxedProperties;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.boot.CommandLineRunner;
|
||||||
|
import org.springframework.context.annotation.Profile;
|
||||||
|
import org.springframework.core.env.Environment;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same question as {@link RelaxedBindingProbe}, but asked of a real process with a real
|
||||||
|
* operating-system environment variable rather than a synthesised property source.
|
||||||
|
*
|
||||||
|
* <p>{@code scripts/demo-env-binding.sh} runs this once per spelling. It exists because the
|
||||||
|
* synthesised answer was surprising enough to be worth re-checking against reality: the
|
||||||
|
* spelling most articles recommend does not bind.
|
||||||
|
*
|
||||||
|
* <p>{@code @Value} here carries a default so that a miss is reported rather than crashing
|
||||||
|
* the process — the difference between the two columns is the point.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@Profile("envprobe")
|
||||||
|
public class RealEnvironmentProbe implements CommandLineRunner {
|
||||||
|
|
||||||
|
private final RelaxedProperties bound;
|
||||||
|
private final Environment environment;
|
||||||
|
|
||||||
|
@Value("${demo.relaxed.api-key:(MISS)}")
|
||||||
|
private String viaValue;
|
||||||
|
|
||||||
|
public RealEnvironmentProbe(RelaxedProperties bound, Environment environment) {
|
||||||
|
this.bound = bound;
|
||||||
|
this.environment = environment;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void run(String... args) {
|
||||||
|
System.out.println("env-var-set : " + describeEnv());
|
||||||
|
System.out.println("@ConfigurationProps: " + bound.apiKey());
|
||||||
|
System.out.println("@Value : " + viaValue);
|
||||||
|
System.out.println("Environment.get : "
|
||||||
|
+ environment.getProperty("demo.relaxed.api-key", "(MISS)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String describeEnv() {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
System.getenv().forEach((k, v) -> {
|
||||||
|
if (k.toUpperCase().startsWith("DEMO_RELAXED") || k.toUpperCase().startsWith("DEMO.")) {
|
||||||
|
sb.append(k).append('=').append(v).append(' ');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return sb.isEmpty() ? "(none)" : sb.toString().trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
package com.ankurm.configprops.web;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import com.ankurm.configprops.props.RelaxedProperties;
|
||||||
|
|
||||||
|
import org.springframework.boot.CommandLineRunner;
|
||||||
|
import org.springframework.boot.context.properties.bind.BindResult;
|
||||||
|
import org.springframework.boot.context.properties.bind.Binder;
|
||||||
|
import org.springframework.boot.context.properties.source.ConfigurationPropertySources;
|
||||||
|
import org.springframework.context.annotation.Profile;
|
||||||
|
import org.springframework.core.env.MapPropertySource;
|
||||||
|
import org.springframework.core.env.StandardEnvironment;
|
||||||
|
import org.springframework.core.env.SystemEnvironmentPropertySource;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates the relaxed-binding matrix in {@code docs/02-relaxed-binding.md} by actually
|
||||||
|
* binding each spelling, rather than by quoting the reference documentation.
|
||||||
|
*
|
||||||
|
* <p>For every spelling of the canonical property {@code demo.relaxed.api-key} the probe asks
|
||||||
|
* three questions, and the three answers are not the same:
|
||||||
|
*
|
||||||
|
* <ol>
|
||||||
|
* <li><strong>BINDER</strong> — does {@link Binder}, the engine behind
|
||||||
|
* {@code @ConfigurationProperties}, resolve it?</li>
|
||||||
|
* <li><strong>${} plain</strong> — does {@code ${demo.relaxed.api-key}} resolve against
|
||||||
|
* a bare {@link StandardEnvironment}? This is placeholder resolution as the Spring
|
||||||
|
* Framework alone defines it.</li>
|
||||||
|
* <li><strong>${} boot</strong> — does the same placeholder resolve once
|
||||||
|
* {@link ConfigurationPropertySources#attach} has been called? Spring Boot calls it on
|
||||||
|
* every environment it prepares, which quietly gives {@code @Value} relaxed resolution
|
||||||
|
* that plain Spring does not have.</li>
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* <p>That third column is the one worth knowing about. "{@code @Value} does not support relaxed
|
||||||
|
* binding" is repeated everywhere and is true of the Spring Framework; inside a Spring Boot
|
||||||
|
* application it is not, and the difference is one method call made on your behalf during
|
||||||
|
* environment preparation.
|
||||||
|
*
|
||||||
|
* <p>Two harness details were paid for in wrong answers and are worth stating:
|
||||||
|
* the system-environment source must be <em>named</em>
|
||||||
|
* {@link StandardEnvironment#SYSTEM_ENVIRONMENT_PROPERTY_SOURCE_NAME} or Boot maps it with the
|
||||||
|
* ordinary rules, and a bare {@code StandardEnvironment} is not what a Boot application runs
|
||||||
|
* with. Both produced plausible, publishable, incorrect results before being checked against a
|
||||||
|
* real process by {@code scripts/demo-env-binding.sh}.
|
||||||
|
*
|
||||||
|
* <p>Activated by the {@code probe} profile so it does not run during the other demos.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@Profile("probe")
|
||||||
|
public class RelaxedBindingProbe implements CommandLineRunner {
|
||||||
|
|
||||||
|
/** The one canonical name. Everything else in the matrix is a spelling of this. */
|
||||||
|
private static final String CANONICAL = "demo.relaxed.api-key";
|
||||||
|
|
||||||
|
private static final List<String> MAP_SPELLINGS = List.of(
|
||||||
|
"demo.relaxed.api-key", // kebab-case, the canonical form
|
||||||
|
"demo.relaxed.apiKey", // camelCase
|
||||||
|
"demo.relaxed.api_key", // underscore notation
|
||||||
|
"demo.relaxed.APIKEY", // upper case, no separator
|
||||||
|
"DEMO.RELAXED.API-KEY", // upper case with separators
|
||||||
|
"demo.relaxed.apikey", // no separator at all
|
||||||
|
"demo.relaxed.api.key"); // dot as separator -- a DIFFERENT property
|
||||||
|
|
||||||
|
private static final List<String> ENV_SPELLINGS = List.of(
|
||||||
|
"DEMO_RELAXED_API_KEY", // underscore for every separator -- the safe form
|
||||||
|
"DEMO_RELAXED_APIKEY", // word joined up
|
||||||
|
"demo_relaxed_api_key", // lower case underscores
|
||||||
|
"DEMO.RELAXED.API-KEY"); // dots and dashes, which most shells reject anyway
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void run(String... args) {
|
||||||
|
System.out.println("== relaxed binding matrix ==");
|
||||||
|
System.out.println("canonical property: " + CANONICAL);
|
||||||
|
System.out.println();
|
||||||
|
System.out.printf("%-24s %-6s %-8s %-10s %-10s%n",
|
||||||
|
"SPELLING", "SOURCE", "BINDER", "${} plain", "${} boot");
|
||||||
|
System.out.println("-".repeat(62));
|
||||||
|
|
||||||
|
for (String spelling : MAP_SPELLINGS) {
|
||||||
|
report(spelling, "map", () -> mapEnvironment(spelling));
|
||||||
|
}
|
||||||
|
for (String spelling : ENV_SPELLINGS) {
|
||||||
|
report(spelling, "env", () -> envEnvironment(spelling));
|
||||||
|
}
|
||||||
|
|
||||||
|
System.out.println();
|
||||||
|
System.out.println("BINDER = what @ConfigurationProperties sees (Binder)");
|
||||||
|
System.out.println("${} plain = placeholder resolution against a bare StandardEnvironment");
|
||||||
|
System.out.println("${} boot = the same, after ConfigurationPropertySources.attach(env),");
|
||||||
|
System.out.println(" which Spring Boot performs on every environment it prepares");
|
||||||
|
System.out.println("bound = the value arrived; MISS = the property was not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void report(String spelling, String kind,
|
||||||
|
java.util.function.Supplier<StandardEnvironment> factory) {
|
||||||
|
|
||||||
|
StandardEnvironment forBinder = factory.get();
|
||||||
|
BindResult<RelaxedProperties> result =
|
||||||
|
Binder.get(forBinder).bind("demo.relaxed", RelaxedProperties.class);
|
||||||
|
String binder = result.isBound() && !"(unset)".equals(result.get().apiKey())
|
||||||
|
? "bound" : "MISS";
|
||||||
|
|
||||||
|
String plain = placeholder(factory.get());
|
||||||
|
|
||||||
|
StandardEnvironment attached = factory.get();
|
||||||
|
ConfigurationPropertySources.attach(attached);
|
||||||
|
String boot = placeholder(attached);
|
||||||
|
|
||||||
|
System.out.printf("%-24s %-6s %-8s %-10s %-10s%n", spelling, kind, binder, plain, boot);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String placeholder(StandardEnvironment environment) {
|
||||||
|
try {
|
||||||
|
environment.resolveRequiredPlaceholders("${" + CANONICAL + "}");
|
||||||
|
return "bound";
|
||||||
|
} catch (IllegalArgumentException ex) {
|
||||||
|
return "MISS";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An environment whose only property source behaves like a properties or YAML file. */
|
||||||
|
private StandardEnvironment mapEnvironment(String spelling) {
|
||||||
|
StandardEnvironment environment = new StandardEnvironment();
|
||||||
|
environment.getPropertySources().addFirst(
|
||||||
|
new MapPropertySource("probe", Map.of(spelling, "from-" + spelling)));
|
||||||
|
return environment;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An environment whose only property source is a {@link SystemEnvironmentPropertySource}.
|
||||||
|
*
|
||||||
|
* <p>The source <strong>must</strong> carry the name
|
||||||
|
* {@link StandardEnvironment#SYSTEM_ENVIRONMENT_PROPERTY_SOURCE_NAME}: Spring Boot decides
|
||||||
|
* which name mapper applies by comparing the source's name against that constant, not by
|
||||||
|
* checking its type. A {@code SystemEnvironmentPropertySource} called anything else is
|
||||||
|
* mapped with the ordinary rules and the underscore spellings stop resolving.
|
||||||
|
*/
|
||||||
|
private StandardEnvironment envEnvironment(String spelling) {
|
||||||
|
StandardEnvironment environment = new StandardEnvironment();
|
||||||
|
Map<String, Object> entries = new LinkedHashMap<>();
|
||||||
|
entries.put(spelling, "from-" + spelling);
|
||||||
|
environment.getPropertySources().addFirst(new SystemEnvironmentPropertySource(
|
||||||
|
StandardEnvironment.SYSTEM_ENVIRONMENT_PROPERTY_SOURCE_NAME, entries));
|
||||||
|
return environment;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package com.ankurm.configprops.web;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The {@code @Value} side of the comparison, written the way people actually write it.
|
||||||
|
*
|
||||||
|
* <p>Everything here works. The point of the class is what it costs to make it work, and what
|
||||||
|
* it still cannot do:
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>Every field repeats the property name as a string literal. Rename the property and the
|
||||||
|
* compiler says nothing.</li>
|
||||||
|
* <li>The name has to be the exact canonical form. {@code ${demo.mail.apiKey}} does not find
|
||||||
|
* {@code demo.mail.api-key} — proved by
|
||||||
|
* {@link RelaxedBindingProbe}.</li>
|
||||||
|
* <li>There is no validation. {@code :-1} below is accepted silently.</li>
|
||||||
|
* <li>A missing property without a {@code :default} is a startup failure whose message names
|
||||||
|
* the field, not the property's purpose.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>What {@code @Value} does have that binding does not: SpEL. {@code #{...}} can compute a
|
||||||
|
* value; {@code @ConfigurationProperties} only maps one. That is the honest reason to keep it.
|
||||||
|
* See {@code docs/06-when-value-still-wins.md}.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class ValueHolder {
|
||||||
|
|
||||||
|
/** Exact canonical name required -- no relaxed spelling is accepted here. */
|
||||||
|
@Value("${demo.mail.host}")
|
||||||
|
private String host;
|
||||||
|
|
||||||
|
/** The ":" default is the only defaulting mechanism @Value has. */
|
||||||
|
@Value("${demo.mail.port:587}")
|
||||||
|
private int port;
|
||||||
|
|
||||||
|
/** Conversion works: the ConversionService is shared with the binder. */
|
||||||
|
@Value("${demo.mail.timeout:30s}")
|
||||||
|
private Duration timeout;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A comma-separated string splits into a List. A YAML block list does NOT --
|
||||||
|
* that is one of the concrete gaps captured in docs/output/03-value-vs-binding.txt.
|
||||||
|
*/
|
||||||
|
@Value("${demo.mail.recipients:}")
|
||||||
|
private List<String> recipients;
|
||||||
|
|
||||||
|
/** No constraint is applied. A negative pool size is simply a negative pool size. */
|
||||||
|
@Value("${demo.validated.pool.size:-1}")
|
||||||
|
private int poolSize;
|
||||||
|
|
||||||
|
/** SpEL -- the capability @ConfigurationProperties genuinely does not have. */
|
||||||
|
@Value("#{T(java.lang.Runtime).getRuntime().availableProcessors() * 2}")
|
||||||
|
private int computedThreads;
|
||||||
|
|
||||||
|
public String host() { return host; }
|
||||||
|
public int port() { return port; }
|
||||||
|
public Duration timeout() { return timeout; }
|
||||||
|
public List<String> recipients() { return recipients; }
|
||||||
|
public int poolSize() { return poolSize; }
|
||||||
|
public int computedThreads() { return computedThreads; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
demo:
|
||||||
|
validated:
|
||||||
|
# @NotBlank -- blank is exactly what it rejects.
|
||||||
|
name: " "
|
||||||
|
# @Min(1) @Max(65535)
|
||||||
|
port: 99999
|
||||||
|
# @Pattern(regexp = "https?://.*")
|
||||||
|
endpoint: "ftp://files.example.com"
|
||||||
|
pool:
|
||||||
|
# @Min(1) @Max(100) on a nested record. Only checked because the component
|
||||||
|
# carries @Valid; without it this passes.
|
||||||
|
size: 4000
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# Overrides the YAML block list in application.yaml with a comma-separated string.
|
||||||
|
# demo.csvlist.loaded is a marker so the transcript can prove this file was read.
|
||||||
|
demo:
|
||||||
|
csvlist:
|
||||||
|
loaded: "yes"
|
||||||
|
mail:
|
||||||
|
recipients: "ops@example.com,oncall@example.com,sre@example.com"
|
||||||
37
configuration-properties/src/main/resources/application.yaml
Normal file
37
configuration-properties/src/main/resources/application.yaml
Normal file
@@ -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
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
package com.ankurm.configprops;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import com.ankurm.configprops.props.MailProperties;
|
||||||
|
import com.ankurm.configprops.props.RelaxedProperties;
|
||||||
|
import com.ankurm.configprops.props.ValidatedProperties;
|
||||||
|
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import jakarta.validation.constraints.Max;
|
||||||
|
import jakarta.validation.constraints.Min;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.boot.context.properties.bind.BindException;
|
||||||
|
import org.springframework.boot.context.properties.bind.Binder;
|
||||||
|
import org.springframework.boot.context.properties.bind.DefaultValue;
|
||||||
|
import org.springframework.boot.context.properties.bind.validation.BindValidationException;
|
||||||
|
import org.springframework.boot.context.properties.source.ConfigurationPropertySources;
|
||||||
|
import org.springframework.core.env.MapPropertySource;
|
||||||
|
import org.springframework.core.env.StandardEnvironment;
|
||||||
|
import org.springframework.validation.annotation.Validated;
|
||||||
|
import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contract tests for the claims the article makes. Each one pins a behaviour that would
|
||||||
|
* otherwise be an assertion in prose.
|
||||||
|
*/
|
||||||
|
class BindingContractTests {
|
||||||
|
|
||||||
|
private static Binder binderFor(Map<String, Object> properties) {
|
||||||
|
StandardEnvironment environment = new StandardEnvironment();
|
||||||
|
environment.getPropertySources().addFirst(new MapPropertySource("test", properties));
|
||||||
|
return Binder.get(environment);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("kebab-case, camelCase, underscores and no separator are all the same property")
|
||||||
|
void relaxedSpellingsAreEquivalent() {
|
||||||
|
for (String spelling : new String[] {
|
||||||
|
"demo.relaxed.api-key", "demo.relaxed.apiKey",
|
||||||
|
"demo.relaxed.api_key", "demo.relaxed.apikey", "demo.relaxed.APIKEY" }) {
|
||||||
|
RelaxedProperties bound = binderFor(Map.of(spelling, "v"))
|
||||||
|
.bind("demo.relaxed", RelaxedProperties.class).get();
|
||||||
|
assertThat(bound.apiKey()).as(spelling).isEqualTo("v");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("a dot is a name separator, so api.key is a different property from api-key")
|
||||||
|
void dotIsNotAWordSeparator() {
|
||||||
|
// Nothing under the prefix matches, so the bind produces no result at all. Note this
|
||||||
|
// is *not* an all-defaults object: @DefaultValue applies to a component of an object
|
||||||
|
// that is being constructed, and here no object is constructed.
|
||||||
|
assertThat(binderFor(Map.of("demo.relaxed.api.key", "v"))
|
||||||
|
.bind("demo.relaxed", RelaxedProperties.class).isBound()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("a record binds through its canonical constructor with no annotation")
|
||||||
|
void recordConstructorBinding() {
|
||||||
|
MailProperties bound = binderFor(Map.of(
|
||||||
|
"demo.mail.host", "smtp.test",
|
||||||
|
"demo.mail.retries.max-attempts", "9"))
|
||||||
|
.bind("demo.mail", MailProperties.class).get();
|
||||||
|
|
||||||
|
assertThat(bound.host()).isEqualTo("smtp.test");
|
||||||
|
assertThat(bound.port()).as("@DefaultValue supplied it").isEqualTo(587);
|
||||||
|
assertThat(bound.timeout()).isEqualTo(Duration.ofSeconds(30));
|
||||||
|
assertThat(bound.retries().maxAttempts()).isEqualTo(9);
|
||||||
|
assertThat(bound.retries().backoff()).as("nested default").isEqualTo(Duration.ofSeconds(2));
|
||||||
|
assertThat(bound.recipients()).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("a component with no value and no @DefaultValue binds to null, not to a failure")
|
||||||
|
void absentPropertyWithoutDefault() {
|
||||||
|
MailProperties bound = binderFor(Map.of("demo.mail.port", "25"))
|
||||||
|
.bind("demo.mail", MailProperties.class).get();
|
||||||
|
assertThat(bound.host()).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("@Validated turns a bad value into a BindValidationException")
|
||||||
|
void validationRejectsBadValues() {
|
||||||
|
assertThatExceptionOfType(BindException.class)
|
||||||
|
.isThrownBy(() -> binderFor(Map.of(
|
||||||
|
"demo.validated.name", "ok",
|
||||||
|
"demo.validated.port", "99999"))
|
||||||
|
.bind("demo.validated", org.springframework.boot.context.properties.bind
|
||||||
|
.Bindable.of(ValidatedProperties.class),
|
||||||
|
new org.springframework.boot.context.properties.bind.validation
|
||||||
|
.ValidationBindHandler(validator()))
|
||||||
|
.get())
|
||||||
|
.withCauseInstanceOf(BindValidationException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contradicts a widely repeated rule.
|
||||||
|
*
|
||||||
|
* <p>The advice "put {@code @Valid} on nested {@code @ConfigurationProperties} types or
|
||||||
|
* their constraints are ignored" is true of ordinary bean validation, where cascading is
|
||||||
|
* opt-in. It is not true of the binder. {@code ValidationBindHandler} validates every
|
||||||
|
* object it finishes binding, nested ones included, so the constraint below fires with no
|
||||||
|
* {@code @Valid} anywhere in the type.
|
||||||
|
*
|
||||||
|
* <p>This test was originally written to assert the opposite and failed, which is how the
|
||||||
|
* article came to say the opposite of what most of its neighbours say.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
@DisplayName("nested constraints are evaluated even without @Valid")
|
||||||
|
void nestedConstraintsFireWithoutValid() {
|
||||||
|
assertThatExceptionOfType(BindException.class)
|
||||||
|
.isThrownBy(() -> binderFor(Map.of("demo.unchecked.pool.size", "4000"))
|
||||||
|
.bind("demo.unchecked", org.springframework.boot.context.properties.bind
|
||||||
|
.Bindable.of(Unchecked.class),
|
||||||
|
new org.springframework.boot.context.properties.bind.validation
|
||||||
|
.ValidationBindHandler(validator()))
|
||||||
|
.get())
|
||||||
|
.withCauseInstanceOf(BindValidationException.class)
|
||||||
|
.withMessageContaining("demo.unchecked");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Boot's attached property source gives ${} the same relaxed rules as the binder")
|
||||||
|
void attachGivesPlaceholdersRelaxedResolution() {
|
||||||
|
StandardEnvironment plain = new StandardEnvironment();
|
||||||
|
plain.getPropertySources()
|
||||||
|
.addFirst(new MapPropertySource("test", Map.of("demo.relaxed.apiKey", "v")));
|
||||||
|
|
||||||
|
assertThat(plain.resolvePlaceholders("${demo.relaxed.api-key:MISS}"))
|
||||||
|
.as("plain Spring: no relaxed placeholder resolution")
|
||||||
|
.isEqualTo("MISS");
|
||||||
|
|
||||||
|
ConfigurationPropertySources.attach(plain);
|
||||||
|
assertThat(plain.resolvePlaceholders("${demo.relaxed.api-key:MISS}"))
|
||||||
|
.as("Spring Boot attaches this source on every environment it prepares")
|
||||||
|
.isEqualTo("v");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LocalValidatorFactoryBean validator() {
|
||||||
|
LocalValidatorFactoryBean factory = new LocalValidatorFactoryBean();
|
||||||
|
factory.afterPropertiesSet();
|
||||||
|
return factory;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deliberately missing {@code @Valid} on the nested component. */
|
||||||
|
@Validated
|
||||||
|
@ConfigurationProperties(prefix = "demo.unchecked")
|
||||||
|
record Unchecked(@DefaultValue Pool pool) {
|
||||||
|
record Pool(@Min(1) @Max(100) @DefaultValue("10") int size) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
74
profiles-and-config/README.md
Normal file
74
profiles-and-config/README.md
Normal file
@@ -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.
|
||||||
56
profiles-and-config/docs/01-the-precedence-list.md
Normal file
56
profiles-and-config/docs/01-the-precedence-list.md
Normal file
@@ -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).
|
||||||
70
profiles-and-config/docs/02-profiles.md
Normal file
70
profiles-and-config/docs/02-profiles.md
Normal file
@@ -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-<profile>.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-<name>.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.
|
||||||
59
profiles-and-config/docs/03-seeing-precedence.md
Normal file
59
profiles-and-config/docs/03-seeing-precedence.md
Normal file
@@ -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.
|
||||||
73
profiles-and-config/docs/04-why-your-profile-file-lost.md
Normal file
73
profiles-and-config/docs/04-why-your-profile-file-lost.md
Normal file
@@ -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).
|
||||||
58
profiles-and-config/docs/05-config-import.md
Normal file
58
profiles-and-config/docs/05-config-import.md
Normal file
@@ -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.
|
||||||
85
profiles-and-config/docs/06-config-trees-and-configmaps.md
Normal file
85
profiles-and-config/docs/06-config-trees-and-configmaps.md
Normal file
@@ -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:
|
||||||
|
|
||||||
|
```
|
||||||
|
<configmap-mount>/demo.datasource-url
|
||||||
|
<configmap-mount>/demo.greeting
|
||||||
|
<configmap-mount>/demo.pool-size
|
||||||
|
<configmap-mount>/demo/nested/value
|
||||||
|
<secret-mount>/demo.api-key
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
$ cat <configmap-mount>/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 `<mount>-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.
|
||||||
83
profiles-and-config/docs/output/01-precedence.txt
Normal file
83
profiles-and-config/docs/output/01-precedence.txt
Normal file
@@ -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
|
||||||
41
profiles-and-config/docs/output/02-profile-file-loses.txt
Normal file
41
profiles-and-config/docs/output/02-profile-file-loses.txt
Normal file
@@ -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
|
||||||
45
profiles-and-config/docs/output/03-config-tree.txt
Normal file
45
profiles-and-config/docs/output/03-config-tree.txt
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
== what Kubernetes actually mounts ==
|
||||||
|
$ find /tmp/demo-configmap /tmp/demo-secret -type f | sort
|
||||||
|
<configmap-mount>/demo.datasource-url
|
||||||
|
<configmap-mount>/demo.greeting
|
||||||
|
<configmap-mount>/demo.pool-size
|
||||||
|
<configmap-mount>/demo/nested/value
|
||||||
|
<secret-mount>/demo.api-key
|
||||||
|
|
||||||
|
$ cat <configmap-mount>/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
|
||||||
|
<mount>-prod directory convention; a per-environment ConfigMap is a different mount
|
||||||
|
chosen by the deployment, not by spring.profiles.active.
|
||||||
60
profiles-and-config/docs/output/04-import-and-multidoc.txt
Normal file
60
profiles-and-config/docs/output/04-import-and-multidoc.txt
Normal file
@@ -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=<none> (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)
|
||||||
44
profiles-and-config/pom.xml
Normal file
44
profiles-and-config/pom.xml
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-parent</artifactId>
|
||||||
|
<version>4.1.1</version>
|
||||||
|
<relativePath/>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<groupId>com.ankurm</groupId>
|
||||||
|
<artifactId>profiles-and-config</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<name>profiles-and-config</name>
|
||||||
|
<description>Spring Boot profiles, config import, config trees and ConfigMaps</description>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<java.version>25</java.version>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-webmvc</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
66
profiles-and-config/scripts/demo-config-tree.sh
Executable file
66
profiles-and-config/scripts/demo-config-tree.sh
Executable file
@@ -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|<configmap-mount>|;s|$SECRET|<secret-mount>|"
|
||||||
|
echo
|
||||||
|
echo "\$ cat <configmap-mount>/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 "<mount>-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
|
||||||
56
profiles-and-config/scripts/demo-import-and-multidoc.sh
Executable file
56
profiles-and-config/scripts/demo-import-and-multidoc.sh
Executable file
@@ -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:-<none>}"
|
||||||
|
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
|
||||||
42
profiles-and-config/scripts/demo-precedence.sh
Executable file
42
profiles-and-config/scripts/demo-precedence.sh
Executable file
@@ -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
|
||||||
44
profiles-and-config/scripts/demo-profile-file-loses.sh
Executable file
44
profiles-and-config/scripts/demo-profile-file-loses.sh
Executable file
@@ -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
|
||||||
12
profiles-and-config/scripts/demo-versions.sh
Executable file
12
profiles-and-config/scripts/demo-versions.sh
Executable file
@@ -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 '<artifactId>spring-boot-starter-parent' pom.xml | grep '<version>' | sed 's/.*<version>\(.*\)<\/version>.*/\1/')"
|
||||||
|
} > docs/output/00-versions.txt 2>&1
|
||||||
|
cat docs/output/00-versions.txt
|
||||||
76
profiles-and-config/scripts/env.sh
Executable file
76
profiles-and-config/scripts/env.sh
Executable file
@@ -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 <jar name>` 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"])'
|
||||||
|
}
|
||||||
16
profiles-and-config/scripts/run-all.sh
Executable file
16
profiles-and-config/scripts/run-all.sh
Executable file
@@ -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/
|
||||||
34
profiles-and-config/scripts/run.sh
Executable file
34
profiles-and-config/scripts/run.sh
Executable file
@@ -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
|
||||||
28
profiles-and-config/scripts/stop.sh
Executable file
28
profiles-and-config/scripts/stop.sh
Executable file
@@ -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
|
||||||
@@ -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".
|
||||||
|
*
|
||||||
|
* <p>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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>"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.
|
||||||
|
*
|
||||||
|
* <p>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<String, Object> precedence(
|
||||||
|
@RequestParam(defaultValue = "demo.greeting") String name) {
|
||||||
|
|
||||||
|
ConfigurationPropertyName propertyName = ConfigurationPropertyName.of(name);
|
||||||
|
List<Map<String, Object>> holders = new ArrayList<>();
|
||||||
|
|
||||||
|
for (ConfigurationPropertySource source : ConfigurationPropertySources.get(environment)) {
|
||||||
|
var property = source.getConfigurationProperty(propertyName);
|
||||||
|
if (property == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Map<String, Object> row = new LinkedHashMap<>();
|
||||||
|
row.put("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<String, Object> 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<Map<String, Object>> sources() {
|
||||||
|
List<Map<String, Object>> rows = new ArrayList<>();
|
||||||
|
int rank = 1;
|
||||||
|
for (PropertySource<?> source : environment.getPropertySources()) {
|
||||||
|
Map<String, Object> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Activated as part of the "prod" profile group declared in application.yaml.
|
||||||
|
demo:
|
||||||
|
pool-size: 40
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# The second member of the "prod" group.
|
||||||
|
demo:
|
||||||
|
metrics-enabled: true
|
||||||
@@ -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
|
||||||
22
profiles-and-config/src/main/resources/application.yaml
Normal file
22
profiles-and-config/src/main/resources/application.yaml
Normal file
@@ -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-<profile>.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
|
||||||
4
profiles-and-config/src/main/resources/imported.yaml
Normal file
4
profiles-and-config/src/main/resources/imported.yaml
Normal file
@@ -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
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
67
spring-aop/README.md
Normal file
67
spring-aop/README.md
Normal file
@@ -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.
|
||||||
51
spring-aop/docs/01-what-spring-aop-is.md
Normal file
51
spring-aop/docs/01-what-spring-aop-is.md
Normal file
@@ -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).
|
||||||
86
spring-aop/docs/02-designators.md
Normal file
86
spring-aop/docs/02-designators.md
Normal file
@@ -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.
|
||||||
78
spring-aop/docs/03-proxy-types.md
Normal file
78
spring-aop/docs/03-proxy-types.md
Normal file
@@ -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`.
|
||||||
56
spring-aop/docs/04-advice-types.md
Normal file
56
spring-aop/docs/04-advice-types.md
Normal file
@@ -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.
|
||||||
84
spring-aop/docs/05-broken-aspect-gallery.md
Normal file
84
spring-aop/docs/05-broken-aspect-gallery.md
Normal file
@@ -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.
|
||||||
75
spring-aop/docs/06-diagnosing-a-silent-aspect.md
Normal file
75
spring-aop/docs/06-diagnosing-a-silent-aspect.md
Normal file
@@ -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.
|
||||||
11
spring-aop/docs/output/00-versions.txt
Normal file
11
spring-aop/docs/output/00-versions.txt
Normal file
@@ -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
|
||||||
59
spring-aop/docs/output/01-designators.txt
Normal file
59
spring-aop/docs/output/01-designators.txt
Normal file
@@ -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.
|
||||||
53
spring-aop/docs/output/02-proxy-types.txt
Normal file
53
spring-aop/docs/output/02-proxy-types.txt
Normal file
@@ -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.
|
||||||
37
spring-aop/docs/output/03-broken-gallery.txt
Normal file
37
spring-aop/docs/output/03-broken-gallery.txt
Normal file
@@ -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.
|
||||||
60
spring-aop/pom.xml
Normal file
60
spring-aop/pom.xml
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-parent</artifactId>
|
||||||
|
<version>4.1.1</version>
|
||||||
|
<relativePath/>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<groupId>com.ankurm</groupId>
|
||||||
|
<artifactId>spring-aop-demo</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<name>spring-aop-demo</name>
|
||||||
|
<description>Spring AOP: pointcut designators, proxy types, and why aspects do not fire</description>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<java.version>25</java.version>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-webmvc</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<!-- NOTE THE NAME. In Spring Boot 3 this was spring-boot-starter-aop. That artifact was
|
||||||
|
renamed to spring-boot-starter-aspectj during the Boot 4 milestones: its last
|
||||||
|
published version is 4.0.0-M2 and its last GA is 3.5.16, while
|
||||||
|
spring-boot-starter-aspectj starts at 4.0.0-M3. Copying an AOP dependency block out
|
||||||
|
of any pre-4 tutorial therefore fails resolution, because the old name is no longer
|
||||||
|
in the Boot BOM and therefore has no managed version.
|
||||||
|
|
||||||
|
Contents are unchanged: spring-boot-starter, spring-aop and aspectjweaver. It is
|
||||||
|
aspectjweaver that supplies the pointcut PARSER: Spring AOP does its own weaving
|
||||||
|
at runtime with proxies, and borrows only the expression language from AspectJ.
|
||||||
|
Without it, @Aspect classes are ordinary beans and every pointcut here silently
|
||||||
|
matches nothing. See docs/01-what-spring-aop-is.md. -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-aspectj</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
28
spring-aop/scripts/demo-broken-gallery.sh
Executable file
28
spring-aop/scripts/demo-broken-gallery.sh
Executable file
@@ -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
|
||||||
48
spring-aop/scripts/demo-designators.sh
Executable file
48
spring-aop/scripts/demo-designators.sh
Executable file
@@ -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
|
||||||
56
spring-aop/scripts/demo-proxy-types.sh
Executable file
56
spring-aop/scripts/demo-proxy-types.sh
Executable file
@@ -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
|
||||||
16
spring-aop/scripts/demo-versions.sh
Executable file
16
spring-aop/scripts/demo-versions.sh
Executable file
@@ -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 '<artifactId>spring-boot-starter-parent' pom.xml | grep '<version>' | sed 's/.*<version>\(.*\)<\/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
|
||||||
61
spring-aop/scripts/env.sh
Executable file
61
spring-aop/scripts/env.sh
Executable file
@@ -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 <jar name>` 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.
|
||||||
16
spring-aop/scripts/run-all.sh
Executable file
16
spring-aop/scripts/run-all.sh
Executable file
@@ -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/
|
||||||
20
spring-aop/src/main/java/com/ankurm/aop/AopApplication.java
Normal file
20
spring-aop/src/main/java/com/ankurm/aop/AopApplication.java
Normal file
@@ -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".
|
||||||
|
*
|
||||||
|
* <p>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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>The designators fall into three groups worth keeping straight:
|
||||||
|
* <ul>
|
||||||
|
* <li><strong>Signature matching</strong> — {@code execution}, {@code within}.
|
||||||
|
* Evaluated statically against the method signature.</li>
|
||||||
|
* <li><strong>Runtime type matching</strong> — {@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.</li>
|
||||||
|
* <li><strong>Annotation and bean matching</strong> — {@code @within},
|
||||||
|
* {@code @annotation}, {@code bean}. The last is Spring's own, not AspectJ's.</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
@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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, Set<String>> matches = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
public void record(String designator, String joinPoint) {
|
||||||
|
matches.computeIfAbsent(designator, key -> new LinkedHashSet<>()).add(joinPoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, Set<String>> matches() {
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void clear() {
|
||||||
|
matches.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>The working versions live in {@link com.ankurm.aop.aspect.DesignatorAspect}; each entry
|
||||||
|
* below names the fix.
|
||||||
|
*/
|
||||||
|
public final class BrokenAspects {
|
||||||
|
|
||||||
|
private BrokenAspects() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <strong>1. The aspect is not a Spring bean.</strong>
|
||||||
|
*
|
||||||
|
* <p>{@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.
|
||||||
|
*
|
||||||
|
* <p>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.
|
||||||
|
*
|
||||||
|
* <p><strong>Fix:</strong> add {@code @Component}.
|
||||||
|
*/
|
||||||
|
@Aspect
|
||||||
|
public static class NotABean {
|
||||||
|
@Before("execution(* com.ankurm.aop.service.*.*(..))")
|
||||||
|
public void neverRuns() {
|
||||||
|
Recorder.record("1. @Aspect without @Component");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <strong>2. The pointcut expression does not match anything.</strong>
|
||||||
|
*
|
||||||
|
* <p>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.
|
||||||
|
*
|
||||||
|
* <p><strong>Fix:</strong> 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)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <strong>3. Advising a private method.</strong>
|
||||||
|
*
|
||||||
|
* <p>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.
|
||||||
|
*
|
||||||
|
* <p><strong>Fix:</strong> 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <strong>4. Advising a final method.</strong>
|
||||||
|
*
|
||||||
|
* <p>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 <em>class</em>
|
||||||
|
* fails loudly, because then the subclass itself is impossible.
|
||||||
|
*
|
||||||
|
* <p><strong>Fix:</strong> 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.ankurm.aop.broken;
|
||||||
|
|
||||||
|
import com.ankurm.aop.service.Marker;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <strong>6. The object was never a bean.</strong>
|
||||||
|
*
|
||||||
|
* <p>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.
|
||||||
|
*
|
||||||
|
* <p><strong>Fix:</strong> 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package com.ankurm.aop.broken;
|
||||||
|
|
||||||
|
import com.ankurm.aop.service.Marker;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <strong>5. Self-invocation.</strong>
|
||||||
|
*
|
||||||
|
* <p>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.
|
||||||
|
*
|
||||||
|
* <p>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.
|
||||||
|
*
|
||||||
|
* <p><strong>Fix:</strong> 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
12
spring-aop/src/main/java/com/ankurm/aop/service/Audited.java
Normal file
12
spring-aop/src/main/java/com/ankurm/aop/service/Audited.java
Normal file
@@ -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 {
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
12
spring-aop/src/main/java/com/ankurm/aop/service/Marker.java
Normal file
12
spring-aop/src/main/java/com/ankurm/aop/service/Marker.java
Normal file
@@ -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 {
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user