diff --git a/README.md b/README.md index 82486f5..c0f8501 100644 --- a/README.md +++ b/README.md @@ -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/` diff --git a/core-beans/.gitignore b/core-beans/.gitignore new file mode 100644 index 0000000..e97c6ee --- /dev/null +++ b/core-beans/.gitignore @@ -0,0 +1,2 @@ +target/ +*.class diff --git a/core-beans/README.md b/core-beans/README.md new file mode 100644 index 0000000..480f2b2 --- /dev/null +++ b/core-beans/README.md @@ -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. diff --git a/core-beans/output/01-instance-counts.txt b/core-beans/output/01-instance-counts.txt new file mode 100644 index 0000000..bb4e26a --- /dev/null +++ b/core-beans/output/01-instance-counts.txt @@ -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 diff --git a/core-beans/output/02-prototype-destroy.txt b/core-beans/output/02-prototype-destroy.txt new file mode 100644 index 0000000..eb612ac --- /dev/null +++ b/core-beans/output/02-prototype-destroy.txt @@ -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] diff --git a/core-beans/output/03-prototype-in-singleton.txt b/core-beans/output/03-prototype-in-singleton.txt new file mode 100644 index 0000000..ffdcd6c --- /dev/null +++ b/core-beans/output/03-prototype-in-singleton.txt @@ -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 diff --git a/core-beans/output/04-singleton-thread-safety.txt b/core-beans/output/04-singleton-thread-safety.txt new file mode 100644 index 0000000..a3b55c1 --- /dev/null +++ b/core-beans/output/04-singleton-thread-safety.txt @@ -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" diff --git a/core-beans/output/05-web-scopes.txt b/core-beans/output/05-web-scopes.txt new file mode 100644 index 0000000..cc77035 --- /dev/null +++ b/core-beans/output/05-web-scopes.txt @@ -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 diff --git a/core-beans/output/06-request-scope-without-proxy.txt b/core-beans/output/06-request-scope-without-proxy.txt new file mode 100644 index 0000000..fe8a330 --- /dev/null +++ b/core-beans/output/06-request-scope-without-proxy.txt @@ -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. diff --git a/core-beans/output/07-full-callback-order.txt b/core-beans/output/07-full-callback-order.txt new file mode 100644 index 0000000..37fe940 --- /dev/null +++ b/core-beans/output/07-full-callback-order.txt @@ -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") diff --git a/core-beans/output/08-non-static-bpp-warning.txt b/core-beans/output/08-non-static-bpp-warning.txt new file mode 100644 index 0000000..76f7fd1 --- /dev/null +++ b/core-beans/output/08-non-static-bpp-warning.txt @@ -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. diff --git a/core-beans/output/09-postconstruct-before-proxy.txt b/core-beans/output/09-postconstruct-before-proxy.txt new file mode 100644 index 0000000..405650c --- /dev/null +++ b/core-beans/output/09-postconstruct-before-proxy.txt @@ -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 diff --git a/core-beans/output/10-postconstruct-failure.txt b/core-beans/output/10-postconstruct-failure.txt new file mode 100644 index 0000000..a5c1edb --- /dev/null +++ b/core-beans/output/10-postconstruct-failure.txt @@ -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 diff --git a/core-beans/output/11-smartlifecycle-phases.txt b/core-beans/output/11-smartlifecycle-phases.txt new file mode 100644 index 0000000..1976852 --- /dev/null +++ b/core-beans/output/11-smartlifecycle-phases.txt @@ -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) diff --git a/core-beans/output/12-blocking-vs-async-stop.txt b/core-beans/output/12-blocking-vs-async-stop.txt new file mode 100644 index 0000000..28d112c --- /dev/null +++ b/core-beans/output/12-blocking-vs-async-stop.txt @@ -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) diff --git a/core-beans/output/13-plain-lifecycle.txt b/core-beans/output/13-plain-lifecycle.txt new file mode 100644 index 0000000..d784b60 --- /dev/null +++ b/core-beans/output/13-plain-lifecycle.txt @@ -0,0 +1,4 @@ +# Lifecycle vs SmartLifecycle: who starts at refresh()? + +after refresh() : events=[] isRunning=false +after start() : events=[PlainLifecycle.start] isRunning=true diff --git a/core-beans/output/14-shutdown-timeout.txt b/core-beans/output/14-shutdown-timeout.txt new file mode 100644 index 0000000..9359b93 --- /dev/null +++ b/core-beans/output/14-shutdown-timeout.txt @@ -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] diff --git a/core-beans/output/15-smartlifecycle-beans-in-boot.txt b/core-beans/output/15-smartlifecycle-beans-in-boot.txt new file mode 100644 index 0000000..a181cc2 --- /dev/null +++ b/core-beans/output/15-smartlifecycle-beans-in-boot.txt @@ -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 diff --git a/core-beans/output/16-graceful-shutdown-in-flight.txt b/core-beans/output/16-graceful-shutdown-in-flight.txt new file mode 100644 index 0000000..db884fd --- /dev/null +++ b/core-beans/output/16-graceful-shutdown-in-flight.txt @@ -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 diff --git a/core-beans/output/17-worker-phase-vs-web-server.txt b/core-beans/output/17-worker-phase-vs-web-server.txt new file mode 100644 index 0000000..5678467 --- /dev/null +++ b/core-beans/output/17-worker-phase-vs-web-server.txt @@ -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 diff --git a/core-beans/output/18-property-defaults.txt b/core-beans/output/18-property-defaults.txt new file mode 100644 index 0000000..6ee5fd4 --- /dev/null +++ b/core-beans/output/18-property-defaults.txt @@ -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 diff --git a/core-beans/output/19-bean-post-processors.txt b/core-beans/output/19-bean-post-processors.txt new file mode 100644 index 0000000..7659096 --- /dev/null +++ b/core-beans/output/19-bean-post-processors.txt @@ -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 diff --git a/core-beans/pom.xml b/core-beans/pom.xml new file mode 100644 index 0000000..eed87b7 --- /dev/null +++ b/core-beans/pom.xml @@ -0,0 +1,48 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 4.1.1 + + + + com.ankurm + core-beans + 1.0.0 + core-beans + Spring bean scopes and the bean lifecycle in Spring Boot 4 + + + 25 + + + + + org.springframework.boot + spring-boot-starter + + + org.springframework.boot + spring-boot-starter-webmvc + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/core-beans/scripts/capture-metadata.sh b/core-beans/scripts/capture-metadata.sh new file mode 100755 index 0000000..7e725d2 --- /dev/null +++ b/core-beans/scripts/capture-metadata.sh @@ -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 diff --git a/core-beans/scripts/run-all.sh b/core-beans/scripts/run-all.sh new file mode 100755 index 0000000..42cb807 --- /dev/null +++ b/core-beans/scripts/run-all.sh @@ -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 diff --git a/core-beans/src/main/java/com/ankurm/corebeans/Trace.java b/core-beans/src/main/java/com/ankurm/corebeans/Trace.java new file mode 100644 index 0000000..34833a1 --- /dev/null +++ b/core-beans/src/main/java/com/ankurm/corebeans/Trace.java @@ -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 EVENTS = Collections.synchronizedList(new ArrayList<>()); + + private Trace() { + } + + public static void log(String event) { + EVENTS.add(event); + } + + public static List drain() { + List copy; + synchronized (EVENTS) { + copy = new ArrayList<>(EVENTS); + EVENTS.clear(); + } + return copy; + } +} diff --git a/core-beans/src/main/java/com/ankurm/corebeans/lifecycle/AsyncMailer.java b/core-beans/src/main/java/com/ankurm/corebeans/lifecycle/AsyncMailer.java new file mode 100644 index 0000000..f366f76 --- /dev/null +++ b/core-beans/src/main/java/com/ankurm/corebeans/lifecycle/AsyncMailer.java @@ -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 { + } +} diff --git a/core-beans/src/main/java/com/ankurm/corebeans/lifecycle/Dependency.java b/core-beans/src/main/java/com/ankurm/corebeans/lifecycle/Dependency.java new file mode 100644 index 0000000..51c3d1d --- /dev/null +++ b/core-beans/src/main/java/com/ankurm/corebeans/lifecycle/Dependency.java @@ -0,0 +1,4 @@ +package com.ankurm.corebeans.lifecycle; + +public class Dependency { +} diff --git a/core-beans/src/main/java/com/ankurm/corebeans/lifecycle/EventLogger.java b/core-beans/src/main/java/com/ankurm/corebeans/lifecycle/EventLogger.java new file mode 100644 index 0000000..aa8ac71 --- /dev/null +++ b/core-beans/src/main/java/com/ankurm/corebeans/lifecycle/EventLogger.java @@ -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"); + } +} diff --git a/core-beans/src/main/java/com/ankurm/corebeans/lifecycle/FailingInit.java b/core-beans/src/main/java/com/ankurm/corebeans/lifecycle/FailingInit.java new file mode 100644 index 0000000..47d20fa --- /dev/null +++ b/core-beans/src/main/java/com/ankurm/corebeans/lifecycle/FailingInit.java @@ -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"); + } +} diff --git a/core-beans/src/main/java/com/ankurm/corebeans/lifecycle/KitchenSink.java b/core-beans/src/main/java/com/ankurm/corebeans/lifecycle/KitchenSink.java new file mode 100644 index 0000000..392277d --- /dev/null +++ b/core-beans/src/main/java/com/ankurm/corebeans/lifecycle/KitchenSink.java @@ -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\")"); + } +} diff --git a/core-beans/src/main/java/com/ankurm/corebeans/lifecycle/LifecycleConfig.java b/core-beans/src/main/java/com/ankurm/corebeans/lifecycle/LifecycleConfig.java new file mode 100644 index 0000000..fcbd7f3 --- /dev/null +++ b/core-beans/src/main/java/com/ankurm/corebeans/lifecycle/LifecycleConfig.java @@ -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(); + } +} diff --git a/core-beans/src/main/java/com/ankurm/corebeans/lifecycle/NeverStops.java b/core-beans/src/main/java/com/ankurm/corebeans/lifecycle/NeverStops.java new file mode 100644 index 0000000..22a8d89 --- /dev/null +++ b/core-beans/src/main/java/com/ankurm/corebeans/lifecycle/NeverStops.java @@ -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; + } +} diff --git a/core-beans/src/main/java/com/ankurm/corebeans/lifecycle/NonStaticProcessorConfig.java b/core-beans/src/main/java/com/ankurm/corebeans/lifecycle/NonStaticProcessorConfig.java new file mode 100644 index 0000000..3e23249 --- /dev/null +++ b/core-beans/src/main/java/com/ankurm/corebeans/lifecycle/NonStaticProcessorConfig.java @@ -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(); + } +} diff --git a/core-beans/src/main/java/com/ankurm/corebeans/lifecycle/PhasedWorker.java b/core-beans/src/main/java/com/ankurm/corebeans/lifecycle/PhasedWorker.java new file mode 100644 index 0000000..b85b416 --- /dev/null +++ b/core-beans/src/main/java/com/ankurm/corebeans/lifecycle/PhasedWorker.java @@ -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(); + } + } +} diff --git a/core-beans/src/main/java/com/ankurm/corebeans/lifecycle/PlainLifecycle.java b/core-beans/src/main/java/com/ankurm/corebeans/lifecycle/PlainLifecycle.java new file mode 100644 index 0000000..cc475c1 --- /dev/null +++ b/core-beans/src/main/java/com/ankurm/corebeans/lifecycle/PlainLifecycle.java @@ -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; + } +} diff --git a/core-beans/src/main/java/com/ankurm/corebeans/lifecycle/TracingPostProcessor.java b/core-beans/src/main/java/com/ankurm/corebeans/lifecycle/TracingPostProcessor.java new file mode 100644 index 0000000..da7a59b --- /dev/null +++ b/core-beans/src/main/java/com/ankurm/corebeans/lifecycle/TracingPostProcessor.java @@ -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; + } +} diff --git a/core-beans/src/main/java/com/ankurm/corebeans/scopes/ContextConsumer.java b/core-beans/src/main/java/com/ankurm/corebeans/scopes/ContextConsumer.java new file mode 100644 index 0000000..3fe33d2 --- /dev/null +++ b/core-beans/src/main/java/com/ankurm/corebeans/scopes/ContextConsumer.java @@ -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(); + } +} diff --git a/core-beans/src/main/java/com/ankurm/corebeans/scopes/Instances.java b/core-beans/src/main/java/com/ankurm/corebeans/scopes/Instances.java new file mode 100644 index 0000000..451e8e4 --- /dev/null +++ b/core-beans/src/main/java/com/ankurm/corebeans/scopes/Instances.java @@ -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 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(); + } +} diff --git a/core-beans/src/main/java/com/ankurm/corebeans/scopes/LazySingletonBean.java b/core-beans/src/main/java/com/ankurm/corebeans/scopes/LazySingletonBean.java new file mode 100644 index 0000000..ae625d6 --- /dev/null +++ b/core-beans/src/main/java/com/ankurm/corebeans/scopes/LazySingletonBean.java @@ -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; + } +} diff --git a/core-beans/src/main/java/com/ankurm/corebeans/scopes/LookupConsumer.java b/core-beans/src/main/java/com/ankurm/corebeans/scopes/LookupConsumer.java new file mode 100644 index 0000000..4a043ac --- /dev/null +++ b/core-beans/src/main/java/com/ankurm/corebeans/scopes/LookupConsumer.java @@ -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(); + } +} diff --git a/core-beans/src/main/java/com/ankurm/corebeans/scopes/NaiveConsumer.java b/core-beans/src/main/java/com/ankurm/corebeans/scopes/NaiveConsumer.java new file mode 100644 index 0000000..dca13a7 --- /dev/null +++ b/core-beans/src/main/java/com/ankurm/corebeans/scopes/NaiveConsumer.java @@ -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(); + } +} diff --git a/core-beans/src/main/java/com/ankurm/corebeans/scopes/PrototypeBean.java b/core-beans/src/main/java/com/ankurm/corebeans/scopes/PrototypeBean.java new file mode 100644 index 0000000..816d6e1 --- /dev/null +++ b/core-beans/src/main/java/com/ankurm/corebeans/scopes/PrototypeBean.java @@ -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"); + } +} diff --git a/core-beans/src/main/java/com/ankurm/corebeans/scopes/ProviderConsumer.java b/core-beans/src/main/java/com/ankurm/corebeans/scopes/ProviderConsumer.java new file mode 100644 index 0000000..eb78168 --- /dev/null +++ b/core-beans/src/main/java/com/ankurm/corebeans/scopes/ProviderConsumer.java @@ -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 provider; + + public ProviderConsumer(ObjectProvider provider) { + this.provider = provider; + } + + public int use() { + return provider.getObject().serial(); + } +} diff --git a/core-beans/src/main/java/com/ankurm/corebeans/scopes/ProxyConsumer.java b/core-beans/src/main/java/com/ankurm/corebeans/scopes/ProxyConsumer.java new file mode 100644 index 0000000..22aeca0 --- /dev/null +++ b/core-beans/src/main/java/com/ankurm/corebeans/scopes/ProxyConsumer.java @@ -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(); + } +} diff --git a/core-beans/src/main/java/com/ankurm/corebeans/scopes/SafeGreeter.java b/core-beans/src/main/java/com/ankurm/corebeans/scopes/SafeGreeter.java new file mode 100644 index 0000000..a4edbc9 --- /dev/null +++ b/core-beans/src/main/java/com/ankurm/corebeans/scopes/SafeGreeter.java @@ -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; + } +} diff --git a/core-beans/src/main/java/com/ankurm/corebeans/scopes/ScopedPrototypeBean.java b/core-beans/src/main/java/com/ankurm/corebeans/scopes/ScopedPrototypeBean.java new file mode 100644 index 0000000..4d8b1fb --- /dev/null +++ b/core-beans/src/main/java/com/ankurm/corebeans/scopes/ScopedPrototypeBean.java @@ -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; + } +} diff --git a/core-beans/src/main/java/com/ankurm/corebeans/scopes/SingletonBean.java b/core-beans/src/main/java/com/ankurm/corebeans/scopes/SingletonBean.java new file mode 100644 index 0000000..cf887ef --- /dev/null +++ b/core-beans/src/main/java/com/ankurm/corebeans/scopes/SingletonBean.java @@ -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"); + } +} diff --git a/core-beans/src/main/java/com/ankurm/corebeans/scopes/UnsafeGreeter.java b/core-beans/src/main/java/com/ankurm/corebeans/scopes/UnsafeGreeter.java new file mode 100644 index 0000000..138a8ca --- /dev/null +++ b/core-beans/src/main/java/com/ankurm/corebeans/scopes/UnsafeGreeter.java @@ -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; + } +} diff --git a/core-beans/src/main/java/com/ankurm/corebeans/shutdown/EarlyWorker.java b/core-beans/src/main/java/com/ankurm/corebeans/shutdown/EarlyWorker.java new file mode 100644 index 0000000..ecd28e5 --- /dev/null +++ b/core-beans/src/main/java/com/ankurm/corebeans/shutdown/EarlyWorker.java @@ -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; + } +} diff --git a/core-beans/src/main/java/com/ankurm/corebeans/shutdown/LateWorker.java b/core-beans/src/main/java/com/ankurm/corebeans/shutdown/LateWorker.java new file mode 100644 index 0000000..60ab4af --- /dev/null +++ b/core-beans/src/main/java/com/ankurm/corebeans/shutdown/LateWorker.java @@ -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; + } +} diff --git a/core-beans/src/main/java/com/ankurm/corebeans/shutdown/ShutdownApp.java b/core-beans/src/main/java/com/ankurm/corebeans/shutdown/ShutdownApp.java new file mode 100644 index 0000000..da4862d --- /dev/null +++ b/core-beans/src/main/java/com/ankurm/corebeans/shutdown/ShutdownApp.java @@ -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 { +} diff --git a/core-beans/src/main/java/com/ankurm/corebeans/shutdown/SlowController.java b/core-beans/src/main/java/com/ankurm/corebeans/shutdown/SlowController.java new file mode 100644 index 0000000..cef8955 --- /dev/null +++ b/core-beans/src/main/java/com/ankurm/corebeans/shutdown/SlowController.java @@ -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(); + } + } +} diff --git a/core-beans/src/main/java/com/ankurm/corebeans/web/ApplicationBean.java b/core-beans/src/main/java/com/ankurm/corebeans/web/ApplicationBean.java new file mode 100644 index 0000000..d3baf87 --- /dev/null +++ b/core-beans/src/main/java/com/ankurm/corebeans/web/ApplicationBean.java @@ -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; + } +} diff --git a/core-beans/src/main/java/com/ankurm/corebeans/web/BrokenRequestConsumer.java b/core-beans/src/main/java/com/ankurm/corebeans/web/BrokenRequestConsumer.java new file mode 100644 index 0000000..a31d6cd --- /dev/null +++ b/core-beans/src/main/java/com/ankurm/corebeans/web/BrokenRequestConsumer.java @@ -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) { + } +} diff --git a/core-beans/src/main/java/com/ankurm/corebeans/web/RequestBean.java b/core-beans/src/main/java/com/ankurm/corebeans/web/RequestBean.java new file mode 100644 index 0000000..175ad5d --- /dev/null +++ b/core-beans/src/main/java/com/ankurm/corebeans/web/RequestBean.java @@ -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; + } +} diff --git a/core-beans/src/main/java/com/ankurm/corebeans/web/ScopesController.java b/core-beans/src/main/java/com/ankurm/corebeans/web/ScopesController.java new file mode 100644 index 0000000..d64f5bc --- /dev/null +++ b/core-beans/src/main/java/com/ankurm/corebeans/web/ScopesController.java @@ -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(); + } +} diff --git a/core-beans/src/main/java/com/ankurm/corebeans/web/SessionBean.java b/core-beans/src/main/java/com/ankurm/corebeans/web/SessionBean.java new file mode 100644 index 0000000..9999ed6 --- /dev/null +++ b/core-beans/src/main/java/com/ankurm/corebeans/web/SessionBean.java @@ -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; + } +} diff --git a/core-beans/src/main/java/com/ankurm/corebeans/web/WebScopesApp.java b/core-beans/src/main/java/com/ankurm/corebeans/web/WebScopesApp.java new file mode 100644 index 0000000..14fd87f --- /dev/null +++ b/core-beans/src/main/java/com/ankurm/corebeans/web/WebScopesApp.java @@ -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 { +} diff --git a/core-beans/src/main/resources/application.yml b/core-beans/src/main/resources/application.yml new file mode 100644 index 0000000..b645f84 --- /dev/null +++ b/core-beans/src/main/resources/application.yml @@ -0,0 +1,5 @@ +spring: + application: + name: core-beans + main: + banner-mode: off diff --git a/core-beans/src/test/java/com/ankurm/corebeans/Ctx.java b/core-beans/src/test/java/com/ankurm/corebeans/Ctx.java new file mode 100644 index 0000000..a1d3849 --- /dev/null +++ b/core-beans/src/test/java/com/ankurm/corebeans/Ctx.java @@ -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 String attempt(Callable 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(); + } + } + } +} diff --git a/core-beans/src/test/java/com/ankurm/corebeans/GracefulShutdownTest.java b/core-beans/src/test/java/com/ankurm/corebeans/GracefulShutdownTest.java new file mode 100644 index 0000000..08c9c3d --- /dev/null +++ b/core-beans/src/test/java/com/ankurm/corebeans/GracefulShutdownTest.java @@ -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 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()); + } + } +} diff --git a/core-beans/src/test/java/com/ankurm/corebeans/LifecycleTest.java b/core-beans/src/test/java/com/ankurm/corebeans/LifecycleTest.java new file mode 100644 index 0000000..cdbf4f4 --- /dev/null +++ b/core-beans/src/test/java/com/ankurm/corebeans/LifecycleTest.java @@ -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 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 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(); + } + } + } +} diff --git a/core-beans/src/test/java/com/ankurm/corebeans/ScopesTest.java b/core-beans/src/test/java/com/ankurm/corebeans/ScopesTest.java new file mode 100644 index 0000000..594c06a --- /dev/null +++ b/core-beans/src/test/java/com/ankurm/corebeans/ScopesTest.java @@ -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 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 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(); + } + } + } +} diff --git a/core-beans/src/test/java/com/ankurm/corebeans/Transcript.java b/core-beans/src/test/java/com/ankurm/corebeans/Transcript.java new file mode 100644 index 0000000..3b9141d --- /dev/null +++ b/core-beans/src/test/java/com/ankurm/corebeans/Transcript.java @@ -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); + } +}