Add core-beans: bean scopes, the prototype-in-singleton trap, lifecycle callback order and graceful shutdown phases on Boot 4.1
Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01JoVmf2fWvcpoXndcDSwRf7
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
package com.ankurm.corebeans;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/** A shared event log so a test can print the order in which Spring touches a bean. */
|
||||
public final class Trace {
|
||||
|
||||
private static final List<String> EVENTS = Collections.synchronizedList(new ArrayList<>());
|
||||
|
||||
private Trace() {
|
||||
}
|
||||
|
||||
public static void log(String event) {
|
||||
EVENTS.add(event);
|
||||
}
|
||||
|
||||
public static List<String> drain() {
|
||||
List<String> copy;
|
||||
synchronized (EVENTS) {
|
||||
copy = new ArrayList<>(EVENTS);
|
||||
EVENTS.clear();
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.ankurm.corebeans.lifecycle;
|
||||
|
||||
import com.ankurm.corebeans.Trace;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import org.springframework.aop.support.AopUtils;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.scheduling.annotation.EnableAsync;
|
||||
|
||||
/** A bean that gets proxied ({@code @Async}); @PostConstruct runs on the raw target, before the proxy exists. */
|
||||
public class AsyncMailer {
|
||||
|
||||
@PostConstruct
|
||||
void init() {
|
||||
Trace.log("@PostConstruct sees this = " + getClass().getName() + ", isAopProxy(this) = "
|
||||
+ AopUtils.isAopProxy(this));
|
||||
}
|
||||
|
||||
@Async
|
||||
public void send() {
|
||||
}
|
||||
|
||||
@org.springframework.context.annotation.Configuration
|
||||
@EnableAsync
|
||||
public static class Config {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.ankurm.corebeans.lifecycle;
|
||||
|
||||
public class Dependency {
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.ankurm.corebeans.lifecycle;
|
||||
|
||||
import com.ankurm.corebeans.Trace;
|
||||
import org.springframework.boot.ApplicationArguments;
|
||||
import org.springframework.boot.ApplicationRunner;
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.boot.context.event.ApplicationStartedEvent;
|
||||
import org.springframework.context.event.ContextClosedEvent;
|
||||
import org.springframework.context.event.ContextRefreshedEvent;
|
||||
import org.springframework.context.event.EventListener;
|
||||
|
||||
/** Where the application events fall between the bean callbacks. Only fires under SpringApplication. */
|
||||
public class EventLogger implements ApplicationRunner {
|
||||
|
||||
@EventListener
|
||||
void refreshed(ContextRefreshedEvent e) {
|
||||
Trace.log("ContextRefreshedEvent");
|
||||
}
|
||||
|
||||
@EventListener
|
||||
void started(ApplicationStartedEvent e) {
|
||||
Trace.log("ApplicationStartedEvent");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run(ApplicationArguments args) {
|
||||
Trace.log("ApplicationRunner.run");
|
||||
}
|
||||
|
||||
@EventListener
|
||||
void ready(ApplicationReadyEvent e) {
|
||||
Trace.log("ApplicationReadyEvent");
|
||||
}
|
||||
|
||||
@EventListener
|
||||
void closing(ContextClosedEvent e) {
|
||||
Trace.log("ContextClosedEvent");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.ankurm.corebeans.lifecycle;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
|
||||
public class FailingInit {
|
||||
|
||||
@PostConstruct
|
||||
void warmUp() {
|
||||
throw new IllegalStateException("cache warm-up failed");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package com.ankurm.corebeans.lifecycle;
|
||||
|
||||
import com.ankurm.corebeans.Trace;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import org.springframework.beans.BeansException;
|
||||
import org.springframework.beans.factory.BeanFactory;
|
||||
import org.springframework.beans.factory.BeanFactoryAware;
|
||||
import org.springframework.beans.factory.BeanNameAware;
|
||||
import org.springframework.beans.factory.DisposableBean;
|
||||
import org.springframework.beans.factory.InitializingBean;
|
||||
import org.springframework.beans.factory.SmartInitializingSingleton;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.context.ApplicationContextAware;
|
||||
import org.springframework.context.SmartLifecycle;
|
||||
|
||||
/**
|
||||
* One bean that implements every lifecycle hook Spring offers, each one logging when it is called.
|
||||
* Nobody should write a class like this; it exists so the order can be printed from a real run.
|
||||
*/
|
||||
public class KitchenSink implements BeanNameAware, BeanFactoryAware, ApplicationContextAware,
|
||||
InitializingBean, DisposableBean, SmartInitializingSingleton, SmartLifecycle {
|
||||
|
||||
private volatile boolean running;
|
||||
|
||||
public KitchenSink() {
|
||||
Trace.log("constructor");
|
||||
}
|
||||
|
||||
@Autowired
|
||||
public void setDependency(Dependency dependency) {
|
||||
Trace.log("setter injection (@Autowired setDependency)");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setBeanName(String name) {
|
||||
Trace.log("BeanNameAware.setBeanName(\"" + name + "\")");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setBeanFactory(BeanFactory beanFactory) throws BeansException {
|
||||
Trace.log("BeanFactoryAware.setBeanFactory");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
|
||||
Trace.log("ApplicationContextAware.setApplicationContext");
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
void postConstruct() {
|
||||
Trace.log("@PostConstruct");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterPropertiesSet() {
|
||||
Trace.log("InitializingBean.afterPropertiesSet");
|
||||
}
|
||||
|
||||
/** Named by {@code @Bean(initMethod = "customInit")}. */
|
||||
public void customInit() {
|
||||
Trace.log("@Bean(initMethod = \"customInit\")");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterSingletonsInstantiated() {
|
||||
Trace.log("SmartInitializingSingleton.afterSingletonsInstantiated");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start() {
|
||||
running = true;
|
||||
Trace.log("SmartLifecycle.start (phase " + getPhase() + ")");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop() {
|
||||
running = false;
|
||||
Trace.log("SmartLifecycle.stop");
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isRunning() {
|
||||
return running;
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
void preDestroy() {
|
||||
Trace.log("@PreDestroy");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void destroy() {
|
||||
Trace.log("DisposableBean.destroy");
|
||||
}
|
||||
|
||||
/** Named by {@code @Bean(destroyMethod = "customDestroy")}. */
|
||||
public void customDestroy() {
|
||||
Trace.log("@Bean(destroyMethod = \"customDestroy\")");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.ankurm.corebeans.lifecycle;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
public class LifecycleConfig {
|
||||
|
||||
@Bean
|
||||
static TracingPostProcessor tracingPostProcessor() {
|
||||
return new TracingPostProcessor();
|
||||
}
|
||||
|
||||
@Bean
|
||||
Dependency dependency() {
|
||||
return new Dependency();
|
||||
}
|
||||
|
||||
@Bean(initMethod = "customInit", destroyMethod = "customDestroy")
|
||||
KitchenSink kitchenSink() {
|
||||
return new KitchenSink();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.ankurm.corebeans.lifecycle;
|
||||
|
||||
import org.springframework.context.SmartLifecycle;
|
||||
|
||||
/** A SmartLifecycle whose asynchronous stop never invokes the callback: shutdown must time out. */
|
||||
public class NeverStops implements SmartLifecycle {
|
||||
|
||||
private volatile boolean running;
|
||||
|
||||
@Override
|
||||
public void start() {
|
||||
running = true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop() {
|
||||
running = false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop(Runnable callback) {
|
||||
// forgot to call callback.run()
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isRunning() {
|
||||
return running;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.ankurm.corebeans.lifecycle;
|
||||
|
||||
import org.springframework.beans.factory.config.BeanPostProcessor;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/** A BeanPostProcessor declared with a NON-static {@code @Bean} method: the trap. */
|
||||
@Configuration
|
||||
public class NonStaticProcessorConfig {
|
||||
|
||||
@Bean
|
||||
BeanPostProcessor quietProcessor() {
|
||||
return new BeanPostProcessor() {
|
||||
};
|
||||
}
|
||||
|
||||
@Bean
|
||||
Dependency plainBean() {
|
||||
return new Dependency();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.ankurm.corebeans.lifecycle;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
import com.ankurm.corebeans.Trace;
|
||||
import org.springframework.context.SmartLifecycle;
|
||||
|
||||
/** A SmartLifecycle with a chosen phase and a stop that takes a chosen time, blocking or asynchronous. */
|
||||
public class PhasedWorker implements SmartLifecycle {
|
||||
|
||||
private final String name;
|
||||
private final int phase;
|
||||
private final long stopMillis;
|
||||
private final boolean asyncStop;
|
||||
private final AtomicBoolean running = new AtomicBoolean();
|
||||
|
||||
public PhasedWorker(String name, int phase, long stopMillis, boolean asyncStop) {
|
||||
this.name = name;
|
||||
this.phase = phase;
|
||||
this.stopMillis = stopMillis;
|
||||
this.asyncStop = asyncStop;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getPhase() {
|
||||
return phase;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start() {
|
||||
running.set(true);
|
||||
Trace.log("start " + name + " (phase " + phase + ")");
|
||||
}
|
||||
|
||||
/** Blocking variant: Spring's default stop(Runnable) calls this, then the callback, on its own thread. */
|
||||
@Override
|
||||
public void stop() {
|
||||
sleep(stopMillis);
|
||||
running.set(false);
|
||||
Trace.log("stop " + name + " (phase " + phase + ")");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop(Runnable callback) {
|
||||
if (!asyncStop) {
|
||||
SmartLifecycle.super.stop(callback);
|
||||
return;
|
||||
}
|
||||
Thread.ofVirtual().start(() -> {
|
||||
sleep(stopMillis);
|
||||
running.set(false);
|
||||
Trace.log("stop " + name + " (phase " + phase + ", async callback)");
|
||||
callback.run();
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isRunning() {
|
||||
return running.get();
|
||||
}
|
||||
|
||||
private static void sleep(long millis) {
|
||||
try {
|
||||
Thread.sleep(millis);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.ankurm.corebeans.lifecycle;
|
||||
|
||||
import com.ankurm.corebeans.Trace;
|
||||
import org.springframework.context.Lifecycle;
|
||||
|
||||
/** A plain Lifecycle (not Smart): started only by an explicit context.start(), never by refresh. */
|
||||
public class PlainLifecycle implements Lifecycle {
|
||||
|
||||
private boolean running;
|
||||
|
||||
@Override
|
||||
public void start() {
|
||||
running = true;
|
||||
Trace.log("PlainLifecycle.start");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop() {
|
||||
running = false;
|
||||
Trace.log("PlainLifecycle.stop");
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isRunning() {
|
||||
return running;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.ankurm.corebeans.lifecycle;
|
||||
|
||||
import com.ankurm.corebeans.Trace;
|
||||
import org.springframework.beans.BeansException;
|
||||
import org.springframework.beans.factory.config.BeanPostProcessor;
|
||||
|
||||
/** Logs the two BeanPostProcessor callbacks, but only for the bean named kitchenSink. */
|
||||
public class TracingPostProcessor implements BeanPostProcessor {
|
||||
|
||||
@Override
|
||||
public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {
|
||||
if (beanName.equals("kitchenSink")) {
|
||||
Trace.log("BeanPostProcessor.postProcessBeforeInitialization");
|
||||
}
|
||||
return bean;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
|
||||
if (beanName.equals("kitchenSink")) {
|
||||
Trace.log("BeanPostProcessor.postProcessAfterInitialization");
|
||||
}
|
||||
return bean;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.ankurm.corebeans.scopes;
|
||||
|
||||
import org.springframework.context.ApplicationContext;
|
||||
|
||||
/** Fix 4 (the one to avoid): reach back into the container. It works and couples the class to Spring. */
|
||||
public class ContextConsumer {
|
||||
|
||||
private final ApplicationContext context;
|
||||
|
||||
public ContextConsumer(ApplicationContext context) {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
public int use() {
|
||||
return context.getBean(PrototypeBean.class).serial();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.ankurm.corebeans.scopes;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/** Counts how many instances of each demo bean the container has actually constructed. */
|
||||
public final class Instances {
|
||||
|
||||
private static final Map<String, AtomicInteger> COUNTS = new ConcurrentHashMap<>();
|
||||
|
||||
private Instances() {
|
||||
}
|
||||
|
||||
/** Called from a constructor; returns this instance's serial number (1, 2, 3, ...). */
|
||||
public static int next(String key) {
|
||||
return COUNTS.computeIfAbsent(key, k -> new AtomicInteger()).incrementAndGet();
|
||||
}
|
||||
|
||||
public static int count(String key) {
|
||||
AtomicInteger c = COUNTS.get(key);
|
||||
return c == null ? 0 : c.get();
|
||||
}
|
||||
|
||||
public static void reset() {
|
||||
COUNTS.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.ankurm.corebeans.scopes;
|
||||
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
|
||||
/** Still a singleton, but not created until something asks for it. */
|
||||
@Lazy
|
||||
public class LazySingletonBean {
|
||||
|
||||
private final int serial = Instances.next("lazy");
|
||||
|
||||
public int serial() {
|
||||
return serial;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.ankurm.corebeans.scopes;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Lookup;
|
||||
|
||||
/** Fix 2: Spring subclasses this at runtime and overrides the abstract method to call getBean(). */
|
||||
public abstract class LookupConsumer {
|
||||
|
||||
@Lookup
|
||||
protected abstract PrototypeBean create();
|
||||
|
||||
public int use() {
|
||||
return create().serial();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.ankurm.corebeans.scopes;
|
||||
|
||||
/** The trap: a singleton that receives a prototype through its constructor. */
|
||||
public class NaiveConsumer {
|
||||
|
||||
private final PrototypeBean prototype;
|
||||
|
||||
public NaiveConsumer(PrototypeBean prototype) {
|
||||
this.prototype = prototype;
|
||||
}
|
||||
|
||||
public int use() {
|
||||
return prototype.serial();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.ankurm.corebeans.scopes;
|
||||
|
||||
import jakarta.annotation.PreDestroy;
|
||||
|
||||
import com.ankurm.corebeans.Trace;
|
||||
import org.springframework.context.annotation.Scope;
|
||||
|
||||
/** A new instance every time the container is asked for one. */
|
||||
@Scope("prototype")
|
||||
public class PrototypeBean {
|
||||
|
||||
private final int serial = Instances.next("prototype");
|
||||
|
||||
public int serial() {
|
||||
return serial;
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
void destroy() {
|
||||
Trace.log("prototype @PreDestroy called");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.ankurm.corebeans.scopes;
|
||||
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
|
||||
/** Fix 1: inject a provider and ask it every time. */
|
||||
public class ProviderConsumer {
|
||||
|
||||
private final ObjectProvider<PrototypeBean> provider;
|
||||
|
||||
public ProviderConsumer(ObjectProvider<PrototypeBean> provider) {
|
||||
this.provider = provider;
|
||||
}
|
||||
|
||||
public int use() {
|
||||
return provider.getObject().serial();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.ankurm.corebeans.scopes;
|
||||
|
||||
/** Fix 3: inject a scoped proxy; the proxy fetches a fresh target for every call. */
|
||||
public class ProxyConsumer {
|
||||
|
||||
private final ScopedPrototypeBean prototype;
|
||||
|
||||
public ProxyConsumer(ScopedPrototypeBean prototype) {
|
||||
this.prototype = prototype;
|
||||
}
|
||||
|
||||
public int use() {
|
||||
return prototype.serial();
|
||||
}
|
||||
|
||||
public String injectedClass() {
|
||||
return prototype.getClass().getName();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.ankurm.corebeans.scopes;
|
||||
|
||||
/** The same singleton with the state in a parameter and a local variable: nothing shared to corrupt. */
|
||||
public class SafeGreeter {
|
||||
|
||||
public String greet(String user, Runnable pause) {
|
||||
String currentUser = user;
|
||||
pause.run();
|
||||
return "Hello, " + currentUser;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.ankurm.corebeans.scopes;
|
||||
|
||||
import org.springframework.context.annotation.Scope;
|
||||
import org.springframework.context.annotation.ScopedProxyMode;
|
||||
|
||||
/** Prototype scope behind a proxy: every method call on the injected reference reaches a new target. */
|
||||
@Scope(value = "prototype", proxyMode = ScopedProxyMode.TARGET_CLASS)
|
||||
public class ScopedPrototypeBean {
|
||||
|
||||
private final int serial = Instances.next("scopedPrototype");
|
||||
|
||||
public int serial() {
|
||||
return serial;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.ankurm.corebeans.scopes;
|
||||
|
||||
import jakarta.annotation.PreDestroy;
|
||||
|
||||
import com.ankurm.corebeans.Trace;
|
||||
|
||||
/** No {@code @Scope}: the default. One instance per container, created at start-up. */
|
||||
public class SingletonBean {
|
||||
|
||||
private final int serial = Instances.next("singleton");
|
||||
|
||||
public int serial() {
|
||||
return serial;
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
void destroy() {
|
||||
Trace.log("singleton @PreDestroy called");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.ankurm.corebeans.scopes;
|
||||
|
||||
/**
|
||||
* A singleton that keeps per-caller data in a field. The {@code pause} hook lets a test force the
|
||||
* interleaving that production traffic produces only occasionally, so the demonstration is exact.
|
||||
*/
|
||||
public class UnsafeGreeter {
|
||||
|
||||
private String currentUser;
|
||||
|
||||
public String greet(String user, Runnable pause) {
|
||||
this.currentUser = user;
|
||||
pause.run();
|
||||
return "Hello, " + currentUser;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.ankurm.corebeans.shutdown;
|
||||
|
||||
import com.ankurm.corebeans.Trace;
|
||||
import org.springframework.context.SmartLifecycle;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/** Leaves getPhase() at its default, which is Integer.MAX_VALUE. */
|
||||
@Component
|
||||
public class EarlyWorker implements SmartLifecycle {
|
||||
|
||||
private volatile boolean running;
|
||||
|
||||
@Override
|
||||
public void start() {
|
||||
running = true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop() {
|
||||
running = false;
|
||||
Trace.log("EarlyWorker.stop (phase " + getPhase() + "): requests still in flight = "
|
||||
+ SlowController.IN_FLIGHT.get());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isRunning() {
|
||||
return running;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.ankurm.corebeans.shutdown;
|
||||
|
||||
import com.ankurm.corebeans.Trace;
|
||||
import org.springframework.context.SmartLifecycle;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/** Phase 1000: far below the web server's phases, so it stops after the server has drained. */
|
||||
@Component
|
||||
public class LateWorker implements SmartLifecycle {
|
||||
|
||||
private volatile boolean running;
|
||||
|
||||
@Override
|
||||
public int getPhase() {
|
||||
return 1000;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start() {
|
||||
running = true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop() {
|
||||
running = false;
|
||||
Trace.log("LateWorker.stop (phase " + getPhase() + "): requests still in flight = "
|
||||
+ SlowController.IN_FLIGHT.get());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isRunning() {
|
||||
return running;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.ankurm.corebeans.shutdown;
|
||||
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
/** A web application with one slow endpoint and two SmartLifecycle beans, used by the shutdown tests. */
|
||||
@SpringBootApplication
|
||||
public class ShutdownApp {
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.ankurm.corebeans.shutdown;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/** Diagnostic only: delete before shipping. */
|
||||
@RestController
|
||||
public class SlowController {
|
||||
|
||||
/** How many /slow requests are executing right now. */
|
||||
public static final AtomicInteger IN_FLIGHT = new AtomicInteger();
|
||||
|
||||
/** How many /slow handlers ran to the end, whether or not the client was still listening. */
|
||||
public static final AtomicInteger COMPLETED = new AtomicInteger();
|
||||
|
||||
@GetMapping("/slow")
|
||||
public String slow(@RequestParam long ms) throws InterruptedException {
|
||||
IN_FLIGHT.incrementAndGet();
|
||||
try {
|
||||
Thread.sleep(ms);
|
||||
COMPLETED.incrementAndGet();
|
||||
return "finished after " + ms + " ms, virtual thread = " + Thread.currentThread().isVirtual();
|
||||
} finally {
|
||||
IN_FLIGHT.decrementAndGet();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.ankurm.corebeans.web;
|
||||
|
||||
import com.ankurm.corebeans.scopes.Instances;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.context.annotation.ApplicationScope;
|
||||
|
||||
/** One per ServletContext. In a single Boot application that is one per JVM, so it looks like a singleton. */
|
||||
@Component
|
||||
@ApplicationScope
|
||||
public class ApplicationBean {
|
||||
|
||||
private final int serial = Instances.next("application");
|
||||
|
||||
public int serial() {
|
||||
return serial;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.ankurm.corebeans.web;
|
||||
|
||||
import org.springframework.context.annotation.Scope;
|
||||
|
||||
/** A singleton that takes a request-scoped bean WITHOUT a scoped proxy: the classic start-up failure. */
|
||||
public class BrokenRequestConsumer {
|
||||
|
||||
/** Registered by hand in the test, with proxyMode left at its default (NO). */
|
||||
@Scope("request")
|
||||
public static class RawRequestBean {
|
||||
}
|
||||
|
||||
public BrokenRequestConsumer(RawRequestBean bean) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.ankurm.corebeans.web;
|
||||
|
||||
import com.ankurm.corebeans.scopes.Instances;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.context.annotation.RequestScope;
|
||||
|
||||
/** {@code @RequestScope} is {@code @Scope("request")} plus a TARGET_CLASS scoped proxy. */
|
||||
@Component
|
||||
@RequestScope
|
||||
public class RequestBean {
|
||||
|
||||
private final int serial = Instances.next("request");
|
||||
|
||||
public int serial() {
|
||||
return serial;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.ankurm.corebeans.web;
|
||||
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* A singleton controller holding three differently scoped collaborators. The injected references
|
||||
* are scoped proxies, so each request reaches the instance that belongs to it.
|
||||
* Diagnostic only: delete before shipping.
|
||||
*/
|
||||
@RestController
|
||||
public class ScopesController {
|
||||
|
||||
private final RequestBean request;
|
||||
private final SessionBean session;
|
||||
private final ApplicationBean application;
|
||||
|
||||
public ScopesController(RequestBean request, SessionBean session, ApplicationBean application) {
|
||||
this.request = request;
|
||||
this.session = session;
|
||||
this.application = application;
|
||||
}
|
||||
|
||||
@GetMapping("/scopes")
|
||||
public String scopes() {
|
||||
return "request=" + request.serial() + " session=" + session.serial() + " application="
|
||||
+ application.serial() + " injectedRequestClass=" + request.getClass().getSimpleName();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.ankurm.corebeans.web;
|
||||
|
||||
import com.ankurm.corebeans.scopes.Instances;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.context.annotation.SessionScope;
|
||||
|
||||
@Component
|
||||
@SessionScope
|
||||
public class SessionBean {
|
||||
|
||||
private final int serial = Instances.next("session");
|
||||
|
||||
public int serial() {
|
||||
return serial;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.ankurm.corebeans.web;
|
||||
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
/** A minimal servlet application, started by the tests. Scans only this package. */
|
||||
@SpringBootApplication
|
||||
public class WebScopesApp {
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
spring:
|
||||
application:
|
||||
name: core-beans
|
||||
main:
|
||||
banner-mode: off
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.ankurm.corebeans;
|
||||
|
||||
import java.util.concurrent.Callable;
|
||||
|
||||
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
|
||||
|
||||
/** Small helpers so each test reads as the scenario, not the plumbing. */
|
||||
public final class Ctx {
|
||||
|
||||
private Ctx() {
|
||||
}
|
||||
|
||||
/** A plain Spring context (no Boot) with the given classes registered and refreshed. */
|
||||
public static AnnotationConfigApplicationContext plain(Class<?>... classes) {
|
||||
var ctx = new AnnotationConfigApplicationContext();
|
||||
ctx.register(classes);
|
||||
ctx.refresh();
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/** Refreshes a context and returns either "started" or the exception, so failures can be printed. */
|
||||
public static Outcome tryStart(Class<?>... classes) {
|
||||
var ctx = new AnnotationConfigApplicationContext();
|
||||
try {
|
||||
ctx.register(classes);
|
||||
ctx.refresh();
|
||||
return new Outcome(ctx, null);
|
||||
} catch (RuntimeException e) {
|
||||
ctx.close();
|
||||
return new Outcome(null, e);
|
||||
}
|
||||
}
|
||||
|
||||
public static <T> String attempt(Callable<T> call) {
|
||||
try {
|
||||
return "OK -> " + call.call();
|
||||
} catch (Exception e) {
|
||||
return e.getClass().getSimpleName() + ": " + e.getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
public static Throwable root(Throwable t) {
|
||||
while (t.getCause() != null && t.getCause() != t) {
|
||||
t = t.getCause();
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
public record Outcome(AnnotationConfigApplicationContext context, RuntimeException failure) {
|
||||
|
||||
public boolean started() {
|
||||
return failure == null;
|
||||
}
|
||||
|
||||
public void closeQuietly() {
|
||||
if (context != null) {
|
||||
context.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.ankurm.corebeans;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
import com.ankurm.corebeans.shutdown.ShutdownApp;
|
||||
import com.ankurm.corebeans.shutdown.SlowController;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.WebApplicationType;
|
||||
import org.springframework.boot.builder.SpringApplicationBuilder;
|
||||
import org.springframework.context.ConfigurableApplicationContext;
|
||||
|
||||
/** Post 19: what happens to a request that is mid-flight when the context closes. */
|
||||
class GracefulShutdownTest {
|
||||
|
||||
@BeforeEach
|
||||
void reset() {
|
||||
Trace.drain();
|
||||
SlowController.COMPLETED.set(0);
|
||||
}
|
||||
|
||||
private record Result(String response, long closeMillis, int handlersCompleted) {
|
||||
}
|
||||
|
||||
private static Result runAndClose(String shutdownMode, boolean virtualThreads) throws Exception {
|
||||
ConfigurableApplicationContext ctx = new SpringApplicationBuilder(ShutdownApp.class)
|
||||
.web(WebApplicationType.SERVLET)
|
||||
.properties("server.port=0", "logging.level.root=OFF", "spring.main.banner-mode=off",
|
||||
"server.shutdown=" + shutdownMode,
|
||||
"spring.threads.virtual.enabled=" + virtualThreads,
|
||||
"spring.lifecycle.timeout-per-shutdown-phase=10s")
|
||||
.run();
|
||||
int port = Integer.parseInt(ctx.getEnvironment().getProperty("local.server.port"));
|
||||
HttpClient client = HttpClient.newHttpClient();
|
||||
CompletableFuture<String> pending = client
|
||||
.sendAsync(HttpRequest.newBuilder(URI.create("http://localhost:" + port + "/slow?ms=1500")).build(),
|
||||
HttpResponse.BodyHandlers.ofString())
|
||||
.thenApply(r -> "HTTP " + r.statusCode() + ": " + r.body())
|
||||
.exceptionally(e -> "FAILED: connection dropped, no response");
|
||||
long deadline = System.currentTimeMillis() + 5000;
|
||||
while (SlowController.IN_FLIGHT.get() == 0 && System.currentTimeMillis() < deadline) {
|
||||
Thread.sleep(10);
|
||||
}
|
||||
assertThat(SlowController.IN_FLIGHT.get()).isEqualTo(1);
|
||||
long begin = System.nanoTime();
|
||||
ctx.close();
|
||||
long closeMs = (System.nanoTime() - begin) / 1_000_000;
|
||||
String response = pending.get();
|
||||
int completed = SlowController.COMPLETED.get();
|
||||
SlowController.COMPLETED.set(0);
|
||||
return new Result(response, closeMs, completed);
|
||||
}
|
||||
|
||||
@Test
|
||||
void inFlightRequestDuringClose() throws Exception {
|
||||
try (var t = new Transcript("16-graceful-shutdown-in-flight.txt",
|
||||
"A /slow?ms=1500 request is in flight when the context closes")) {
|
||||
Result gracefulVirtual = runAndClose("graceful", true);
|
||||
Result gracefulPlatform = runAndClose("graceful", false);
|
||||
Result immediateVirtual = runAndClose("immediate", true);
|
||||
t.line("%-44s %-52s %-32s %s", "settings", "the client saw", "close() blocked", "handler ran to the end");
|
||||
t.line("%-44s %-52s %-32s %s", "-".repeat(44), "-".repeat(52), "-".repeat(32), "-".repeat(22));
|
||||
row(t, "server.shutdown=graceful, virtual threads", gracefulVirtual);
|
||||
row(t, "server.shutdown=graceful, platform threads", gracefulPlatform);
|
||||
row(t, "server.shutdown=immediate, virtual threads", immediateVirtual);
|
||||
assertThat(gracefulVirtual.response()).startsWith("HTTP 200");
|
||||
assertThat(gracefulPlatform.response()).startsWith("HTTP 200");
|
||||
}
|
||||
}
|
||||
|
||||
private static void row(Transcript t, String label, Result r) {
|
||||
String blocked = r.closeMillis() >= 1000 ? ">= 1 s (waited for the request)" : "< 1 s (did not wait)";
|
||||
t.line("%-44s %-52s %-32s %s", label, r.response(), blocked, r.handlersCompleted() == 1 ? "yes" : "no");
|
||||
}
|
||||
|
||||
@Test
|
||||
void workerPhasesAgainstTheWebServer() throws Exception {
|
||||
try (var t = new Transcript("17-worker-phase-vs-web-server.txt",
|
||||
"SmartLifecycle beans with the default phase and with phase 1000, while a request is in flight")) {
|
||||
Result r = runAndClose("graceful", true);
|
||||
Trace.drain().forEach(e -> t.line("%s", e));
|
||||
t.line("the client saw: %s", r.response());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package com.ankurm.corebeans;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
|
||||
import com.ankurm.corebeans.lifecycle.*;
|
||||
import com.ankurm.corebeans.scopes.Instances;
|
||||
import com.ankurm.corebeans.shutdown.ShutdownApp;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.springframework.boot.WebApplicationType;
|
||||
import org.springframework.boot.builder.SpringApplicationBuilder;
|
||||
import org.springframework.boot.test.system.CapturedOutput;
|
||||
import org.springframework.boot.test.system.OutputCaptureExtension;
|
||||
import org.springframework.context.ConfigurableApplicationContext;
|
||||
import org.springframework.context.SmartLifecycle;
|
||||
import org.springframework.context.support.DefaultLifecycleProcessor;
|
||||
|
||||
/** Post 19: the bean lifecycle, printed from real runs. */
|
||||
@ExtendWith(OutputCaptureExtension.class)
|
||||
class LifecycleTest {
|
||||
|
||||
@BeforeEach
|
||||
void reset() {
|
||||
Instances.reset();
|
||||
Trace.drain();
|
||||
// Earlier tests start Boot contexts with logging.level.root=OFF, and that state outlives them.
|
||||
((ch.qos.logback.classic.Logger) org.slf4j.LoggerFactory.getLogger(org.slf4j.Logger.ROOT_LOGGER_NAME))
|
||||
.setLevel(ch.qos.logback.classic.Level.INFO);
|
||||
}
|
||||
|
||||
@Test
|
||||
void fullOrderUnderSpringBoot() {
|
||||
try (var t = new Transcript("07-full-callback-order.txt",
|
||||
"Every callback for one bean, from constructor to the last destroy hook (SpringApplication, no web server)")) {
|
||||
ConfigurableApplicationContext ctx = new SpringApplicationBuilder(LifecycleConfig.class, EventLogger.class)
|
||||
.web(WebApplicationType.NONE)
|
||||
.properties("logging.level.root=OFF", "spring.main.banner-mode=off")
|
||||
.run();
|
||||
t.line("=== startup ===");
|
||||
Trace.drain().forEach(e -> t.line("%s", e));
|
||||
t.blank();
|
||||
t.line("=== ctx.close() ===");
|
||||
ctx.close();
|
||||
var shutdown = Trace.drain();
|
||||
shutdown.forEach(e -> t.line("%s", e));
|
||||
assertThat(shutdown).contains("@PreDestroy", "DisposableBean.destroy");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void beanPostProcessorsInABootContext() {
|
||||
try (var t = new Transcript("19-bean-post-processors.txt",
|
||||
"The BeanPostProcessors registered in a plain Spring Boot context, in the order they run")) {
|
||||
ConfigurableApplicationContext ctx = new SpringApplicationBuilder(LifecycleConfig.class)
|
||||
.web(WebApplicationType.NONE)
|
||||
.properties("logging.level.root=OFF", "spring.main.banner-mode=off")
|
||||
.run();
|
||||
var bf = (org.springframework.beans.factory.support.AbstractBeanFactory) ctx.getBeanFactory();
|
||||
int i = 1;
|
||||
for (var bpp : bf.getBeanPostProcessors()) {
|
||||
t.line("%2d %s", i++, bpp.getClass().getName());
|
||||
}
|
||||
ctx.close();
|
||||
assertThat(bf.getBeanPostProcessors()).isNotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonStaticBeanPostProcessorWarns(CapturedOutput output) {
|
||||
try (var t = new Transcript("08-non-static-bpp-warning.txt",
|
||||
"A BeanPostProcessor declared with a non-static @Bean method")) {
|
||||
try (var ctx = Ctx.plain(NonStaticProcessorConfig.class)) {
|
||||
// context started; only the log matters
|
||||
}
|
||||
List<String> lines = output.getAll().lines()
|
||||
.filter(l -> l.contains("non-static") || l.contains("not eligible for getting processed"))
|
||||
.toList();
|
||||
lines.forEach(l -> t.line("%s", l));
|
||||
assertThat(lines).isNotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void postConstructRunsOnTheRawTarget() {
|
||||
try (var t = new Transcript("09-postconstruct-before-proxy.txt",
|
||||
"@PostConstruct runs before the @Async proxy exists")) {
|
||||
try (var ctx = Ctx.plain(AsyncMailer.Config.class, AsyncMailer.class)) {
|
||||
Trace.drain().forEach(e -> t.line("%s", e));
|
||||
t.line("the bean other code receives : %s", ctx.getBean(AsyncMailer.class).getClass().getName());
|
||||
assertThat(ctx.getBean(AsyncMailer.class).getClass().getName()).contains("SpringCGLIB");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void failingPostConstructStopsStartup() {
|
||||
try (var t = new Transcript("10-postconstruct-failure.txt", "An exception thrown from @PostConstruct")) {
|
||||
var outcome = Ctx.tryStart(FailingInit.class);
|
||||
assertThat(outcome.started()).isFalse();
|
||||
t.line("context started: false");
|
||||
t.line("top exception : %s", outcome.failure().getClass().getName());
|
||||
t.line("top message : %s", outcome.failure().getMessage());
|
||||
t.line("root cause : %s", Ctx.root(outcome.failure()));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void smartLifecyclePhases() {
|
||||
try (var t = new Transcript("11-smartlifecycle-phases.txt",
|
||||
"Three SmartLifecycle beans registered in the order 300, 100, 200")) {
|
||||
var ctx = new org.springframework.context.annotation.AnnotationConfigApplicationContext();
|
||||
ctx.registerBean("workerC", PhasedWorker.class, () -> new PhasedWorker("C", 300, 0, false));
|
||||
ctx.registerBean("workerA", PhasedWorker.class, () -> new PhasedWorker("A", 100, 0, false));
|
||||
ctx.registerBean("workerB", PhasedWorker.class, () -> new PhasedWorker("B", 200, 0, false));
|
||||
ctx.refresh();
|
||||
t.line("=== refresh() ===");
|
||||
Trace.drain().forEach(e -> t.line("%s", e));
|
||||
ctx.close();
|
||||
t.line("=== close() ===");
|
||||
var stops = Trace.drain();
|
||||
stops.forEach(e -> t.line("%s", e));
|
||||
assertThat(stops.get(0)).contains("phase 300");
|
||||
assertThat(stops.get(2)).contains("phase 100");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameScopeBlockingVersusAsyncStop() {
|
||||
try (var t = new Transcript("12-blocking-vs-async-stop.txt",
|
||||
"Three SmartLifecycle beans in the SAME phase, each needing 400 ms to stop")) {
|
||||
for (boolean async : new boolean[] {false, true}) {
|
||||
var ctx = new org.springframework.context.annotation.AnnotationConfigApplicationContext();
|
||||
for (String name : List.of("one", "two", "three")) {
|
||||
ctx.registerBean(name, PhasedWorker.class, () -> new PhasedWorker(name, 0, 400, async));
|
||||
}
|
||||
ctx.refresh();
|
||||
Trace.drain();
|
||||
long begin = System.nanoTime();
|
||||
ctx.close();
|
||||
long ms = (System.nanoTime() - begin) / 1_000_000;
|
||||
t.line("stop(Runnable) %-22s: close() took %s", async ? "returns immediately" : "blocks (the default)",
|
||||
ms >= 1100 ? ">= 1100 ms (sequential)" : ms < 800 ? "< 800 ms (concurrent)" : "in between");
|
||||
if (async) {
|
||||
assertThat(ms).isLessThan(800);
|
||||
} else {
|
||||
assertThat(ms).isGreaterThanOrEqualTo(1100);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void plainLifecycleIsNotAutoStarted() {
|
||||
try (var t = new Transcript("13-plain-lifecycle.txt", "Lifecycle vs SmartLifecycle: who starts at refresh()?")) {
|
||||
var ctx = Ctx.plain(PlainLifecycle.class);
|
||||
t.line("after refresh() : events=%s isRunning=%s", Trace.drain(), ctx.getBean(PlainLifecycle.class).isRunning());
|
||||
assertThat(ctx.getBean(PlainLifecycle.class).isRunning()).isFalse();
|
||||
ctx.start();
|
||||
t.line("after start() : events=%s isRunning=%s", Trace.drain(), ctx.getBean(PlainLifecycle.class).isRunning());
|
||||
ctx.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void shutdownPhaseTimeout(CapturedOutput output) {
|
||||
try (var t = new Transcript("14-shutdown-timeout.txt",
|
||||
"A SmartLifecycle whose stop(callback) never calls the callback, timeout 500 ms")) {
|
||||
var ctx = new org.springframework.context.annotation.AnnotationConfigApplicationContext();
|
||||
ctx.registerBean("lifecycleProcessor", DefaultLifecycleProcessor.class, () -> {
|
||||
var p = new DefaultLifecycleProcessor();
|
||||
p.setTimeoutPerShutdownPhase(500);
|
||||
return p;
|
||||
});
|
||||
ctx.registerBean("neverStops", NeverStops.class);
|
||||
ctx.refresh();
|
||||
long begin = System.nanoTime();
|
||||
ctx.close();
|
||||
long ms = (System.nanoTime() - begin) / 1_000_000;
|
||||
t.line("close() returned after roughly the timeout: %s", ms >= 450 && ms < 3000);
|
||||
output.getAll().lines().filter(l -> l.contains("Shutdown phase")).forEach(l -> t.line("%s", l));
|
||||
assertThat(ms).isBetween(450L, 3000L);
|
||||
assertThat(output.getAll()).contains("Shutdown phase 2147483647 ends with 1 bean still running after timeout of 500ms: [neverStops]");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void smartLifecycleBeansInARealWebApplication() {
|
||||
try (var t = new Transcript("15-smartlifecycle-beans-in-boot.txt",
|
||||
"Every SmartLifecycle bean in a Boot web application, highest phase (stops first) at the top")) {
|
||||
try (ConfigurableApplicationContext ctx = new SpringApplicationBuilder(ShutdownApp.class)
|
||||
.web(WebApplicationType.SERVLET)
|
||||
.properties("server.port=0", "logging.level.root=OFF", "spring.main.banner-mode=off")
|
||||
.run()) {
|
||||
Map<String, SmartLifecycle> beans = ctx.getBeansOfType(SmartLifecycle.class);
|
||||
var sorted = new java.util.ArrayList<>(beans.entrySet());
|
||||
sorted.sort((a, b) -> Integer.compare(b.getValue().getPhase(), a.getValue().getPhase()));
|
||||
t.line("%-14s %-34s %s", "phase", "bean name", "class");
|
||||
for (var e : sorted) {
|
||||
t.line("%-14d %-34s %s", e.getValue().getPhase(), e.getKey(), e.getValue().getClass().getSimpleName());
|
||||
}
|
||||
t.blank();
|
||||
t.line("SmartLifecycle.DEFAULT_PHASE = %d", SmartLifecycle.DEFAULT_PHASE);
|
||||
assertThat(beans).isNotEmpty();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package com.ankurm.corebeans;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.net.CookieManager;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.IntSupplier;
|
||||
|
||||
import com.ankurm.corebeans.scopes.*;
|
||||
import com.ankurm.corebeans.web.BrokenRequestConsumer;
|
||||
import com.ankurm.corebeans.web.WebScopesApp;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.WebApplicationType;
|
||||
import org.springframework.boot.builder.SpringApplicationBuilder;
|
||||
import org.springframework.context.ConfigurableApplicationContext;
|
||||
import org.springframework.mock.web.MockServletContext;
|
||||
import org.springframework.web.context.support.AnnotationConfigWebApplicationContext;
|
||||
|
||||
/** Post 18: scopes, counted. */
|
||||
class ScopesTest {
|
||||
|
||||
@BeforeEach
|
||||
void reset() {
|
||||
Instances.reset();
|
||||
Trace.drain();
|
||||
}
|
||||
|
||||
@Test
|
||||
void singletonAndPrototypeCounts() {
|
||||
try (var t = new Transcript("01-instance-counts.txt", "How many instances did the container really construct?")) {
|
||||
try (var ctx = Ctx.plain(SingletonBean.class, LazySingletonBean.class, PrototypeBean.class)) {
|
||||
t.line("right after refresh():");
|
||||
t.line(" singleton instances : %d", Instances.count("singleton"));
|
||||
t.line(" @Lazy singleton : %d", Instances.count("lazy"));
|
||||
t.line(" prototype : %d", Instances.count("prototype"));
|
||||
assertThat(Instances.count("singleton")).isEqualTo(1);
|
||||
assertThat(Instances.count("lazy")).isZero();
|
||||
assertThat(Instances.count("prototype")).isZero();
|
||||
|
||||
var s1 = ctx.getBean(SingletonBean.class);
|
||||
var s2 = ctx.getBean(SingletonBean.class);
|
||||
var l1 = ctx.getBean(LazySingletonBean.class);
|
||||
var l2 = ctx.getBean(LazySingletonBean.class);
|
||||
var p1 = ctx.getBean(PrototypeBean.class);
|
||||
var p2 = ctx.getBean(PrototypeBean.class);
|
||||
var p3 = ctx.getBean(PrototypeBean.class);
|
||||
t.blank();
|
||||
t.line("after getBean() twice for singleton and lazy, three times for prototype:");
|
||||
t.line(" singleton instances : %d same object both times: %s", Instances.count("singleton"), s1 == s2);
|
||||
t.line(" @Lazy singleton : %d same object both times: %s", Instances.count("lazy"), l1 == l2);
|
||||
t.line(" prototype : %d serials: %d, %d, %d", Instances.count("prototype"), p1.serial(), p2.serial(), p3.serial());
|
||||
assertThat(s1).isSameAs(s2);
|
||||
assertThat(Instances.count("prototype")).isEqualTo(3);
|
||||
}
|
||||
Instances.reset();
|
||||
try (var first = Ctx.plain(SingletonBean.class); var second = Ctx.plain(SingletonBean.class)) {
|
||||
t.blank();
|
||||
t.line("two separate contexts, each with SingletonBean:");
|
||||
t.line(" singleton instances : %d same object across contexts: %s", Instances.count("singleton"),
|
||||
first.getBean(SingletonBean.class) == second.getBean(SingletonBean.class));
|
||||
assertThat(Instances.count("singleton")).isEqualTo(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void prototypeDestroyIsNeverCalled() {
|
||||
try (var t = new Transcript("02-prototype-destroy.txt", "@PreDestroy on a singleton and on a prototype, then context.close()")) {
|
||||
var ctx = Ctx.plain(SingletonBean.class, PrototypeBean.class);
|
||||
ctx.getBean(PrototypeBean.class);
|
||||
t.line("prototype instances created: %d", Instances.count("prototype"));
|
||||
ctx.close();
|
||||
var events = Trace.drain();
|
||||
t.line("callbacks after close(): %s", events);
|
||||
assertThat(events).contains("singleton @PreDestroy called").doesNotContain("prototype @PreDestroy called");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void prototypeInsideSingleton() {
|
||||
try (var t = new Transcript("03-prototype-in-singleton.txt",
|
||||
"A singleton calls use() five times. How many prototype instances did it touch?")) {
|
||||
t.line("%-34s %-22s %s", "how the prototype is obtained", "serials seen", "instances built");
|
||||
t.line("%-34s %-22s %s", "-".repeat(34), "-".repeat(22), "-".repeat(15));
|
||||
Object[][] cases = {
|
||||
{"constructor injection (naive)", "prototype", NaiveConsumer.class, PrototypeBean.class},
|
||||
{"ObjectProvider.getObject()", "prototype", ProviderConsumer.class, PrototypeBean.class},
|
||||
{"@Lookup method", "prototype", LookupConsumer.class, PrototypeBean.class},
|
||||
{"scoped proxy (TARGET_CLASS)", "scopedPrototype", ProxyConsumer.class, ScopedPrototypeBean.class},
|
||||
{"ApplicationContext.getBean()", "prototype", ContextConsumer.class, PrototypeBean.class},
|
||||
};
|
||||
String proxyClass = null;
|
||||
for (Object[] c : cases) {
|
||||
Instances.reset();
|
||||
try (var ctx = Ctx.plain((Class<?>) c[2], (Class<?>) c[3])) {
|
||||
Object consumer = ctx.getBean((Class<?>) c[2]);
|
||||
List<Integer> serials = new ArrayList<>();
|
||||
for (int i = 0; i < 5; i++) {
|
||||
serials.add(use(consumer));
|
||||
}
|
||||
t.line("%-34s %-22s %d", c[0], serials, Instances.count((String) c[1]));
|
||||
if (c[2] == NaiveConsumer.class) {
|
||||
assertThat(Instances.count("prototype")).isEqualTo(1);
|
||||
} else {
|
||||
assertThat(Instances.count((String) c[1])).isEqualTo(5);
|
||||
}
|
||||
if (c[2] == ProxyConsumer.class) {
|
||||
proxyClass = ((ProxyConsumer) consumer).injectedClass();
|
||||
}
|
||||
}
|
||||
}
|
||||
t.blank();
|
||||
t.line("ProxyConsumer's injected reference is a %s", proxyClass);
|
||||
}
|
||||
}
|
||||
|
||||
private static int use(Object consumer) {
|
||||
try {
|
||||
return (Integer) consumer.getClass().getMethod("use").invoke(consumer);
|
||||
} catch (ReflectiveOperationException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void singletonStateIsSharedAcrossThreads() throws Exception {
|
||||
try (var t = new Transcript("04-singleton-thread-safety.txt",
|
||||
"Two threads call the same singleton; a latch forces the interleaving")) {
|
||||
try (var ctx = Ctx.plain(UnsafeGreeter.class, SafeGreeter.class)) {
|
||||
String unsafe = interleave(ctx.getBean(UnsafeGreeter.class)::greet);
|
||||
String safe = interleave(ctx.getBean(SafeGreeter.class)::greet);
|
||||
t.line("UnsafeGreeter (state in a field) : alice's call returned \"%s\"", unsafe);
|
||||
t.line("SafeGreeter (state in a local) : alice's call returned \"%s\"", safe);
|
||||
assertThat(unsafe).isEqualTo("Hello, bob");
|
||||
assertThat(safe).isEqualTo("Hello, alice");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Alice sets her name and pauses; Bob sets his name meanwhile; Alice then reads. Returns Alice's result. */
|
||||
private static String interleave(java.util.function.BiFunction<String, Runnable, String> greet) throws Exception {
|
||||
CountDownLatch bobHasSet = new CountDownLatch(1);
|
||||
String[] aliceResult = new String[1];
|
||||
Thread alice = Thread.ofPlatform().start(() -> aliceResult[0] = greet.apply("alice", () -> {
|
||||
try {
|
||||
bobHasSet.await(5, TimeUnit.SECONDS);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}));
|
||||
Thread bob = Thread.ofPlatform().start(() -> greet.apply("bob", bobHasSet::countDown));
|
||||
alice.join();
|
||||
bob.join();
|
||||
return aliceResult[0];
|
||||
}
|
||||
|
||||
@Test
|
||||
void webScopes() throws Exception {
|
||||
try (var t = new Transcript("05-web-scopes.txt", "request, session and application scope over real HTTP")) {
|
||||
try (ConfigurableApplicationContext ctx = new SpringApplicationBuilder(WebScopesApp.class)
|
||||
.web(WebApplicationType.SERVLET)
|
||||
.properties("server.port=0", "logging.level.root=OFF", "spring.main.banner-mode=off")
|
||||
.run()) {
|
||||
int port = Integer.parseInt(ctx.getEnvironment().getProperty("local.server.port"));
|
||||
URI uri = URI.create("http://localhost:" + port + "/scopes");
|
||||
var clientA = HttpClient.newBuilder().cookieHandler(new CookieManager()).build();
|
||||
var clientB = HttpClient.newBuilder().cookieHandler(new CookieManager()).build();
|
||||
t.line("%-10s %s", "client", "response");
|
||||
String lastA = null;
|
||||
for (int i = 1; i <= 3; i++) {
|
||||
lastA = get(clientA, uri);
|
||||
t.line("%-10s %s", "A #" + i, lastA);
|
||||
}
|
||||
for (int i = 1; i <= 2; i++) {
|
||||
t.line("%-10s %s", "B #" + i, get(clientB, uri));
|
||||
}
|
||||
t.blank();
|
||||
t.line("instances constructed: request=%d session=%d application=%d", Instances.count("request"),
|
||||
Instances.count("session"), Instances.count("application"));
|
||||
assertThat(Instances.count("request")).isEqualTo(5);
|
||||
assertThat(Instances.count("session")).isEqualTo(2);
|
||||
assertThat(Instances.count("application")).isEqualTo(1);
|
||||
assertThat(lastA).contains("session=1").contains("application=1");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static String get(HttpClient client, URI uri) throws Exception {
|
||||
return client.send(HttpRequest.newBuilder(uri).build(), HttpResponse.BodyHandlers.ofString()).body();
|
||||
}
|
||||
|
||||
@Test
|
||||
void requestScopeWithoutProxyFailsAtStartup() {
|
||||
try (var t = new Transcript("06-request-scope-without-proxy.txt",
|
||||
"A request-scoped bean (no proxy) injected into a singleton")) {
|
||||
var ctx = new AnnotationConfigWebApplicationContext();
|
||||
ctx.setServletContext(new MockServletContext());
|
||||
ctx.register(BrokenRequestConsumer.RawRequestBean.class, BrokenRequestConsumer.class);
|
||||
try {
|
||||
ctx.refresh();
|
||||
t.line("context started (unexpected)");
|
||||
assertThat(false).isTrue();
|
||||
} catch (RuntimeException e) {
|
||||
t.line("top exception : %s", e.getClass().getName());
|
||||
t.line("top message : %s", e.getMessage());
|
||||
t.blank();
|
||||
t.line("root cause : %s", Ctx.root(e));
|
||||
} finally {
|
||||
ctx.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.ankurm.corebeans;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.PrintWriter;
|
||||
import java.io.StringWriter;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
/**
|
||||
* Writes a numbered transcript under {@code output/} and echoes it to the console.
|
||||
* Every console block quoted in the article comes out of one of these files verbatim.
|
||||
*/
|
||||
public final class Transcript implements AutoCloseable {
|
||||
|
||||
private final Path path;
|
||||
private final StringWriter buffer = new StringWriter();
|
||||
private final PrintWriter out = new PrintWriter(buffer);
|
||||
|
||||
public Transcript(String fileName, String title) {
|
||||
this.path = Path.of("output", fileName);
|
||||
out.println("# " + title);
|
||||
out.println();
|
||||
}
|
||||
|
||||
public Transcript line(String format, Object... args) {
|
||||
out.println(args.length == 0 ? format : String.format(format, args));
|
||||
return this;
|
||||
}
|
||||
|
||||
public Transcript blank() {
|
||||
out.println();
|
||||
return this;
|
||||
}
|
||||
|
||||
public Transcript section(String heading) {
|
||||
out.println();
|
||||
out.println("--- " + heading + " ---");
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
out.flush();
|
||||
try {
|
||||
Files.createDirectories(path.getParent());
|
||||
Files.writeString(path, buffer.toString());
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("could not write " + path, e);
|
||||
}
|
||||
System.out.print(buffer);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user