Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51f3ecd5da | ||
|
|
9825efa081 |
@@ -31,6 +31,12 @@ files.
|
||||
| [`custom-validation/`](custom-validation) | [Custom Validation in Spring Boot: Beyond the Basics!](https://ankurm.com/custom-validation-in-spring-boot-beyond-the-basics/) | the `javax.validation` to `jakarta.validation` namespace fix Boot 3 already required, Jakarta Validation 3.1's record-validation clarification proven on both field- and class-level custom constraints, a record validation failure's empty 400 body by default, and what `spring.mvc.problemdetails.enabled` does and does not fix |
|
||||
| [`etag-caching/`](etag-caching) | [Mastering Cache Control with ETag in Spring Boot RESTful APIs](https://ankurm.com/etag-cache-control-rest-api-spring-boot/) | `spring-boot-starter-web`'s own POM now reading "deprecated in favor of spring-boot-starter-webmvc", `ShallowEtagHeaderFilter` and `WebRequest.checkNotModified()` re-verified unchanged on Spring Framework 7, deep cache vs shallow cache, and conditional `PUT` with `If-Match` as optimistic locking |
|
||||
| [`restclient-basic-auth/`](restclient-basic-auth) | [Spring Boot RestTemplate with Basic Auth: A Modern Guide](https://ankurm.com/spring-boot-resttemplate-with-basic-auth-a-modern-guide/) | RestClient with Basic Auth two ways against a real embedded server, `{noop}` passwords confirmed to emit no runtime warning at all, `spring-boot-starter-restclient` as its own required Boot 4 module, and the `RestClient.exchange()` trap covered in depth by the [RestTemplate to RestClient migration guide](https://ankurm.com/resttemplate-to-restclient-migration-guide/) |
|
||||
| [`core-di/`](core-di) | [Dependency Injection in Spring Boot 4: Constructor vs Setter vs Field (and Why Field Injection Hurts)](https://ankurm.com/spring-boot-4-dependency-injection-constructor-setter-field/) and [@Autowired Explained: By-Type Resolution, @Qualifier, @Primary, ObjectProvider and List Injection](https://ankurm.com/spring-autowired-qualifier-primary-objectprovider-list-injection/) | one `OrderService` in three injection styles built with plain `new`, a field-injected dependency used in a constructor, circular dependencies in plain Spring vs Boot with the real failure report, `@Lazy` injecting a proxy, and the `@Autowired` resolution ladder read from Spring 7.0.9 bytecode (name match beats `@Priority`), plus the empty-collection trap that only bites field and setter injection |
|
||||
| [`core-beans/`](core-beans) | [Spring Bean Scopes: Singleton, Prototype, Request, Session and the Prototype-in-Singleton Trap](https://ankurm.com/spring-bean-scopes-singleton-prototype-request-session-prototype-in-singleton-trap/) and [Spring Bean Lifecycle in Boot 4: @PostConstruct, InitializingBean, SmartLifecycle and Shutdown Order](https://ankurm.com/spring-bean-lifecycle-postconstruct-smartlifecycle-shutdown-order/) | instance counts per scope, five fixes for a prototype inside a singleton, request scope without a proxy failing at start-up, every lifecycle callback in order, `@PostConstruct` running before the proxy exists, `SmartLifecycle` phases measured, and a `SmartLifecycle` at the default phase stopping before Tomcat has drained |
|
||||
|
||||
`core-di` and `core-beans` are the exception: they have **no `docs/` folder**. Their deeper material lives in
|
||||
collapsible sections inside the articles themselves, and their captured output sits in a top-level `output/`
|
||||
directory instead of `docs/output/`.
|
||||
|
||||
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
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
target/
|
||||
*.class
|
||||
@@ -0,0 +1,80 @@
|
||||
# core-beans
|
||||
|
||||
Companion project for two articles on **[ankurm.com](https://ankurm.com)**.
|
||||
|
||||
| Article | What it demonstrates |
|
||||
|---|---|
|
||||
| [Spring Bean Scopes: Singleton, Prototype, Request, Session and the Prototype-in-Singleton Trap](https://ankurm.com/spring-bean-scopes-singleton-prototype-request-session-prototype-in-singleton-trap/) | instance counts for every scope, five ways to get a fresh prototype inside a singleton, a singleton that leaks one caller's data into another's, and request scope without a proxy failing at start-up |
|
||||
| [Spring Bean Lifecycle in Boot 4: @PostConstruct, InitializingBean, SmartLifecycle and Shutdown Order](https://ankurm.com/spring-bean-lifecycle-postconstruct-smartlifecycle-shutdown-order/) | every callback in order from a real run, `@PostConstruct` running before the proxy exists, the non-static `BeanPostProcessor` warning, `SmartLifecycle` phases measured, and a request in flight during graceful shutdown with virtual threads |
|
||||
|
||||
Every console block, exception message and count quoted in those articles came out of `output/`,
|
||||
and every file there is regenerated by one script. Most are written by the test suite, so if a
|
||||
claim stops being true the build goes red.
|
||||
|
||||
There is deliberately **no `docs/` folder**: the deeper material lives in collapsible "going
|
||||
deeper" sections inside the articles themselves, next to the paragraph each one extends.
|
||||
|
||||
## Versions
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Spring Boot | 4.1.1 |
|
||||
| Spring Framework | 7.0.9 |
|
||||
| JDK | 25 (Temurin 25.0.4.1+1) |
|
||||
| Maven | 3.9 |
|
||||
|
||||
## Quickstart
|
||||
|
||||
```bash
|
||||
export JAVA_HOME=/path/to/jdk-25
|
||||
mvn test # runs the scenarios and rewrites the test-written files in output/
|
||||
./scripts/run-all.sh # everything, including the script-captured files
|
||||
```
|
||||
|
||||
## Source layout
|
||||
|
||||
| Package | What it holds |
|
||||
|---|---|
|
||||
| `scopes/` | singleton, `@Lazy`, prototype, scoped-prototype and the five ways a singleton can obtain a prototype; the shared-state greeter |
|
||||
| `web/` | `@RequestScope`, `@SessionScope`, `@ApplicationScope` beans and a diagnostic `/scopes` controller, plus the un-proxied request bean that fails at start-up |
|
||||
| `lifecycle/` | `KitchenSink` (every callback), the post-processor traps, `PhasedWorker` and `NeverStops` for `SmartLifecycle` |
|
||||
| `shutdown/` | a web application with a `/slow` endpoint and two `SmartLifecycle` beans at different phases |
|
||||
|
||||
## Endpoints
|
||||
|
||||
Both endpoints exist only to be called by the tests. They have no authorisation. Delete them before shipping.
|
||||
|
||||
| Endpoint | Application | Purpose |
|
||||
|---|---|---|
|
||||
| `GET /scopes` | `WebScopesApp` | request, session and application instance serials, and the injected proxy class |
|
||||
| `GET /slow?ms=1500` | `ShutdownApp` | sleeps, so a request can be in flight when the context closes |
|
||||
|
||||
## Captured output
|
||||
|
||||
Files 01-17 and 19 (tests) and 18 (`capture-metadata.sh`). Timing rows assert coarse thresholds, not exact milliseconds; treat them as indicative.
|
||||
|
||||
| File | What it shows |
|
||||
|---|---|
|
||||
| [`01-instance-counts.txt`](output/01-instance-counts.txt) | How many instances did the container really construct? |
|
||||
| [`02-prototype-destroy.txt`](output/02-prototype-destroy.txt) | @PreDestroy on a singleton and on a prototype, then context.close() |
|
||||
| [`03-prototype-in-singleton.txt`](output/03-prototype-in-singleton.txt) | A singleton calls use() five times. How many prototype instances did it touch? |
|
||||
| [`04-singleton-thread-safety.txt`](output/04-singleton-thread-safety.txt) | Two threads call the same singleton; a latch forces the interleaving |
|
||||
| [`05-web-scopes.txt`](output/05-web-scopes.txt) | request, session and application scope over real HTTP |
|
||||
| [`06-request-scope-without-proxy.txt`](output/06-request-scope-without-proxy.txt) | A request-scoped bean (no proxy) injected into a singleton |
|
||||
| [`07-full-callback-order.txt`](output/07-full-callback-order.txt) | Every callback for one bean, from constructor to the last destroy hook (SpringApplication, no web server) |
|
||||
| [`08-non-static-bpp-warning.txt`](output/08-non-static-bpp-warning.txt) | A BeanPostProcessor declared with a non-static @Bean method |
|
||||
| [`09-postconstruct-before-proxy.txt`](output/09-postconstruct-before-proxy.txt) | @PostConstruct runs before the @Async proxy exists |
|
||||
| [`10-postconstruct-failure.txt`](output/10-postconstruct-failure.txt) | An exception thrown from @PostConstruct |
|
||||
| [`11-smartlifecycle-phases.txt`](output/11-smartlifecycle-phases.txt) | Three SmartLifecycle beans registered in the order 300, 100, 200 |
|
||||
| [`12-blocking-vs-async-stop.txt`](output/12-blocking-vs-async-stop.txt) | Three SmartLifecycle beans in the SAME phase, each needing 400 ms to stop |
|
||||
| [`13-plain-lifecycle.txt`](output/13-plain-lifecycle.txt) | Lifecycle vs SmartLifecycle: who starts at refresh()? |
|
||||
| [`14-shutdown-timeout.txt`](output/14-shutdown-timeout.txt) | A SmartLifecycle whose stop(callback) never calls the callback, timeout 500 ms |
|
||||
| [`15-smartlifecycle-beans-in-boot.txt`](output/15-smartlifecycle-beans-in-boot.txt) | Every SmartLifecycle bean in a Boot web application, highest phase (stops first) at the top |
|
||||
| [`16-graceful-shutdown-in-flight.txt`](output/16-graceful-shutdown-in-flight.txt) | A /slow?ms=1500 request is in flight when the context closes |
|
||||
| [`17-worker-phase-vs-web-server.txt`](output/17-worker-phase-vs-web-server.txt) | SmartLifecycle beans with the default phase and with phase 1000, while a request is in flight |
|
||||
| [`18-property-defaults.txt`](output/18-property-defaults.txt) | Property defaults read from spring-configuration-metadata.json (Boot 4.1.1 jars) |
|
||||
| [`19-bean-post-processors.txt`](output/19-bean-post-processors.txt) | The BeanPostProcessors registered in a plain Spring Boot context, in the order they run |
|
||||
|
||||
## Licence
|
||||
|
||||
MIT, see the repository root.
|
||||
@@ -0,0 +1,14 @@
|
||||
# How many instances did the container really construct?
|
||||
|
||||
right after refresh():
|
||||
singleton instances : 1
|
||||
@Lazy singleton : 0
|
||||
prototype : 0
|
||||
|
||||
after getBean() twice for singleton and lazy, three times for prototype:
|
||||
singleton instances : 1 same object both times: true
|
||||
@Lazy singleton : 1 same object both times: true
|
||||
prototype : 3 serials: 1, 2, 3
|
||||
|
||||
two separate contexts, each with SingletonBean:
|
||||
singleton instances : 2 same object across contexts: false
|
||||
@@ -0,0 +1,4 @@
|
||||
# @PreDestroy on a singleton and on a prototype, then context.close()
|
||||
|
||||
prototype instances created: 1
|
||||
callbacks after close(): [singleton @PreDestroy called]
|
||||
@@ -0,0 +1,11 @@
|
||||
# A singleton calls use() five times. How many prototype instances did it touch?
|
||||
|
||||
how the prototype is obtained serials seen instances built
|
||||
---------------------------------- ---------------------- ---------------
|
||||
constructor injection (naive) [1, 1, 1, 1, 1] 1
|
||||
ObjectProvider.getObject() [1, 2, 3, 4, 5] 5
|
||||
@Lookup method [1, 2, 3, 4, 5] 5
|
||||
scoped proxy (TARGET_CLASS) [1, 2, 3, 4, 5] 5
|
||||
ApplicationContext.getBean() [1, 2, 3, 4, 5] 5
|
||||
|
||||
ProxyConsumer's injected reference is a com.ankurm.corebeans.scopes.ScopedPrototypeBean$$SpringCGLIB$$0
|
||||
@@ -0,0 +1,4 @@
|
||||
# Two threads call the same singleton; a latch forces the interleaving
|
||||
|
||||
UnsafeGreeter (state in a field) : alice's call returned "Hello, bob"
|
||||
SafeGreeter (state in a local) : alice's call returned "Hello, alice"
|
||||
@@ -0,0 +1,10 @@
|
||||
# request, session and application scope over real HTTP
|
||||
|
||||
client response
|
||||
A #1 request=1 session=1 application=1 injectedRequestClass=RequestBean$$SpringCGLIB$$0
|
||||
A #2 request=2 session=1 application=1 injectedRequestClass=RequestBean$$SpringCGLIB$$0
|
||||
A #3 request=3 session=1 application=1 injectedRequestClass=RequestBean$$SpringCGLIB$$0
|
||||
B #1 request=4 session=2 application=1 injectedRequestClass=RequestBean$$SpringCGLIB$$0
|
||||
B #2 request=5 session=2 application=1 injectedRequestClass=RequestBean$$SpringCGLIB$$0
|
||||
|
||||
instances constructed: request=5 session=2 application=1
|
||||
@@ -0,0 +1,6 @@
|
||||
# A request-scoped bean (no proxy) injected into a singleton
|
||||
|
||||
top exception : org.springframework.beans.factory.UnsatisfiedDependencyException
|
||||
top message : Error creating bean with name 'brokenRequestConsumer': Unsatisfied dependency expressed through constructor parameter 0: Error creating bean with name 'brokenRequestConsumer.RawRequestBean': Scope 'request' is not active for the current thread; consider defining a scoped proxy for this bean if you intend to refer to it from a singleton
|
||||
|
||||
root cause : java.lang.IllegalStateException: No thread-bound request found: Are you referring to request attributes outside of an actual web request, or processing a request outside of the originally receiving thread? If you are actually operating within a web request and still receive this message, your code is probably running outside of DispatcherServlet: In this case, use RequestContextListener or RequestContextFilter to expose the current request.
|
||||
@@ -0,0 +1,26 @@
|
||||
# Every callback for one bean, from constructor to the last destroy hook (SpringApplication, no web server)
|
||||
|
||||
=== startup ===
|
||||
constructor
|
||||
setter injection (@Autowired setDependency)
|
||||
BeanNameAware.setBeanName("kitchenSink")
|
||||
BeanFactoryAware.setBeanFactory
|
||||
ApplicationContextAware.setApplicationContext
|
||||
BeanPostProcessor.postProcessBeforeInitialization
|
||||
@PostConstruct
|
||||
InitializingBean.afterPropertiesSet
|
||||
@Bean(initMethod = "customInit")
|
||||
BeanPostProcessor.postProcessAfterInitialization
|
||||
SmartInitializingSingleton.afterSingletonsInstantiated
|
||||
SmartLifecycle.start (phase 2147483647)
|
||||
ContextRefreshedEvent
|
||||
ApplicationStartedEvent
|
||||
ApplicationRunner.run
|
||||
ApplicationReadyEvent
|
||||
|
||||
=== ctx.close() ===
|
||||
ContextClosedEvent
|
||||
SmartLifecycle.stop
|
||||
@PreDestroy
|
||||
DisposableBean.destroy
|
||||
@Bean(destroyMethod = "customDestroy")
|
||||
@@ -0,0 +1,3 @@
|
||||
# A BeanPostProcessor declared with a non-static @Bean method
|
||||
|
||||
2026-09-24T10:28:59.979+05:30 WARN 3358 --- [core-beans] [ main] trationDelegate$BeanPostProcessorChecker : Bean 'nonStaticProcessorConfig' of type [com.ankurm.corebeans.lifecycle.NonStaticProcessorConfig$$SpringCGLIB$$0] is not eligible for getting processed by all BeanPostProcessors (for example: not eligible for auto-proxying). The currently created BeanPostProcessor [quietProcessor] is declared through a non-static factory method on that class; consider declaring it as static instead.
|
||||
@@ -0,0 +1,4 @@
|
||||
# @PostConstruct runs before the @Async proxy exists
|
||||
|
||||
@PostConstruct sees this = com.ankurm.corebeans.lifecycle.AsyncMailer, isAopProxy(this) = false
|
||||
the bean other code receives : com.ankurm.corebeans.lifecycle.AsyncMailer$$SpringCGLIB$$0
|
||||
@@ -0,0 +1,6 @@
|
||||
# An exception thrown from @PostConstruct
|
||||
|
||||
context started: false
|
||||
top exception : org.springframework.beans.factory.BeanCreationException
|
||||
top message : Error creating bean with name 'failingInit': Invocation of init method failed
|
||||
root cause : java.lang.IllegalStateException: cache warm-up failed
|
||||
@@ -0,0 +1,10 @@
|
||||
# Three SmartLifecycle beans registered in the order 300, 100, 200
|
||||
|
||||
=== refresh() ===
|
||||
start A (phase 100)
|
||||
start B (phase 200)
|
||||
start C (phase 300)
|
||||
=== close() ===
|
||||
stop C (phase 300)
|
||||
stop B (phase 200)
|
||||
stop A (phase 100)
|
||||
@@ -0,0 +1,4 @@
|
||||
# Three SmartLifecycle beans in the SAME phase, each needing 400 ms to stop
|
||||
|
||||
stop(Runnable) blocks (the default) : close() took >= 1100 ms (sequential)
|
||||
stop(Runnable) returns immediately : close() took < 800 ms (concurrent)
|
||||
@@ -0,0 +1,4 @@
|
||||
# Lifecycle vs SmartLifecycle: who starts at refresh()?
|
||||
|
||||
after refresh() : events=[] isRunning=false
|
||||
after start() : events=[PlainLifecycle.start] isRunning=true
|
||||
@@ -0,0 +1,4 @@
|
||||
# A SmartLifecycle whose stop(callback) never calls the callback, timeout 500 ms
|
||||
|
||||
close() returned after roughly the timeout: true
|
||||
2026-09-24T10:28:58.263+05:30 INFO 3358 --- [core-beans] [ main] o.s.c.support.DefaultLifecycleProcessor : Shutdown phase 2147483647 ends with 1 bean still running after timeout of 500ms: [neverStops]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Every SmartLifecycle bean in a Boot web application, highest phase (stops first) at the top
|
||||
|
||||
phase bean name class
|
||||
2147483647 earlyWorker EarlyWorker
|
||||
2147482623 webServerGracefulShutdown WebServerGracefulShutdownLifecycle
|
||||
2147481599 webServerStartStop WebServerStartStopLifecycle
|
||||
1073741823 applicationTaskExecutor ThreadPoolTaskExecutor
|
||||
1000 lateWorker LateWorker
|
||||
-2147483647 springBootLoggingLifecycle Lifecycle
|
||||
|
||||
SmartLifecycle.DEFAULT_PHASE = 2147483647
|
||||
@@ -0,0 +1,7 @@
|
||||
# A /slow?ms=1500 request is in flight when the context closes
|
||||
|
||||
settings the client saw close() blocked handler ran to the end
|
||||
-------------------------------------------- ---------------------------------------------------- -------------------------------- ----------------------
|
||||
server.shutdown=graceful, virtual threads HTTP 200: finished after 1500 ms, virtual thread = true >= 1 s (waited for the request) yes
|
||||
server.shutdown=graceful, platform threads HTTP 200: finished after 1500 ms, virtual thread = false >= 1 s (waited for the request) yes
|
||||
server.shutdown=immediate, virtual threads FAILED: connection dropped, no response >= 1 s (waited for the request) yes
|
||||
@@ -0,0 +1,5 @@
|
||||
# SmartLifecycle beans with the default phase and with phase 1000, while a request is in flight
|
||||
|
||||
EarlyWorker.stop (phase 2147483647): requests still in flight = 1
|
||||
LateWorker.stop (phase 1000): requests still in flight = 0
|
||||
the client saw: HTTP 200: finished after 1500 ms, virtual thread = true
|
||||
@@ -0,0 +1,5 @@
|
||||
# Property defaults read from spring-configuration-metadata.json (Boot 4.1.1 jars)
|
||||
|
||||
spring.lifecycle.timeout-per-shutdown-phase type=java.time.Duration default=30s
|
||||
spring.threads.virtual.enabled type=java.lang.Boolean default=False
|
||||
server.shutdown type=org.springframework.boot.web.server.Shutdown default=graceful
|
||||
@@ -0,0 +1,9 @@
|
||||
# The BeanPostProcessors registered in a plain Spring Boot context, in the order they run
|
||||
|
||||
1 org.springframework.context.support.ApplicationContextAwareProcessor
|
||||
2 org.springframework.context.annotation.ConfigurationClassPostProcessor$ImportAwareBeanPostProcessor
|
||||
3 org.springframework.context.support.PostProcessorRegistrationDelegate$BeanPostProcessorChecker
|
||||
4 com.ankurm.corebeans.lifecycle.TracingPostProcessor
|
||||
5 org.springframework.context.annotation.CommonAnnotationBeanPostProcessor
|
||||
6 org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor
|
||||
7 org.springframework.context.support.ApplicationListenerDetector
|
||||
@@ -0,0 +1,48 @@
|
||||
<?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>core-beans</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<name>core-beans</name>
|
||||
<description>Spring bean scopes and the bean lifecycle in Spring Boot 4</description>
|
||||
|
||||
<properties>
|
||||
<java.version>25</java.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter</artifactId>
|
||||
</dependency>
|
||||
<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>
|
||||
Executable
+25
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env bash
|
||||
# Reads the shutdown-related property defaults out of Boot's own configuration metadata, and the
|
||||
# SmartLifecycle phase constants out of the Boot jars with javap, instead of trusting doc prose.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
mkdir -p output target/meta
|
||||
BOOT=4.1.1
|
||||
M2=~/.m2/repository/org/springframework/boot
|
||||
{
|
||||
echo "# Property defaults read from spring-configuration-metadata.json (Boot $BOOT jars)"
|
||||
echo
|
||||
for jar in $(find "$M2" -name "*-$BOOT.jar" ! -name '*sources*' ! -name '*javadoc*' | sort); do
|
||||
{ unzip -p "$jar" META-INF/spring-configuration-metadata.json 2>/dev/null || true; } | python3 -c '
|
||||
import json,sys
|
||||
try:
|
||||
d=json.load(sys.stdin)
|
||||
except Exception:
|
||||
sys.exit(0)
|
||||
for p in d.get("properties",[]):
|
||||
if p["name"] in ("server.shutdown","spring.lifecycle.timeout-per-shutdown-phase","spring.threads.virtual.enabled"):
|
||||
print("%-46s type=%-40s default=%s" % (p["name"], p.get("type"), p.get("defaultValue")))
|
||||
'
|
||||
done
|
||||
} > output/18-property-defaults.txt
|
||||
cat output/18-property-defaults.txt
|
||||
Executable
+20
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regenerates every file under output/.
|
||||
#
|
||||
# ./scripts/run-all.sh
|
||||
#
|
||||
# Needs a JDK 25 and Maven 3.9. Transcripts 01-17 and 19 come out of the test suite, which is the point:
|
||||
# the figures in the two articles are assertions that fail the build if they stop being true.
|
||||
# Timing-based rows (12, 16) assert coarse thresholds, not exact milliseconds.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
echo "== test suite (transcripts 01-17)"
|
||||
mvn -B test
|
||||
|
||||
echo "== property defaults read from Boot's metadata (18)"
|
||||
./scripts/capture-metadata.sh
|
||||
|
||||
echo
|
||||
echo "output:"
|
||||
ls -1 output
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.ankurm.corebeans;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/** A shared event log so a test can print the order in which Spring touches a bean. */
|
||||
public final class Trace {
|
||||
|
||||
private static final List<String> EVENTS = Collections.synchronizedList(new ArrayList<>());
|
||||
|
||||
private Trace() {
|
||||
}
|
||||
|
||||
public static void log(String event) {
|
||||
EVENTS.add(event);
|
||||
}
|
||||
|
||||
public static List<String> drain() {
|
||||
List<String> copy;
|
||||
synchronized (EVENTS) {
|
||||
copy = new ArrayList<>(EVENTS);
|
||||
EVENTS.clear();
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.ankurm.corebeans.lifecycle;
|
||||
|
||||
import com.ankurm.corebeans.Trace;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import org.springframework.aop.support.AopUtils;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.scheduling.annotation.EnableAsync;
|
||||
|
||||
/** A bean that gets proxied ({@code @Async}); @PostConstruct runs on the raw target, before the proxy exists. */
|
||||
public class AsyncMailer {
|
||||
|
||||
@PostConstruct
|
||||
void init() {
|
||||
Trace.log("@PostConstruct sees this = " + getClass().getName() + ", isAopProxy(this) = "
|
||||
+ AopUtils.isAopProxy(this));
|
||||
}
|
||||
|
||||
@Async
|
||||
public void send() {
|
||||
}
|
||||
|
||||
@org.springframework.context.annotation.Configuration
|
||||
@EnableAsync
|
||||
public static class Config {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.ankurm.corebeans.lifecycle;
|
||||
|
||||
public class Dependency {
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.ankurm.corebeans.lifecycle;
|
||||
|
||||
import com.ankurm.corebeans.Trace;
|
||||
import org.springframework.boot.ApplicationArguments;
|
||||
import org.springframework.boot.ApplicationRunner;
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.boot.context.event.ApplicationStartedEvent;
|
||||
import org.springframework.context.event.ContextClosedEvent;
|
||||
import org.springframework.context.event.ContextRefreshedEvent;
|
||||
import org.springframework.context.event.EventListener;
|
||||
|
||||
/** Where the application events fall between the bean callbacks. Only fires under SpringApplication. */
|
||||
public class EventLogger implements ApplicationRunner {
|
||||
|
||||
@EventListener
|
||||
void refreshed(ContextRefreshedEvent e) {
|
||||
Trace.log("ContextRefreshedEvent");
|
||||
}
|
||||
|
||||
@EventListener
|
||||
void started(ApplicationStartedEvent e) {
|
||||
Trace.log("ApplicationStartedEvent");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run(ApplicationArguments args) {
|
||||
Trace.log("ApplicationRunner.run");
|
||||
}
|
||||
|
||||
@EventListener
|
||||
void ready(ApplicationReadyEvent e) {
|
||||
Trace.log("ApplicationReadyEvent");
|
||||
}
|
||||
|
||||
@EventListener
|
||||
void closing(ContextClosedEvent e) {
|
||||
Trace.log("ContextClosedEvent");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.ankurm.corebeans.lifecycle;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
|
||||
public class FailingInit {
|
||||
|
||||
@PostConstruct
|
||||
void warmUp() {
|
||||
throw new IllegalStateException("cache warm-up failed");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package com.ankurm.corebeans.lifecycle;
|
||||
|
||||
import com.ankurm.corebeans.Trace;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import org.springframework.beans.BeansException;
|
||||
import org.springframework.beans.factory.BeanFactory;
|
||||
import org.springframework.beans.factory.BeanFactoryAware;
|
||||
import org.springframework.beans.factory.BeanNameAware;
|
||||
import org.springframework.beans.factory.DisposableBean;
|
||||
import org.springframework.beans.factory.InitializingBean;
|
||||
import org.springframework.beans.factory.SmartInitializingSingleton;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.context.ApplicationContextAware;
|
||||
import org.springframework.context.SmartLifecycle;
|
||||
|
||||
/**
|
||||
* One bean that implements every lifecycle hook Spring offers, each one logging when it is called.
|
||||
* Nobody should write a class like this; it exists so the order can be printed from a real run.
|
||||
*/
|
||||
public class KitchenSink implements BeanNameAware, BeanFactoryAware, ApplicationContextAware,
|
||||
InitializingBean, DisposableBean, SmartInitializingSingleton, SmartLifecycle {
|
||||
|
||||
private volatile boolean running;
|
||||
|
||||
public KitchenSink() {
|
||||
Trace.log("constructor");
|
||||
}
|
||||
|
||||
@Autowired
|
||||
public void setDependency(Dependency dependency) {
|
||||
Trace.log("setter injection (@Autowired setDependency)");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setBeanName(String name) {
|
||||
Trace.log("BeanNameAware.setBeanName(\"" + name + "\")");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setBeanFactory(BeanFactory beanFactory) throws BeansException {
|
||||
Trace.log("BeanFactoryAware.setBeanFactory");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
|
||||
Trace.log("ApplicationContextAware.setApplicationContext");
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
void postConstruct() {
|
||||
Trace.log("@PostConstruct");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterPropertiesSet() {
|
||||
Trace.log("InitializingBean.afterPropertiesSet");
|
||||
}
|
||||
|
||||
/** Named by {@code @Bean(initMethod = "customInit")}. */
|
||||
public void customInit() {
|
||||
Trace.log("@Bean(initMethod = \"customInit\")");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterSingletonsInstantiated() {
|
||||
Trace.log("SmartInitializingSingleton.afterSingletonsInstantiated");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start() {
|
||||
running = true;
|
||||
Trace.log("SmartLifecycle.start (phase " + getPhase() + ")");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop() {
|
||||
running = false;
|
||||
Trace.log("SmartLifecycle.stop");
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isRunning() {
|
||||
return running;
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
void preDestroy() {
|
||||
Trace.log("@PreDestroy");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void destroy() {
|
||||
Trace.log("DisposableBean.destroy");
|
||||
}
|
||||
|
||||
/** Named by {@code @Bean(destroyMethod = "customDestroy")}. */
|
||||
public void customDestroy() {
|
||||
Trace.log("@Bean(destroyMethod = \"customDestroy\")");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.ankurm.corebeans.lifecycle;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
public class LifecycleConfig {
|
||||
|
||||
@Bean
|
||||
static TracingPostProcessor tracingPostProcessor() {
|
||||
return new TracingPostProcessor();
|
||||
}
|
||||
|
||||
@Bean
|
||||
Dependency dependency() {
|
||||
return new Dependency();
|
||||
}
|
||||
|
||||
@Bean(initMethod = "customInit", destroyMethod = "customDestroy")
|
||||
KitchenSink kitchenSink() {
|
||||
return new KitchenSink();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.ankurm.corebeans.lifecycle;
|
||||
|
||||
import org.springframework.context.SmartLifecycle;
|
||||
|
||||
/** A SmartLifecycle whose asynchronous stop never invokes the callback: shutdown must time out. */
|
||||
public class NeverStops implements SmartLifecycle {
|
||||
|
||||
private volatile boolean running;
|
||||
|
||||
@Override
|
||||
public void start() {
|
||||
running = true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop() {
|
||||
running = false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop(Runnable callback) {
|
||||
// forgot to call callback.run()
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isRunning() {
|
||||
return running;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.ankurm.corebeans.lifecycle;
|
||||
|
||||
import org.springframework.beans.factory.config.BeanPostProcessor;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/** A BeanPostProcessor declared with a NON-static {@code @Bean} method: the trap. */
|
||||
@Configuration
|
||||
public class NonStaticProcessorConfig {
|
||||
|
||||
@Bean
|
||||
BeanPostProcessor quietProcessor() {
|
||||
return new BeanPostProcessor() {
|
||||
};
|
||||
}
|
||||
|
||||
@Bean
|
||||
Dependency plainBean() {
|
||||
return new Dependency();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.ankurm.corebeans.lifecycle;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
import com.ankurm.corebeans.Trace;
|
||||
import org.springframework.context.SmartLifecycle;
|
||||
|
||||
/** A SmartLifecycle with a chosen phase and a stop that takes a chosen time, blocking or asynchronous. */
|
||||
public class PhasedWorker implements SmartLifecycle {
|
||||
|
||||
private final String name;
|
||||
private final int phase;
|
||||
private final long stopMillis;
|
||||
private final boolean asyncStop;
|
||||
private final AtomicBoolean running = new AtomicBoolean();
|
||||
|
||||
public PhasedWorker(String name, int phase, long stopMillis, boolean asyncStop) {
|
||||
this.name = name;
|
||||
this.phase = phase;
|
||||
this.stopMillis = stopMillis;
|
||||
this.asyncStop = asyncStop;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getPhase() {
|
||||
return phase;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start() {
|
||||
running.set(true);
|
||||
Trace.log("start " + name + " (phase " + phase + ")");
|
||||
}
|
||||
|
||||
/** Blocking variant: Spring's default stop(Runnable) calls this, then the callback, on its own thread. */
|
||||
@Override
|
||||
public void stop() {
|
||||
sleep(stopMillis);
|
||||
running.set(false);
|
||||
Trace.log("stop " + name + " (phase " + phase + ")");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop(Runnable callback) {
|
||||
if (!asyncStop) {
|
||||
SmartLifecycle.super.stop(callback);
|
||||
return;
|
||||
}
|
||||
Thread.ofVirtual().start(() -> {
|
||||
sleep(stopMillis);
|
||||
running.set(false);
|
||||
Trace.log("stop " + name + " (phase " + phase + ", async callback)");
|
||||
callback.run();
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isRunning() {
|
||||
return running.get();
|
||||
}
|
||||
|
||||
private static void sleep(long millis) {
|
||||
try {
|
||||
Thread.sleep(millis);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.ankurm.corebeans.lifecycle;
|
||||
|
||||
import com.ankurm.corebeans.Trace;
|
||||
import org.springframework.context.Lifecycle;
|
||||
|
||||
/** A plain Lifecycle (not Smart): started only by an explicit context.start(), never by refresh. */
|
||||
public class PlainLifecycle implements Lifecycle {
|
||||
|
||||
private boolean running;
|
||||
|
||||
@Override
|
||||
public void start() {
|
||||
running = true;
|
||||
Trace.log("PlainLifecycle.start");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop() {
|
||||
running = false;
|
||||
Trace.log("PlainLifecycle.stop");
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isRunning() {
|
||||
return running;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.ankurm.corebeans.lifecycle;
|
||||
|
||||
import com.ankurm.corebeans.Trace;
|
||||
import org.springframework.beans.BeansException;
|
||||
import org.springframework.beans.factory.config.BeanPostProcessor;
|
||||
|
||||
/** Logs the two BeanPostProcessor callbacks, but only for the bean named kitchenSink. */
|
||||
public class TracingPostProcessor implements BeanPostProcessor {
|
||||
|
||||
@Override
|
||||
public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {
|
||||
if (beanName.equals("kitchenSink")) {
|
||||
Trace.log("BeanPostProcessor.postProcessBeforeInitialization");
|
||||
}
|
||||
return bean;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
|
||||
if (beanName.equals("kitchenSink")) {
|
||||
Trace.log("BeanPostProcessor.postProcessAfterInitialization");
|
||||
}
|
||||
return bean;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.ankurm.corebeans.scopes;
|
||||
|
||||
import org.springframework.context.ApplicationContext;
|
||||
|
||||
/** Fix 4 (the one to avoid): reach back into the container. It works and couples the class to Spring. */
|
||||
public class ContextConsumer {
|
||||
|
||||
private final ApplicationContext context;
|
||||
|
||||
public ContextConsumer(ApplicationContext context) {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
public int use() {
|
||||
return context.getBean(PrototypeBean.class).serial();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.ankurm.corebeans.scopes;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/** Counts how many instances of each demo bean the container has actually constructed. */
|
||||
public final class Instances {
|
||||
|
||||
private static final Map<String, AtomicInteger> COUNTS = new ConcurrentHashMap<>();
|
||||
|
||||
private Instances() {
|
||||
}
|
||||
|
||||
/** Called from a constructor; returns this instance's serial number (1, 2, 3, ...). */
|
||||
public static int next(String key) {
|
||||
return COUNTS.computeIfAbsent(key, k -> new AtomicInteger()).incrementAndGet();
|
||||
}
|
||||
|
||||
public static int count(String key) {
|
||||
AtomicInteger c = COUNTS.get(key);
|
||||
return c == null ? 0 : c.get();
|
||||
}
|
||||
|
||||
public static void reset() {
|
||||
COUNTS.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.ankurm.corebeans.scopes;
|
||||
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
|
||||
/** Still a singleton, but not created until something asks for it. */
|
||||
@Lazy
|
||||
public class LazySingletonBean {
|
||||
|
||||
private final int serial = Instances.next("lazy");
|
||||
|
||||
public int serial() {
|
||||
return serial;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.ankurm.corebeans.scopes;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Lookup;
|
||||
|
||||
/** Fix 2: Spring subclasses this at runtime and overrides the abstract method to call getBean(). */
|
||||
public abstract class LookupConsumer {
|
||||
|
||||
@Lookup
|
||||
protected abstract PrototypeBean create();
|
||||
|
||||
public int use() {
|
||||
return create().serial();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.ankurm.corebeans.scopes;
|
||||
|
||||
/** The trap: a singleton that receives a prototype through its constructor. */
|
||||
public class NaiveConsumer {
|
||||
|
||||
private final PrototypeBean prototype;
|
||||
|
||||
public NaiveConsumer(PrototypeBean prototype) {
|
||||
this.prototype = prototype;
|
||||
}
|
||||
|
||||
public int use() {
|
||||
return prototype.serial();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.ankurm.corebeans.scopes;
|
||||
|
||||
import jakarta.annotation.PreDestroy;
|
||||
|
||||
import com.ankurm.corebeans.Trace;
|
||||
import org.springframework.context.annotation.Scope;
|
||||
|
||||
/** A new instance every time the container is asked for one. */
|
||||
@Scope("prototype")
|
||||
public class PrototypeBean {
|
||||
|
||||
private final int serial = Instances.next("prototype");
|
||||
|
||||
public int serial() {
|
||||
return serial;
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
void destroy() {
|
||||
Trace.log("prototype @PreDestroy called");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.ankurm.corebeans.scopes;
|
||||
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
|
||||
/** Fix 1: inject a provider and ask it every time. */
|
||||
public class ProviderConsumer {
|
||||
|
||||
private final ObjectProvider<PrototypeBean> provider;
|
||||
|
||||
public ProviderConsumer(ObjectProvider<PrototypeBean> provider) {
|
||||
this.provider = provider;
|
||||
}
|
||||
|
||||
public int use() {
|
||||
return provider.getObject().serial();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.ankurm.corebeans.scopes;
|
||||
|
||||
/** Fix 3: inject a scoped proxy; the proxy fetches a fresh target for every call. */
|
||||
public class ProxyConsumer {
|
||||
|
||||
private final ScopedPrototypeBean prototype;
|
||||
|
||||
public ProxyConsumer(ScopedPrototypeBean prototype) {
|
||||
this.prototype = prototype;
|
||||
}
|
||||
|
||||
public int use() {
|
||||
return prototype.serial();
|
||||
}
|
||||
|
||||
public String injectedClass() {
|
||||
return prototype.getClass().getName();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.ankurm.corebeans.scopes;
|
||||
|
||||
/** The same singleton with the state in a parameter and a local variable: nothing shared to corrupt. */
|
||||
public class SafeGreeter {
|
||||
|
||||
public String greet(String user, Runnable pause) {
|
||||
String currentUser = user;
|
||||
pause.run();
|
||||
return "Hello, " + currentUser;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.ankurm.corebeans.scopes;
|
||||
|
||||
import org.springframework.context.annotation.Scope;
|
||||
import org.springframework.context.annotation.ScopedProxyMode;
|
||||
|
||||
/** Prototype scope behind a proxy: every method call on the injected reference reaches a new target. */
|
||||
@Scope(value = "prototype", proxyMode = ScopedProxyMode.TARGET_CLASS)
|
||||
public class ScopedPrototypeBean {
|
||||
|
||||
private final int serial = Instances.next("scopedPrototype");
|
||||
|
||||
public int serial() {
|
||||
return serial;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.ankurm.corebeans.scopes;
|
||||
|
||||
import jakarta.annotation.PreDestroy;
|
||||
|
||||
import com.ankurm.corebeans.Trace;
|
||||
|
||||
/** No {@code @Scope}: the default. One instance per container, created at start-up. */
|
||||
public class SingletonBean {
|
||||
|
||||
private final int serial = Instances.next("singleton");
|
||||
|
||||
public int serial() {
|
||||
return serial;
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
void destroy() {
|
||||
Trace.log("singleton @PreDestroy called");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.ankurm.corebeans.scopes;
|
||||
|
||||
/**
|
||||
* A singleton that keeps per-caller data in a field. The {@code pause} hook lets a test force the
|
||||
* interleaving that production traffic produces only occasionally, so the demonstration is exact.
|
||||
*/
|
||||
public class UnsafeGreeter {
|
||||
|
||||
private String currentUser;
|
||||
|
||||
public String greet(String user, Runnable pause) {
|
||||
this.currentUser = user;
|
||||
pause.run();
|
||||
return "Hello, " + currentUser;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.ankurm.corebeans.shutdown;
|
||||
|
||||
import com.ankurm.corebeans.Trace;
|
||||
import org.springframework.context.SmartLifecycle;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/** Leaves getPhase() at its default, which is Integer.MAX_VALUE. */
|
||||
@Component
|
||||
public class EarlyWorker implements SmartLifecycle {
|
||||
|
||||
private volatile boolean running;
|
||||
|
||||
@Override
|
||||
public void start() {
|
||||
running = true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop() {
|
||||
running = false;
|
||||
Trace.log("EarlyWorker.stop (phase " + getPhase() + "): requests still in flight = "
|
||||
+ SlowController.IN_FLIGHT.get());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isRunning() {
|
||||
return running;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.ankurm.corebeans.shutdown;
|
||||
|
||||
import com.ankurm.corebeans.Trace;
|
||||
import org.springframework.context.SmartLifecycle;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/** Phase 1000: far below the web server's phases, so it stops after the server has drained. */
|
||||
@Component
|
||||
public class LateWorker implements SmartLifecycle {
|
||||
|
||||
private volatile boolean running;
|
||||
|
||||
@Override
|
||||
public int getPhase() {
|
||||
return 1000;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start() {
|
||||
running = true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop() {
|
||||
running = false;
|
||||
Trace.log("LateWorker.stop (phase " + getPhase() + "): requests still in flight = "
|
||||
+ SlowController.IN_FLIGHT.get());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isRunning() {
|
||||
return running;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.ankurm.corebeans.shutdown;
|
||||
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
/** A web application with one slow endpoint and two SmartLifecycle beans, used by the shutdown tests. */
|
||||
@SpringBootApplication
|
||||
public class ShutdownApp {
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.ankurm.corebeans.shutdown;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/** Diagnostic only: delete before shipping. */
|
||||
@RestController
|
||||
public class SlowController {
|
||||
|
||||
/** How many /slow requests are executing right now. */
|
||||
public static final AtomicInteger IN_FLIGHT = new AtomicInteger();
|
||||
|
||||
/** How many /slow handlers ran to the end, whether or not the client was still listening. */
|
||||
public static final AtomicInteger COMPLETED = new AtomicInteger();
|
||||
|
||||
@GetMapping("/slow")
|
||||
public String slow(@RequestParam long ms) throws InterruptedException {
|
||||
IN_FLIGHT.incrementAndGet();
|
||||
try {
|
||||
Thread.sleep(ms);
|
||||
COMPLETED.incrementAndGet();
|
||||
return "finished after " + ms + " ms, virtual thread = " + Thread.currentThread().isVirtual();
|
||||
} finally {
|
||||
IN_FLIGHT.decrementAndGet();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.ankurm.corebeans.web;
|
||||
|
||||
import com.ankurm.corebeans.scopes.Instances;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.context.annotation.ApplicationScope;
|
||||
|
||||
/** One per ServletContext. In a single Boot application that is one per JVM, so it looks like a singleton. */
|
||||
@Component
|
||||
@ApplicationScope
|
||||
public class ApplicationBean {
|
||||
|
||||
private final int serial = Instances.next("application");
|
||||
|
||||
public int serial() {
|
||||
return serial;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.ankurm.corebeans.web;
|
||||
|
||||
import org.springframework.context.annotation.Scope;
|
||||
|
||||
/** A singleton that takes a request-scoped bean WITHOUT a scoped proxy: the classic start-up failure. */
|
||||
public class BrokenRequestConsumer {
|
||||
|
||||
/** Registered by hand in the test, with proxyMode left at its default (NO). */
|
||||
@Scope("request")
|
||||
public static class RawRequestBean {
|
||||
}
|
||||
|
||||
public BrokenRequestConsumer(RawRequestBean bean) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.ankurm.corebeans.web;
|
||||
|
||||
import com.ankurm.corebeans.scopes.Instances;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.context.annotation.RequestScope;
|
||||
|
||||
/** {@code @RequestScope} is {@code @Scope("request")} plus a TARGET_CLASS scoped proxy. */
|
||||
@Component
|
||||
@RequestScope
|
||||
public class RequestBean {
|
||||
|
||||
private final int serial = Instances.next("request");
|
||||
|
||||
public int serial() {
|
||||
return serial;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.ankurm.corebeans.web;
|
||||
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* A singleton controller holding three differently scoped collaborators. The injected references
|
||||
* are scoped proxies, so each request reaches the instance that belongs to it.
|
||||
* Diagnostic only: delete before shipping.
|
||||
*/
|
||||
@RestController
|
||||
public class ScopesController {
|
||||
|
||||
private final RequestBean request;
|
||||
private final SessionBean session;
|
||||
private final ApplicationBean application;
|
||||
|
||||
public ScopesController(RequestBean request, SessionBean session, ApplicationBean application) {
|
||||
this.request = request;
|
||||
this.session = session;
|
||||
this.application = application;
|
||||
}
|
||||
|
||||
@GetMapping("/scopes")
|
||||
public String scopes() {
|
||||
return "request=" + request.serial() + " session=" + session.serial() + " application="
|
||||
+ application.serial() + " injectedRequestClass=" + request.getClass().getSimpleName();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.ankurm.corebeans.web;
|
||||
|
||||
import com.ankurm.corebeans.scopes.Instances;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.context.annotation.SessionScope;
|
||||
|
||||
@Component
|
||||
@SessionScope
|
||||
public class SessionBean {
|
||||
|
||||
private final int serial = Instances.next("session");
|
||||
|
||||
public int serial() {
|
||||
return serial;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.ankurm.corebeans.web;
|
||||
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
/** A minimal servlet application, started by the tests. Scans only this package. */
|
||||
@SpringBootApplication
|
||||
public class WebScopesApp {
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
spring:
|
||||
application:
|
||||
name: core-beans
|
||||
main:
|
||||
banner-mode: off
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.ankurm.corebeans;
|
||||
|
||||
import java.util.concurrent.Callable;
|
||||
|
||||
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
|
||||
|
||||
/** Small helpers so each test reads as the scenario, not the plumbing. */
|
||||
public final class Ctx {
|
||||
|
||||
private Ctx() {
|
||||
}
|
||||
|
||||
/** A plain Spring context (no Boot) with the given classes registered and refreshed. */
|
||||
public static AnnotationConfigApplicationContext plain(Class<?>... classes) {
|
||||
var ctx = new AnnotationConfigApplicationContext();
|
||||
ctx.register(classes);
|
||||
ctx.refresh();
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/** Refreshes a context and returns either "started" or the exception, so failures can be printed. */
|
||||
public static Outcome tryStart(Class<?>... classes) {
|
||||
var ctx = new AnnotationConfigApplicationContext();
|
||||
try {
|
||||
ctx.register(classes);
|
||||
ctx.refresh();
|
||||
return new Outcome(ctx, null);
|
||||
} catch (RuntimeException e) {
|
||||
ctx.close();
|
||||
return new Outcome(null, e);
|
||||
}
|
||||
}
|
||||
|
||||
public static <T> String attempt(Callable<T> call) {
|
||||
try {
|
||||
return "OK -> " + call.call();
|
||||
} catch (Exception e) {
|
||||
return e.getClass().getSimpleName() + ": " + e.getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
public static Throwable root(Throwable t) {
|
||||
while (t.getCause() != null && t.getCause() != t) {
|
||||
t = t.getCause();
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
public record Outcome(AnnotationConfigApplicationContext context, RuntimeException failure) {
|
||||
|
||||
public boolean started() {
|
||||
return failure == null;
|
||||
}
|
||||
|
||||
public void closeQuietly() {
|
||||
if (context != null) {
|
||||
context.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.ankurm.corebeans;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
import com.ankurm.corebeans.shutdown.ShutdownApp;
|
||||
import com.ankurm.corebeans.shutdown.SlowController;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.WebApplicationType;
|
||||
import org.springframework.boot.builder.SpringApplicationBuilder;
|
||||
import org.springframework.context.ConfigurableApplicationContext;
|
||||
|
||||
/** Post 19: what happens to a request that is mid-flight when the context closes. */
|
||||
class GracefulShutdownTest {
|
||||
|
||||
@BeforeEach
|
||||
void reset() {
|
||||
Trace.drain();
|
||||
SlowController.COMPLETED.set(0);
|
||||
}
|
||||
|
||||
private record Result(String response, long closeMillis, int handlersCompleted) {
|
||||
}
|
||||
|
||||
private static Result runAndClose(String shutdownMode, boolean virtualThreads) throws Exception {
|
||||
ConfigurableApplicationContext ctx = new SpringApplicationBuilder(ShutdownApp.class)
|
||||
.web(WebApplicationType.SERVLET)
|
||||
.properties("server.port=0", "logging.level.root=OFF", "spring.main.banner-mode=off",
|
||||
"server.shutdown=" + shutdownMode,
|
||||
"spring.threads.virtual.enabled=" + virtualThreads,
|
||||
"spring.lifecycle.timeout-per-shutdown-phase=10s")
|
||||
.run();
|
||||
int port = Integer.parseInt(ctx.getEnvironment().getProperty("local.server.port"));
|
||||
HttpClient client = HttpClient.newHttpClient();
|
||||
CompletableFuture<String> pending = client
|
||||
.sendAsync(HttpRequest.newBuilder(URI.create("http://localhost:" + port + "/slow?ms=1500")).build(),
|
||||
HttpResponse.BodyHandlers.ofString())
|
||||
.thenApply(r -> "HTTP " + r.statusCode() + ": " + r.body())
|
||||
.exceptionally(e -> "FAILED: connection dropped, no response");
|
||||
long deadline = System.currentTimeMillis() + 5000;
|
||||
while (SlowController.IN_FLIGHT.get() == 0 && System.currentTimeMillis() < deadline) {
|
||||
Thread.sleep(10);
|
||||
}
|
||||
assertThat(SlowController.IN_FLIGHT.get()).isEqualTo(1);
|
||||
long begin = System.nanoTime();
|
||||
ctx.close();
|
||||
long closeMs = (System.nanoTime() - begin) / 1_000_000;
|
||||
String response = pending.get();
|
||||
int completed = SlowController.COMPLETED.get();
|
||||
SlowController.COMPLETED.set(0);
|
||||
return new Result(response, closeMs, completed);
|
||||
}
|
||||
|
||||
@Test
|
||||
void inFlightRequestDuringClose() throws Exception {
|
||||
try (var t = new Transcript("16-graceful-shutdown-in-flight.txt",
|
||||
"A /slow?ms=1500 request is in flight when the context closes")) {
|
||||
Result gracefulVirtual = runAndClose("graceful", true);
|
||||
Result gracefulPlatform = runAndClose("graceful", false);
|
||||
Result immediateVirtual = runAndClose("immediate", true);
|
||||
t.line("%-44s %-52s %-32s %s", "settings", "the client saw", "close() blocked", "handler ran to the end");
|
||||
t.line("%-44s %-52s %-32s %s", "-".repeat(44), "-".repeat(52), "-".repeat(32), "-".repeat(22));
|
||||
row(t, "server.shutdown=graceful, virtual threads", gracefulVirtual);
|
||||
row(t, "server.shutdown=graceful, platform threads", gracefulPlatform);
|
||||
row(t, "server.shutdown=immediate, virtual threads", immediateVirtual);
|
||||
assertThat(gracefulVirtual.response()).startsWith("HTTP 200");
|
||||
assertThat(gracefulPlatform.response()).startsWith("HTTP 200");
|
||||
}
|
||||
}
|
||||
|
||||
private static void row(Transcript t, String label, Result r) {
|
||||
String blocked = r.closeMillis() >= 1000 ? ">= 1 s (waited for the request)" : "< 1 s (did not wait)";
|
||||
t.line("%-44s %-52s %-32s %s", label, r.response(), blocked, r.handlersCompleted() == 1 ? "yes" : "no");
|
||||
}
|
||||
|
||||
@Test
|
||||
void workerPhasesAgainstTheWebServer() throws Exception {
|
||||
try (var t = new Transcript("17-worker-phase-vs-web-server.txt",
|
||||
"SmartLifecycle beans with the default phase and with phase 1000, while a request is in flight")) {
|
||||
Result r = runAndClose("graceful", true);
|
||||
Trace.drain().forEach(e -> t.line("%s", e));
|
||||
t.line("the client saw: %s", r.response());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package com.ankurm.corebeans;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
|
||||
import com.ankurm.corebeans.lifecycle.*;
|
||||
import com.ankurm.corebeans.scopes.Instances;
|
||||
import com.ankurm.corebeans.shutdown.ShutdownApp;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.springframework.boot.WebApplicationType;
|
||||
import org.springframework.boot.builder.SpringApplicationBuilder;
|
||||
import org.springframework.boot.test.system.CapturedOutput;
|
||||
import org.springframework.boot.test.system.OutputCaptureExtension;
|
||||
import org.springframework.context.ConfigurableApplicationContext;
|
||||
import org.springframework.context.SmartLifecycle;
|
||||
import org.springframework.context.support.DefaultLifecycleProcessor;
|
||||
|
||||
/** Post 19: the bean lifecycle, printed from real runs. */
|
||||
@ExtendWith(OutputCaptureExtension.class)
|
||||
class LifecycleTest {
|
||||
|
||||
@BeforeEach
|
||||
void reset() {
|
||||
Instances.reset();
|
||||
Trace.drain();
|
||||
// Earlier tests start Boot contexts with logging.level.root=OFF, and that state outlives them.
|
||||
((ch.qos.logback.classic.Logger) org.slf4j.LoggerFactory.getLogger(org.slf4j.Logger.ROOT_LOGGER_NAME))
|
||||
.setLevel(ch.qos.logback.classic.Level.INFO);
|
||||
}
|
||||
|
||||
@Test
|
||||
void fullOrderUnderSpringBoot() {
|
||||
try (var t = new Transcript("07-full-callback-order.txt",
|
||||
"Every callback for one bean, from constructor to the last destroy hook (SpringApplication, no web server)")) {
|
||||
ConfigurableApplicationContext ctx = new SpringApplicationBuilder(LifecycleConfig.class, EventLogger.class)
|
||||
.web(WebApplicationType.NONE)
|
||||
.properties("logging.level.root=OFF", "spring.main.banner-mode=off")
|
||||
.run();
|
||||
t.line("=== startup ===");
|
||||
Trace.drain().forEach(e -> t.line("%s", e));
|
||||
t.blank();
|
||||
t.line("=== ctx.close() ===");
|
||||
ctx.close();
|
||||
var shutdown = Trace.drain();
|
||||
shutdown.forEach(e -> t.line("%s", e));
|
||||
assertThat(shutdown).contains("@PreDestroy", "DisposableBean.destroy");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void beanPostProcessorsInABootContext() {
|
||||
try (var t = new Transcript("19-bean-post-processors.txt",
|
||||
"The BeanPostProcessors registered in a plain Spring Boot context, in the order they run")) {
|
||||
ConfigurableApplicationContext ctx = new SpringApplicationBuilder(LifecycleConfig.class)
|
||||
.web(WebApplicationType.NONE)
|
||||
.properties("logging.level.root=OFF", "spring.main.banner-mode=off")
|
||||
.run();
|
||||
var bf = (org.springframework.beans.factory.support.AbstractBeanFactory) ctx.getBeanFactory();
|
||||
int i = 1;
|
||||
for (var bpp : bf.getBeanPostProcessors()) {
|
||||
t.line("%2d %s", i++, bpp.getClass().getName());
|
||||
}
|
||||
ctx.close();
|
||||
assertThat(bf.getBeanPostProcessors()).isNotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonStaticBeanPostProcessorWarns(CapturedOutput output) {
|
||||
try (var t = new Transcript("08-non-static-bpp-warning.txt",
|
||||
"A BeanPostProcessor declared with a non-static @Bean method")) {
|
||||
try (var ctx = Ctx.plain(NonStaticProcessorConfig.class)) {
|
||||
// context started; only the log matters
|
||||
}
|
||||
List<String> lines = output.getAll().lines()
|
||||
.filter(l -> l.contains("non-static") || l.contains("not eligible for getting processed"))
|
||||
.toList();
|
||||
lines.forEach(l -> t.line("%s", l));
|
||||
assertThat(lines).isNotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void postConstructRunsOnTheRawTarget() {
|
||||
try (var t = new Transcript("09-postconstruct-before-proxy.txt",
|
||||
"@PostConstruct runs before the @Async proxy exists")) {
|
||||
try (var ctx = Ctx.plain(AsyncMailer.Config.class, AsyncMailer.class)) {
|
||||
Trace.drain().forEach(e -> t.line("%s", e));
|
||||
t.line("the bean other code receives : %s", ctx.getBean(AsyncMailer.class).getClass().getName());
|
||||
assertThat(ctx.getBean(AsyncMailer.class).getClass().getName()).contains("SpringCGLIB");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void failingPostConstructStopsStartup() {
|
||||
try (var t = new Transcript("10-postconstruct-failure.txt", "An exception thrown from @PostConstruct")) {
|
||||
var outcome = Ctx.tryStart(FailingInit.class);
|
||||
assertThat(outcome.started()).isFalse();
|
||||
t.line("context started: false");
|
||||
t.line("top exception : %s", outcome.failure().getClass().getName());
|
||||
t.line("top message : %s", outcome.failure().getMessage());
|
||||
t.line("root cause : %s", Ctx.root(outcome.failure()));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void smartLifecyclePhases() {
|
||||
try (var t = new Transcript("11-smartlifecycle-phases.txt",
|
||||
"Three SmartLifecycle beans registered in the order 300, 100, 200")) {
|
||||
var ctx = new org.springframework.context.annotation.AnnotationConfigApplicationContext();
|
||||
ctx.registerBean("workerC", PhasedWorker.class, () -> new PhasedWorker("C", 300, 0, false));
|
||||
ctx.registerBean("workerA", PhasedWorker.class, () -> new PhasedWorker("A", 100, 0, false));
|
||||
ctx.registerBean("workerB", PhasedWorker.class, () -> new PhasedWorker("B", 200, 0, false));
|
||||
ctx.refresh();
|
||||
t.line("=== refresh() ===");
|
||||
Trace.drain().forEach(e -> t.line("%s", e));
|
||||
ctx.close();
|
||||
t.line("=== close() ===");
|
||||
var stops = Trace.drain();
|
||||
stops.forEach(e -> t.line("%s", e));
|
||||
assertThat(stops.get(0)).contains("phase 300");
|
||||
assertThat(stops.get(2)).contains("phase 100");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameScopeBlockingVersusAsyncStop() {
|
||||
try (var t = new Transcript("12-blocking-vs-async-stop.txt",
|
||||
"Three SmartLifecycle beans in the SAME phase, each needing 400 ms to stop")) {
|
||||
for (boolean async : new boolean[] {false, true}) {
|
||||
var ctx = new org.springframework.context.annotation.AnnotationConfigApplicationContext();
|
||||
for (String name : List.of("one", "two", "three")) {
|
||||
ctx.registerBean(name, PhasedWorker.class, () -> new PhasedWorker(name, 0, 400, async));
|
||||
}
|
||||
ctx.refresh();
|
||||
Trace.drain();
|
||||
long begin = System.nanoTime();
|
||||
ctx.close();
|
||||
long ms = (System.nanoTime() - begin) / 1_000_000;
|
||||
t.line("stop(Runnable) %-22s: close() took %s", async ? "returns immediately" : "blocks (the default)",
|
||||
ms >= 1100 ? ">= 1100 ms (sequential)" : ms < 800 ? "< 800 ms (concurrent)" : "in between");
|
||||
if (async) {
|
||||
assertThat(ms).isLessThan(800);
|
||||
} else {
|
||||
assertThat(ms).isGreaterThanOrEqualTo(1100);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void plainLifecycleIsNotAutoStarted() {
|
||||
try (var t = new Transcript("13-plain-lifecycle.txt", "Lifecycle vs SmartLifecycle: who starts at refresh()?")) {
|
||||
var ctx = Ctx.plain(PlainLifecycle.class);
|
||||
t.line("after refresh() : events=%s isRunning=%s", Trace.drain(), ctx.getBean(PlainLifecycle.class).isRunning());
|
||||
assertThat(ctx.getBean(PlainLifecycle.class).isRunning()).isFalse();
|
||||
ctx.start();
|
||||
t.line("after start() : events=%s isRunning=%s", Trace.drain(), ctx.getBean(PlainLifecycle.class).isRunning());
|
||||
ctx.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void shutdownPhaseTimeout(CapturedOutput output) {
|
||||
try (var t = new Transcript("14-shutdown-timeout.txt",
|
||||
"A SmartLifecycle whose stop(callback) never calls the callback, timeout 500 ms")) {
|
||||
var ctx = new org.springframework.context.annotation.AnnotationConfigApplicationContext();
|
||||
ctx.registerBean("lifecycleProcessor", DefaultLifecycleProcessor.class, () -> {
|
||||
var p = new DefaultLifecycleProcessor();
|
||||
p.setTimeoutPerShutdownPhase(500);
|
||||
return p;
|
||||
});
|
||||
ctx.registerBean("neverStops", NeverStops.class);
|
||||
ctx.refresh();
|
||||
long begin = System.nanoTime();
|
||||
ctx.close();
|
||||
long ms = (System.nanoTime() - begin) / 1_000_000;
|
||||
t.line("close() returned after roughly the timeout: %s", ms >= 450 && ms < 3000);
|
||||
output.getAll().lines().filter(l -> l.contains("Shutdown phase")).forEach(l -> t.line("%s", l));
|
||||
assertThat(ms).isBetween(450L, 3000L);
|
||||
assertThat(output.getAll()).contains("Shutdown phase 2147483647 ends with 1 bean still running after timeout of 500ms: [neverStops]");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void smartLifecycleBeansInARealWebApplication() {
|
||||
try (var t = new Transcript("15-smartlifecycle-beans-in-boot.txt",
|
||||
"Every SmartLifecycle bean in a Boot web application, highest phase (stops first) at the top")) {
|
||||
try (ConfigurableApplicationContext ctx = new SpringApplicationBuilder(ShutdownApp.class)
|
||||
.web(WebApplicationType.SERVLET)
|
||||
.properties("server.port=0", "logging.level.root=OFF", "spring.main.banner-mode=off")
|
||||
.run()) {
|
||||
Map<String, SmartLifecycle> beans = ctx.getBeansOfType(SmartLifecycle.class);
|
||||
var sorted = new java.util.ArrayList<>(beans.entrySet());
|
||||
sorted.sort((a, b) -> Integer.compare(b.getValue().getPhase(), a.getValue().getPhase()));
|
||||
t.line("%-14s %-34s %s", "phase", "bean name", "class");
|
||||
for (var e : sorted) {
|
||||
t.line("%-14d %-34s %s", e.getValue().getPhase(), e.getKey(), e.getValue().getClass().getSimpleName());
|
||||
}
|
||||
t.blank();
|
||||
t.line("SmartLifecycle.DEFAULT_PHASE = %d", SmartLifecycle.DEFAULT_PHASE);
|
||||
assertThat(beans).isNotEmpty();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package com.ankurm.corebeans;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.net.CookieManager;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.IntSupplier;
|
||||
|
||||
import com.ankurm.corebeans.scopes.*;
|
||||
import com.ankurm.corebeans.web.BrokenRequestConsumer;
|
||||
import com.ankurm.corebeans.web.WebScopesApp;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.WebApplicationType;
|
||||
import org.springframework.boot.builder.SpringApplicationBuilder;
|
||||
import org.springframework.context.ConfigurableApplicationContext;
|
||||
import org.springframework.mock.web.MockServletContext;
|
||||
import org.springframework.web.context.support.AnnotationConfigWebApplicationContext;
|
||||
|
||||
/** Post 18: scopes, counted. */
|
||||
class ScopesTest {
|
||||
|
||||
@BeforeEach
|
||||
void reset() {
|
||||
Instances.reset();
|
||||
Trace.drain();
|
||||
}
|
||||
|
||||
@Test
|
||||
void singletonAndPrototypeCounts() {
|
||||
try (var t = new Transcript("01-instance-counts.txt", "How many instances did the container really construct?")) {
|
||||
try (var ctx = Ctx.plain(SingletonBean.class, LazySingletonBean.class, PrototypeBean.class)) {
|
||||
t.line("right after refresh():");
|
||||
t.line(" singleton instances : %d", Instances.count("singleton"));
|
||||
t.line(" @Lazy singleton : %d", Instances.count("lazy"));
|
||||
t.line(" prototype : %d", Instances.count("prototype"));
|
||||
assertThat(Instances.count("singleton")).isEqualTo(1);
|
||||
assertThat(Instances.count("lazy")).isZero();
|
||||
assertThat(Instances.count("prototype")).isZero();
|
||||
|
||||
var s1 = ctx.getBean(SingletonBean.class);
|
||||
var s2 = ctx.getBean(SingletonBean.class);
|
||||
var l1 = ctx.getBean(LazySingletonBean.class);
|
||||
var l2 = ctx.getBean(LazySingletonBean.class);
|
||||
var p1 = ctx.getBean(PrototypeBean.class);
|
||||
var p2 = ctx.getBean(PrototypeBean.class);
|
||||
var p3 = ctx.getBean(PrototypeBean.class);
|
||||
t.blank();
|
||||
t.line("after getBean() twice for singleton and lazy, three times for prototype:");
|
||||
t.line(" singleton instances : %d same object both times: %s", Instances.count("singleton"), s1 == s2);
|
||||
t.line(" @Lazy singleton : %d same object both times: %s", Instances.count("lazy"), l1 == l2);
|
||||
t.line(" prototype : %d serials: %d, %d, %d", Instances.count("prototype"), p1.serial(), p2.serial(), p3.serial());
|
||||
assertThat(s1).isSameAs(s2);
|
||||
assertThat(Instances.count("prototype")).isEqualTo(3);
|
||||
}
|
||||
Instances.reset();
|
||||
try (var first = Ctx.plain(SingletonBean.class); var second = Ctx.plain(SingletonBean.class)) {
|
||||
t.blank();
|
||||
t.line("two separate contexts, each with SingletonBean:");
|
||||
t.line(" singleton instances : %d same object across contexts: %s", Instances.count("singleton"),
|
||||
first.getBean(SingletonBean.class) == second.getBean(SingletonBean.class));
|
||||
assertThat(Instances.count("singleton")).isEqualTo(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void prototypeDestroyIsNeverCalled() {
|
||||
try (var t = new Transcript("02-prototype-destroy.txt", "@PreDestroy on a singleton and on a prototype, then context.close()")) {
|
||||
var ctx = Ctx.plain(SingletonBean.class, PrototypeBean.class);
|
||||
ctx.getBean(PrototypeBean.class);
|
||||
t.line("prototype instances created: %d", Instances.count("prototype"));
|
||||
ctx.close();
|
||||
var events = Trace.drain();
|
||||
t.line("callbacks after close(): %s", events);
|
||||
assertThat(events).contains("singleton @PreDestroy called").doesNotContain("prototype @PreDestroy called");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void prototypeInsideSingleton() {
|
||||
try (var t = new Transcript("03-prototype-in-singleton.txt",
|
||||
"A singleton calls use() five times. How many prototype instances did it touch?")) {
|
||||
t.line("%-34s %-22s %s", "how the prototype is obtained", "serials seen", "instances built");
|
||||
t.line("%-34s %-22s %s", "-".repeat(34), "-".repeat(22), "-".repeat(15));
|
||||
Object[][] cases = {
|
||||
{"constructor injection (naive)", "prototype", NaiveConsumer.class, PrototypeBean.class},
|
||||
{"ObjectProvider.getObject()", "prototype", ProviderConsumer.class, PrototypeBean.class},
|
||||
{"@Lookup method", "prototype", LookupConsumer.class, PrototypeBean.class},
|
||||
{"scoped proxy (TARGET_CLASS)", "scopedPrototype", ProxyConsumer.class, ScopedPrototypeBean.class},
|
||||
{"ApplicationContext.getBean()", "prototype", ContextConsumer.class, PrototypeBean.class},
|
||||
};
|
||||
String proxyClass = null;
|
||||
for (Object[] c : cases) {
|
||||
Instances.reset();
|
||||
try (var ctx = Ctx.plain((Class<?>) c[2], (Class<?>) c[3])) {
|
||||
Object consumer = ctx.getBean((Class<?>) c[2]);
|
||||
List<Integer> serials = new ArrayList<>();
|
||||
for (int i = 0; i < 5; i++) {
|
||||
serials.add(use(consumer));
|
||||
}
|
||||
t.line("%-34s %-22s %d", c[0], serials, Instances.count((String) c[1]));
|
||||
if (c[2] == NaiveConsumer.class) {
|
||||
assertThat(Instances.count("prototype")).isEqualTo(1);
|
||||
} else {
|
||||
assertThat(Instances.count((String) c[1])).isEqualTo(5);
|
||||
}
|
||||
if (c[2] == ProxyConsumer.class) {
|
||||
proxyClass = ((ProxyConsumer) consumer).injectedClass();
|
||||
}
|
||||
}
|
||||
}
|
||||
t.blank();
|
||||
t.line("ProxyConsumer's injected reference is a %s", proxyClass);
|
||||
}
|
||||
}
|
||||
|
||||
private static int use(Object consumer) {
|
||||
try {
|
||||
return (Integer) consumer.getClass().getMethod("use").invoke(consumer);
|
||||
} catch (ReflectiveOperationException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void singletonStateIsSharedAcrossThreads() throws Exception {
|
||||
try (var t = new Transcript("04-singleton-thread-safety.txt",
|
||||
"Two threads call the same singleton; a latch forces the interleaving")) {
|
||||
try (var ctx = Ctx.plain(UnsafeGreeter.class, SafeGreeter.class)) {
|
||||
String unsafe = interleave(ctx.getBean(UnsafeGreeter.class)::greet);
|
||||
String safe = interleave(ctx.getBean(SafeGreeter.class)::greet);
|
||||
t.line("UnsafeGreeter (state in a field) : alice's call returned \"%s\"", unsafe);
|
||||
t.line("SafeGreeter (state in a local) : alice's call returned \"%s\"", safe);
|
||||
assertThat(unsafe).isEqualTo("Hello, bob");
|
||||
assertThat(safe).isEqualTo("Hello, alice");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Alice sets her name and pauses; Bob sets his name meanwhile; Alice then reads. Returns Alice's result. */
|
||||
private static String interleave(java.util.function.BiFunction<String, Runnable, String> greet) throws Exception {
|
||||
CountDownLatch bobHasSet = new CountDownLatch(1);
|
||||
String[] aliceResult = new String[1];
|
||||
Thread alice = Thread.ofPlatform().start(() -> aliceResult[0] = greet.apply("alice", () -> {
|
||||
try {
|
||||
bobHasSet.await(5, TimeUnit.SECONDS);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}));
|
||||
Thread bob = Thread.ofPlatform().start(() -> greet.apply("bob", bobHasSet::countDown));
|
||||
alice.join();
|
||||
bob.join();
|
||||
return aliceResult[0];
|
||||
}
|
||||
|
||||
@Test
|
||||
void webScopes() throws Exception {
|
||||
try (var t = new Transcript("05-web-scopes.txt", "request, session and application scope over real HTTP")) {
|
||||
try (ConfigurableApplicationContext ctx = new SpringApplicationBuilder(WebScopesApp.class)
|
||||
.web(WebApplicationType.SERVLET)
|
||||
.properties("server.port=0", "logging.level.root=OFF", "spring.main.banner-mode=off")
|
||||
.run()) {
|
||||
int port = Integer.parseInt(ctx.getEnvironment().getProperty("local.server.port"));
|
||||
URI uri = URI.create("http://localhost:" + port + "/scopes");
|
||||
var clientA = HttpClient.newBuilder().cookieHandler(new CookieManager()).build();
|
||||
var clientB = HttpClient.newBuilder().cookieHandler(new CookieManager()).build();
|
||||
t.line("%-10s %s", "client", "response");
|
||||
String lastA = null;
|
||||
for (int i = 1; i <= 3; i++) {
|
||||
lastA = get(clientA, uri);
|
||||
t.line("%-10s %s", "A #" + i, lastA);
|
||||
}
|
||||
for (int i = 1; i <= 2; i++) {
|
||||
t.line("%-10s %s", "B #" + i, get(clientB, uri));
|
||||
}
|
||||
t.blank();
|
||||
t.line("instances constructed: request=%d session=%d application=%d", Instances.count("request"),
|
||||
Instances.count("session"), Instances.count("application"));
|
||||
assertThat(Instances.count("request")).isEqualTo(5);
|
||||
assertThat(Instances.count("session")).isEqualTo(2);
|
||||
assertThat(Instances.count("application")).isEqualTo(1);
|
||||
assertThat(lastA).contains("session=1").contains("application=1");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static String get(HttpClient client, URI uri) throws Exception {
|
||||
return client.send(HttpRequest.newBuilder(uri).build(), HttpResponse.BodyHandlers.ofString()).body();
|
||||
}
|
||||
|
||||
@Test
|
||||
void requestScopeWithoutProxyFailsAtStartup() {
|
||||
try (var t = new Transcript("06-request-scope-without-proxy.txt",
|
||||
"A request-scoped bean (no proxy) injected into a singleton")) {
|
||||
var ctx = new AnnotationConfigWebApplicationContext();
|
||||
ctx.setServletContext(new MockServletContext());
|
||||
ctx.register(BrokenRequestConsumer.RawRequestBean.class, BrokenRequestConsumer.class);
|
||||
try {
|
||||
ctx.refresh();
|
||||
t.line("context started (unexpected)");
|
||||
assertThat(false).isTrue();
|
||||
} catch (RuntimeException e) {
|
||||
t.line("top exception : %s", e.getClass().getName());
|
||||
t.line("top message : %s", e.getMessage());
|
||||
t.blank();
|
||||
t.line("root cause : %s", Ctx.root(e));
|
||||
} finally {
|
||||
ctx.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.ankurm.corebeans;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.PrintWriter;
|
||||
import java.io.StringWriter;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
/**
|
||||
* Writes a numbered transcript under {@code output/} and echoes it to the console.
|
||||
* Every console block quoted in the article comes out of one of these files verbatim.
|
||||
*/
|
||||
public final class Transcript implements AutoCloseable {
|
||||
|
||||
private final Path path;
|
||||
private final StringWriter buffer = new StringWriter();
|
||||
private final PrintWriter out = new PrintWriter(buffer);
|
||||
|
||||
public Transcript(String fileName, String title) {
|
||||
this.path = Path.of("output", fileName);
|
||||
out.println("# " + title);
|
||||
out.println();
|
||||
}
|
||||
|
||||
public Transcript line(String format, Object... args) {
|
||||
out.println(args.length == 0 ? format : String.format(format, args));
|
||||
return this;
|
||||
}
|
||||
|
||||
public Transcript blank() {
|
||||
out.println();
|
||||
return this;
|
||||
}
|
||||
|
||||
public Transcript section(String heading) {
|
||||
out.println();
|
||||
out.println("--- " + heading + " ---");
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
out.flush();
|
||||
try {
|
||||
Files.createDirectories(path.getParent());
|
||||
Files.writeString(path, buffer.toString());
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("could not write " + path, e);
|
||||
}
|
||||
System.out.print(buffer);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
target/
|
||||
*.class
|
||||
@@ -0,0 +1,86 @@
|
||||
# core-di
|
||||
|
||||
Companion project for two articles on **[ankurm.com](https://ankurm.com)**.
|
||||
|
||||
| Article | What it demonstrates |
|
||||
|---|---|
|
||||
| [Dependency Injection in Spring Boot 4: Constructor vs Setter vs Field (and Why Field Injection Hurts)](https://ankurm.com/spring-boot-4-dependency-injection-constructor-setter-field/) | the same service in three injection styles built without Spring, injection order, a field-injected dependency used in a constructor, two-constructor ambiguity, circular dependencies in plain Spring vs Boot, and what `@Lazy` really injects |
|
||||
| [@Autowired Explained: By-Type Resolution, @Qualifier, @Primary, ObjectProvider and List Injection](https://ankurm.com/spring-autowired-qualifier-primary-objectprovider-list-injection/) | the real `NoUniqueBeanDefinitionException`, the resolution ladder read from Spring 7.0.9 bytecode, `Optional` vs `@Nullable` vs `ObjectProvider`, `List`/`Map` injection order, and the empty-collection trap |
|
||||
|
||||
Every console block, exception message and count quoted in those articles came out of `output/`,
|
||||
and every file there is regenerated by one script. Most are written by the test suite, so if a
|
||||
claim stops being true the build goes red.
|
||||
|
||||
There is deliberately **no `docs/` folder**: the deeper material lives in collapsible "going
|
||||
deeper" sections inside the articles themselves, next to the paragraph each one extends.
|
||||
|
||||
## Versions
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Spring Boot | 4.1.1 |
|
||||
| Spring Framework | 7.0.9 |
|
||||
| JDK | 25 (Temurin 25.0.4.1+1) |
|
||||
| Maven | 3.9 |
|
||||
|
||||
## Quickstart
|
||||
|
||||
```bash
|
||||
export JAVA_HOME=/path/to/jdk-25
|
||||
mvn test # runs the scenarios and rewrites the test-written files in output/
|
||||
./scripts/run-all.sh # everything, including the script-captured files
|
||||
```
|
||||
|
||||
Reproduce the Boot start-up failure report by hand:
|
||||
|
||||
```bash
|
||||
mvn -DskipTests package
|
||||
java -jar target/core-di-1.0.0.jar --spring.profiles.active=ctor-cycle
|
||||
java -jar target/core-di-1.0.0.jar --spring.profiles.active=field-cycle
|
||||
java -jar target/core-di-1.0.0.jar --spring.profiles.active=field-cycle --spring.main.allow-circular-references=true
|
||||
```
|
||||
|
||||
## Source layout
|
||||
|
||||
| Package | What it holds |
|
||||
|---|---|
|
||||
| `injection/` | one `OrderService` written three ways (constructor, setter, field), the trap classes (field used in a constructor, two constructors, circular pairs, `@Lazy`) |
|
||||
| `resolution/` | two gateways and every way to choose between them (`@Primary`, `@Qualifier`, name, `@Priority`, `@Fallback`, custom qualifier), optional injection, collections, generics, `@Resource` |
|
||||
| `boot/` | profile-gated cycle beans for the real "APPLICATION FAILED TO START" report |
|
||||
|
||||
## Captured output
|
||||
|
||||
Files 01-18 (tests) and 19-22 (`capture-failure-analysis.sh`), 23-26 (`capture-bytecode.sh`). Timing rows assert coarse thresholds, not exact milliseconds; treat them as indicative.
|
||||
|
||||
| File | What it shows |
|
||||
|---|---|
|
||||
| [`01-three-styles-under-plain-new.txt`](output/01-three-styles-under-plain-new.txt) | The same OrderService built with plain 'new', no Spring anywhere |
|
||||
| [`02-fields-and-finality.txt`](output/02-fields-and-finality.txt) | Which injected fields can be final? (reflection over the three variants) |
|
||||
| [`03-injection-order.txt`](output/03-injection-order.txt) | The order Spring touches one bean that uses all three styles |
|
||||
| [`04-field-used-in-constructor.txt`](output/04-field-used-in-constructor.txt) | A field-injected collaborator used in the constructor |
|
||||
| [`05-multiple-constructors.txt`](output/05-multiple-constructors.txt) | Two constructors: which one does Spring pick? |
|
||||
| [`06-optional-setter.txt`](output/06-optional-setter.txt) | @Autowired(required = false) on a setter |
|
||||
| [`07-spring-wires-all-three.txt`](output/07-spring-wires-all-three.txt) | Inside a Spring context all three styles work |
|
||||
| [`08-circular-plain-vs-boot.txt`](output/08-circular-plain-vs-boot.txt) | Constructor cycle vs field cycle: plain Spring and Spring Boot |
|
||||
| [`09-lazy-breaks-constructor-cycle.txt`](output/09-lazy-breaks-constructor-cycle.txt) | @Lazy on one constructor parameter breaks a constructor cycle |
|
||||
| [`10-no-unique-bean.txt`](output/10-no-unique-bean.txt) | Two PaymentGateway beans and a consumer that asks for one |
|
||||
| [`11-resolution-ladder.txt`](output/11-resolution-ladder.txt) | Who wins when several rules apply at once? (one mini-context per row) |
|
||||
| [`12-missing-bean.txt`](output/12-missing-bean.txt) | No PaymentGateway bean at all |
|
||||
| [`13-optional-and-objectprovider.txt`](output/13-optional-and-objectprovider.txt) | Five ways to say 'this may not exist', with zero and with two Notifier beans (Email registered before Sms) |
|
||||
| [`14-list-map-set-injection.txt`](output/14-list-map-set-injection.txt) | List, Map and Set injection; registered Push, Email, Sms in that order |
|
||||
| [`15-empty-collection.txt`](output/15-empty-collection.txt) | A List<Plugin> injection point when zero Plugin beans exist |
|
||||
| [`16-generics-and-resource.txt`](output/16-generics-and-resource.txt) | Generic type arguments and @Resource |
|
||||
| [`17-dependency-graph.txt`](output/17-dependency-graph.txt) | getDependenciesForBean: who did Spring wire into whom? |
|
||||
| [`18-parameters-flag.txt`](output/18-parameters-flag.txt) | The parameter-name fallback needs javac -parameters (compiled twice from the same source) |
|
||||
| [`19-boot-failure-analysis-ctor-cycle.txt`](output/19-boot-failure-analysis-ctor-cycle.txt) | Boot start-up failure for a constructor cycle: --spring.profiles.active=ctor-cycle |
|
||||
| [`20-boot-failure-analysis-field-cycle.txt`](output/20-boot-failure-analysis-field-cycle.txt) | Boot start-up failure for a field-injection cycle: --spring.profiles.active=field-cycle |
|
||||
| [`21-boot-allow-circular-references.txt`](output/21-boot-allow-circular-references.txt) | Same field cycle with --spring.main.allow-circular-references=true |
|
||||
| [`22-boot-lazy-cycle.txt`](output/22-boot-lazy-cycle.txt) | @Lazy on one constructor parameter: --spring.profiles.active=lazy-cycle |
|
||||
| [`23-determine-autowire-candidate-bytecode.txt`](output/23-determine-autowire-candidate-bytecode.txt) | DefaultListableBeanFactory.determineAutowireCandidate, read with javap |
|
||||
| [`24-early-reference-caches.txt`](output/24-early-reference-caches.txt) | DefaultSingletonBeanRegistry: the fields that hold early references, read with javap |
|
||||
| [`25-spring-nullable-deprecation.txt`](output/25-spring-nullable-deprecation.txt) | org.springframework.lang.Nullable in spring-core-7.0.9.jar, read with javap -v |
|
||||
| [`26-boot-parent-parameters-flag.txt`](output/26-boot-parent-parameters-flag.txt) | spring-boot-starter-parent-4.1.1.pom: the compiler flag that parameter-name matching depends on |
|
||||
|
||||
## Licence
|
||||
|
||||
MIT, see the repository root.
|
||||
@@ -0,0 +1,18 @@
|
||||
# The same OrderService built with plain 'new', no Spring anywhere
|
||||
|
||||
|
||||
--- constructor injection ---
|
||||
new ConstructorOrderService(gateway, notifier).place("A-1", 500)
|
||||
OK -> charged 500 cents for A-1
|
||||
charges=1, notifications=1
|
||||
|
||||
--- setter injection, one setter forgotten ---
|
||||
new SetterOrderService(); setter.setGateway(gateway); // setNotifier never called
|
||||
NullPointerException: Cannot invoke "com.ankurm.coredi.injection.Notifier.send(String)" because "this.notifier" is null
|
||||
charges=1 <-- the customer was charged before the failure
|
||||
|
||||
--- field injection ---
|
||||
new FieldOrderService().place("A-3", 500)
|
||||
NullPointerException: Cannot invoke "com.ankurm.coredi.injection.PaymentGateway.charge(String, int)" because "this.gateway" is null
|
||||
after ReflectionTestUtils.setField(...) twice:
|
||||
OK -> charged 500 cents for A-3
|
||||
@@ -0,0 +1,8 @@
|
||||
# Which injected fields can be final? (reflection over the three variants)
|
||||
|
||||
ConstructorOrderService gateway final=true
|
||||
ConstructorOrderService notifier final=true
|
||||
SetterOrderService gateway final=false
|
||||
SetterOrderService notifier final=false
|
||||
FieldOrderService gateway final=false
|
||||
FieldOrderService notifier final=false
|
||||
@@ -0,0 +1,5 @@
|
||||
# The order Spring touches one bean that uses all three styles
|
||||
|
||||
constructor : constructor arg=set, field=null, setter=null
|
||||
setter : setter arg=set, field=set
|
||||
@PostConstruct: constructor=set, field=set, setter=set
|
||||
@@ -0,0 +1,6 @@
|
||||
# A field-injected collaborator used in the constructor
|
||||
|
||||
context started: false
|
||||
top exception : org.springframework.beans.factory.BeanCreationException
|
||||
top message : Error creating bean with name 'fieldTrapService': Failed to instantiate [com.ankurm.coredi.injection.FieldTrapService]: Constructor threw exception
|
||||
root cause : java.lang.NullPointerException: Cannot invoke "com.ankurm.coredi.injection.PaymentGateway.charge(String, int)" because "this.gateway" is null
|
||||
@@ -0,0 +1,14 @@
|
||||
# Two constructors: which one does Spring pick?
|
||||
|
||||
|
||||
--- TwoConstructorsService: no-arg + one-arg, neither annotated ---
|
||||
[no-arg constructor used]
|
||||
gateway injected: false
|
||||
|
||||
--- TwoConstructorsNoDefault: one-arg + two-arg, neither annotated ---
|
||||
org.springframework.beans.factory.BeanCreationException
|
||||
Error creating bean with name 'twoConstructorsNoDefault': Failed to instantiate [com.ankurm.coredi.injection.TwoConstructorsNoDefault]: No default constructor found
|
||||
root cause: java.lang.NoSuchMethodException: com.ankurm.coredi.injection.TwoConstructorsNoDefault.<init>()
|
||||
|
||||
--- TwoConstructorsAnnotated: @Autowired on one constructor ---
|
||||
[annotated constructor used]
|
||||
@@ -0,0 +1,4 @@
|
||||
# @Autowired(required = false) on a setter
|
||||
|
||||
with a Notifier bean : notifier present
|
||||
without a Notifier bean : no notifier configured, auditing silently
|
||||
@@ -0,0 +1,5 @@
|
||||
# Inside a Spring context all three styles work
|
||||
|
||||
ConstructorOrderService -> charged 700 cents for B-1
|
||||
SetterOrderService -> charged 700 cents for B-1
|
||||
FieldOrderService -> charged 700 cents for B-1
|
||||
@@ -0,0 +1,20 @@
|
||||
# Constructor cycle vs field cycle: plain Spring and Spring Boot
|
||||
|
||||
|
||||
--- plain Spring, constructor cycle (CtorA <-> CtorB) ---
|
||||
started: false
|
||||
root cause: org.springframework.beans.factory.BeanCurrentlyInCreationException
|
||||
Error creating bean with name 'ctorA': Requested bean is currently in creation: Is there an unresolvable circular reference or an asynchronous initialization dependency?
|
||||
|
||||
--- plain Spring, field cycle (FieldA <-> FieldB) ---
|
||||
started: true
|
||||
FieldA.b is FieldB: true
|
||||
|
||||
--- Spring Boot, field cycle, default settings ---
|
||||
FAILED: BeanCurrentlyInCreationException: Error creating bean with name 'fieldA': Requested bean is currently in creation: Is there an unresolvable circular reference or an asynchronous initialization dependency?
|
||||
|
||||
--- Spring Boot, field cycle, spring.main.allow-circular-references=true ---
|
||||
started
|
||||
|
||||
--- Spring Boot, constructor cycle, spring.main.allow-circular-references=true ---
|
||||
FAILED: BeanCurrentlyInCreationException: Error creating bean with name 'ctorA': Requested bean is currently in creation: Is there an unresolvable circular reference or an asynchronous initialization dependency?
|
||||
@@ -0,0 +1,6 @@
|
||||
# @Lazy on one constructor parameter breaks a constructor cycle
|
||||
|
||||
context started: true
|
||||
LazyA.b runtime class : com.ankurm.coredi.injection.LazyB$$SpringCGLIB$$0
|
||||
LazyA.b.hello() : LazyB reached through LazyA's proxy
|
||||
LazyA.b is the real LazyB bean: false
|
||||
@@ -0,0 +1,8 @@
|
||||
# Two PaymentGateway beans and a consumer that asks for one
|
||||
|
||||
started: false
|
||||
top exception : org.springframework.beans.factory.UnsatisfiedDependencyException
|
||||
top message : Error creating bean with name 'checkoutPlain': Unsatisfied dependency expressed through constructor parameter 0: No qualifying bean of type 'com.ankurm.coredi.injection.PaymentGateway' available: expected single matching bean but found 2: stripeGateway,paypalGateway
|
||||
|
||||
root cause : org.springframework.beans.factory.NoUniqueBeanDefinitionException
|
||||
root message : No qualifying bean of type 'com.ankurm.coredi.injection.PaymentGateway' available: expected single matching bean but found 2: stripeGateway,paypalGateway
|
||||
@@ -0,0 +1,11 @@
|
||||
# Who wins when several rules apply at once? (one mini-context per row)
|
||||
|
||||
beans registered / injection point winner
|
||||
---------------------------------------------------------- --------------------
|
||||
@Primary Stripe + Paypal; CheckoutPlain PrimaryStripeGateway
|
||||
@Primary Stripe + Paypal; @Qualifier("paypalGateway") PaypalGateway
|
||||
@Priority(1) Stripe + @Priority(2) Paypal; CheckoutPlain PriorityOneGateway
|
||||
@Priority(1) + Paypal(no priority); param named paypalGateway PaypalGateway
|
||||
Stripe + Paypal (plain); param named paypalGateway PaypalGateway
|
||||
Stripe (plain) + @Fallback Paypal; CheckoutPlain StripeGateway
|
||||
@Fast custom qualifier, FastGateway + Paypal; @Fast param FastGateway
|
||||
@@ -0,0 +1,7 @@
|
||||
# No PaymentGateway bean at all
|
||||
|
||||
top exception : org.springframework.beans.factory.UnsatisfiedDependencyException
|
||||
top message : Error creating bean with name 'checkoutPlain': Unsatisfied dependency expressed through constructor parameter 0: No qualifying bean of type 'com.ankurm.coredi.injection.PaymentGateway' available: expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: {}
|
||||
|
||||
root cause : org.springframework.beans.factory.NoSuchBeanDefinitionException
|
||||
root message : No qualifying bean of type 'com.ankurm.coredi.injection.PaymentGateway' available: expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: {}
|
||||
@@ -0,0 +1,19 @@
|
||||
# Five ways to say 'this may not exist', with zero and with two Notifier beans (Email registered before Sms)
|
||||
|
||||
|
||||
--- zero Notifier beans ---
|
||||
plain constructor param : FAILED NoSuchBeanDefinitionException
|
||||
Optional<Notifier> : isPresent=false
|
||||
@Nullable Notifier : value=null
|
||||
ObjectProvider.getIfAvailable() : null
|
||||
ObjectProvider.getIfUnique() : null
|
||||
ObjectProvider.getObject() : NoSuchBeanDefinitionException: No qualifying bean of type 'com.ankurm.coredi.injection.Notifier' available: expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: {}
|
||||
@Autowired(required=false) setter: value=null
|
||||
|
||||
--- two Notifier beans (Sms, Email), no @Primary ---
|
||||
ObjectProvider.getIfAvailable() : NoUniqueBeanDefinitionException: No qualifying bean of type 'com.ankurm.coredi.injection.Notifier' available: expected single matching bean but found 2: emailNotifier,smsNotifier
|
||||
ObjectProvider.getIfUnique() : null
|
||||
ObjectProvider.stream() classes : [EmailNotifier, SmsNotifier]
|
||||
ObjectProvider.orderedStream() : [SmsNotifier, EmailNotifier]
|
||||
Optional<Notifier> with two beans: FAILED NoUniqueBeanDefinitionException
|
||||
No qualifying bean of type 'com.ankurm.coredi.injection.Notifier' available: expected single matching bean but found 2: emailNotifier,smsNotifier
|
||||
@@ -0,0 +1,5 @@
|
||||
# List, Map and Set injection; registered Push, Email, Sms in that order
|
||||
|
||||
List<Notifier> order : [SmsNotifier, EmailNotifier, PushNotifier]
|
||||
Map<String,Notifier> : [pushNotifier, emailNotifier, smsNotifier]
|
||||
Set<Notifier> size : 3
|
||||
@@ -0,0 +1,16 @@
|
||||
# A List<Plugin> injection point when zero Plugin beans exist
|
||||
|
||||
|
||||
--- required List<Plugin>, zero Plugin beans ---
|
||||
single constructor param : started (empty list injected)
|
||||
@Autowired ctor + no-arg ctor : started (empty list injected)
|
||||
FieldInjected : FAILED NoSuchBeanDefinitionException
|
||||
No qualifying bean of type 'java.util.List<com.ankurm.coredi.resolution.Plugin>' available: expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: {@org.springframework.beans.factory.annotation.Autowired(required=true)}
|
||||
SetterInjected : FAILED NoSuchBeanDefinitionException
|
||||
No qualifying bean of type 'java.util.List<com.ankurm.coredi.resolution.Plugin>' available: expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: {}
|
||||
|
||||
--- ways to make an absent collection legal ---
|
||||
Optional<List<Plugin>> : isPresent=false
|
||||
@Nullable List<Plugin> : value=null
|
||||
ObjectProvider<Plugin> -> list : []
|
||||
@Autowired(required=false) list: []
|
||||
@@ -0,0 +1,5 @@
|
||||
# Generic type arguments and @Resource
|
||||
|
||||
List<Handler<String>> : [StringHandler]
|
||||
Handler<Integer> : IntegerHandler
|
||||
@Resource : paypalGateway field -> PaypalGateway, whateverIWantToCallIt field -> StripeGateway
|
||||
@@ -0,0 +1,6 @@
|
||||
# getDependenciesForBean: who did Spring wire into whom?
|
||||
|
||||
constructorOrderService depends on [paymentGateway, notifier]
|
||||
setterOrderService depends on [paymentGateway, notifier]
|
||||
fieldOrderService depends on [paymentGateway, notifier]
|
||||
paymentGateway is used by [constructorOrderService, setterOrderService, fieldOrderService]
|
||||
@@ -0,0 +1,5 @@
|
||||
# The parameter-name fallback needs javac -parameters (compiled twice from the same source)
|
||||
|
||||
javac -parameters : started, picked PaypalGateway
|
||||
javac (no flag) : FAILED NoUniqueBeanDefinitionException
|
||||
No qualifying bean of type 'com.ankurm.coredi.injection.PaymentGateway' available: expected single matching bean but found 2: stripeGateway,paypalGateway
|
||||
@@ -0,0 +1,23 @@
|
||||
# Boot start-up failure for a constructor cycle: --spring.profiles.active=ctor-cycle
|
||||
|
||||
ERROR --- o.s.b.d.LoggingFailureAnalysisReporter :
|
||||
|
||||
***************************
|
||||
APPLICATION FAILED TO START
|
||||
***************************
|
||||
|
||||
Description:
|
||||
|
||||
The dependencies of some of the beans in the application context form a cycle:
|
||||
|
||||
┌─────┐
|
||||
| bootCycles.CtorOne defined in URL [jar:nested:target/core-di-1.0.0.jar/!BOOT-INF/classes/!/com/ankurm/coredi/boot/BootCycles$CtorOne.class]
|
||||
↑ ↓
|
||||
| bootCycles.CtorTwo defined in URL [jar:nested:target/core-di-1.0.0.jar/!BOOT-INF/classes/!/com/ankurm/coredi/boot/BootCycles$CtorTwo.class]
|
||||
└─────┘
|
||||
|
||||
|
||||
Action:
|
||||
|
||||
Relying upon circular references is discouraged and they are prohibited by default. Update your application to remove the dependency cycle between beans. As a last resort, it may be possible to break the cycle automatically by setting spring.main.allow-circular-references to true.
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Boot start-up failure for a field-injection cycle: --spring.profiles.active=field-cycle
|
||||
|
||||
ERROR --- o.s.b.d.LoggingFailureAnalysisReporter :
|
||||
|
||||
***************************
|
||||
APPLICATION FAILED TO START
|
||||
***************************
|
||||
|
||||
Description:
|
||||
|
||||
The dependencies of some of the beans in the application context form a cycle:
|
||||
|
||||
┌─────┐
|
||||
| bootCycles.FieldOne (field com.ankurm.coredi.boot.BootCycles$FieldTwo com.ankurm.coredi.boot.BootCycles$FieldOne.two)
|
||||
↑ ↓
|
||||
| bootCycles.FieldTwo (field com.ankurm.coredi.boot.BootCycles$FieldOne com.ankurm.coredi.boot.BootCycles$FieldTwo.one)
|
||||
└─────┘
|
||||
|
||||
|
||||
Action:
|
||||
|
||||
Relying upon circular references is discouraged and they are prohibited by default. Update your application to remove the dependency cycle between beans. As a last resort, it may be possible to break the cycle automatically by setting spring.main.allow-circular-references to true.
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# Same field cycle with --spring.main.allow-circular-references=true
|
||||
|
||||
STARTED. Beans from the cycle: [bootCycles.FieldOne, bootCycles.FieldTwo]
|
||||
@@ -0,0 +1,3 @@
|
||||
# @Lazy on one constructor parameter: --spring.profiles.active=lazy-cycle
|
||||
|
||||
STARTED. Beans from the cycle: [bootCycles.LazyOne, bootCycles.LazyTwo]
|
||||
@@ -0,0 +1,28 @@
|
||||
# DefaultListableBeanFactory.determineAutowireCandidate, read with javap
|
||||
# jar: spring-beans-7.0.9.jar
|
||||
|
||||
protected java.lang.String determineAutowireCandidate(java.util.Map<java.lang.String, java.lang.Object>, org.springframework.beans.factory.config.DependencyDescriptor);
|
||||
invokevirtual #1076 // Method org/springframework/beans/factory/config/DependencyDescriptor.getDependencyType:()Ljava/lang/Class;
|
||||
invokevirtual #1039 // Method determinePrimaryCandidate:(Ljava/util/Map;Ljava/lang/Class;)Ljava/lang/String;
|
||||
invokevirtual #1169 // Method org/springframework/beans/factory/config/DependencyDescriptor.getDependencyName:()Ljava/lang/String;
|
||||
invokeinterface #970, 1 // InterfaceMethod java/util/Map.keySet:()Ljava/util/Set;
|
||||
invokeinterface #397, 1 // InterfaceMethod java/util/Set.iterator:()Ljava/util/Iterator;
|
||||
invokeinterface #304, 1 // InterfaceMethod java/util/Iterator.hasNext:()Z
|
||||
invokeinterface #309, 1 // InterfaceMethod java/util/Iterator.next:()Ljava/lang/Object;
|
||||
invokevirtual #1393 // Method matchesBeanName:(Ljava/lang/String;Ljava/lang/String;)Z
|
||||
invokevirtual #150 // Method getAutowireCandidateResolver:()Lorg/springframework/beans/factory/support/AutowireCandidateResolver;
|
||||
invokeinterface #1172, 2 // InterfaceMethod org/springframework/beans/factory/support/AutowireCandidateResolver.getSuggestedName:(Lorg/springframework/beans/factory/config/DependencyDescriptor;)Ljava/lang/String;
|
||||
invokeinterface #970, 1 // InterfaceMethod java/util/Map.keySet:()Ljava/util/Set;
|
||||
invokeinterface #397, 1 // InterfaceMethod java/util/Set.iterator:()Ljava/util/Iterator;
|
||||
invokeinterface #304, 1 // InterfaceMethod java/util/Iterator.hasNext:()Z
|
||||
invokeinterface #309, 1 // InterfaceMethod java/util/Iterator.next:()Ljava/lang/Object;
|
||||
invokevirtual #1393 // Method matchesBeanName:(Ljava/lang/String;Ljava/lang/String;)Z
|
||||
invokevirtual #1043 // Method determineHighestPriorityCandidate:(Ljava/util/Map;Ljava/lang/Class;)Ljava/lang/String;
|
||||
invokevirtual #1046 // Method determineDefaultCandidate:(Ljava/util/Map;)Ljava/lang/String;
|
||||
invokeinterface #1230, 1 // InterfaceMethod java/util/Map.entrySet:()Ljava/util/Set;
|
||||
invokeinterface #397, 1 // InterfaceMethod java/util/Set.iterator:()Ljava/util/Iterator;
|
||||
invokeinterface #304, 1 // InterfaceMethod java/util/Iterator.hasNext:()Z
|
||||
invokeinterface #309, 1 // InterfaceMethod java/util/Iterator.next:()Ljava/lang/Object;
|
||||
invokeinterface #1235, 1 // InterfaceMethod java/util/Map$Entry.getKey:()Ljava/lang/Object;
|
||||
invokeinterface #1238, 1 // InterfaceMethod java/util/Map$Entry.getValue:()Ljava/lang/Object;
|
||||
invokeinterface #1399, 2 // InterfaceMethod java/util/Map.containsValue:(Ljava/lang/Object;)Z
|
||||
@@ -0,0 +1,12 @@
|
||||
# DefaultSingletonBeanRegistry: the fields that hold early references, read with javap
|
||||
# jar: spring-beans-7.0.9.jar
|
||||
|
||||
private final java.util.Map<java.lang.String, java.lang.Object> singletonObjects;
|
||||
private final java.util.Map<java.lang.String, org.springframework.beans.factory.ObjectFactory<?>> singletonFactories;
|
||||
private final java.util.Map<java.lang.String, java.lang.Object> earlySingletonObjects;
|
||||
private final java.util.Set<java.lang.String> singletonsCurrentlyInCreation;
|
||||
|
||||
# DefaultListableBeanFactory / AbstractAutowireCapableBeanFactory: the allow-circular-references switch
|
||||
private boolean allowCircularReferences;
|
||||
public void setAllowCircularReferences(boolean);
|
||||
public boolean isAllowCircularReferences();
|
||||
@@ -0,0 +1,10 @@
|
||||
# org.springframework.lang.Nullable in spring-core-7.0.9.jar, read with javap -v
|
||||
|
||||
#9 = Utf8 Deprecated
|
||||
#10 = Utf8 RuntimeVisibleAnnotations
|
||||
#22 = Utf8 Ljava/lang/Deprecated;
|
||||
#23 = Utf8 since
|
||||
Deprecated: true
|
||||
RuntimeVisibleAnnotations:
|
||||
java.lang.Deprecated(
|
||||
since="7.0"
|
||||
@@ -0,0 +1,7 @@
|
||||
# spring-boot-starter-parent-4.1.1.pom: the compiler flag that parameter-name matching depends on
|
||||
|
||||
111- <artifactId>maven-compiler-plugin</artifactId>
|
||||
112- <configuration>
|
||||
113: <parameters>true</parameters>
|
||||
114- </configuration>
|
||||
115- </plugin>
|
||||
@@ -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>core-di</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<name>core-di</name>
|
||||
<description>Dependency injection in Spring Boot 4: constructor vs setter vs field, and how @Autowired resolves candidates</description>
|
||||
|
||||
<properties>
|
||||
<java.version>25</java.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter</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>
|
||||
Executable
+54
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env bash
|
||||
# Reads the resolution order out of Spring's own bytecode instead of trusting the reference
|
||||
# documentation's prose: which determine* helpers DefaultListableBeanFactory calls, in which order.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
mkdir -p output target/bytecode
|
||||
JAR=$(find ~/.m2/repository/org/springframework/spring-beans -name 'spring-beans-*.jar' ! -name '*sources*' | sort | tail -1)
|
||||
unzip -o -q "$JAR" 'org/springframework/beans/factory/support/DefaultListableBeanFactory*' -d target/bytecode
|
||||
{
|
||||
echo "# DefaultListableBeanFactory.determineAutowireCandidate, read with javap"
|
||||
echo "# jar: $(basename "$JAR")"
|
||||
echo
|
||||
javap -p -c -cp target/bytecode org.springframework.beans.factory.support.DefaultListableBeanFactory 2>&1 \
|
||||
| grep -v 'Picked up' \
|
||||
| awk '/protected java.lang.String determineAutowireCandidate\(/{f=1} f{print} /^$/{if(f) exit}' \
|
||||
| grep -E 'determineAutowireCandidate|invoke|instanceof' | sed 's/^ *[0-9]*: //'
|
||||
} > output/23-determine-autowire-candidate-bytecode.txt
|
||||
cat output/23-determine-autowire-candidate-bytecode.txt
|
||||
|
||||
# --- the early-reference caches behind circular-reference support
|
||||
JAR2=$(find ~/.m2/repository/org/springframework/spring-beans -name 'spring-beans-*.jar' ! -name '*sources*' | sort | tail -1)
|
||||
unzip -o -q "$JAR2" 'org/springframework/beans/factory/support/DefaultSingletonBeanRegistry.class' -d target/bytecode
|
||||
{
|
||||
echo "# DefaultSingletonBeanRegistry: the fields that hold early references, read with javap"
|
||||
echo "# jar: $(basename "$JAR2")"
|
||||
echo
|
||||
javap -p -cp target/bytecode org.springframework.beans.factory.support.DefaultSingletonBeanRegistry 2>&1 \
|
||||
| grep -v 'Picked up' | grep -E ' (singletonObjects|earlySingletonObjects|singletonFactories|singletonsCurrentlyInCreation|allowCircularReferences)[;]?' || true
|
||||
echo
|
||||
echo "# DefaultListableBeanFactory / AbstractAutowireCapableBeanFactory: the allow-circular-references switch"
|
||||
unzip -o -q "$JAR2" 'org/springframework/beans/factory/support/AbstractAutowireCapableBeanFactory.class' -d target/bytecode
|
||||
javap -p -cp target/bytecode org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory 2>&1 \
|
||||
| grep -v 'Picked up' | grep -i 'allowCircularReferences' || true
|
||||
} > output/24-early-reference-caches.txt
|
||||
cat output/24-early-reference-caches.txt
|
||||
|
||||
# --- is the old Spring-specific @Nullable deprecated in 7.0.9?
|
||||
JAR3=$(find ~/.m2/repository/org/springframework/spring-core -name 'spring-core-7*.jar' ! -name '*sources*' | sort | tail -1)
|
||||
unzip -o -q "$JAR3" 'org/springframework/lang/Nullable.class' -d target/bytecode
|
||||
{
|
||||
echo "# org.springframework.lang.Nullable in $(basename "$JAR3"), read with javap -v"
|
||||
echo
|
||||
javap -v -cp target/bytecode org.springframework.lang.Nullable 2>&1 | grep -v 'Picked up' | grep -E 'Deprecated|RuntimeVisibleAnnotations|Ljava/lang/Deprecated|forRemoval|since' || true
|
||||
} > output/25-spring-nullable-deprecation.txt
|
||||
cat output/25-spring-nullable-deprecation.txt
|
||||
|
||||
# --- does the Boot parent compile with -parameters?
|
||||
PARENT=$(find ~/.m2/repository/org/springframework/boot/spring-boot-starter-parent -name '*.pom' | sort | tail -1)
|
||||
{
|
||||
echo "# $(basename "$PARENT"): the compiler flag that parameter-name matching depends on"
|
||||
echo
|
||||
grep -n -B2 -A2 '<parameters>' "$PARENT" || echo "(no <parameters> element found)"
|
||||
} > output/26-boot-parent-parameters-flag.txt
|
||||
cat output/26-boot-parent-parameters-flag.txt
|
||||
Executable
+42
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env bash
|
||||
# Starts the real Boot application with three different profiles and captures what a developer
|
||||
# actually sees: the "APPLICATION FAILED TO START" report for a circular dependency, and the same
|
||||
# application starting once spring.main.allow-circular-references is switched on.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
mkdir -p output
|
||||
JAR=$(ls target/core-di-*.jar | head -1)
|
||||
|
||||
# Drops JAVA_TOOL_OPTIONS noise, machine-specific paths, timestamps and PIDs so reruns diff cleanly.
|
||||
strip() {
|
||||
grep -v -E '^Picked up JAVA_TOOL_OPTIONS' \
|
||||
| sed -E 's#jar:nested:[^ ]*/target/#jar:nested:target/#; s#^[0-9T:.+-]+ (ERROR|WARN|INFO) [0-9]+ --- \[core-di\] \[[^]]*\] #\1 --- #' \
|
||||
|| true
|
||||
}
|
||||
|
||||
{
|
||||
echo "# Boot start-up failure for a constructor cycle: --spring.profiles.active=ctor-cycle"
|
||||
echo
|
||||
java -jar "$JAR" --spring.profiles.active=ctor-cycle --spring.main.banner-mode=off --logging.level.root=ERROR 2>&1 | strip || true
|
||||
} > output/19-boot-failure-analysis-ctor-cycle.txt
|
||||
|
||||
{
|
||||
echo "# Boot start-up failure for a field-injection cycle: --spring.profiles.active=field-cycle"
|
||||
echo
|
||||
java -jar "$JAR" --spring.profiles.active=field-cycle --spring.main.banner-mode=off --logging.level.root=ERROR 2>&1 | strip || true
|
||||
} > output/20-boot-failure-analysis-field-cycle.txt
|
||||
|
||||
{
|
||||
echo "# Same field cycle with --spring.main.allow-circular-references=true"
|
||||
echo
|
||||
java -jar "$JAR" --spring.profiles.active=field-cycle --spring.main.allow-circular-references=true \
|
||||
--spring.main.banner-mode=off --logging.level.root=WARN 2>&1 | strip
|
||||
} > output/21-boot-allow-circular-references.txt
|
||||
|
||||
{
|
||||
echo "# @Lazy on one constructor parameter: --spring.profiles.active=lazy-cycle"
|
||||
echo
|
||||
java -jar "$JAR" --spring.profiles.active=lazy-cycle --spring.main.banner-mode=off --logging.level.root=WARN 2>&1 | strip
|
||||
} > output/22-boot-lazy-cycle.txt
|
||||
|
||||
cat output/19-*.txt output/20-*.txt output/21-*.txt output/22-*.txt
|
||||
Executable
+25
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regenerates every file under output/.
|
||||
#
|
||||
# ./scripts/run-all.sh
|
||||
#
|
||||
# Needs a JDK 25 and Maven 3.9. Transcripts 01-18 come out of the test suite, which is the point:
|
||||
# the figures in the two articles are assertions that fail the build if they stop being true.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
echo "== test suite (transcripts 01-18)"
|
||||
mvn -B test
|
||||
|
||||
echo "== package the runnable Boot app"
|
||||
mvn -B -q -DskipTests package
|
||||
|
||||
echo "== Boot start-up failure reports (19-22)"
|
||||
./scripts/capture-failure-analysis.sh
|
||||
|
||||
echo "== bytecode and pom facts (23-26)"
|
||||
./scripts/capture-bytecode.sh
|
||||
|
||||
echo
|
||||
echo "output:"
|
||||
ls -1 output
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.ankurm.coredi;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
/**
|
||||
* Only used by scripts/capture-failure-analysis.sh: it starts a real Boot application so the
|
||||
* "APPLICATION FAILED TO START" report for a circular dependency can be captured verbatim.
|
||||
* Everything else in this module runs from the tests.
|
||||
*/
|
||||
@SpringBootApplication(scanBasePackages = "com.ankurm.coredi.boot")
|
||||
public class DiApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
var ctx = SpringApplication.run(DiApplication.class, args);
|
||||
var cycleBeans = java.util.Arrays.stream(ctx.getBeanDefinitionNames())
|
||||
.filter(n -> n.startsWith("bootCycles.")).sorted().toList();
|
||||
System.out.println("STARTED. Beans from the cycle: " + cycleBeans);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.ankurm.coredi.boot;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/** Three ways to start the same application with a cycle in it. Pick with --spring.profiles.active. */
|
||||
public final class BootCycles {
|
||||
|
||||
private BootCycles() {
|
||||
}
|
||||
|
||||
@Component
|
||||
@Profile("ctor-cycle")
|
||||
public static class CtorOne {
|
||||
public CtorOne(CtorTwo two) {
|
||||
}
|
||||
}
|
||||
|
||||
@Component
|
||||
@Profile("ctor-cycle")
|
||||
public static class CtorTwo {
|
||||
public CtorTwo(CtorOne one) {
|
||||
}
|
||||
}
|
||||
|
||||
@Component
|
||||
@Profile("field-cycle")
|
||||
public static class FieldOne {
|
||||
@Autowired
|
||||
FieldTwo two;
|
||||
}
|
||||
|
||||
@Component
|
||||
@Profile("field-cycle")
|
||||
public static class FieldTwo {
|
||||
@Autowired
|
||||
FieldOne one;
|
||||
}
|
||||
|
||||
@Component
|
||||
@Profile("lazy-cycle")
|
||||
public static class LazyOne {
|
||||
public LazyOne(@Lazy LazyTwo two) {
|
||||
}
|
||||
}
|
||||
|
||||
@Component
|
||||
@Profile("lazy-cycle")
|
||||
public static class LazyTwo {
|
||||
public LazyTwo(LazyOne one) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.ankurm.coredi.injection;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
|
||||
/** One bean using all three styles at once, only to make Spring's order of operations visible. */
|
||||
public class AllThreeStyles {
|
||||
|
||||
@Autowired
|
||||
private Notifier fieldDependency;
|
||||
|
||||
private PaymentGateway setterDependency;
|
||||
|
||||
private final Notifier constructorDependency;
|
||||
|
||||
public AllThreeStyles(Notifier constructorDependency) {
|
||||
this.constructorDependency = constructorDependency;
|
||||
Trace.log("constructor : constructor arg=" + describe(constructorDependency)
|
||||
+ ", field=" + describe(fieldDependency) + ", setter=" + describe(setterDependency));
|
||||
}
|
||||
|
||||
@Autowired
|
||||
public void setSetterDependency(PaymentGateway gateway) {
|
||||
this.setterDependency = gateway;
|
||||
Trace.log("setter : setter arg=" + describe(gateway) + ", field=" + describe(fieldDependency));
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
void afterInjection() {
|
||||
Trace.log("@PostConstruct: constructor=" + describe(constructorDependency)
|
||||
+ ", field=" + describe(fieldDependency) + ", setter=" + describe(setterDependency));
|
||||
}
|
||||
|
||||
private static String describe(Object o) {
|
||||
return o == null ? "null" : "set";
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user