Add core-beans: bean scopes, the prototype-in-singleton trap, lifecycle callback order and graceful shutdown phases on Boot 4.1
Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01JoVmf2fWvcpoXndcDSwRf7
This commit is contained in:
@@ -32,6 +32,7 @@ files.
|
||||
| [`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/`
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user