Add cycles to core-beans: the circular-dependency failure report, what allow-circular-references repairs and hides, and four fixes on Boot 4.1
Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Uu7q8vPeREyT4218EJPzz1
This commit is contained in:
+22
-2
@@ -1,11 +1,12 @@
|
|||||||
# core-beans
|
# core-beans
|
||||||
|
|
||||||
Companion project for two articles on **[ankurm.com](https://ankurm.com)**.
|
Companion project for three articles on **[ankurm.com](https://ankurm.com)**.
|
||||||
|
|
||||||
| Article | What it demonstrates |
|
| 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 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 |
|
| [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 |
|
||||||
|
| [Circular Dependencies in Spring Boot 4: Why Startup Fails and 4 Ways to Fix It](https://ankurm.com/spring-boot-4-circular-dependencies-startup-fails-4-fixes/) | the start-up failure report for a three-service ring, the creation order behind it, what `allow-circular-references` does and does not repair (a half-built bean, `@Async` versus `@Cacheable`, an INFO line that swallows an exception), and four fixes run side by side: redesign, events, `@Lazy`, `ObjectProvider` |
|
||||||
|
|
||||||
Every console block, exception message and count quoted in those articles came out of `output/`,
|
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
|
and every file there is regenerated by one script. Most are written by the test suite, so if a
|
||||||
@@ -39,6 +40,7 @@ mvn test # runs the scenarios and rewrites the test-writte
|
|||||||
| `web/` | `@RequestScope`, `@SessionScope`, `@ApplicationScope` beans and a diagnostic `/scopes` controller, plus the un-proxied request bean that fails at start-up |
|
| `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` |
|
| `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 |
|
| `shutdown/` | a web application with a `/slow` endpoint and two `SmartLifecycle` beans at different phases |
|
||||||
|
| `cycles/` | one package per scenario: `broken/` (the three-service ring), `allowed/` (field cycles with the flag on), `redesign/`, `events/`, `lazy/` and `provider/` (the four fixes), `beanmethods/` (a cycle written as `@Bean` methods) |
|
||||||
|
|
||||||
## Endpoints
|
## Endpoints
|
||||||
|
|
||||||
@@ -51,7 +53,7 @@ Both endpoints exist only to be called by the tests. They have no authorisation.
|
|||||||
|
|
||||||
## Captured output
|
## 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.
|
Files 01-17 and 19-35 (tests) and 18 (`capture-metadata.sh`). Timing rows assert coarse thresholds, not exact milliseconds; treat them as indicative.
|
||||||
|
|
||||||
| File | What it shows |
|
| File | What it shows |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -74,6 +76,24 @@ Files 01-17 and 19 (tests) and 18 (`capture-metadata.sh`). Timing rows assert co
|
|||||||
| [`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 |
|
| [`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) |
|
| [`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 |
|
| [`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 |
|
||||||
|
| [`20-boot-cycle-failure-report.txt`](output/20-boot-cycle-failure-report.txt) | Three constructor-injected services in a ring: what Spring Boot prints when start-up fails |
|
||||||
|
| [`21-cycle-exception-and-creation-order.txt`](output/21-cycle-exception-and-creation-order.txt) | The exception behind the report, and the order in which the container started creating beans |
|
||||||
|
| [`22-bean-method-cycle-failure-report.txt`](output/22-bean-method-cycle-failure-report.txt) | A two-bean cycle written as `@Bean` methods |
|
||||||
|
| [`23-lazy-initialization-hides-the-cycle.txt`](output/23-lazy-initialization-hides-the-cycle.txt) | `spring.main.lazy-initialization=true` on the same ring: it starts, then fails on first use |
|
||||||
|
| [`24-allow-circular-half-built-bean.txt`](output/24-allow-circular-half-built-bean.txt) | A field cycle with the flag off and on: what each `@PostConstruct` sees |
|
||||||
|
| [`25-allow-circular-async-raw-reference.txt`](output/25-allow-circular-async-raw-reference.txt) | The same cycle with `@Async`: repaired silently at start-up, a raw-version error under lazy initialisation |
|
||||||
|
| [`26-allow-circular-cacheable-early-proxy.txt`](output/26-allow-circular-cacheable-early-proxy.txt) | The same cycle with `@Cacheable`: the injected reference is the proxy |
|
||||||
|
| [`27-fix-redesign.txt`](output/27-fix-redesign.txt) | Fix 1: extract the one thing the second bean needed |
|
||||||
|
| [`28-fix-events.txt`](output/28-fix-events.txt) | Fix 2: an event instead of a call, and what the publisher sees when the listener throws |
|
||||||
|
| [`29-fix-lazy.txt`](output/29-fix-lazy.txt) | Fix 3: `@Lazy` on the parameter, and the proxy class that arrives |
|
||||||
|
| [`30-fix-object-provider.txt`](output/30-fix-object-provider.txt) | Fix 4: `ObjectProvider`, resolving in the constructor, and a final class |
|
||||||
|
| [`31-lazy-on-a-final-class.txt`](output/31-lazy-on-a-final-class.txt) | `@Lazy` on a parameter whose type is a final class |
|
||||||
|
| [`32-lazy-injection-point-versus-lazy-bean.txt`](output/32-lazy-injection-point-versus-lazy-bean.txt) | `@Lazy` on the injection point versus on the bean, with a target that cannot be built |
|
||||||
|
| [`33-dispatcher-handler-cycle.txt`](output/33-dispatcher-handler-cycle.txt) | A cycle through `List<Handler>` and its `ObjectProvider` repair |
|
||||||
|
| [`34-depends-on-cycle.txt`](output/34-depends-on-cycle.txt) | `@DependsOn` in both directions: no injection involved |
|
||||||
|
| [`35-lazy-used-during-construction.txt`](output/35-lazy-used-during-construction.txt) | `@Lazy` on the parameter, but the bean calls the dependency from `@PostConstruct` |
|
||||||
|
| [`36-main-property-defaults.txt`](output/36-main-property-defaults.txt) | The defaults of `spring.main.allow-circular-references` and `spring.main.lazy-initialization`, from Boot's own metadata |
|
||||||
|
| [`37-early-reference-bytecode.txt`](output/37-early-reference-bytecode.txt) | Which post-processors hand out an early reference, and the `BeanCurrentlyInCreationException` handler in `preInstantiateSingleton` |
|
||||||
|
|
||||||
## Licence
|
## Licence
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Three constructor-injected services in a ring: what Spring Boot 4.1.1 prints when start-up fails
|
||||||
|
|
||||||
|
***************************
|
||||||
|
APPLICATION FAILED TO START
|
||||||
|
***************************
|
||||||
|
|
||||||
|
Description:
|
||||||
|
|
||||||
|
The dependencies of some of the beans in the application context form a cycle:
|
||||||
|
|
||||||
|
┌─────┐
|
||||||
|
| notificationService defined in file [<core-beans>/target/classes/com/ankurm/corebeans/cycles/broken/NotificationService.class]
|
||||||
|
↑ ↓
|
||||||
|
| orderService defined in file [<core-beans>/target/classes/com/ankurm/corebeans/cycles/broken/OrderService.class]
|
||||||
|
↑ ↓
|
||||||
|
| paymentService defined in file [<core-beans>/target/classes/com/ankurm/corebeans/cycles/broken/PaymentService.class]
|
||||||
|
└─────┘
|
||||||
|
|
||||||
|
|
||||||
|
Action:
|
||||||
|
|
||||||
|
Relying upon circular references is discouraged and they are prohibited by default. Update your application to remove the dependency cycle between beans. As a last resort, it may be possible to break the cycle automatically by setting spring.main.allow-circular-references to true.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# The exception behind the report, and the order in which the container started creating beans
|
||||||
|
|
||||||
|
|
||||||
|
--- exception classes, outermost first ---
|
||||||
|
UnsatisfiedDependencyException -> UnsatisfiedDependencyException -> UnsatisfiedDependencyException -> BeanCurrentlyInCreationException
|
||||||
|
|
||||||
|
--- root cause ---
|
||||||
|
org.springframework.beans.factory.BeanCurrentlyInCreationException
|
||||||
|
Error creating bean with name 'notificationService': Requested bean is currently in creation: Is there an unresolvable circular reference or an asynchronous initialization dependency?
|
||||||
|
|
||||||
|
--- creation order (one line per bean the container began to create) ---
|
||||||
|
start creating notificationService
|
||||||
|
start creating orderService
|
||||||
|
start creating paymentService
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# A two-bean cycle written as @Bean methods: the report names the configuration class instead of a class file
|
||||||
|
|
||||||
|
***************************
|
||||||
|
APPLICATION FAILED TO START
|
||||||
|
***************************
|
||||||
|
|
||||||
|
Description:
|
||||||
|
|
||||||
|
The dependencies of some of the beans in the application context form a cycle:
|
||||||
|
|
||||||
|
┌─────┐
|
||||||
|
| clock defined in com.ankurm.corebeans.cycles.beanmethods.BeanMethodCycle
|
||||||
|
↑ ↓
|
||||||
|
| calendar defined in com.ankurm.corebeans.cycles.beanmethods.BeanMethodCycle
|
||||||
|
└─────┘
|
||||||
|
|
||||||
|
|
||||||
|
Action:
|
||||||
|
|
||||||
|
Relying upon circular references is discouraged and they are prohibited by default. Update your application to remove the dependency cycle between beans. As a last resort, it may be possible to break the cycle automatically by setting spring.main.allow-circular-references to true.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# spring.main.lazy-initialization=true on the same three-service ring
|
||||||
|
|
||||||
|
context started: true
|
||||||
|
getBean(OrderService.class) failed: UnsatisfiedDependencyException -> UnsatisfiedDependencyException -> UnsatisfiedDependencyException -> BeanCurrentlyInCreationException
|
||||||
|
root cause: Error creating bean with name 'orderService': Requested bean is currently in creation: Is there an unresolvable circular reference or an asynchronous initialization dependency?
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# A field-injected cycle with spring.main.allow-circular-references=true: what each @PostConstruct sees
|
||||||
|
|
||||||
|
|
||||||
|
--- flag off (the Boot default) ---
|
||||||
|
started: false
|
||||||
|
root cause: Error creating bean with name 'firstService': Requested bean is currently in creation: Is there an unresolvable circular reference or an asynchronous initialization dependency?
|
||||||
|
|
||||||
|
--- flag on ---
|
||||||
|
started: true
|
||||||
|
start creating firstService
|
||||||
|
FirstService constructed
|
||||||
|
start creating secondService
|
||||||
|
SecondService constructed
|
||||||
|
SecondService @PostConstruct sees first.isReady() = false
|
||||||
|
FirstService @PostConstruct done, ready=true
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# The same field cycle, with @Async on AsyncFirst and the flag on: start-up versus a lazy first getBean
|
||||||
|
|
||||||
|
|
||||||
|
--- eager start-up (the default) ---
|
||||||
|
start creating com.ankurm.corebeans.cycles.allowed.AsyncFirst
|
||||||
|
start creating com.ankurm.corebeans.cycles.allowed.AsyncSecond
|
||||||
|
AsyncSecond constructed, identity 2f0ed952
|
||||||
|
AsyncSecond.setFirst on instance 2f0ed952 received a proxy: false
|
||||||
|
AsyncSecond constructed, identity 5a8816cc
|
||||||
|
AsyncSecond.setFirst on instance 5a8816cc received a proxy: true
|
||||||
|
started: true
|
||||||
|
AsyncFirst bean is a proxy : true
|
||||||
|
AsyncSecond.first is a proxy : true
|
||||||
|
AsyncSecond.first == the bean : true
|
||||||
|
log line from DefaultListableBeanFactory (INFO):
|
||||||
|
Bean 'com.ankurm.corebeans.cycles.allowed.AsyncFirst' marked for pre-instantiation (not lazy-init) but currently initialized by other thread - skipping it in mainline thread
|
||||||
|
|
||||||
|
--- spring.main.lazy-initialization=true, then getBean(AsyncFirst.class) ---
|
||||||
|
started: true
|
||||||
|
getBean(AsyncFirst.class) failed: BeanCurrentlyInCreationException
|
||||||
|
root cause: Error creating bean with name 'com.ankurm.corebeans.cycles.allowed.AsyncFirst': Bean with name 'com.ankurm.corebeans.cycles.allowed.AsyncFirst' has been injected into other beans [com.ankurm.corebeans.cycles.allowed.AsyncSecond] in its raw version as part of a circular reference, but has eventually been wrapped. This means that said other beans do not use the final version of the bean. This is often the result of over-eager type matching - consider using 'getBeanNamesForType' with the 'allowEagerInit' flag turned off, for example.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# The same field cycle, with @Cacheable on one of the two beans, and the flag on
|
||||||
|
|
||||||
|
started: true
|
||||||
|
CachedFirst bean is a proxy : true (CachedFirst$$SpringCGLIB$$0)
|
||||||
|
CachedSecond.first is a proxy : true
|
||||||
|
CachedSecond.first == the bean : true
|
||||||
|
two calls through the injected reference return 1 and 1, method body ran 1 time(s)
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Fix 1: extract OrderCatalog, the one thing NotificationService needed
|
||||||
|
|
||||||
|
started: true
|
||||||
|
place("kettle") -> order placed: charged, e-mail says: 1 x kettle
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Fix 2: PaymentService publishes an event instead of calling NotificationService
|
||||||
|
|
||||||
|
started: true
|
||||||
|
place("kettle") -> order placed: charged
|
||||||
|
listener on thread main: e-mail says: 1 x kettle
|
||||||
|
|
||||||
|
--- what the publisher sees when the listener throws ---
|
||||||
|
place("boom") threw IllegalStateException: mail server down
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Fix 3: @Lazy on NotificationService's OrderService parameter
|
||||||
|
|
||||||
|
started: true
|
||||||
|
NotificationService holds a com.ankurm.corebeans.cycles.lazy.OrderService$$SpringCGLIB$$0
|
||||||
|
place("kettle") -> order placed: charged, e-mail says: 1 x kettle
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Fix 4: ObjectProvider<OrderService> in NotificationService
|
||||||
|
|
||||||
|
started: true
|
||||||
|
place("kettle") -> order placed: charged, e-mail says: 1 x kettle
|
||||||
|
|
||||||
|
--- the same provider, but getObject() is called inside the constructor ---
|
||||||
|
started: false
|
||||||
|
root cause: Error creating bean with name 'cycleFixesTest.EagerNotifier': Requested bean is currently in creation: Is there an unresolvable circular reference or an asynchronous initialization dependency?
|
||||||
|
|
||||||
|
--- the provider needs no proxy, so a final class is fine ---
|
||||||
|
started: true
|
||||||
|
hello() -> hello
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# @Lazy on a constructor parameter whose type is a final class
|
||||||
|
|
||||||
|
started: false
|
||||||
|
exception classes: BeanCreationException -> AopConfigException -> IllegalArgumentException
|
||||||
|
root cause: java.lang.IllegalArgumentException: Cannot subclass final class com.ankurm.corebeans.CycleFixesTest$FinalTarget
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# @Lazy on the injection point, with a target that cannot be built
|
||||||
|
|
||||||
|
|
||||||
|
--- @Lazy on the parameter, target is an ordinary singleton ---
|
||||||
|
started: false
|
||||||
|
root cause: cannot reach the payment provider
|
||||||
|
|
||||||
|
--- @Lazy on the parameter AND on the target bean ---
|
||||||
|
started: true
|
||||||
|
first call threw: BeanCreationException -> BeanInstantiationException -> IllegalStateException
|
||||||
|
root cause: cannot reach the payment provider
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Dispatcher(List<Handler>) and a Handler that needs the Dispatcher
|
||||||
|
|
||||||
|
|
||||||
|
--- Handler injects the Dispatcher ---
|
||||||
|
started: false
|
||||||
|
exception classes: UnsatisfiedDependencyException -> UnsatisfiedDependencyException -> BeanCurrentlyInCreationException
|
||||||
|
root cause: Error creating bean with name 'hiddenCyclesTest.Dispatcher': Requested bean is currently in creation: Is there an unresolvable circular reference or an asynchronous initialization dependency?
|
||||||
|
|
||||||
|
--- Handler injects ObjectProvider<Dispatcher> ---
|
||||||
|
started: true
|
||||||
|
dispatch("ping") -> [seen ping (dispatcher available: true)]
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# @DependsOn("b") on a and @DependsOn("a") on b: no injection involved at all
|
||||||
|
|
||||||
|
started: false
|
||||||
|
exception classes: BeanCreationException
|
||||||
|
root cause: Error creating bean with name 'b' defined in com.ankurm.corebeans.HiddenCyclesTest$DependsOnCycle: Circular depends-on relationship between 'b' and 'a'
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# @Lazy on the parameter, but the bean calls the dependency from @PostConstruct
|
||||||
|
|
||||||
|
started: false
|
||||||
|
exception classes: BeanCreationException -> UnsatisfiedDependencyException -> BeanCurrentlyInCreationException
|
||||||
|
root cause: Error creating bean with name 'cycleFixesTest.LazyUserA': Requested bean is currently in creation: Is there an unresolvable circular reference or an asynchronous initialization dependency?
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Defaults read from spring-configuration-metadata.json in spring-boot-4.1.1.jar
|
||||||
|
|
||||||
|
spring.main.allow-circular-references default=False
|
||||||
|
spring.main.lazy-initialization default=False
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Who hands out an early reference, and the handler in preInstantiateSingleton (Spring Framework 7.0.9 jars)
|
||||||
|
|
||||||
|
--- the class that draws the report, in spring-boot-4.1.1.jar ---
|
||||||
|
org/springframework/boot/diagnostics/analyzer/BeanCurrentlyInCreationFailureAnalyzer$BeanInCycle.class
|
||||||
|
org/springframework/boot/diagnostics/analyzer/BeanCurrentlyInCreationFailureAnalyzer$DependencyCycle.class
|
||||||
|
org/springframework/boot/diagnostics/analyzer/BeanCurrentlyInCreationFailureAnalyzer.class
|
||||||
|
|
||||||
|
--- AbstractAutoProxyCreator (spring-aop): declared methods that mention 'early' ---
|
||||||
|
private final java.util.Map<java.lang.Object, java.lang.Object> earlyBeanReferences;
|
||||||
|
public java.lang.Object getEarlyBeanReference(java.lang.Object, java.lang.String);
|
||||||
|
|
||||||
|
--- AbstractAdvisingBeanPostProcessor (spring-aop): declared members that mention 'early' ---
|
||||||
|
matches: 0
|
||||||
|
|
||||||
|
--- the @Async post-processor's ancestry ---
|
||||||
|
class org.springframework.scheduling.annotation.AsyncAnnotationBeanPostProcessor extends org.springframework.aop.framework.autoproxy.AbstractBeanFactoryAwareAdvisingPostProcessor
|
||||||
|
class org.springframework.aop.framework.autoproxy.AbstractBeanFactoryAwareAdvisingPostProcessor extends org.springframework.aop.framework.AbstractAdvisingBeanPostProcessor
|
||||||
|
|
||||||
|
--- DefaultListableBeanFactory.preInstantiateSingleton(String, RootBeanDefinition): exception table ---
|
||||||
|
Exception table:
|
||||||
|
from to target type
|
||||||
|
157 162 165 Class org/springframework/beans/factory/BeanCurrentlyInCreationException
|
||||||
|
|
||||||
+1
-1
@@ -15,7 +15,7 @@
|
|||||||
<artifactId>core-beans</artifactId>
|
<artifactId>core-beans</artifactId>
|
||||||
<version>1.0.0</version>
|
<version>1.0.0</version>
|
||||||
<name>core-beans</name>
|
<name>core-beans</name>
|
||||||
<description>Spring bean scopes and the bean lifecycle in Spring Boot 4</description>
|
<description>Spring bean scopes, the bean lifecycle and circular dependencies in Spring Boot 4</description>
|
||||||
|
|
||||||
<properties>
|
<properties>
|
||||||
<java.version>25</java.version>
|
<java.version>25</java.version>
|
||||||
|
|||||||
Executable
+49
@@ -0,0 +1,49 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Two facts about the circular-reference machinery, read out of the Framework and Boot jars rather than from docs:
|
||||||
|
# 36 the defaults of spring.main.allow-circular-references and spring.main.lazy-initialization
|
||||||
|
# 37 which post-processors supply an early reference, and the exception handler in preInstantiateSingleton
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
mkdir -p output
|
||||||
|
M2=~/.m2/repository/org/springframework
|
||||||
|
BOOT=$M2/boot/spring-boot/4.1.1/spring-boot-4.1.1.jar
|
||||||
|
AOP=$M2/spring-aop/7.0.9/spring-aop-7.0.9.jar
|
||||||
|
CTX=$M2/spring-context/7.0.9/spring-context-7.0.9.jar
|
||||||
|
BEANS=$M2/spring-beans/7.0.9/spring-beans-7.0.9.jar
|
||||||
|
JP() { javap "$@" 2>&1 | grep -v '^Picked up'; }
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "# Defaults read from spring-configuration-metadata.json in spring-boot-4.1.1.jar"
|
||||||
|
echo
|
||||||
|
unzip -p "$BOOT" META-INF/spring-configuration-metadata.json | python3 -c '
|
||||||
|
import json,sys
|
||||||
|
d=json.load(sys.stdin)
|
||||||
|
for p in d["properties"]:
|
||||||
|
if p["name"] in ("spring.main.allow-circular-references","spring.main.lazy-initialization"):
|
||||||
|
print("%-40s default=%s" % (p["name"], p.get("defaultValue")))
|
||||||
|
'
|
||||||
|
} > output/36-main-property-defaults.txt
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "# Who hands out an early reference, and the handler in preInstantiateSingleton (Spring Framework 7.0.9 jars)"
|
||||||
|
echo
|
||||||
|
echo "--- the class that draws the report, in spring-boot-4.1.1.jar ---"
|
||||||
|
unzip -l "$BOOT" | awk '/BeanCurrentlyInCreationFailureAnalyzer/ {print $4}'
|
||||||
|
echo
|
||||||
|
echo "--- AbstractAutoProxyCreator (spring-aop): declared methods that mention 'early' ---"
|
||||||
|
JP -p -cp "$AOP" org.springframework.aop.framework.autoproxy.AbstractAutoProxyCreator | grep -i early | sed 's/^ *//'
|
||||||
|
echo
|
||||||
|
echo "--- AbstractAdvisingBeanPostProcessor (spring-aop): declared members that mention 'early' ---"
|
||||||
|
n=$(JP -p -cp "$AOP" org.springframework.aop.framework.AbstractAdvisingBeanPostProcessor | grep -ci early || true)
|
||||||
|
echo "matches: $n"
|
||||||
|
echo
|
||||||
|
echo "--- the @Async post-processor's ancestry ---"
|
||||||
|
JP -cp "$CTX:$AOP" org.springframework.scheduling.annotation.AsyncAnnotationBeanPostProcessor | grep -o 'class [^ ]* extends [^ ]*'
|
||||||
|
JP -cp "$AOP" org.springframework.aop.framework.autoproxy.AbstractBeanFactoryAwareAdvisingPostProcessor | grep -o 'class [^ ]* extends [^ ]*'
|
||||||
|
echo
|
||||||
|
echo "--- DefaultListableBeanFactory.preInstantiateSingleton(String, RootBeanDefinition): exception table ---"
|
||||||
|
JP -p -c -cp "$BEANS" org.springframework.beans.factory.support.DefaultListableBeanFactory \
|
||||||
|
| awk '/private java.util.concurrent.CompletableFuture<\?> preInstantiateSingleton\(/ {f=1} f&&/Exception table:/ {t=1} t {print} t&&/^ private void instantiateSingletonInBackgroundThread/ {exit}' \
|
||||||
|
| grep -v 'instantiateSingletonInBackgroundThread' | sed 's/^ *//'
|
||||||
|
} > output/37-early-reference-bytecode.txt
|
||||||
|
cat output/36-main-property-defaults.txt output/37-early-reference-bytecode.txt
|
||||||
@@ -3,18 +3,21 @@
|
|||||||
#
|
#
|
||||||
# ./scripts/run-all.sh
|
# ./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:
|
# Needs a JDK 25 and Maven 3.9. Transcripts 01-17 and 19-35 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.
|
# the figures in the articles are assertions that fail the build if they stop being true.
|
||||||
# Timing-based rows (12, 16) assert coarse thresholds, not exact milliseconds.
|
# Timing-based rows (12, 16) assert coarse thresholds, not exact milliseconds.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
echo "== test suite (transcripts 01-17)"
|
echo "== test suite (transcripts 01-17 and 19-35)"
|
||||||
mvn -B test
|
mvn -B test
|
||||||
|
|
||||||
echo "== property defaults read from Boot's metadata (18)"
|
echo "== property defaults read from Boot's metadata (18)"
|
||||||
./scripts/capture-metadata.sh
|
./scripts/capture-metadata.sh
|
||||||
|
|
||||||
|
echo "== early-reference and property facts read from the jars (36-37)"
|
||||||
|
./scripts/capture-cycle-facts.sh
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "output:"
|
echo "output:"
|
||||||
ls -1 output
|
ls -1 output
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.ankurm.corebeans.cycles.allowed;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.ComponentScan;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@ComponentScan(basePackageClasses = AllowedConfig.class,
|
||||||
|
excludeFilters = @org.springframework.context.annotation.ComponentScan.Filter(
|
||||||
|
type = org.springframework.context.annotation.FilterType.REGEX, pattern = ".*(Async|Cached).*"))
|
||||||
|
public class AllowedConfig {
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.ankurm.corebeans.cycles.allowed;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.scheduling.annotation.EnableAsync;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@EnableAsync
|
||||||
|
@Import({AsyncFirst.class, AsyncSecond.class})
|
||||||
|
public class AsyncConfig {
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.ankurm.corebeans.cycles.allowed;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.scheduling.annotation.Async;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
/** A field cycle, with one bean carrying @Async. */
|
||||||
|
@Service
|
||||||
|
public class AsyncFirst {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
AsyncSecond second;
|
||||||
|
|
||||||
|
@Async
|
||||||
|
public void work() {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.ankurm.corebeans.cycles.allowed;
|
||||||
|
|
||||||
|
import com.ankurm.corebeans.Trace;
|
||||||
|
import org.springframework.aop.support.AopUtils;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class AsyncSecond {
|
||||||
|
|
||||||
|
AsyncFirst first;
|
||||||
|
|
||||||
|
public AsyncSecond() {
|
||||||
|
Trace.log("AsyncSecond constructed, identity " + Integer.toHexString(System.identityHashCode(this)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
void setFirst(AsyncFirst first) {
|
||||||
|
Trace.log("AsyncSecond.setFirst on instance " + Integer.toHexString(System.identityHashCode(this)) + " received a proxy: " + AopUtils.isAopProxy(first));
|
||||||
|
this.first = first;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AsyncFirst first() {
|
||||||
|
return first;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.ankurm.corebeans.cycles.allowed;
|
||||||
|
|
||||||
|
import org.springframework.cache.CacheManager;
|
||||||
|
import org.springframework.cache.annotation.EnableCaching;
|
||||||
|
import org.springframework.cache.concurrent.ConcurrentMapCacheManager;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@EnableCaching
|
||||||
|
@Import({CachedFirst.class, CachedSecond.class})
|
||||||
|
public class CachedConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
CacheManager cacheManager() {
|
||||||
|
return new ConcurrentMapCacheManager("answers");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.ankurm.corebeans.cycles.allowed;
|
||||||
|
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.cache.annotation.Cacheable;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
/** The same field cycle as AsyncFirst, but the proxying comes from @Cacheable instead of @Async. */
|
||||||
|
@Service
|
||||||
|
public class CachedFirst {
|
||||||
|
|
||||||
|
static final AtomicInteger CALLS = new AtomicInteger();
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
CachedSecond second;
|
||||||
|
|
||||||
|
public static int calls() {
|
||||||
|
return CALLS.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Cacheable("answers")
|
||||||
|
public int answer() {
|
||||||
|
return CALLS.incrementAndGet();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.ankurm.corebeans.cycles.allowed;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class CachedSecond {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
CachedFirst first;
|
||||||
|
|
||||||
|
public CachedFirst first() {
|
||||||
|
return first;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.ankurm.corebeans.cycles.allowed;
|
||||||
|
|
||||||
|
import com.ankurm.corebeans.Trace;
|
||||||
|
import jakarta.annotation.PostConstruct;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class FirstService {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
SecondService second;
|
||||||
|
|
||||||
|
boolean ready;
|
||||||
|
|
||||||
|
public FirstService() {
|
||||||
|
Trace.log("FirstService constructed");
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostConstruct
|
||||||
|
void init() {
|
||||||
|
ready = true;
|
||||||
|
Trace.log("FirstService @PostConstruct done, ready=true");
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isReady() {
|
||||||
|
return ready;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.ankurm.corebeans.cycles.allowed;
|
||||||
|
|
||||||
|
import com.ankurm.corebeans.Trace;
|
||||||
|
import jakarta.annotation.PostConstruct;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class SecondService {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
FirstService first;
|
||||||
|
|
||||||
|
public SecondService() {
|
||||||
|
Trace.log("SecondService constructed");
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostConstruct
|
||||||
|
void init() {
|
||||||
|
Trace.log("SecondService @PostConstruct sees first.isReady() = " + first.isReady());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.ankurm.corebeans.cycles.beanmethods;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
/** The same cycle written as @Bean methods: each method asks for the other bean as a parameter. */
|
||||||
|
@Configuration
|
||||||
|
public class BeanMethodCycle {
|
||||||
|
|
||||||
|
public record Clock(Calendar calendar) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record Calendar(Clock clock) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
Clock clock(Calendar calendar) {
|
||||||
|
return new Clock(calendar);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
Calendar calendar(Clock clock) {
|
||||||
|
return new Calendar(clock);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.ankurm.corebeans.cycles.broken;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.ComponentScan;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
/** Scans only this package: OrderService -> PaymentService -> NotificationService -> OrderService. */
|
||||||
|
@Configuration
|
||||||
|
@ComponentScan
|
||||||
|
public class BrokenConfig {
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.ankurm.corebeans.cycles.broken;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class NotificationService {
|
||||||
|
|
||||||
|
private final OrderService orders;
|
||||||
|
|
||||||
|
public NotificationService(OrderService orders) {
|
||||||
|
this.orders = orders;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String confirm(String sku) {
|
||||||
|
return "e-mail says: " + orders.describe(sku);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.ankurm.corebeans.cycles.broken;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class OrderService {
|
||||||
|
|
||||||
|
private final PaymentService payments;
|
||||||
|
|
||||||
|
public OrderService(PaymentService payments) {
|
||||||
|
this.payments = payments;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String place(String sku) {
|
||||||
|
return "order placed: " + payments.charge(sku);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String describe(String sku) {
|
||||||
|
return "1 x " + sku;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.ankurm.corebeans.cycles.broken;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class PaymentService {
|
||||||
|
|
||||||
|
private final NotificationService notifications;
|
||||||
|
|
||||||
|
public PaymentService(NotificationService notifications) {
|
||||||
|
this.notifications = notifications;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String charge(String sku) {
|
||||||
|
return "charged, " + notifications.confirm(sku);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.ankurm.corebeans.cycles.events;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.ComponentScan;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@ComponentScan
|
||||||
|
public class EventsConfig {
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.ankurm.corebeans.cycles.events;
|
||||||
|
|
||||||
|
import com.ankurm.corebeans.Trace;
|
||||||
|
import org.springframework.context.event.EventListener;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class NotificationService {
|
||||||
|
|
||||||
|
private final OrderService orders;
|
||||||
|
|
||||||
|
public NotificationService(OrderService orders) {
|
||||||
|
this.orders = orders;
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventListener
|
||||||
|
void onPaymentCaptured(PaymentCaptured event) {
|
||||||
|
if (event.sku().equals("boom")) {
|
||||||
|
throw new IllegalStateException("mail server down");
|
||||||
|
}
|
||||||
|
Trace.log("listener on thread " + Thread.currentThread().getName()
|
||||||
|
+ ": e-mail says: " + orders.describe(event.sku()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.ankurm.corebeans.cycles.events;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class OrderService {
|
||||||
|
|
||||||
|
private final PaymentService payments;
|
||||||
|
|
||||||
|
public OrderService(PaymentService payments) {
|
||||||
|
this.payments = payments;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String place(String sku) {
|
||||||
|
return "order placed: " + payments.charge(sku);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String describe(String sku) {
|
||||||
|
return "1 x " + sku;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package com.ankurm.corebeans.cycles.events;
|
||||||
|
|
||||||
|
/** A plain record: since Spring 4.2 an event does not have to extend ApplicationEvent. */
|
||||||
|
public record PaymentCaptured(String sku) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.ankurm.corebeans.cycles.events;
|
||||||
|
|
||||||
|
import org.springframework.context.ApplicationEventPublisher;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class PaymentService {
|
||||||
|
|
||||||
|
private final ApplicationEventPublisher events;
|
||||||
|
|
||||||
|
public PaymentService(ApplicationEventPublisher events) {
|
||||||
|
this.events = events;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String charge(String sku) {
|
||||||
|
events.publishEvent(new PaymentCaptured(sku));
|
||||||
|
return "charged";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.ankurm.corebeans.cycles.lazy;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.ComponentScan;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@ComponentScan
|
||||||
|
public class LazyConfig {
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.ankurm.corebeans.cycles.lazy;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Lazy;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class NotificationService {
|
||||||
|
|
||||||
|
private final OrderService orders;
|
||||||
|
|
||||||
|
/** @Lazy makes Spring inject a proxy and look the real OrderService up on the first call. */
|
||||||
|
public NotificationService(@Lazy OrderService orders) {
|
||||||
|
this.orders = orders;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String confirm(String sku) {
|
||||||
|
return "e-mail says: " + orders.describe(sku);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String injectedType() {
|
||||||
|
return orders.getClass().getName();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.ankurm.corebeans.cycles.lazy;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class OrderService {
|
||||||
|
|
||||||
|
private final PaymentService payments;
|
||||||
|
|
||||||
|
public OrderService(PaymentService payments) {
|
||||||
|
this.payments = payments;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String place(String sku) {
|
||||||
|
return "order placed: " + payments.charge(sku);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String describe(String sku) {
|
||||||
|
return "1 x " + sku;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.ankurm.corebeans.cycles.lazy;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class PaymentService {
|
||||||
|
|
||||||
|
private final NotificationService notifications;
|
||||||
|
|
||||||
|
public PaymentService(NotificationService notifications) {
|
||||||
|
this.notifications = notifications;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String charge(String sku) {
|
||||||
|
return "charged, " + notifications.confirm(sku);
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
package com.ankurm.corebeans.cycles.provider;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.ObjectProvider;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class NotificationService {
|
||||||
|
|
||||||
|
private final ObjectProvider<OrderService> orders;
|
||||||
|
|
||||||
|
/** Nothing is resolved here: the provider is a handle, not the bean. */
|
||||||
|
public NotificationService(ObjectProvider<OrderService> orders) {
|
||||||
|
this.orders = orders;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String confirm(String sku) {
|
||||||
|
return "e-mail says: " + orders.getObject().describe(sku);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.ankurm.corebeans.cycles.provider;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class OrderService {
|
||||||
|
|
||||||
|
private final PaymentService payments;
|
||||||
|
|
||||||
|
public OrderService(PaymentService payments) {
|
||||||
|
this.payments = payments;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String place(String sku) {
|
||||||
|
return "order placed: " + payments.charge(sku);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String describe(String sku) {
|
||||||
|
return "1 x " + sku;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.ankurm.corebeans.cycles.provider;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class PaymentService {
|
||||||
|
|
||||||
|
private final NotificationService notifications;
|
||||||
|
|
||||||
|
public PaymentService(NotificationService notifications) {
|
||||||
|
this.notifications = notifications;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String charge(String sku) {
|
||||||
|
return "charged, " + notifications.confirm(sku);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.ankurm.corebeans.cycles.provider;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.ComponentScan;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@ComponentScan
|
||||||
|
public class ProviderConfig {
|
||||||
|
}
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
package com.ankurm.corebeans.cycles.redesign;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class NotificationService {
|
||||||
|
|
||||||
|
private final OrderCatalog catalog;
|
||||||
|
|
||||||
|
public NotificationService(OrderCatalog catalog) {
|
||||||
|
this.catalog = catalog;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String confirm(String sku) {
|
||||||
|
return "e-mail says: " + catalog.describe(sku);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.ankurm.corebeans.cycles.redesign;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
/** The one thing NotificationService actually needed from OrderService, pulled out into its own bean. */
|
||||||
|
@Service
|
||||||
|
public class OrderCatalog {
|
||||||
|
|
||||||
|
public String describe(String sku) {
|
||||||
|
return "1 x " + sku;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.ankurm.corebeans.cycles.redesign;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class OrderService {
|
||||||
|
|
||||||
|
private final PaymentService payments;
|
||||||
|
private final OrderCatalog catalog;
|
||||||
|
|
||||||
|
public OrderService(PaymentService payments, OrderCatalog catalog) {
|
||||||
|
this.payments = payments;
|
||||||
|
this.catalog = catalog;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String place(String sku) {
|
||||||
|
return "order placed: " + payments.charge(sku);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String describe(String sku) {
|
||||||
|
return catalog.describe(sku);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.ankurm.corebeans.cycles.redesign;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class PaymentService {
|
||||||
|
|
||||||
|
private final NotificationService notifications;
|
||||||
|
|
||||||
|
public PaymentService(NotificationService notifications) {
|
||||||
|
this.notifications = notifications;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String charge(String sku) {
|
||||||
|
return "charged, " + notifications.confirm(sku);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.ankurm.corebeans.cycles.redesign;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.ComponentScan;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@ComponentScan
|
||||||
|
public class RedesignConfig {
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.ankurm.corebeans.cycles.support;
|
||||||
|
|
||||||
|
import com.ankurm.corebeans.Trace;
|
||||||
|
import org.springframework.beans.factory.config.InstantiationAwareBeanPostProcessor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logs the moment the container starts creating each demo bean. It is how the creation
|
||||||
|
* chain (orderService, then paymentService, then notificationService) becomes visible.
|
||||||
|
*/
|
||||||
|
public class CreationTracer implements InstantiationAwareBeanPostProcessor {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Object postProcessBeforeInstantiation(Class<?> beanClass, String beanName) {
|
||||||
|
String simple = beanClass.getSimpleName();
|
||||||
|
if (beanClass.getPackageName().startsWith("com.ankurm.corebeans.cycles")
|
||||||
|
&& !simple.endsWith("Config") && !simple.endsWith("Tracer")) {
|
||||||
|
Trace.log("start creating " + beanName);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package com.ankurm.corebeans;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
import com.ankurm.corebeans.cycles.allowed.AllowedConfig;
|
||||||
|
import com.ankurm.corebeans.cycles.allowed.AsyncConfig;
|
||||||
|
import com.ankurm.corebeans.cycles.allowed.AsyncFirst;
|
||||||
|
import com.ankurm.corebeans.cycles.allowed.AsyncSecond;
|
||||||
|
import com.ankurm.corebeans.cycles.allowed.CachedConfig;
|
||||||
|
import com.ankurm.corebeans.cycles.allowed.CachedFirst;
|
||||||
|
import com.ankurm.corebeans.cycles.allowed.CachedSecond;
|
||||||
|
import com.ankurm.corebeans.cycles.support.CreationTracer;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.springframework.boot.test.system.CapturedOutput;
|
||||||
|
import org.springframework.boot.test.system.OutputCaptureExtension;
|
||||||
|
import org.springframework.aop.support.AopUtils;
|
||||||
|
|
||||||
|
/** Post 20: what spring.main.allow-circular-references=true really buys, and what it costs. */
|
||||||
|
@ExtendWith(OutputCaptureExtension.class)
|
||||||
|
class AllowCircularTest {
|
||||||
|
|
||||||
|
private static final String[] ALLOW = {"spring.main.allow-circular-references=true"};
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void reset() {
|
||||||
|
Trace.drain();
|
||||||
|
((ch.qos.logback.classic.Logger) org.slf4j.LoggerFactory.getLogger(org.slf4j.Logger.ROOT_LOGGER_NAME))
|
||||||
|
.setLevel(ch.qos.logback.classic.Level.INFO);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void theFlagMakesAFieldCycleStartButOneBeanIsHalfBuilt() {
|
||||||
|
try (var t = new Transcript("24-allow-circular-half-built-bean.txt",
|
||||||
|
"A field-injected cycle with spring.main.allow-circular-references=true: what each @PostConstruct sees")) {
|
||||||
|
t.section("flag off (the Boot default)");
|
||||||
|
var off = BootRun.run(new String[0], AllowedConfig.class);
|
||||||
|
t.line("started: %s", off.started());
|
||||||
|
t.line("root cause: %s", BootRun.root(off.failure()).getMessage());
|
||||||
|
assertThat(off.started()).isFalse();
|
||||||
|
|
||||||
|
Trace.drain();
|
||||||
|
t.section("flag on");
|
||||||
|
var on = BootRun.run(ALLOW, AllowedConfig.class, CreationTracer.class);
|
||||||
|
t.line("started: %s", on.started());
|
||||||
|
assertThat(on.started()).isTrue();
|
||||||
|
Trace.drain().forEach(e -> t.line("%s", e));
|
||||||
|
on.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void asyncInTheCycleIsRepairedSilentlyAtStartupAndFailsOnAnyOtherPath(CapturedOutput output) {
|
||||||
|
try (var t = new Transcript("25-allow-circular-async-raw-reference.txt",
|
||||||
|
"The same field cycle, with @Async on AsyncFirst and the flag on: start-up versus a lazy first getBean")) {
|
||||||
|
t.section("eager start-up (the default)");
|
||||||
|
var eager = BootRun.run(new String[] {"spring.main.allow-circular-references=true", "logging.level.root=INFO"},
|
||||||
|
AsyncConfig.class, CreationTracer.class);
|
||||||
|
Trace.drain().forEach(e -> t.line("%s", e));
|
||||||
|
t.line("started: %s", eager.started());
|
||||||
|
describe(t, eager);
|
||||||
|
eager.close();
|
||||||
|
var infoLines = java.util.Arrays.stream(output.getAll().split("\n"))
|
||||||
|
.filter(l -> l.contains("DefaultListableBeanFactory")).map(l -> l.substring(l.indexOf(" : ") + 3)).toList();
|
||||||
|
t.line("log line from DefaultListableBeanFactory (INFO):");
|
||||||
|
infoLines.forEach(l -> t.line(" %s", l));
|
||||||
|
assertThat(eager.started()).isTrue();
|
||||||
|
assertThat(infoLines).isNotEmpty();
|
||||||
|
|
||||||
|
t.section("spring.main.lazy-initialization=true, then getBean(AsyncFirst.class)");
|
||||||
|
var lazy = BootRun.run(new String[] {"spring.main.allow-circular-references=true", "spring.main.lazy-initialization=true"},
|
||||||
|
AsyncConfig.class);
|
||||||
|
t.line("started: %s", lazy.started());
|
||||||
|
assertThat(lazy.started()).isTrue();
|
||||||
|
try {
|
||||||
|
lazy.context().getBean(AsyncFirst.class);
|
||||||
|
t.line("getBean(AsyncFirst.class): returned a bean");
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
t.line("getBean(AsyncFirst.class) failed: %s", BootRun.chain(e));
|
||||||
|
t.line("root cause: %s", BootRun.root(e).getMessage());
|
||||||
|
assertThat(BootRun.root(e).getMessage()).contains("in its raw version");
|
||||||
|
} finally {
|
||||||
|
lazy.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void describe(Transcript t, BootRun.Result result) {
|
||||||
|
if (!result.started()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var first = result.context().getBean(AsyncFirst.class);
|
||||||
|
var second = result.context().getBean(AsyncSecond.class);
|
||||||
|
t.line("AsyncFirst bean is a proxy : %s", AopUtils.isAopProxy(first));
|
||||||
|
t.line("AsyncSecond.first is a proxy : %s", AopUtils.isAopProxy(second.first()));
|
||||||
|
t.line("AsyncSecond.first == the bean : %s", second.first() == first);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cacheableInTheCycleWorksBecauseTheProxyCreatorHandsOutAnEarlyProxy() {
|
||||||
|
try (var t = new Transcript("26-allow-circular-cacheable-early-proxy.txt",
|
||||||
|
"The same field cycle, with @Cacheable on one of the two beans, and the flag on")) {
|
||||||
|
var result = BootRun.run(ALLOW, CachedConfig.class);
|
||||||
|
t.line("started: %s", result.started());
|
||||||
|
assertThat(result.started()).isTrue();
|
||||||
|
var ctx = result.context();
|
||||||
|
var first = ctx.getBean(CachedFirst.class);
|
||||||
|
var second = ctx.getBean(CachedSecond.class);
|
||||||
|
t.line("CachedFirst bean is a proxy : %s (%s)", AopUtils.isAopProxy(first), first.getClass().getSimpleName());
|
||||||
|
t.line("CachedSecond.first is a proxy : %s", AopUtils.isAopProxy(second.first()));
|
||||||
|
t.line("CachedSecond.first == the bean : %s", second.first() == first);
|
||||||
|
int a = second.first().answer();
|
||||||
|
int b = second.first().answer();
|
||||||
|
t.line("two calls through the injected reference return %d and %d, method body ran %d time(s)",
|
||||||
|
a, b, CachedFirst.calls());
|
||||||
|
assertThat(AopUtils.isAopProxy(second.first())).isTrue();
|
||||||
|
assertThat(second.first()).isSameAs(first);
|
||||||
|
assertThat(CachedFirst.calls()).isEqualTo(1);
|
||||||
|
result.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package com.ankurm.corebeans;
|
||||||
|
|
||||||
|
import org.springframework.boot.WebApplicationType;
|
||||||
|
import org.springframework.boot.builder.SpringApplicationBuilder;
|
||||||
|
import org.springframework.context.ConfigurableApplicationContext;
|
||||||
|
|
||||||
|
/** Starts a Spring Boot context without a web server and returns either the context or the failure. */
|
||||||
|
public final class BootRun {
|
||||||
|
|
||||||
|
private BootRun() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Result run(String[] extraProperties, Class<?>... sources) {
|
||||||
|
var props = new java.util.ArrayList<String>();
|
||||||
|
props.add("spring.main.banner-mode=off");
|
||||||
|
props.add("logging.level.root=OFF");
|
||||||
|
props.addAll(java.util.List.of(extraProperties));
|
||||||
|
try {
|
||||||
|
ConfigurableApplicationContext ctx = new SpringApplicationBuilder(sources)
|
||||||
|
.web(WebApplicationType.NONE)
|
||||||
|
.properties(props.toArray(String[]::new))
|
||||||
|
.run();
|
||||||
|
return new Result(ctx, null);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
return new Result(null, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Names the exception classes from the outside in: what a stack trace's "Caused by:" lines say, minus the noise. */
|
||||||
|
public static String chain(Throwable t) {
|
||||||
|
var names = new java.util.ArrayList<String>();
|
||||||
|
for (Throwable c = t; c != null && !names.contains(c.getClass().getSimpleName() + c.hashCode()); c = c.getCause()) {
|
||||||
|
names.add(c.getClass().getSimpleName());
|
||||||
|
if (c.getCause() == c) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return String.join(" -> ", names);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Throwable root(Throwable t) {
|
||||||
|
while (t.getCause() != null && t.getCause() != t) {
|
||||||
|
t = t.getCause();
|
||||||
|
}
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
public record Result(ConfigurableApplicationContext context, RuntimeException failure) {
|
||||||
|
|
||||||
|
public boolean started() {
|
||||||
|
return failure == null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void close() {
|
||||||
|
if (context != null) {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package com.ankurm.corebeans;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
import com.ankurm.corebeans.cycles.beanmethods.BeanMethodCycle;
|
||||||
|
import com.ankurm.corebeans.cycles.broken.BrokenConfig;
|
||||||
|
import com.ankurm.corebeans.cycles.broken.OrderService;
|
||||||
|
import com.ankurm.corebeans.cycles.support.CreationTracer;
|
||||||
|
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;
|
||||||
|
|
||||||
|
/** Post 20: the start-up failure itself, read line by line, and what the container was doing when it hit it. */
|
||||||
|
@ExtendWith(OutputCaptureExtension.class)
|
||||||
|
class CycleFailureTest {
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void reset() {
|
||||||
|
Trace.drain();
|
||||||
|
((ch.qos.logback.classic.Logger) org.slf4j.LoggerFactory.getLogger(org.slf4j.Logger.ROOT_LOGGER_NAME))
|
||||||
|
.setLevel(ch.qos.logback.classic.Level.INFO);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** From the row of asterisks to the end of the "Action:" paragraph. */
|
||||||
|
static String report(String console) {
|
||||||
|
int start = console.indexOf("***************************\nAPPLICATION FAILED TO START");
|
||||||
|
assertThat(start).as("Boot printed a failure report").isGreaterThanOrEqualTo(0);
|
||||||
|
String tail = console.substring(start);
|
||||||
|
int action = tail.indexOf("Action:");
|
||||||
|
int end = tail.indexOf("\n\n", action + "Action:\n\n".length());
|
||||||
|
return (end < 0 ? tail : tail.substring(0, end)).stripTrailing();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void bootPrintsTheCycleAsAPicture(CapturedOutput output) {
|
||||||
|
try (var t = new Transcript("20-boot-cycle-failure-report.txt",
|
||||||
|
"Three constructor-injected services in a ring: what Spring Boot 4.1.1 prints when start-up fails")) {
|
||||||
|
var thrown = new java.util.concurrent.atomic.AtomicReference<Throwable>();
|
||||||
|
try {
|
||||||
|
new SpringApplicationBuilder(BrokenConfig.class)
|
||||||
|
.web(WebApplicationType.NONE)
|
||||||
|
.properties("logging.level.root=INFO", "spring.main.banner-mode=off")
|
||||||
|
.run();
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
thrown.set(e);
|
||||||
|
}
|
||||||
|
assertThat(thrown.get()).isNotNull();
|
||||||
|
String report = report(output.getAll());
|
||||||
|
t.line("%s", report);
|
||||||
|
assertThat(report).contains("The dependencies of some of the beans in the application context form a cycle:")
|
||||||
|
.contains("┌─────┐").contains("└─────┘")
|
||||||
|
.contains("orderService").contains("paymentService").contains("notificationService")
|
||||||
|
.contains("spring.main.allow-circular-references");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void theExceptionUnderneathAndTheCreationOrderBehindIt() {
|
||||||
|
try (var t = new Transcript("21-cycle-exception-and-creation-order.txt",
|
||||||
|
"The exception behind the report, and the order in which the container started creating beans")) {
|
||||||
|
var result = BootRun.run(new String[0], BrokenConfig.class, CreationTracer.class);
|
||||||
|
assertThat(result.started()).isFalse();
|
||||||
|
t.section("exception classes, outermost first");
|
||||||
|
t.line("%s", BootRun.chain(result.failure()));
|
||||||
|
t.section("root cause");
|
||||||
|
t.line("%s", BootRun.root(result.failure()).getClass().getName());
|
||||||
|
t.line("%s", BootRun.root(result.failure()).getMessage());
|
||||||
|
t.section("creation order (one line per bean the container began to create)");
|
||||||
|
var events = Trace.drain();
|
||||||
|
events.forEach(e -> t.line("%s", e));
|
||||||
|
assertThat(events).containsExactly(
|
||||||
|
"start creating notificationService", "start creating orderService", "start creating paymentService");
|
||||||
|
assertThat(BootRun.root(result.failure()).getMessage())
|
||||||
|
.contains("Requested bean is currently in creation").contains("notificationService");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void theSameCycleWrittenAsBeanMethods(CapturedOutput output) {
|
||||||
|
try (var t = new Transcript("22-bean-method-cycle-failure-report.txt",
|
||||||
|
"A two-bean cycle written as @Bean methods: the report names the configuration class instead of a class file")) {
|
||||||
|
try {
|
||||||
|
new SpringApplicationBuilder(BeanMethodCycle.class)
|
||||||
|
.web(WebApplicationType.NONE)
|
||||||
|
.properties("logging.level.root=INFO", "spring.main.banner-mode=off")
|
||||||
|
.run();
|
||||||
|
} catch (RuntimeException expected) {
|
||||||
|
// the report is what we came for
|
||||||
|
}
|
||||||
|
String report = report(output.getAll());
|
||||||
|
t.line("%s", report);
|
||||||
|
assertThat(report).contains("clock defined in com.ankurm.corebeans.cycles.beanmethods.BeanMethodCycle");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void lazyInitialisationMovesTheFailureToTheFirstUse() {
|
||||||
|
try (var t = new Transcript("23-lazy-initialization-hides-the-cycle.txt",
|
||||||
|
"spring.main.lazy-initialization=true on the same three-service ring")) {
|
||||||
|
var result = BootRun.run(new String[] {"spring.main.lazy-initialization=true"}, BrokenConfig.class);
|
||||||
|
t.line("context started: %s", result.started());
|
||||||
|
assertThat(result.started()).isTrue();
|
||||||
|
try {
|
||||||
|
result.context().getBean(OrderService.class);
|
||||||
|
t.line("getBean(OrderService.class): returned a bean");
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
t.line("getBean(OrderService.class) failed: %s", BootRun.chain(e));
|
||||||
|
t.line("root cause: %s", BootRun.root(e).getMessage());
|
||||||
|
assertThat(BootRun.root(e).getMessage()).contains("currently in creation");
|
||||||
|
} finally {
|
||||||
|
result.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
package com.ankurm.corebeans;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
import com.ankurm.corebeans.cycles.events.EventsConfig;
|
||||||
|
import com.ankurm.corebeans.cycles.lazy.LazyConfig;
|
||||||
|
import com.ankurm.corebeans.cycles.provider.ProviderConfig;
|
||||||
|
import com.ankurm.corebeans.cycles.redesign.RedesignConfig;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.ObjectProvider;
|
||||||
|
import org.springframework.context.annotation.Lazy;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/** Post 20: the four fixes, each starting the same three-service application. */
|
||||||
|
class CycleFixesTest {
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void reset() {
|
||||||
|
Trace.drain();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void fixOneRedesign() {
|
||||||
|
try (var t = new Transcript("27-fix-redesign.txt", "Fix 1: extract OrderCatalog, the one thing NotificationService needed")) {
|
||||||
|
var result = BootRun.run(new String[0], RedesignConfig.class);
|
||||||
|
t.line("started: %s", result.started());
|
||||||
|
assertThat(result.started()).isTrue();
|
||||||
|
var orders = result.context().getBean(com.ankurm.corebeans.cycles.redesign.OrderService.class);
|
||||||
|
t.line("place(\"kettle\") -> %s", orders.place("kettle"));
|
||||||
|
assertThat(orders.place("kettle")).contains("1 x kettle");
|
||||||
|
result.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void fixTwoEvents() {
|
||||||
|
try (var t = new Transcript("28-fix-events.txt", "Fix 2: PaymentService publishes an event instead of calling NotificationService")) {
|
||||||
|
var result = BootRun.run(new String[0], EventsConfig.class);
|
||||||
|
t.line("started: %s", result.started());
|
||||||
|
assertThat(result.started()).isTrue();
|
||||||
|
var orders = result.context().getBean(com.ankurm.corebeans.cycles.events.OrderService.class);
|
||||||
|
t.line("place(\"kettle\") -> %s", orders.place("kettle"));
|
||||||
|
Trace.drain().forEach(e -> t.line("%s", e));
|
||||||
|
t.section("what the publisher sees when the listener throws");
|
||||||
|
try {
|
||||||
|
orders.place("boom");
|
||||||
|
t.line("place(\"boom\") returned normally");
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
t.line("place(\"boom\") threw %s: %s", e.getClass().getSimpleName(), e.getMessage());
|
||||||
|
assertThat(e).hasMessage("mail server down");
|
||||||
|
}
|
||||||
|
result.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void fixThreeLazy() {
|
||||||
|
try (var t = new Transcript("29-fix-lazy.txt", "Fix 3: @Lazy on NotificationService's OrderService parameter")) {
|
||||||
|
var result = BootRun.run(new String[0], LazyConfig.class);
|
||||||
|
t.line("started: %s", result.started());
|
||||||
|
assertThat(result.started()).isTrue();
|
||||||
|
var ctx = result.context();
|
||||||
|
var orders = ctx.getBean(com.ankurm.corebeans.cycles.lazy.OrderService.class);
|
||||||
|
var notifications = ctx.getBean(com.ankurm.corebeans.cycles.lazy.NotificationService.class);
|
||||||
|
t.line("NotificationService holds a %s", notifications.injectedType());
|
||||||
|
t.line("place(\"kettle\") -> %s", orders.place("kettle"));
|
||||||
|
assertThat(notifications.injectedType()).contains("SpringCGLIB");
|
||||||
|
result.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void fixFourObjectProvider() {
|
||||||
|
try (var t = new Transcript("30-fix-object-provider.txt", "Fix 4: ObjectProvider<OrderService> in NotificationService")) {
|
||||||
|
var result = BootRun.run(new String[0], ProviderConfig.class);
|
||||||
|
t.line("started: %s", result.started());
|
||||||
|
assertThat(result.started()).isTrue();
|
||||||
|
var orders = result.context().getBean(com.ankurm.corebeans.cycles.provider.OrderService.class);
|
||||||
|
t.line("place(\"kettle\") -> %s", orders.place("kettle"));
|
||||||
|
result.close();
|
||||||
|
|
||||||
|
t.section("the same provider, but getObject() is called inside the constructor");
|
||||||
|
var eager = Ctx.tryStart(EagerNotifier.class, EagerOrders.class);
|
||||||
|
t.line("started: %s", eager.started());
|
||||||
|
assertThat(eager.started()).isFalse();
|
||||||
|
t.line("root cause: %s", Ctx.root(eager.failure()).getMessage());
|
||||||
|
|
||||||
|
t.section("the provider needs no proxy, so a final class is fine");
|
||||||
|
var finalOutcome = Ctx.tryStart(FinalProviderConsumer.class, FinalTarget.class);
|
||||||
|
t.line("started: %s", finalOutcome.started());
|
||||||
|
assertThat(finalOutcome.started()).isTrue();
|
||||||
|
t.line("hello() -> %s", finalOutcome.context().getBean(FinalProviderConsumer.class).hello());
|
||||||
|
finalOutcome.closeQuietly();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component
|
||||||
|
static class FinalProviderConsumer {
|
||||||
|
private final ObjectProvider<FinalTarget> target;
|
||||||
|
|
||||||
|
FinalProviderConsumer(ObjectProvider<FinalTarget> target) {
|
||||||
|
this.target = target;
|
||||||
|
}
|
||||||
|
|
||||||
|
String hello() {
|
||||||
|
return target.getObject().hello();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the provider trap: resolving in the constructor rebuilds the cycle -------------------------------
|
||||||
|
|
||||||
|
@Component
|
||||||
|
static class EagerOrders {
|
||||||
|
EagerOrders(EagerNotifier notifier) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component
|
||||||
|
static class EagerNotifier {
|
||||||
|
EagerNotifier(ObjectProvider<EagerOrders> orders) {
|
||||||
|
orders.getObject();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the two ways @Lazy fails later ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
static final class FinalTarget {
|
||||||
|
String hello() {
|
||||||
|
return "hello";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component
|
||||||
|
static class FinalConsumer {
|
||||||
|
FinalConsumer(@Lazy FinalTarget target) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A singleton like any other: the context builds it at start-up whether or not anyone injected it lazily. */
|
||||||
|
@Component
|
||||||
|
static class Flaky {
|
||||||
|
Flaky() {
|
||||||
|
throw new IllegalStateException("cannot reach the payment provider");
|
||||||
|
}
|
||||||
|
|
||||||
|
String hello() {
|
||||||
|
return "hello";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The same class, but the bean itself is lazy. */
|
||||||
|
@Component
|
||||||
|
@Lazy
|
||||||
|
static class LazyFlaky {
|
||||||
|
LazyFlaky() {
|
||||||
|
throw new IllegalStateException("cannot reach the payment provider");
|
||||||
|
}
|
||||||
|
|
||||||
|
String hello() {
|
||||||
|
return "hello";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component
|
||||||
|
static class FlakyConsumer {
|
||||||
|
private final Flaky flaky;
|
||||||
|
|
||||||
|
FlakyConsumer(@Lazy Flaky flaky) {
|
||||||
|
this.flaky = flaky;
|
||||||
|
}
|
||||||
|
|
||||||
|
String call() {
|
||||||
|
return flaky.hello();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component
|
||||||
|
static class LazyFlakyConsumer {
|
||||||
|
private final LazyFlaky flaky;
|
||||||
|
|
||||||
|
LazyFlakyConsumer(@Lazy LazyFlaky flaky) {
|
||||||
|
this.flaky = flaky;
|
||||||
|
}
|
||||||
|
|
||||||
|
String call() {
|
||||||
|
return flaky.hello();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component
|
||||||
|
static class LazyUserA {
|
||||||
|
private final LazyUserB b;
|
||||||
|
|
||||||
|
LazyUserA(@Lazy LazyUserB b) {
|
||||||
|
this.b = b;
|
||||||
|
}
|
||||||
|
|
||||||
|
@jakarta.annotation.PostConstruct
|
||||||
|
void init() {
|
||||||
|
b.hello();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component
|
||||||
|
static class LazyUserB {
|
||||||
|
LazyUserB(LazyUserA a) {
|
||||||
|
}
|
||||||
|
|
||||||
|
String hello() {
|
||||||
|
return "hello";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void lazyProxyResolvedDuringConstructionRebuildsTheCycle() {
|
||||||
|
try (var t = new Transcript("35-lazy-used-during-construction.txt",
|
||||||
|
"@Lazy on the parameter, but the bean calls the dependency from @PostConstruct")) {
|
||||||
|
var outcome = BootRun.run(new String[0], LazyUserA.class, LazyUserB.class);
|
||||||
|
t.line("started: %s", outcome.started());
|
||||||
|
assertThat(outcome.started()).isFalse();
|
||||||
|
t.line("exception classes: %s", BootRun.chain(outcome.failure()));
|
||||||
|
t.line("root cause: %s", BootRun.root(outcome.failure()).getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void lazyOnAFinalClass() {
|
||||||
|
try (var t = new Transcript("31-lazy-on-a-final-class.txt", "@Lazy on a constructor parameter whose type is a final class")) {
|
||||||
|
var outcome = Ctx.tryStart(FinalConsumer.class, FinalTarget.class);
|
||||||
|
t.line("started: %s", outcome.started());
|
||||||
|
if (!outcome.started()) {
|
||||||
|
t.line("exception classes: %s", BootRun.chain(outcome.failure()));
|
||||||
|
t.line("root cause: %s: %s", Ctx.root(outcome.failure()).getClass().getName(), Ctx.root(outcome.failure()).getMessage());
|
||||||
|
} else {
|
||||||
|
outcome.closeQuietly();
|
||||||
|
}
|
||||||
|
assertThat(outcome.started()).isFalse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void lazyDelaysOnlyWhatTheBeanItselfDelays() {
|
||||||
|
try (var t = new Transcript("32-lazy-injection-point-versus-lazy-bean.txt",
|
||||||
|
"@Lazy on the injection point, with a target that cannot be built")) {
|
||||||
|
t.section("@Lazy on the parameter, target is an ordinary singleton");
|
||||||
|
var eager = Ctx.tryStart(FlakyConsumer.class, Flaky.class);
|
||||||
|
t.line("started: %s", eager.started());
|
||||||
|
assertThat(eager.started()).isFalse();
|
||||||
|
t.line("root cause: %s", Ctx.root(eager.failure()).getMessage());
|
||||||
|
|
||||||
|
t.section("@Lazy on the parameter AND on the target bean");
|
||||||
|
var lazy = Ctx.tryStart(LazyFlakyConsumer.class, LazyFlaky.class);
|
||||||
|
t.line("started: %s", lazy.started());
|
||||||
|
assertThat(lazy.started()).isTrue();
|
||||||
|
var consumer = lazy.context().getBean(LazyFlakyConsumer.class);
|
||||||
|
assertThatThrownBy(consumer::call).satisfies(e -> {
|
||||||
|
t.line("first call threw: %s", BootRun.chain(e));
|
||||||
|
t.line("root cause: %s", Ctx.root(e).getMessage());
|
||||||
|
});
|
||||||
|
lazy.closeQuietly();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package com.ankurm.corebeans;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.ObjectProvider;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.context.annotation.DependsOn;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/** Post 20: cycles that do not look like A -> B -> A when you read the code. */
|
||||||
|
class HiddenCyclesTest {
|
||||||
|
|
||||||
|
interface Handler {
|
||||||
|
String handle(String message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Collects every Handler. Looks harmless. */
|
||||||
|
@Component
|
||||||
|
static class Dispatcher {
|
||||||
|
private final List<Handler> handlers;
|
||||||
|
|
||||||
|
Dispatcher(List<Handler> handlers) {
|
||||||
|
this.handlers = handlers;
|
||||||
|
}
|
||||||
|
|
||||||
|
String dispatch(String message) {
|
||||||
|
return handlers.stream().map(h -> h.handle(message)).toList().toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wants to re-dispatch, so it asks for the Dispatcher. That is the whole cycle. */
|
||||||
|
@Component
|
||||||
|
static class RedispatchingHandler implements Handler {
|
||||||
|
private final Dispatcher dispatcher;
|
||||||
|
|
||||||
|
RedispatchingHandler(Dispatcher dispatcher) {
|
||||||
|
this.dispatcher = dispatcher;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String handle(String message) {
|
||||||
|
return "seen " + message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The repair: hold a handle, not the bean. */
|
||||||
|
@Component
|
||||||
|
static class LazyLookupHandler implements Handler {
|
||||||
|
private final ObjectProvider<LookupDispatcher> dispatcher;
|
||||||
|
|
||||||
|
LazyLookupHandler(ObjectProvider<LookupDispatcher> dispatcher) {
|
||||||
|
this.dispatcher = dispatcher;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String handle(String message) {
|
||||||
|
return "seen " + message + " (dispatcher available: " + (dispatcher.getIfAvailable() != null) + ")";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component
|
||||||
|
static class LookupDispatcher {
|
||||||
|
private final List<Handler> handlers;
|
||||||
|
|
||||||
|
LookupDispatcher(List<Handler> handlers) {
|
||||||
|
this.handlers = handlers;
|
||||||
|
}
|
||||||
|
|
||||||
|
String dispatch(String message) {
|
||||||
|
return handlers.stream().map(h -> h.handle(message)).toList().toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aCycleThroughAListOfCollaborators() {
|
||||||
|
try (var t = new Transcript("33-dispatcher-handler-cycle.txt", "Dispatcher(List<Handler>) and a Handler that needs the Dispatcher")) {
|
||||||
|
t.section("Handler injects the Dispatcher");
|
||||||
|
var broken = Ctx.tryStart(Dispatcher.class, RedispatchingHandler.class);
|
||||||
|
t.line("started: %s", broken.started());
|
||||||
|
assertThat(broken.started()).isFalse();
|
||||||
|
t.line("exception classes: %s", BootRun.chain(broken.failure()));
|
||||||
|
t.line("root cause: %s", Ctx.root(broken.failure()).getMessage());
|
||||||
|
|
||||||
|
t.section("Handler injects ObjectProvider<Dispatcher>");
|
||||||
|
var fixed = Ctx.tryStart(LookupDispatcher.class, LazyLookupHandler.class);
|
||||||
|
t.line("started: %s", fixed.started());
|
||||||
|
assertThat(fixed.started()).isTrue();
|
||||||
|
t.line("dispatch(\"ping\") -> %s", fixed.context().getBean(LookupDispatcher.class).dispatch("ping"));
|
||||||
|
fixed.closeQuietly();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
static class DependsOnCycle {
|
||||||
|
@Bean
|
||||||
|
@DependsOn("b")
|
||||||
|
Object a() {
|
||||||
|
return new Object();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
@DependsOn("a")
|
||||||
|
Object b() {
|
||||||
|
return new Object();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aDependsOnCycle() {
|
||||||
|
try (var t = new Transcript("34-depends-on-cycle.txt", "@DependsOn(\"b\") on a and @DependsOn(\"a\") on b: no injection involved at all")) {
|
||||||
|
var outcome = Ctx.tryStart(DependsOnCycle.class);
|
||||||
|
t.line("started: %s", outcome.started());
|
||||||
|
assertThat(outcome.started()).isFalse();
|
||||||
|
t.line("exception classes: %s", BootRun.chain(outcome.failure()));
|
||||||
|
t.line("root cause: %s", Ctx.root(outcome.failure()).getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,12 +41,14 @@ public final class Transcript implements AutoCloseable {
|
|||||||
@Override
|
@Override
|
||||||
public void close() {
|
public void close() {
|
||||||
out.flush();
|
out.flush();
|
||||||
|
// Absolute paths of whoever ran the build are environment noise, not a finding.
|
||||||
|
String text = buffer.toString().replace(System.getProperty("user.dir"), "<core-beans>");
|
||||||
try {
|
try {
|
||||||
Files.createDirectories(path.getParent());
|
Files.createDirectories(path.getParent());
|
||||||
Files.writeString(path, buffer.toString());
|
Files.writeString(path, text);
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
throw new IllegalStateException("could not write " + path, e);
|
throw new IllegalStateException("could not write " + path, e);
|
||||||
}
|
}
|
||||||
System.out.print(buffer);
|
System.out.print(text);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user