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:
@@ -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
|
||||
public void close() {
|
||||
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 {
|
||||
Files.createDirectories(path.getParent());
|
||||
Files.writeString(path, buffer.toString());
|
||||
Files.writeString(path, text);
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("could not write " + path, e);
|
||||
}
|
||||
System.out.print(buffer);
|
||||
System.out.print(text);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user