New posts for Spring and Hibernate
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
# startup-runners
|
||||
|
||||
Companion project for the article **[CommandLineRunner vs ApplicationRunner vs ApplicationReadyEvent in Spring Boot 4](https://ankurm.com/commandlinerunner-vs-applicationrunner-vs-applicationreadyevent-spring-boot-4/)** on **[ankurm.com](https://ankurm.com)**.
|
||||
|
||||
Every console block quoted in the article came out of `output/`. Transcripts 00-14 are written by the test
|
||||
suite (so a claim that stops being true turns the build red); 15-17 are read out of the Spring Boot and Spring
|
||||
Framework sources and jars by `scripts/capture-facts.sh`.
|
||||
|
||||
There is deliberately **no `docs/` folder**: the deeper material lives in collapsible sections inside the article.
|
||||
|
||||
## Versions
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Spring Boot | 4.1.2 (GA 2026-10-22 on Maven Central) |
|
||||
| Spring Framework | 7.0.10 |
|
||||
| JDK | 25 (25.0.3) |
|
||||
| Maven | 3.9 |
|
||||
| Sources read for contrast | Spring Boot 3.0.13 and 3.1.12 (`callRunners` only, not run) |
|
||||
|
||||
## Quickstart
|
||||
|
||||
```bash
|
||||
export JAVA_HOME=/path/to/jdk-25
|
||||
mvn test # runs the scenarios and rewrites output/00-14
|
||||
./scripts/run-all.sh # everything, including 15-17 (needs network access to Maven Central once)
|
||||
```
|
||||
|
||||
Transcript 03 (four runners without `@Order`) is the one file that is allowed to differ between runs: that is its point.
|
||||
|
||||
## Source layout
|
||||
|
||||
Each scenario is its own package with its own `@SpringBootApplication`.
|
||||
|
||||
| Package | What it holds |
|
||||
|---|---|
|
||||
| `hello/` | the smallest example: a `CommandLineRunner`, an `ApplicationRunner`, an `ApplicationReadyEvent` listener |
|
||||
| `order/` | every startup hook plus `@PostConstruct`, `InitializingBean`, `SmartLifecycle`, printing the sequence and the readiness state |
|
||||
| `ordering/`, `ties/` | `@Order` on classes, `@Bean` methods and `Ordered`; four runners with no order at all |
|
||||
| `args/` | the same command line seen as `String[]` and as `ApplicationArguments` |
|
||||
| `failure/` | a runner that throws, an `ApplicationReadyEvent` listener that throws, `ExitCodeGenerator`, a checked exception (profiles) |
|
||||
| `blocking/` | a runner that blocks (web app, actuator probes) and one that hands its work to a virtual thread |
|
||||
| `lifecycle/`, `lifestartfails/`, `lifeforgetful/` | `SmartLifecycle` phases, a `start()` that throws, an `isRunning()` that never says true |
|
||||
| `slices/` | startup hooks declared four ways, for the `@SpringBootTest` and `@WebMvcTest` scenarios |
|
||||
| `support/` | `Trace`, the in-memory log the tests print |
|
||||
|
||||
## Captured output
|
||||
|
||||
| File | What it shows |
|
||||
|---|---|
|
||||
| `00-hello.txt` | the three hooks of `HelloApp`, started with a real command line |
|
||||
| `01-startup-hooks-in-order.txt` | every hook, in order, non-web and web, with the readiness state each one sees |
|
||||
| `02-order-annotation-and-ordered.txt` | both runner kinds sorted together by `@Order` / `Ordered`; a bean that is both kinds |
|
||||
| `03-ties-have-no-promised-order.txt` | four unordered runners in 8 fresh JVMs |
|
||||
| `04-arguments.txt` | `String[]` versus `ApplicationArguments` |
|
||||
| `05-runner-failure.txt` | a throwing runner: events, log, exit codes (1, 42), checked-exception wrapping |
|
||||
| `06-ready-listener-failure.txt` | a throwing `ApplicationReadyEvent` listener, non-web and web |
|
||||
| `07-blocking-runner-readiness.txt` | a blocking runner: port open, liveness UP, readiness 503 |
|
||||
| `08-async-runner.txt` | a runner that returns at once and a background task that fails |
|
||||
| `09-smartlifecycle-phases.txt` | start in ascending phase, stop in descending phase, async stop, a bean that is not auto-started |
|
||||
| `10-lifecycle-beans-in-a-web-app.txt` | every `SmartLifecycle` bean in a web app with its phase |
|
||||
| `11-smartlifecycle-start-fails.txt` | `start()` throws: no started event, no runner |
|
||||
| `12-smartlifecycle-forgot-isrunning.txt` | `isRunning()` always false: `stop()` never called |
|
||||
| `13-spring-boot-test-runs-the-hooks.txt` | what `@SpringBootTest` runs |
|
||||
| `14-webmvctest-slice-hooks.txt` | what `@WebMvcTest` runs |
|
||||
| `15-callrunners-source.txt` | `SpringApplication.run`, `callRunners`, `callRunner` and the run listener, from the 4.1.2 sources, plus 3.0.13 and 3.1.12 |
|
||||
| `16-javap-callrunners.txt` | the compiled `callRunners` and the runner interfaces, read with `javap` |
|
||||
| `17-lifecycleprocessor-source.txt` | when `DefaultLifecycleProcessor` calls `start()` and `stop()` |
|
||||
@@ -0,0 +1,6 @@
|
||||
# HelloApp started with: --greeting=hi --verbose notes.txt
|
||||
|
||||
hello: CommandLineRunner args = [--greeting=hi, --verbose, notes.txt]
|
||||
hello: ApplicationRunner options = [greeting, verbose], non-option args = [notes.txt]
|
||||
hello: ApplicationReadyEvent listener
|
||||
process exit code: 0
|
||||
@@ -0,0 +1,29 @@
|
||||
# Every startup hook in the order Spring Boot 4.1.2 really calls it
|
||||
|
||||
|
||||
--- non-web application ---
|
||||
@PostConstruct
|
||||
InitializingBean.afterPropertiesSet
|
||||
SmartInitializingSingleton.afterSingletonsInstantiated
|
||||
SmartLifecycle.start (default phase)
|
||||
ContextRefreshedEvent
|
||||
ApplicationStartedEvent
|
||||
AvailabilityChangeEvent -> CORRECT
|
||||
CommandLineRunner (readiness seen from inside: REFUSING_TRAFFIC, liveness: CORRECT)
|
||||
ApplicationRunner (readiness seen from inside: REFUSING_TRAFFIC)
|
||||
ApplicationReadyEvent (readiness seen from inside: REFUSING_TRAFFIC)
|
||||
AvailabilityChangeEvent -> ACCEPTING_TRAFFIC
|
||||
|
||||
--- servlet web application (Tomcat on a random port) ---
|
||||
@PostConstruct
|
||||
InitializingBean.afterPropertiesSet
|
||||
SmartInitializingSingleton.afterSingletonsInstantiated
|
||||
WebServerInitializedEvent (port is open)
|
||||
SmartLifecycle.start (default phase)
|
||||
ContextRefreshedEvent
|
||||
ApplicationStartedEvent
|
||||
AvailabilityChangeEvent -> CORRECT
|
||||
CommandLineRunner (readiness seen from inside: REFUSING_TRAFFIC, liveness: CORRECT)
|
||||
ApplicationRunner (readiness seen from inside: REFUSING_TRAFFIC)
|
||||
ApplicationReadyEvent (readiness seen from inside: REFUSING_TRAFFIC)
|
||||
AvailabilityChangeEvent -> ACCEPTING_TRAFFIC
|
||||
@@ -0,0 +1,12 @@
|
||||
# CommandLineRunner and ApplicationRunner beans sorted together by @Order / Ordered
|
||||
|
||||
(declaration order in OrderingApp.java is NOT the order below)
|
||||
|
||||
CommandLineRunner @Order(HIGHEST_PRECEDENCE)
|
||||
ApplicationRunner @Order(1) on the class
|
||||
CommandLineRunner @Order(2) on the class
|
||||
ApplicationRunner implements Ordered, getOrder() = 3
|
||||
Both @Order(4) -> ApplicationRunner.run
|
||||
Both @Order(4) -> CommandLineRunner.run
|
||||
CommandLineRunner @Order(5) on a @Bean method
|
||||
ApplicationRunner no order at all (lowest precedence)
|
||||
@@ -0,0 +1,16 @@
|
||||
# Four runners without @Order: the order chosen by each of 8 fresh JVMs
|
||||
|
||||
JVM 1: arD arB cliC cliA
|
||||
JVM 2: cliC cliA arD arB
|
||||
JVM 3: cliA arB arD cliC
|
||||
JVM 4: arD arB cliC cliA
|
||||
JVM 5: arD arB cliC cliA
|
||||
JVM 6: cliC cliA arB arD
|
||||
JVM 7: cliC cliA arB arD
|
||||
JVM 8: arD arB cliC cliA
|
||||
|
||||
distinct sequences seen: 4
|
||||
arD arB cliC cliA x4
|
||||
cliC cliA arD arB x1
|
||||
cliA arB arD cliC x1
|
||||
cliC cliA arB arD x2
|
||||
@@ -0,0 +1,14 @@
|
||||
# The same command line, seen by CommandLineRunner and ApplicationRunner
|
||||
|
||||
args passed to SpringApplication.run: [--name=Ankur, --verbose, --name=Second, data.csv, -x, --, --after-the-dashes]
|
||||
|
||||
CommandLineRunner String[] args = [--name=Ankur, --verbose, --name=Second, data.csv, -x, --, --after-the-dashes]
|
||||
ApplicationRunner getSourceArgs() = [--name=Ankur, --verbose, --name=Second, data.csv, -x, --, --after-the-dashes]
|
||||
ApplicationRunner getOptionNames() = [name, verbose]
|
||||
ApplicationRunner getOptionValues(name) = [Ankur, Second]
|
||||
ApplicationRunner getOptionValues(verbose) = []
|
||||
ApplicationRunner getOptionValues(missing) = null
|
||||
ApplicationRunner containsOption(verbose) = true
|
||||
ApplicationRunner getNonOptionArgs() = [data.csv, -x, --after-the-dashes]
|
||||
|
||||
Environment property 'name' (both values, joined by a comma): Ankur,Second
|
||||
@@ -0,0 +1,23 @@
|
||||
# A runner throws: exit code, log, events, and the runners that never ran
|
||||
|
||||
|
||||
--- three ordered runners, the second throws (non-web, in a separate JVM) ---
|
||||
TRACE ApplicationStartedEvent
|
||||
TRACE runner 1 ran
|
||||
TRACE runner 2 is about to throw
|
||||
TRACE ApplicationFailedEvent: java.lang.IllegalStateException: boom from runner 2
|
||||
TRACE ContextClosedEvent (the context is shutting down)
|
||||
ERROR o.s.b.SpringApplication : Application run failed
|
||||
java.lang.IllegalStateException: boom from runner 2
|
||||
process exit code: 1
|
||||
|
||||
--- a runner exception that implements ExitCodeGenerator ---
|
||||
ERROR o.s.b.SpringApplication : Application run failed
|
||||
com.ankurm.runners.failure.FailureApp$ExitWith42Exception: the data file is missing
|
||||
process exit code: 42
|
||||
|
||||
--- a runner that throws a checked exception (declared with throws Exception) ---
|
||||
ERROR o.s.b.SpringApplication : Application run failed
|
||||
java.lang.IllegalStateException: Failed to execute CommandLineRunner
|
||||
Caused by: java.io.IOException: cannot read data.csv
|
||||
process exit code: 1
|
||||
@@ -0,0 +1,20 @@
|
||||
# An ApplicationReadyEvent listener throws: the app was 'ready' and is now gone
|
||||
|
||||
|
||||
--- non-web application ---
|
||||
TRACE ApplicationStartedEvent
|
||||
TRACE ready listener 1 is about to throw (readiness: REFUSING_TRAFFIC)
|
||||
TRACE ContextClosedEvent (the context is shutting down)
|
||||
ERROR o.s.b.SpringApplication : Application run failed
|
||||
java.lang.IllegalStateException: boom from the ready listener
|
||||
process exit code: 1
|
||||
|
||||
--- web application (Tomcat is up when the listener runs) ---
|
||||
TRACE ApplicationStartedEvent
|
||||
TRACE GET /ping from inside the listener -> 200 pong
|
||||
TRACE ready listener 1 is about to throw (readiness: REFUSING_TRAFFIC)
|
||||
TRACE ContextClosedEvent (the context is shutting down)
|
||||
ERROR o.s.b.SpringApplication : Application run failed
|
||||
java.lang.IllegalStateException: boom from the ready listener
|
||||
process exit code: 1
|
||||
does the port answer after the process ended? false
|
||||
@@ -0,0 +1,16 @@
|
||||
# A runner that blocks: the port is open, liveness is UP, readiness is OUT_OF_SERVICE
|
||||
|
||||
|
||||
--- while the runner is blocked ---
|
||||
has SpringApplication.run() returned? false
|
||||
GET /ping -> 200 pong
|
||||
GET /actuator/health/liveness -> 200 {"status":"UP"}
|
||||
GET /actuator/health/readiness -> 503 {"status":"OUT_OF_SERVICE"}
|
||||
|
||||
--- after the runner returned ---
|
||||
has SpringApplication.run() returned? true
|
||||
GET /actuator/health/readiness -> 200 {"status":"UP"}
|
||||
|
||||
--- trace ---
|
||||
runner: started on platform thread app-main, now waiting (think: warming a cache for 40 s)
|
||||
runner: finished, returning to SpringApplication.run
|
||||
@@ -0,0 +1,14 @@
|
||||
# A runner that starts a virtual thread and returns: readiness does not wait, and failures do not fail startup
|
||||
|
||||
|
||||
--- the background work is still waiting ---
|
||||
has SpringApplication.run() returned? true
|
||||
GET /actuator/health/readiness -> 200 {"status":"UP"}
|
||||
|
||||
--- after the background work threw ---
|
||||
GET /actuator/health/readiness -> 200 {"status":"UP"}
|
||||
GET /ping -> 200 pong
|
||||
|
||||
--- trace ---
|
||||
runner: started the work on a virtual thread and returned
|
||||
background work on a virtual thread failed: the warm-up blew up
|
||||
@@ -0,0 +1,22 @@
|
||||
# SmartLifecycle: start in ascending phase, stop in descending phase, runners after all of it
|
||||
|
||||
|
||||
--- startup ---
|
||||
start low (phase -100) on main
|
||||
start mid (phase 0) on main
|
||||
start high (phase 100) on main
|
||||
start slow (phase 200) on main
|
||||
ApplicationStartedEvent
|
||||
CommandLineRunner runs
|
||||
ApplicationReadyEvent
|
||||
manual.isAutoStartup()=false isRunning()=false (never started by the context)
|
||||
|
||||
--- context.close() ---
|
||||
start manual (phase 50) on main
|
||||
ContextClosedEvent (stop phase begins)
|
||||
stop slow (phase 200) begins, will finish in 300 ms
|
||||
stop slow finished, calling the callback
|
||||
stop high (phase 100) on main
|
||||
stop manual (phase 50) on main
|
||||
stop mid (phase 0) on main
|
||||
stop low (phase -100) on main
|
||||
@@ -0,0 +1,15 @@
|
||||
# Every SmartLifecycle bean in a servlet web application, by phase (the ones this project adds are marked)
|
||||
|
||||
phase -2147483647 springBootLoggingLifecycle Lifecycle
|
||||
phase -100 low (this project)
|
||||
phase 0 mid (this project)
|
||||
phase 50 manual (this project)
|
||||
phase 100 high (this project)
|
||||
phase 200 slow (this project)
|
||||
phase 1073741823 applicationTaskExecutor ThreadPoolTaskExecutor
|
||||
phase 2147481599 webServerStartStop WebServerStartStopLifecycle
|
||||
phase 2147482623 webServerGracefulShutdown WebServerGracefulShutdownLifecycle
|
||||
SmartLifecycle.DEFAULT_PHASE = 2147483647
|
||||
Integer.MAX_VALUE = 2147483647
|
||||
timeout per shutdown phase = 30000 ms (spring.lifecycle.timeout-per-shutdown-phase, set by Boot)
|
||||
Spring Boot 4.1.2, Spring Framework 7.0.10
|
||||
@@ -0,0 +1,9 @@
|
||||
# SmartLifecycle.start() throws: no ApplicationStartedEvent, no runner
|
||||
|
||||
start healthy (phase 0)
|
||||
start broken (phase 10)
|
||||
stop healthy
|
||||
|
||||
application started? false
|
||||
org.springframework.context.ApplicationContextException: Failed to start bean 'broken'
|
||||
java.lang.IllegalStateException: could not connect to the broker
|
||||
@@ -0,0 +1,6 @@
|
||||
# isRunning() always false: start() is called, stop() never is
|
||||
|
||||
--- startup ---
|
||||
start() was called
|
||||
--- after context.close() ---
|
||||
(nothing)
|
||||
@@ -0,0 +1,6 @@
|
||||
# @SpringBootTest(classes = SlicesApp.class, args = "--from-the-test=1"): what ran before the first test method
|
||||
|
||||
CommandLineRunner from a top-level @Component ran, args = [--from-the-test=1]
|
||||
CommandLineRunner nested in SlicesApp ran, args = [--from-the-test=1]
|
||||
ApplicationRunner from a @Bean method ran, source args = [--from-the-test=1]
|
||||
ApplicationReadyEvent listener (a top-level @Component) ran
|
||||
@@ -0,0 +1,4 @@
|
||||
# @WebMvcTest(controllers = Hello.class): what ran before the first test method
|
||||
|
||||
CommandLineRunner nested in SlicesApp ran, args = []
|
||||
ApplicationRunner from a @Bean method ran, source args = []
|
||||
@@ -0,0 +1,81 @@
|
||||
# Spring Boot 4.1.2 sources: the order SpringApplication.run() calls things in, and callRunners
|
||||
|
||||
--- SpringApplication.run(): from refresh to ready (lines containing the calls) ---
|
||||
18: refreshContext(context);
|
||||
19: afterRefresh(context, applicationArguments);
|
||||
24: listeners.started(context, timeTakenToStarted);
|
||||
25: callRunners(context, applicationArguments);
|
||||
28: throw handleRunFailure(context, ex, listeners);
|
||||
32: listeners.ready(context, startup.ready());
|
||||
36: throw handleRunFailure(context, ex, null);
|
||||
|
||||
--- SpringApplication.callRunners ---
|
||||
private void callRunners(ConfigurableApplicationContext context, ApplicationArguments args) {
|
||||
ConfigurableListableBeanFactory beanFactory = context.getBeanFactory();
|
||||
String[] beanNames = beanFactory.getBeanNamesForType(Runner.class);
|
||||
Map<Runner, String> instancesToBeanNames = new IdentityHashMap<>();
|
||||
for (String beanName : beanNames) {
|
||||
instancesToBeanNames.put(beanFactory.getBean(beanName, Runner.class), beanName);
|
||||
}
|
||||
Comparator<Object> comparator = getOrderComparator(beanFactory)
|
||||
.withSourceProvider(new FactoryAwareOrderSourceProvider(beanFactory, instancesToBeanNames));
|
||||
instancesToBeanNames.keySet().stream().sorted(comparator).forEach((runner) -> callRunner(runner, args));
|
||||
}
|
||||
|
||||
--- SpringApplication.callRunner (what happens to a bean that is both kinds) ---
|
||||
private void callRunner(Runner runner, ApplicationArguments args) {
|
||||
if (runner instanceof ApplicationRunner) {
|
||||
callRunner(ApplicationRunner.class, runner, (applicationRunner) -> applicationRunner.run(args));
|
||||
}
|
||||
if (runner instanceof CommandLineRunner) {
|
||||
callRunner(CommandLineRunner.class, runner,
|
||||
(commandLineRunner) -> commandLineRunner.run(args.getSourceArgs()));
|
||||
}
|
||||
}
|
||||
|
||||
--- SpringApplication.callRunner(Class, Runner, ThrowingConsumer) (where 'Failed to execute' comes from) ---
|
||||
private <R extends Runner> void callRunner(Class<R> type, Runner runner, ThrowingConsumer<R> call) {
|
||||
call.throwing(
|
||||
(message, ex) -> new IllegalStateException("Failed to execute " + ClassUtils.getShortName(type), ex))
|
||||
.accept((R) runner);
|
||||
}
|
||||
|
||||
--- EventPublishingRunListener.started / ready ---
|
||||
public void started(ConfigurableApplicationContext context, @Nullable Duration timeTaken) {
|
||||
context.publishEvent(new ApplicationStartedEvent(this.application, this.args, context, timeTaken));
|
||||
AvailabilityChangeEvent.publish(context, LivenessState.CORRECT);
|
||||
}
|
||||
public void ready(ConfigurableApplicationContext context, @Nullable Duration timeTaken) {
|
||||
context.publishEvent(new ApplicationReadyEvent(this.application, this.args, context, timeTaken));
|
||||
AvailabilityChangeEvent.publish(context, ReadinessState.ACCEPTING_TRAFFIC);
|
||||
}
|
||||
|
||||
--- the marker interface that both runners extend ---
|
||||
interface Runner {
|
||||
public interface CommandLineRunner extends Runner {
|
||||
public interface ApplicationRunner extends Runner {
|
||||
|
||||
--- the same method in Spring Boot 3.0.13 ---
|
||||
private void callRunners(ApplicationContext context, ApplicationArguments args) {
|
||||
context.getBeanProvider(Runner.class).orderedStream().forEach((runner) -> {
|
||||
if (runner instanceof ApplicationRunner applicationRunner) {
|
||||
callRunner(applicationRunner, args);
|
||||
}
|
||||
if (runner instanceof CommandLineRunner commandLineRunner) {
|
||||
callRunner(commandLineRunner, args);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
--- the same method in Spring Boot 3.1.12 ---
|
||||
private void callRunners(ConfigurableApplicationContext context, ApplicationArguments args) {
|
||||
ConfigurableListableBeanFactory beanFactory = context.getBeanFactory();
|
||||
String[] beanNames = beanFactory.getBeanNamesForType(Runner.class);
|
||||
Map<Runner, String> instancesToBeanNames = new IdentityHashMap<>();
|
||||
for (String beanName : beanNames) {
|
||||
instancesToBeanNames.put(beanFactory.getBean(beanName, Runner.class), beanName);
|
||||
}
|
||||
Comparator<Object> comparator = getOrderComparator(beanFactory)
|
||||
.withSourceProvider(new FactoryAwareOrderSourceProvider(beanFactory, instancesToBeanNames));
|
||||
instancesToBeanNames.keySet().stream().sorted(comparator).forEach((runner) -> callRunner(runner, args));
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
# javap -p -c on spring-boot-4.1.2.jar: the compiled SpringApplication.callRunners
|
||||
|
||||
--- javap -p org.springframework.boot.Runner / CommandLineRunner / ApplicationRunner ---
|
||||
Compiled from "Runner.java"
|
||||
interface org.springframework.boot.Runner {
|
||||
}
|
||||
Compiled from "CommandLineRunner.java"
|
||||
public interface org.springframework.boot.CommandLineRunner extends org.springframework.boot.Runner {
|
||||
public abstract void run(java.lang.String...) throws java.lang.Exception;
|
||||
}
|
||||
Compiled from "ApplicationRunner.java"
|
||||
public interface org.springframework.boot.ApplicationRunner extends org.springframework.boot.Runner {
|
||||
public abstract void run(org.springframework.boot.ApplicationArguments) throws java.lang.Exception;
|
||||
}
|
||||
|
||||
--- invoke* instructions inside callRunners ---
|
||||
invokeinterface #393, 1 // InterfaceMethod org/springframework/context/ConfigurableApplicationContext.getBeanFactory:()Lorg/springframework/beans/factory/config/ConfigurableListableBeanFactory;
|
||||
invokeinterface #966, 2 // InterfaceMethod org/springframework/beans/factory/config/ConfigurableListableBeanFactory.getBeanNamesForType:(Ljava/lang/Class;)[Ljava/lang/String;
|
||||
new #970 // class java/util/IdentityHashMap
|
||||
invokespecial #972 // Method java/util/IdentityHashMap."<init>":()V
|
||||
invokeinterface #973, 3 // InterfaceMethod org/springframework/beans/factory/config/ConfigurableListableBeanFactory.getBean:(Ljava/lang/String;Ljava/lang/Class;)Ljava/lang/Object;
|
||||
invokeinterface #977, 3 // InterfaceMethod java/util/Map.put:(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;
|
||||
invokevirtual #983 // Method getOrderComparator:(Lorg/springframework/beans/factory/config/ConfigurableListableBeanFactory;)Lorg/springframework/core/OrderComparator;
|
||||
new #987 // class org/springframework/boot/SpringApplication$FactoryAwareOrderSourceProvider
|
||||
invokespecial #989 // Method org/springframework/boot/SpringApplication$FactoryAwareOrderSourceProvider."<init>":(Lorg/springframework/beans/factory/config/ConfigurableBeanFactory;Ljava/util/Map;)V
|
||||
invokevirtual #992 // Method org/springframework/core/OrderComparator.withSourceProvider:(Lorg/springframework/core/OrderComparator$OrderSourceProvider;)Ljava/util/Comparator;
|
||||
invokeinterface #998, 1 // InterfaceMethod java/util/Map.keySet:()Ljava/util/Set;
|
||||
invokeinterface #1001, 1 // InterfaceMethod java/util/Set.stream:()Ljava/util/stream/Stream;
|
||||
invokeinterface #1002, 2 // InterfaceMethod java/util/stream/Stream.sorted:(Ljava/util/Comparator;)Ljava/util/stream/Stream;
|
||||
invokedynamic #1006, 0 // InvokeDynamic #12:accept:(Lorg/springframework/boot/SpringApplication;Lorg/springframework/boot/ApplicationArguments;)Ljava/util/function/Consumer;
|
||||
invokeinterface #1009, 2 // InterfaceMethod java/util/stream/Stream.forEach:(Ljava/util/function/Consumer;)V
|
||||
@@ -0,0 +1,97 @@
|
||||
# spring-context 7.0.10 sources: DefaultLifecycleProcessor, the object that starts and stops SmartLifecycle beans
|
||||
|
||||
--- doStart: start() is called only when isRunning() is false ---
|
||||
private void doStart(Map<String, ? extends Lifecycle> lifecycleBeans, String beanName,
|
||||
boolean autoStartupOnly, @Nullable List<CompletableFuture<?>> futures) {
|
||||
|
||||
Lifecycle bean = lifecycleBeans.remove(beanName);
|
||||
if (bean != null && bean != this) {
|
||||
String[] dependenciesForBean = getBeanFactory().getDependenciesForBean(beanName);
|
||||
for (String dependency : dependenciesForBean) {
|
||||
doStart(lifecycleBeans, dependency, autoStartupOnly, futures);
|
||||
}
|
||||
if (!bean.isRunning() && (!autoStartupOnly || toBeStarted(beanName, bean))) {
|
||||
if (futures != null) {
|
||||
futures.add(CompletableFuture.runAsync(() -> doStart(beanName, bean), getBootstrapExecutor()));
|
||||
}
|
||||
else {
|
||||
doStart(beanName, bean);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
--- doStop: stop() is called only when isRunning() is true ---
|
||||
private void doStop(Map<String, ? extends Lifecycle> lifecycleBeans, final String beanName,
|
||||
boolean pauseableOnly, final CountDownLatch latch, final Set<String> countDownBeanNames) {
|
||||
|
||||
Lifecycle bean = lifecycleBeans.remove(beanName);
|
||||
if (bean != null) {
|
||||
String[] dependentBeans = getBeanFactory().getDependentBeans(beanName);
|
||||
for (String dependentBean : dependentBeans) {
|
||||
doStop(lifecycleBeans, dependentBean, pauseableOnly, latch, countDownBeanNames);
|
||||
}
|
||||
try {
|
||||
if (bean.isRunning()) {
|
||||
Set<String> stoppedBeans = this.stoppedBeans;
|
||||
if (stoppedBeans != null) {
|
||||
stoppedBeans.add(beanName);
|
||||
}
|
||||
if (bean instanceof SmartLifecycle smartLifecycle) {
|
||||
if (!pauseableOnly || smartLifecycle.isPauseable()) {
|
||||
if (logger.isTraceEnabled()) {
|
||||
logger.trace("Asking bean '" + beanName + "' of type [" +
|
||||
bean.getClass().getName() + "] to stop");
|
||||
}
|
||||
countDownBeanNames.add(beanName);
|
||||
smartLifecycle.stop(() -> {
|
||||
latch.countDown();
|
||||
countDownBeanNames.remove(beanName);
|
||||
if (logger.isDebugEnabled()) {
|
||||
logger.debug("Bean '" + beanName + "' completed its stop procedure");
|
||||
}
|
||||
});
|
||||
}
|
||||
else {
|
||||
// Don't wait for beans that aren't pauseable...
|
||||
latch.countDown();
|
||||
}
|
||||
}
|
||||
else if (!pauseableOnly) {
|
||||
if (logger.isTraceEnabled()) {
|
||||
logger.trace("Stopping bean '" + beanName + "' of type [" +
|
||||
bean.getClass().getName() + "]");
|
||||
}
|
||||
bean.stop();
|
||||
if (logger.isDebugEnabled()) {
|
||||
logger.debug("Successfully stopped bean '" + beanName + "'");
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (bean instanceof SmartLifecycle) {
|
||||
// Don't wait for beans that aren't running...
|
||||
latch.countDown();
|
||||
}
|
||||
}
|
||||
catch (Throwable ex) {
|
||||
if (logger.isWarnEnabled()) {
|
||||
logger.warn("Failed to stop bean '" + beanName + "'", ex);
|
||||
}
|
||||
if (bean instanceof SmartLifecycle) {
|
||||
latch.countDown();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
--- isAutoStartupCandidate / toBeStarted: what isAutoStartup() decides ---
|
||||
private boolean isAutoStartupCandidate(String beanName, Lifecycle bean) {
|
||||
Set<String> stoppedBeans = this.stoppedBeans;
|
||||
return (stoppedBeans != null ? stoppedBeans.contains(beanName) :
|
||||
(bean instanceof SmartLifecycle smartLifecycle && smartLifecycle.isAutoStartup()));
|
||||
}
|
||||
private boolean toBeStarted(String beanName, Lifecycle bean) {
|
||||
Set<String> stoppedBeans = this.stoppedBeans;
|
||||
return (stoppedBeans != null ? stoppedBeans.contains(beanName) :
|
||||
(!(bean instanceof SmartLifecycle smartLifecycle) || smartLifecycle.isAutoStartup()));
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>4.1.2</version>
|
||||
<relativePath/>
|
||||
</parent>
|
||||
|
||||
<groupId>com.ankurm</groupId>
|
||||
<artifactId>startup-runners</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<name>startup-runners</name>
|
||||
<description>CommandLineRunner vs ApplicationRunner vs ApplicationReadyEvent vs SmartLifecycle on Spring Boot 4</description>
|
||||
|
||||
<properties>
|
||||
<java.version>25</java.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-webmvc</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-webmvc-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<configuration>
|
||||
<!-- some scenarios start a second JVM with the test classpath, so give it a real classpath, not a manifest-only jar -->
|
||||
<useManifestOnlyJar>false</useManifestOnlyJar>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env bash
|
||||
# Facts that come from the shipped jars and their sources rather than from running the scenarios:
|
||||
# 15 SpringApplication.run / callRunners / EventPublishingRunListener, read from the 4.1.2 sources jar,
|
||||
# and the same method in Boot 3.0.13 and 3.1.12 for contrast
|
||||
# 16 the same method read out of the compiled 4.1.2 class with javap (what it really calls)
|
||||
# 17 DefaultLifecycleProcessor: when start() and stop() are called
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
mkdir -p output target/facts
|
||||
BOOT=4.1.2
|
||||
SPRING=7.0.10
|
||||
M2=~/.m2/repository
|
||||
|
||||
fetch() { # url, destination: Maven Central rate-limits shared addresses, so retry politely
|
||||
for i in 1 2 3 4 5 6; do
|
||||
code=$(curl -s -o "$2" -w '%{http_code}' "$1" || true)
|
||||
[ "$code" = 200 ] && return 0
|
||||
sleep $((i * 10))
|
||||
done
|
||||
echo "could not fetch $1 (last status $code)" >&2; return 1
|
||||
}
|
||||
src() { # groupPath artifact version
|
||||
local f="target/facts/$2-$3-sources.jar"
|
||||
[ -s "$f" ] || fetch "https://repo1.maven.org/maven2/$1/$2/$3/$2-$3-sources.jar" "$f"
|
||||
echo "$f"
|
||||
}
|
||||
method() { # sources-jar file-in-jar start-regex -> prints from the first match to the first line that is a single tab + }
|
||||
unzip -p "$1" "$2" | sed -n "/$3/,/^ }/p"
|
||||
}
|
||||
|
||||
BOOT_SRC=$(src org/springframework/boot spring-boot $BOOT)
|
||||
OLD0=$(src org/springframework/boot spring-boot 3.0.13)
|
||||
OLD1=$(src org/springframework/boot spring-boot 3.1.12)
|
||||
FW_SRC=$(src org/springframework spring-context $SPRING)
|
||||
APP=org/springframework/boot/SpringApplication.java
|
||||
|
||||
{
|
||||
echo "# Spring Boot $BOOT sources: the order SpringApplication.run() calls things in, and callRunners"
|
||||
echo
|
||||
echo "--- SpringApplication.run(): from refresh to ready (lines containing the calls) ---"
|
||||
unzip -p "$BOOT_SRC" $APP | sed -n '/public ConfigurableApplicationContext run(String... args)/,/^ }/p' \
|
||||
| grep -n -E 'refreshContext|afterRefresh|listeners\.(started|ready)|callRunners|handleRunFailure' | sed 's/^\([0-9]*\):\s*/\1: /'
|
||||
echo
|
||||
echo "--- SpringApplication.callRunners ---"
|
||||
method "$BOOT_SRC" $APP 'private void callRunners'
|
||||
echo
|
||||
echo "--- SpringApplication.callRunner (what happens to a bean that is both kinds) ---"
|
||||
method "$BOOT_SRC" $APP 'private void callRunner(Runner runner'
|
||||
echo
|
||||
echo "--- SpringApplication.callRunner(Class, Runner, ThrowingConsumer) (where 'Failed to execute' comes from) ---"
|
||||
method "$BOOT_SRC" $APP 'private <R extends Runner> void callRunner'
|
||||
echo
|
||||
echo "--- EventPublishingRunListener.started / ready ---"
|
||||
method "$BOOT_SRC" org/springframework/boot/context/event/EventPublishingRunListener.java 'public void started'
|
||||
method "$BOOT_SRC" org/springframework/boot/context/event/EventPublishingRunListener.java 'public void ready'
|
||||
echo
|
||||
echo "--- the marker interface that both runners extend ---"
|
||||
unzip -p "$BOOT_SRC" org/springframework/boot/Runner.java | grep -E '^(interface|public interface)'
|
||||
unzip -p "$BOOT_SRC" org/springframework/boot/CommandLineRunner.java | grep -E '^public interface'
|
||||
unzip -p "$BOOT_SRC" org/springframework/boot/ApplicationRunner.java | grep -E '^public interface'
|
||||
echo
|
||||
echo "--- the same method in Spring Boot 3.0.13 ---"
|
||||
method "$OLD0" $APP 'private void callRunners'
|
||||
echo
|
||||
echo "--- the same method in Spring Boot 3.1.12 ---"
|
||||
method "$OLD1" $APP 'private void callRunners'
|
||||
} > output/15-callrunners-source.txt
|
||||
|
||||
JAR=$(find "$M2/org/springframework/boot/spring-boot/$BOOT" -name "spring-boot-$BOOT.jar" | head -1)
|
||||
{
|
||||
echo "# javap -p -c on spring-boot-$BOOT.jar: the compiled SpringApplication.callRunners"
|
||||
echo
|
||||
echo "--- javap -p org.springframework.boot.Runner / CommandLineRunner / ApplicationRunner ---"
|
||||
javap -p -cp "$JAR" org.springframework.boot.Runner org.springframework.boot.CommandLineRunner org.springframework.boot.ApplicationRunner
|
||||
echo
|
||||
echo "--- invoke* instructions inside callRunners ---"
|
||||
javap -p -c -cp "$JAR" org.springframework.boot.SpringApplication \
|
||||
| sed -n '/private void callRunners(/,/^$/p' | grep -E 'invoke|new ' | sed 's/^ *[0-9]*: //'
|
||||
} > output/16-javap-callrunners.txt
|
||||
|
||||
CTX=$(find "$M2/org/springframework/spring-context/$SPRING" -name "spring-context-$SPRING.jar" | head -1)
|
||||
LP=org/springframework/context/support/DefaultLifecycleProcessor.java
|
||||
{
|
||||
echo "# spring-context $SPRING sources: DefaultLifecycleProcessor, the object that starts and stops SmartLifecycle beans"
|
||||
echo
|
||||
echo "--- doStart: start() is called only when isRunning() is false ---"
|
||||
method "$FW_SRC" $LP 'private void doStart(Map'
|
||||
echo
|
||||
echo "--- doStop: stop() is called only when isRunning() is true ---"
|
||||
method "$FW_SRC" $LP 'private void doStop(Map'
|
||||
echo
|
||||
echo "--- isAutoStartupCandidate / toBeStarted: what isAutoStartup() decides ---"
|
||||
method "$FW_SRC" $LP 'private boolean isAutoStartupCandidate'
|
||||
method "$FW_SRC" $LP 'private boolean toBeStarted'
|
||||
} > output/17-lifecycleprocessor-source.txt
|
||||
ls -1 output | tail -3
|
||||
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regenerates every file under output/.
|
||||
#
|
||||
# ./scripts/run-all.sh
|
||||
#
|
||||
# Needs a JDK 25 and Maven 3.9. Transcripts 01-14 are written by the test suite, so each figure in the
|
||||
# article is an assertion that fails the build if it stops being true. Transcripts 15-17 are read out of the
|
||||
# Spring Boot / Spring Framework sources and jars by capture-facts.sh (network access to Maven Central needed once).
|
||||
# Transcript 03 (runners without @Order) is the one that is allowed to change from run to run: that is its point.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
echo "== test suite (transcripts 01-14)"
|
||||
mvn -B test
|
||||
|
||||
echo "== facts read from sources and jars (15-17)"
|
||||
./scripts/capture-facts.sh
|
||||
|
||||
echo
|
||||
echo "output:"
|
||||
ls -1 output
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.ankurm.runners.args;
|
||||
|
||||
import com.ankurm.runners.support.Trace;
|
||||
import java.util.Arrays;
|
||||
import org.springframework.boot.ApplicationArguments;
|
||||
import org.springframework.boot.ApplicationRunner;
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/** Scenario 4: the same command line, as the two runner flavours see it. */
|
||||
@SpringBootApplication
|
||||
public class ArgsApp {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(ArgsApp.class, args);
|
||||
}
|
||||
|
||||
@Component
|
||||
@Order(1)
|
||||
static class Cli implements CommandLineRunner {
|
||||
@Override
|
||||
public void run(String... args) {
|
||||
Trace.add("CommandLineRunner String[] args = %s", Arrays.toString(args));
|
||||
}
|
||||
}
|
||||
|
||||
@Component
|
||||
@Order(2)
|
||||
static class App implements ApplicationRunner {
|
||||
@Override
|
||||
public void run(ApplicationArguments args) {
|
||||
Trace.add("ApplicationRunner getSourceArgs() = %s", Arrays.toString(args.getSourceArgs()));
|
||||
Trace.add("ApplicationRunner getOptionNames() = %s", args.getOptionNames());
|
||||
Trace.add("ApplicationRunner getOptionValues(name) = %s", args.getOptionValues("name"));
|
||||
Trace.add("ApplicationRunner getOptionValues(verbose) = %s", args.getOptionValues("verbose"));
|
||||
Trace.add("ApplicationRunner getOptionValues(missing) = %s", args.getOptionValues("missing"));
|
||||
Trace.add("ApplicationRunner containsOption(verbose) = %s", args.containsOption("verbose"));
|
||||
Trace.add("ApplicationRunner getNonOptionArgs() = %s", args.getNonOptionArgs());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.ankurm.runners.blocking;
|
||||
|
||||
import com.ankurm.runners.support.Trace;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* Scenarios 7 and 8: a web application whose runner takes a long time. With profile {@code blocking} the runner
|
||||
* waits on a latch in the runner thread; with profile {@code async} it hands the same wait to a virtual thread and
|
||||
* returns at once. The test opens and closes the latch, so nothing here depends on a sleep.
|
||||
*/
|
||||
@SpringBootApplication
|
||||
public class BlockingApp {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(BlockingApp.class, args);
|
||||
}
|
||||
|
||||
@RestController
|
||||
static class Ping {
|
||||
@GetMapping("/ping")
|
||||
String ping() {
|
||||
return "pong";
|
||||
}
|
||||
}
|
||||
|
||||
@Bean
|
||||
@Profile("blocking")
|
||||
CommandLineRunner blockingRunner() {
|
||||
return args -> {
|
||||
Trace.add("runner: started on %s, now waiting (think: warming a cache for 40 s)", Trace.where());
|
||||
Gate.RUNNER_STARTED.countDown();
|
||||
Gate.RELEASE.await(30, TimeUnit.SECONDS);
|
||||
Trace.add("runner: finished, returning to SpringApplication.run");
|
||||
};
|
||||
}
|
||||
|
||||
@Bean
|
||||
@Profile("async")
|
||||
CommandLineRunner asyncRunner() {
|
||||
return args -> {
|
||||
Thread.ofVirtual()
|
||||
.uncaughtExceptionHandler((thread, e) -> {
|
||||
Trace.add("background work on %s failed: %s", Trace.where(), e.getMessage());
|
||||
Gate.BACKGROUND_DONE.countDown();
|
||||
})
|
||||
.start(() -> {
|
||||
Gate.RUNNER_STARTED.countDown();
|
||||
try {
|
||||
Gate.RELEASE.await(30, TimeUnit.SECONDS);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
throw new IllegalStateException("the warm-up blew up");
|
||||
});
|
||||
Trace.add("runner: started the work on a virtual thread and returned");
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.ankurm.runners.blocking;
|
||||
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
|
||||
/** Latches the test uses to freeze and release the slow work, so the scenario needs no sleeps. */
|
||||
public final class Gate {
|
||||
|
||||
public static volatile CountDownLatch RUNNER_STARTED = new CountDownLatch(1);
|
||||
public static volatile CountDownLatch RELEASE = new CountDownLatch(1);
|
||||
public static volatile CountDownLatch BACKGROUND_DONE = new CountDownLatch(1);
|
||||
|
||||
private Gate() {
|
||||
}
|
||||
|
||||
public static void reset() {
|
||||
RUNNER_STARTED = new CountDownLatch(1);
|
||||
RELEASE = new CountDownLatch(1);
|
||||
BACKGROUND_DONE = new CountDownLatch(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package com.ankurm.runners.failure;
|
||||
|
||||
import com.ankurm.runners.support.Trace;
|
||||
import org.springframework.boot.ApplicationRunner;
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.boot.ExitCodeGenerator;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.availability.ApplicationAvailability;
|
||||
import org.springframework.boot.context.event.ApplicationFailedEvent;
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.boot.context.event.ApplicationStartedEvent;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.context.event.ContextClosedEvent;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* Scenarios 5 and 6. Run it with {@code --spring.profiles.active=runner-fails}, {@code ready-fails}, {@code exit-code}
|
||||
* or {@code checked} and look at the exit code and the console.
|
||||
*/
|
||||
@SpringBootApplication
|
||||
public class FailureApp {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(FailureApp.class, args);
|
||||
}
|
||||
|
||||
/** Always present: shows which of the startup events fire at all. */
|
||||
@Component
|
||||
static class Observer {
|
||||
|
||||
private final ApplicationAvailability availability;
|
||||
|
||||
Observer(ApplicationAvailability availability) {
|
||||
this.availability = availability;
|
||||
}
|
||||
|
||||
@EventListener
|
||||
void started(ApplicationStartedEvent e) {
|
||||
Trace.add("ApplicationStartedEvent");
|
||||
}
|
||||
|
||||
@EventListener
|
||||
void ready(ApplicationReadyEvent e) {
|
||||
Trace.add("ApplicationReadyEvent (observer, readiness: %s)", availability.getReadinessState());
|
||||
}
|
||||
|
||||
@EventListener
|
||||
void failed(ApplicationFailedEvent e) {
|
||||
Trace.add("ApplicationFailedEvent: %s", e.getException());
|
||||
}
|
||||
|
||||
@EventListener
|
||||
void closed(ContextClosedEvent e) {
|
||||
Trace.add("ContextClosedEvent (the context is shutting down)");
|
||||
}
|
||||
}
|
||||
|
||||
@RestController
|
||||
static class Ping {
|
||||
@GetMapping("/ping")
|
||||
String ping() {
|
||||
return "pong";
|
||||
}
|
||||
}
|
||||
|
||||
// ---- profile runner-fails: the second of three runners throws ----------------------------------------
|
||||
|
||||
@Bean
|
||||
@Profile("runner-fails")
|
||||
@Order(1)
|
||||
CommandLineRunner runnerOne() {
|
||||
return args -> Trace.add("runner 1 ran");
|
||||
}
|
||||
|
||||
@Bean
|
||||
@Profile("runner-fails")
|
||||
@Order(2)
|
||||
ApplicationRunner runnerTwo() {
|
||||
return args -> {
|
||||
Trace.add("runner 2 is about to throw");
|
||||
throw new IllegalStateException("boom from runner 2");
|
||||
};
|
||||
}
|
||||
|
||||
@Bean
|
||||
@Profile("runner-fails")
|
||||
@Order(3)
|
||||
CommandLineRunner runnerThree() {
|
||||
return args -> Trace.add("runner 3 ran");
|
||||
}
|
||||
|
||||
// ---- profile ready-fails: an ApplicationReadyEvent listener throws -----------------------------------
|
||||
|
||||
@Component
|
||||
@Profile("ready-fails")
|
||||
static class ThrowingReadyListener {
|
||||
|
||||
private final ApplicationAvailability availability;
|
||||
private final Environment env;
|
||||
|
||||
ThrowingReadyListener(ApplicationAvailability availability, Environment env) {
|
||||
this.availability = availability;
|
||||
this.env = env;
|
||||
}
|
||||
|
||||
@EventListener
|
||||
@Order(1)
|
||||
void ready(ApplicationReadyEvent e) throws Exception {
|
||||
String port = env.getProperty("server.port");
|
||||
if (port != null) {
|
||||
var response = java.net.http.HttpClient.newHttpClient().send(
|
||||
java.net.http.HttpRequest.newBuilder(java.net.URI.create("http://localhost:" + port + "/ping")).build(),
|
||||
java.net.http.HttpResponse.BodyHandlers.ofString());
|
||||
Trace.add("GET /ping from inside the listener -> %d %s", response.statusCode(), response.body());
|
||||
}
|
||||
Trace.add("ready listener 1 is about to throw (readiness: %s)", availability.getReadinessState());
|
||||
throw new IllegalStateException("boom from the ready listener");
|
||||
}
|
||||
|
||||
@EventListener
|
||||
@Order(2)
|
||||
void secondListener(ApplicationReadyEvent e) {
|
||||
Trace.add("ready listener 2 ran");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- profile exit-code: a runner throws an exception that carries its own exit code ------------------
|
||||
|
||||
@Bean
|
||||
@Profile("exit-code")
|
||||
CommandLineRunner exitCodeRunner() {
|
||||
return args -> {
|
||||
throw new ExitWith42Exception("the data file is missing");
|
||||
};
|
||||
}
|
||||
|
||||
// ---- profile checked: a runner declares "throws Exception" and throws a checked one -------------------
|
||||
|
||||
@Bean
|
||||
@Profile("checked")
|
||||
CommandLineRunner checkedExceptionRunner() {
|
||||
return args -> {
|
||||
throw new java.io.IOException("cannot read data.csv");
|
||||
};
|
||||
}
|
||||
|
||||
static class ExitWith42Exception extends RuntimeException implements ExitCodeGenerator {
|
||||
ExitWith42Exception(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getExitCode() {
|
||||
return 42;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.ankurm.runners.hello;
|
||||
|
||||
import java.util.Arrays;
|
||||
import org.springframework.boot.ApplicationRunner;
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.core.annotation.Order;
|
||||
|
||||
/** The smallest working example of all three hooks: each prints one line, so you can see the sequence. */
|
||||
@SpringBootApplication
|
||||
public class HelloApp {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(HelloApp.class, args);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@Order(1)
|
||||
CommandLineRunner commandLineRunner() {
|
||||
return args -> System.out.println("hello: CommandLineRunner args = " + Arrays.toString(args));
|
||||
}
|
||||
|
||||
@Bean
|
||||
@Order(2)
|
||||
ApplicationRunner applicationRunner() {
|
||||
return args -> System.out.println("hello: ApplicationRunner options = " + args.getOptionNames()
|
||||
+ ", non-option args = " + args.getNonOptionArgs());
|
||||
}
|
||||
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
void onReady() {
|
||||
System.out.println("hello: ApplicationReadyEvent listener");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.ankurm.runners.lifecycle;
|
||||
|
||||
import com.ankurm.runners.support.Trace;
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.boot.context.event.ApplicationStartedEvent;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.event.ContextClosedEvent;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Scenario 9: five {@link Svc} lifecycle beans with different phases, one that must be started by hand and one that
|
||||
* takes 300 ms to stop, next to a runner and the startup events.
|
||||
*/
|
||||
@SpringBootApplication
|
||||
public class LifecycleApp {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(LifecycleApp.class, args);
|
||||
}
|
||||
|
||||
@Bean
|
||||
Svc high() {
|
||||
return new Svc("high", 100, true, 0);
|
||||
}
|
||||
|
||||
@Bean
|
||||
Svc low() {
|
||||
return new Svc("low", -100, true, 0);
|
||||
}
|
||||
|
||||
@Bean
|
||||
Svc mid() {
|
||||
return new Svc("mid", 0, true, 0);
|
||||
}
|
||||
|
||||
@Bean
|
||||
Svc manual() {
|
||||
return new Svc("manual", 50, false, 0);
|
||||
}
|
||||
|
||||
@Bean
|
||||
Svc slow() {
|
||||
return new Svc("slow", 200, true, 300);
|
||||
}
|
||||
|
||||
@Bean
|
||||
CommandLineRunner runner() {
|
||||
return args -> Trace.add("CommandLineRunner runs");
|
||||
}
|
||||
|
||||
@Component
|
||||
static class Events {
|
||||
@EventListener
|
||||
void started(ApplicationStartedEvent e) {
|
||||
Trace.add("ApplicationStartedEvent");
|
||||
}
|
||||
|
||||
@EventListener
|
||||
void ready(ApplicationReadyEvent e) {
|
||||
Trace.add("ApplicationReadyEvent");
|
||||
}
|
||||
|
||||
@EventListener
|
||||
void closing(ContextClosedEvent e) {
|
||||
Trace.add("ContextClosedEvent (stop phase begins)");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.ankurm.runners.lifecycle;
|
||||
|
||||
import com.ankurm.runners.support.Trace;
|
||||
import org.springframework.context.SmartLifecycle;
|
||||
|
||||
/** A {@link SmartLifecycle} that writes to the trace when it is started and stopped. */
|
||||
public class Svc implements SmartLifecycle {
|
||||
|
||||
private final String name;
|
||||
private final int phase;
|
||||
private final boolean autoStartup;
|
||||
private final long slowStopMillis;
|
||||
private volatile boolean running;
|
||||
|
||||
public Svc(String name, int phase, boolean autoStartup, long slowStopMillis) {
|
||||
this.name = name;
|
||||
this.phase = phase;
|
||||
this.autoStartup = autoStartup;
|
||||
this.slowStopMillis = slowStopMillis;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start() {
|
||||
running = true;
|
||||
Trace.add("start %-6s (phase %d) on %s", name, phase, Trace.where());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop() {
|
||||
running = false;
|
||||
Trace.add("stop %-6s (phase %d) on %s", name, phase, Trace.where());
|
||||
}
|
||||
|
||||
/**
|
||||
* The asynchronous stop: Spring waits for the callback (up to spring.lifecycle.timeout-per-shutdown-phase) before it moves
|
||||
* on to the next phase. A bean with no slow shutdown simply keeps the default, which calls {@link #stop()} and then
|
||||
* the callback.
|
||||
*/
|
||||
@Override
|
||||
public void stop(Runnable callback) {
|
||||
if (slowStopMillis == 0) {
|
||||
SmartLifecycle.super.stop(callback);
|
||||
return;
|
||||
}
|
||||
Trace.add("stop %-6s (phase %d) begins, will finish in %d ms", name, phase, slowStopMillis);
|
||||
new Thread(() -> {
|
||||
try {
|
||||
Thread.sleep(slowStopMillis);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
running = false;
|
||||
Trace.add("stop %-6s finished, calling the callback", name);
|
||||
callback.run();
|
||||
}, "slow-stop").start();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isRunning() {
|
||||
return running;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isAutoStartup() {
|
||||
return autoStartup;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getPhase() {
|
||||
return phase;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.ankurm.runners.lifeforgetful;
|
||||
|
||||
import com.ankurm.runners.support.Trace;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.context.SmartLifecycle;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
|
||||
/** Scenario 10b: a {@link SmartLifecycle} whose {@code isRunning()} always says false. */
|
||||
@SpringBootApplication
|
||||
public class ForgetfulApp {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(ForgetfulApp.class, args);
|
||||
}
|
||||
|
||||
@Bean
|
||||
SmartLifecycle forgetful() {
|
||||
return new SmartLifecycle() {
|
||||
@Override
|
||||
public void start() {
|
||||
Trace.add("start() was called");
|
||||
// ... and the author forgot to remember that. isRunning() below never turns true.
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop() {
|
||||
Trace.add("stop() was called");
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isRunning() {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.ankurm.runners.lifestartfails;
|
||||
|
||||
import com.ankurm.runners.support.Trace;
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.context.event.ApplicationStartedEvent;
|
||||
import org.springframework.context.SmartLifecycle;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.event.ContextClosedEvent;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/** Scenario 10a: a {@link SmartLifecycle#start()} that throws, next to a healthy one and a runner. */
|
||||
@SpringBootApplication
|
||||
public class StartFailsApp {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(StartFailsApp.class, args);
|
||||
}
|
||||
|
||||
@Bean
|
||||
SmartLifecycle healthy() {
|
||||
return new Named("healthy", 0, false);
|
||||
}
|
||||
|
||||
@Bean
|
||||
SmartLifecycle broken() {
|
||||
return new Named("broken", 10, true);
|
||||
}
|
||||
|
||||
@Bean
|
||||
CommandLineRunner runner() {
|
||||
return args -> Trace.add("CommandLineRunner runs");
|
||||
}
|
||||
|
||||
@Component
|
||||
static class Events {
|
||||
@EventListener
|
||||
void started(ApplicationStartedEvent e) {
|
||||
Trace.add("ApplicationStartedEvent");
|
||||
}
|
||||
|
||||
@EventListener
|
||||
void closing(ContextClosedEvent e) {
|
||||
Trace.add("ContextClosedEvent");
|
||||
}
|
||||
}
|
||||
|
||||
static class Named implements SmartLifecycle {
|
||||
|
||||
private final String name;
|
||||
private final int phase;
|
||||
private final boolean failOnStart;
|
||||
private volatile boolean running;
|
||||
|
||||
Named(String name, int phase, boolean failOnStart) {
|
||||
this.name = name;
|
||||
this.phase = phase;
|
||||
this.failOnStart = failOnStart;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start() {
|
||||
Trace.add("start %s (phase %d)", name, phase);
|
||||
if (failOnStart) {
|
||||
throw new IllegalStateException("could not connect to the broker");
|
||||
}
|
||||
running = true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop() {
|
||||
running = false;
|
||||
Trace.add("stop %s", name);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isRunning() {
|
||||
return running;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getPhase() {
|
||||
return phase;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.ankurm.runners.order;
|
||||
|
||||
import com.ankurm.runners.support.Trace;
|
||||
import org.springframework.boot.availability.ApplicationAvailability;
|
||||
import org.springframework.boot.availability.AvailabilityChangeEvent;
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.boot.context.event.ApplicationStartedEvent;
|
||||
import org.springframework.boot.web.server.context.WebServerInitializedEvent;
|
||||
import org.springframework.context.event.ContextRefreshedEvent;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/** Listens to every startup event and also asks {@link ApplicationAvailability} what state the app thinks it is in. */
|
||||
@Component
|
||||
class EventProbe {
|
||||
|
||||
private final ApplicationAvailability availability;
|
||||
|
||||
EventProbe(ApplicationAvailability availability) {
|
||||
this.availability = availability;
|
||||
}
|
||||
|
||||
@EventListener
|
||||
void webServer(WebServerInitializedEvent event) {
|
||||
Trace.add("WebServerInitializedEvent (port is open)");
|
||||
}
|
||||
|
||||
@EventListener
|
||||
void refreshed(ContextRefreshedEvent event) {
|
||||
Trace.add("ContextRefreshedEvent");
|
||||
}
|
||||
|
||||
@EventListener
|
||||
void started(ApplicationStartedEvent event) {
|
||||
Trace.add("ApplicationStartedEvent");
|
||||
}
|
||||
|
||||
@EventListener
|
||||
void availabilityChanged(AvailabilityChangeEvent<?> event) {
|
||||
Trace.add("AvailabilityChangeEvent -> %s", event.getState());
|
||||
}
|
||||
|
||||
@EventListener
|
||||
void ready(ApplicationReadyEvent event) {
|
||||
Trace.add("ApplicationReadyEvent (readiness seen from inside: %s)", availability.getReadinessState());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.ankurm.runners.order;
|
||||
|
||||
import com.ankurm.runners.support.Trace;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import org.springframework.beans.factory.InitializingBean;
|
||||
import org.springframework.beans.factory.SmartInitializingSingleton;
|
||||
import org.springframework.context.SmartLifecycle;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* One bean that implements every "too early" hook plus {@link SmartLifecycle}, so the trace shows them in the order
|
||||
* Spring really calls them.
|
||||
*/
|
||||
@Component
|
||||
class LifecycleProbe implements InitializingBean, SmartInitializingSingleton, SmartLifecycle {
|
||||
|
||||
private boolean running;
|
||||
|
||||
@PostConstruct
|
||||
void postConstruct() {
|
||||
Trace.add("@PostConstruct");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterPropertiesSet() {
|
||||
Trace.add("InitializingBean.afterPropertiesSet");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterSingletonsInstantiated() {
|
||||
Trace.add("SmartInitializingSingleton.afterSingletonsInstantiated");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start() {
|
||||
running = true;
|
||||
Trace.add("SmartLifecycle.start (default phase)");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop() {
|
||||
running = false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isRunning() {
|
||||
return running;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.ankurm.runners.order;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
/** Scenario 1: where do the startup hooks run, relative to each other? Everything writes to {@code Trace}. */
|
||||
@SpringBootApplication
|
||||
public class OrderApp {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(OrderApp.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.ankurm.runners.order;
|
||||
|
||||
import com.ankurm.runners.support.Trace;
|
||||
import org.springframework.boot.ApplicationArguments;
|
||||
import org.springframework.boot.ApplicationRunner;
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.boot.availability.ApplicationAvailability;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/** The two runner flavours. Explicit {@code @Order} values, because runners without one have no promised order. */
|
||||
class Runners {
|
||||
|
||||
@Component
|
||||
@Order(1)
|
||||
static class Cli implements CommandLineRunner {
|
||||
|
||||
private final ApplicationAvailability availability;
|
||||
|
||||
Cli(ApplicationAvailability availability) {
|
||||
this.availability = availability;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run(String... args) {
|
||||
Trace.add("CommandLineRunner (readiness seen from inside: %s, liveness: %s)",
|
||||
availability.getReadinessState(), availability.getLivenessState());
|
||||
}
|
||||
}
|
||||
|
||||
@Component
|
||||
@Order(2)
|
||||
static class App implements ApplicationRunner {
|
||||
|
||||
private final ApplicationAvailability availability;
|
||||
|
||||
App(ApplicationAvailability availability) {
|
||||
this.availability = availability;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run(ApplicationArguments args) {
|
||||
Trace.add("ApplicationRunner (readiness seen from inside: %s)", availability.getReadinessState());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.ankurm.runners.ordering;
|
||||
|
||||
import com.ankurm.runners.support.Trace;
|
||||
import org.springframework.boot.ApplicationArguments;
|
||||
import org.springframework.boot.ApplicationRunner;
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Scenario 2: seven runners of both kinds, with {@code @Order} on a class, on a {@code @Bean} method and through
|
||||
* the {@link Ordered} interface, plus one with no order at all. The declaration order below is deliberately
|
||||
* NOT the execution order.
|
||||
*/
|
||||
@SpringBootApplication
|
||||
public class OrderingApp {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(OrderingApp.class, args);
|
||||
}
|
||||
|
||||
/** {@code @Order} on a factory method: Spring Boot looks there too, not only on the class. */
|
||||
@Bean
|
||||
@Order(5)
|
||||
CommandLineRunner cliOrderOnBeanMethod() {
|
||||
return args -> Trace.add("CommandLineRunner @Order(5) on a @Bean method");
|
||||
}
|
||||
|
||||
@Bean
|
||||
ApplicationRunner arWithNoOrder() {
|
||||
return args -> Trace.add("ApplicationRunner no order at all (lowest precedence)");
|
||||
}
|
||||
|
||||
@Component
|
||||
@Order(2)
|
||||
static class CliTwo implements CommandLineRunner {
|
||||
@Override
|
||||
public void run(String... args) {
|
||||
Trace.add("CommandLineRunner @Order(2) on the class");
|
||||
}
|
||||
}
|
||||
|
||||
@Component
|
||||
@Order(1)
|
||||
static class ArOne implements ApplicationRunner {
|
||||
@Override
|
||||
public void run(ApplicationArguments args) {
|
||||
Trace.add("ApplicationRunner @Order(1) on the class");
|
||||
}
|
||||
}
|
||||
|
||||
@Component
|
||||
static class ArThree implements ApplicationRunner, Ordered {
|
||||
@Override
|
||||
public int getOrder() {
|
||||
return 3;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run(ApplicationArguments args) {
|
||||
Trace.add("ApplicationRunner implements Ordered, getOrder() = 3");
|
||||
}
|
||||
}
|
||||
|
||||
/** Implements both interfaces. Boot calls both methods, one after the other, at this one position. */
|
||||
@Component
|
||||
@Order(4)
|
||||
static class Both implements CommandLineRunner, ApplicationRunner {
|
||||
@Override
|
||||
public void run(ApplicationArguments args) {
|
||||
Trace.add("Both @Order(4) -> ApplicationRunner.run");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run(String... args) {
|
||||
Trace.add("Both @Order(4) -> CommandLineRunner.run");
|
||||
}
|
||||
}
|
||||
|
||||
@Component
|
||||
@Order(Ordered.HIGHEST_PRECEDENCE)
|
||||
static class First implements CommandLineRunner {
|
||||
@Override
|
||||
public void run(String... args) {
|
||||
Trace.add("CommandLineRunner @Order(HIGHEST_PRECEDENCE)");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.ankurm.runners.slices;
|
||||
|
||||
import com.ankurm.runners.support.Trace;
|
||||
import java.util.Arrays;
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/** A runner found by component scanning, which is what a test slice filters. */
|
||||
@Component
|
||||
class ComponentRunner implements CommandLineRunner {
|
||||
|
||||
@Override
|
||||
public void run(String... args) {
|
||||
Trace.add("CommandLineRunner from a top-level @Component ran, args = %s", Arrays.toString(args));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.ankurm.runners.slices;
|
||||
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/** The one controller the {@code @WebMvcTest} slice is asked to load. */
|
||||
@RestController
|
||||
public class Hello {
|
||||
|
||||
@GetMapping("/hello")
|
||||
String hello() {
|
||||
return "hello";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.ankurm.runners.slices;
|
||||
|
||||
import com.ankurm.runners.support.Trace;
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/** An {@code ApplicationReadyEvent} listener found by component scanning. */
|
||||
@Component
|
||||
class ReadyListener {
|
||||
|
||||
@EventListener
|
||||
void ready(ApplicationReadyEvent e) {
|
||||
Trace.add("ApplicationReadyEvent listener (a top-level @Component) ran");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.ankurm.runners.slices;
|
||||
|
||||
import com.ankurm.runners.support.Trace;
|
||||
import java.util.Arrays;
|
||||
import org.springframework.boot.ApplicationRunner;
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Scenario 11: what do tests do with runners? Four startup hooks declared four ways: a runner that is a top-level
|
||||
* {@code @Component} ({@link ComponentRunner}), an {@code ApplicationReadyEvent} listener that is a top-level
|
||||
* {@code @Component} ({@link ReadyListener}), a runner that is a {@code @Bean} method on this class, and a runner that is
|
||||
* a {@code @Component} nested inside this class.
|
||||
*/
|
||||
@SpringBootApplication
|
||||
public class SlicesApp {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(SlicesApp.class, args);
|
||||
}
|
||||
|
||||
@Bean
|
||||
ApplicationRunner runnerFromBeanMethod() {
|
||||
return args -> Trace.add("ApplicationRunner from a @Bean method ran, source args = %s",
|
||||
Arrays.toString(args.getSourceArgs()));
|
||||
}
|
||||
|
||||
/** Nested in a configuration class: registered as a member class, so no component-scan filter can exclude it. */
|
||||
@Component
|
||||
static class NestedRunner implements CommandLineRunner {
|
||||
@Override
|
||||
public void run(String... args) {
|
||||
Trace.add("CommandLineRunner nested in SlicesApp ran, args = %s", Arrays.toString(args));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.ankurm.runners.support;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
|
||||
/**
|
||||
* A tiny in-memory log the scenarios write to and the tests print, so the order of startup steps is data and
|
||||
* not a guess. When the system property {@code trace.echo} is set (the scenarios that start a second JVM do
|
||||
* that), every line is also printed to stdout with a {@code TRACE } prefix so the parent test can collect it.
|
||||
*/
|
||||
public final class Trace {
|
||||
|
||||
private static final List<String> LINES = new CopyOnWriteArrayList<>();
|
||||
private static final boolean ECHO = System.getProperty("trace.echo") != null;
|
||||
|
||||
private Trace() {
|
||||
}
|
||||
|
||||
public static void add(String format, Object... args) {
|
||||
String line = args.length == 0 ? format : String.format(format, args);
|
||||
LINES.add(line);
|
||||
if (ECHO) {
|
||||
System.out.println("TRACE " + line);
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns everything recorded so far and clears the log. */
|
||||
public static List<String> drain() {
|
||||
var copy = new ArrayList<>(LINES);
|
||||
LINES.clear();
|
||||
return copy;
|
||||
}
|
||||
|
||||
/** Which kind of thread is running this code, in words that stay the same from run to run. */
|
||||
public static String where() {
|
||||
Thread t = Thread.currentThread();
|
||||
if (t.isVirtual()) {
|
||||
return "a virtual thread";
|
||||
}
|
||||
return t.getName().equals("main") ? "main" : "platform thread " + t.getName();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.ankurm.runners.ties;
|
||||
|
||||
import com.ankurm.runners.support.Trace;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.boot.ApplicationRunner;
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
|
||||
/**
|
||||
* Scenario 3: four runners and no {@code @Order} anywhere. The test starts this in several fresh JVMs and
|
||||
* records the order each one chose.
|
||||
*/
|
||||
@SpringBootApplication
|
||||
public class TiesApp {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(TiesApp.class, args);
|
||||
}
|
||||
|
||||
@Bean
|
||||
CommandLineRunner cliA() {
|
||||
return args -> Trace.add("cliA");
|
||||
}
|
||||
|
||||
@Bean
|
||||
ApplicationRunner arB() {
|
||||
return args -> Trace.add("arB");
|
||||
}
|
||||
|
||||
@Bean
|
||||
CommandLineRunner cliC() {
|
||||
return args -> Trace.add("cliC");
|
||||
}
|
||||
|
||||
@Bean
|
||||
ApplicationRunner arD() {
|
||||
return args -> Trace.add("arD");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package com.ankurm.runners;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import com.ankurm.runners.blocking.BlockingApp;
|
||||
import com.ankurm.runners.blocking.Gate;
|
||||
import com.ankurm.runners.support.Trace;
|
||||
import java.io.IOException;
|
||||
import java.net.ServerSocket;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.WebApplicationType;
|
||||
|
||||
/** Sections 7 and 8: a slow runner delays readiness; a runner that hands its work to another thread does not. */
|
||||
class BlockingTests {
|
||||
|
||||
private static final HttpClient HTTP = HttpClient.newHttpClient();
|
||||
|
||||
private static int freePort() throws IOException {
|
||||
try (var s = new ServerSocket(0)) {
|
||||
return s.getLocalPort();
|
||||
}
|
||||
}
|
||||
|
||||
private static String get(int port, String path) throws Exception {
|
||||
var r = HTTP.send(HttpRequest.newBuilder(URI.create("http://localhost:" + port + path)).build(),
|
||||
HttpResponse.BodyHandlers.ofString());
|
||||
return String.format("%-32s -> %d %s", "GET " + path, r.statusCode(), r.body());
|
||||
}
|
||||
|
||||
private static Thread startInBackground(AtomicReference<BootRun.Result> result, int port, String profile) {
|
||||
var thread = new Thread(() -> result.set(BootRun.run(BlockingApp.class, WebApplicationType.SERVLET,
|
||||
"server.port=" + port,
|
||||
"spring.profiles.active=" + profile,
|
||||
"management.endpoint.health.probes.enabled=true",
|
||||
"management.endpoints.web.exposure.include=health")), "app-main");
|
||||
thread.start();
|
||||
return thread;
|
||||
}
|
||||
|
||||
@Test
|
||||
void aBlockingRunnerKeepsReadinessDownAndSpringApplicationRunOpen() throws Exception {
|
||||
try (var t = new Transcript("07-blocking-runner-readiness.txt",
|
||||
"A runner that blocks: the port is open, liveness is UP, readiness is OUT_OF_SERVICE")) {
|
||||
Trace.drain();
|
||||
Gate.reset();
|
||||
int port = freePort();
|
||||
var result = new AtomicReference<BootRun.Result>();
|
||||
var app = startInBackground(result, port, "blocking");
|
||||
assertThat(Gate.RUNNER_STARTED.await(60, TimeUnit.SECONDS)).isTrue();
|
||||
|
||||
t.section("while the runner is blocked");
|
||||
t.line("has SpringApplication.run() returned? %s", result.get() != null);
|
||||
String ping = get(port, "/ping");
|
||||
String live = get(port, "/actuator/health/liveness");
|
||||
String ready = get(port, "/actuator/health/readiness");
|
||||
t.line(ping);
|
||||
t.line(live);
|
||||
t.line(ready);
|
||||
assertThat(ping).contains("200 pong");
|
||||
assertThat(live).contains("200");
|
||||
assertThat(ready).contains("503");
|
||||
|
||||
Gate.RELEASE.countDown();
|
||||
app.join(30_000);
|
||||
|
||||
t.section("after the runner returned");
|
||||
t.line("has SpringApplication.run() returned? %s", result.get() != null);
|
||||
String readyAfter = get(port, "/actuator/health/readiness");
|
||||
t.line(readyAfter);
|
||||
assertThat(readyAfter).contains("200");
|
||||
t.section("trace");
|
||||
Trace.drain().forEach(t::line);
|
||||
result.get().close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void aRunnerThatHandsOffItsWorkLetsTheApplicationBecomeReadyAtOnce() throws Exception {
|
||||
try (var t = new Transcript("08-async-runner.txt",
|
||||
"A runner that starts a virtual thread and returns: readiness does not wait, and failures do not fail startup")) {
|
||||
Trace.drain();
|
||||
Gate.reset();
|
||||
int port = freePort();
|
||||
var result = new AtomicReference<BootRun.Result>();
|
||||
var app = startInBackground(result, port, "async");
|
||||
app.join(60_000);
|
||||
assertThat(result.get()).isNotNull();
|
||||
assertThat(result.get().started()).isTrue();
|
||||
|
||||
t.section("the background work is still waiting");
|
||||
t.line("has SpringApplication.run() returned? %s", true);
|
||||
String ready = get(port, "/actuator/health/readiness");
|
||||
t.line(ready);
|
||||
assertThat(ready).contains("200");
|
||||
|
||||
Gate.RELEASE.countDown();
|
||||
assertThat(Gate.BACKGROUND_DONE.await(30, TimeUnit.SECONDS)).isTrue();
|
||||
t.section("after the background work threw");
|
||||
String readyAfter = get(port, "/actuator/health/readiness");
|
||||
t.line(readyAfter);
|
||||
t.line(get(port, "/ping"));
|
||||
assertThat(readyAfter).contains("200");
|
||||
t.section("trace");
|
||||
Trace.drain().forEach(t::line);
|
||||
result.get().close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.ankurm.runners;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import org.springframework.boot.WebApplicationType;
|
||||
import org.springframework.boot.builder.SpringApplicationBuilder;
|
||||
import org.springframework.context.ConfigurableApplicationContext;
|
||||
|
||||
/** Starts a Spring Boot application in this JVM and returns either the context or the failure. */
|
||||
public final class BootRun {
|
||||
|
||||
private BootRun() {
|
||||
}
|
||||
|
||||
public static Result runWithArgs(Class<?> source, WebApplicationType type, String[] args, String... properties) {
|
||||
var props = new ArrayList<String>();
|
||||
props.add("spring.main.banner-mode=off");
|
||||
props.add("logging.level.root=OFF");
|
||||
props.addAll(List.of(properties));
|
||||
try {
|
||||
ConfigurableApplicationContext ctx = new SpringApplicationBuilder(source)
|
||||
.web(type)
|
||||
.properties(props.toArray(String[]::new))
|
||||
.run(args);
|
||||
return new Result(ctx, null);
|
||||
} catch (RuntimeException e) {
|
||||
return new Result(null, e);
|
||||
}
|
||||
}
|
||||
|
||||
public static Result run(Class<?> source, WebApplicationType type, String... properties) {
|
||||
return runWithArgs(source, type, new String[0], properties);
|
||||
}
|
||||
|
||||
/** Names the exception classes from the outside in, with the message of each one. */
|
||||
public static List<String> chain(Throwable t) {
|
||||
var lines = new ArrayList<String>();
|
||||
for (Throwable c = t; c != null; c = (c.getCause() == c) ? null : c.getCause()) {
|
||||
lines.add(c.getClass().getName() + ": " + c.getMessage());
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
public record Result(ConfigurableApplicationContext context, RuntimeException failure) {
|
||||
|
||||
public boolean started() {
|
||||
return failure == null;
|
||||
}
|
||||
|
||||
public void close() {
|
||||
if (context != null) {
|
||||
context.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.ankurm.runners;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import com.ankurm.runners.failure.FailureApp;
|
||||
import java.io.IOException;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.ServerSocket;
|
||||
import java.net.Socket;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/** Sections 5 and 6: what happens when a runner throws, and when an ApplicationReadyEvent listener throws. */
|
||||
class FailureTests {
|
||||
|
||||
private static int freePort() throws IOException {
|
||||
try (var s = new ServerSocket(0)) {
|
||||
return s.getLocalPort();
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean portAnswers(int port) {
|
||||
try (var s = new Socket()) {
|
||||
s.connect(new InetSocketAddress("localhost", port), 500);
|
||||
return true;
|
||||
} catch (IOException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void aRunnerThatThrowsStopsTheApplication() {
|
||||
try (var t = new Transcript("05-runner-failure.txt",
|
||||
"A runner throws: exit code, log, events, and the runners that never ran")) {
|
||||
t.section("three ordered runners, the second throws (non-web, in a separate JVM)");
|
||||
var r = Forked.run(FailureApp.class,
|
||||
List.of("--spring.profiles.active=runner-fails", "--spring.main.web-application-type=none"));
|
||||
r.trace().forEach(l -> t.line("TRACE %s", l));
|
||||
r.errorLog().forEach(t::line);
|
||||
t.line("process exit code: %d", r.exitCode());
|
||||
assertThat(r.exitCode()).isEqualTo(1);
|
||||
assertThat(r.trace()).contains("runner 1 ran", "runner 2 is about to throw");
|
||||
assertThat(r.trace()).doesNotContain("runner 3 ran");
|
||||
assertThat(r.trace().stream().filter(l -> l.startsWith("ApplicationReadyEvent"))).isEmpty();
|
||||
|
||||
t.section("a runner exception that implements ExitCodeGenerator");
|
||||
var e = Forked.run(FailureApp.class,
|
||||
List.of("--spring.profiles.active=exit-code", "--spring.main.web-application-type=none"));
|
||||
e.errorLog().forEach(t::line);
|
||||
t.line("process exit code: %d", e.exitCode());
|
||||
assertThat(e.exitCode()).isEqualTo(42);
|
||||
|
||||
t.section("a runner that throws a checked exception (declared with throws Exception)");
|
||||
var c = Forked.run(FailureApp.class,
|
||||
List.of("--spring.profiles.active=checked", "--spring.main.web-application-type=none"));
|
||||
c.errorLog().forEach(t::line);
|
||||
t.line("process exit code: %d", c.exitCode());
|
||||
assertThat(c.exitCode()).isEqualTo(1);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void aReadyListenerThatThrowsAlsoStopsTheApplication() throws Exception {
|
||||
try (var t = new Transcript("06-ready-listener-failure.txt",
|
||||
"An ApplicationReadyEvent listener throws: the app was 'ready' and is now gone")) {
|
||||
t.section("non-web application");
|
||||
var r = Forked.run(FailureApp.class,
|
||||
List.of("--spring.profiles.active=ready-fails", "--spring.main.web-application-type=none"));
|
||||
r.trace().forEach(l -> t.line("TRACE %s", l));
|
||||
r.errorLog().forEach(t::line);
|
||||
t.line("process exit code: %d", r.exitCode());
|
||||
assertThat(r.exitCode()).isEqualTo(1);
|
||||
|
||||
t.section("web application (Tomcat is up when the listener runs)");
|
||||
int port = freePort();
|
||||
var w = Forked.run(FailureApp.class,
|
||||
List.of("--spring.profiles.active=ready-fails", "--server.port=" + port));
|
||||
w.trace().forEach(l -> t.line("TRACE %s", l));
|
||||
w.errorLog().forEach(t::line);
|
||||
t.line("process exit code: %d", w.exitCode());
|
||||
t.line("does the port answer after the process ended? %s", portAnswers(port));
|
||||
assertThat(w.exitCode()).isEqualTo(1);
|
||||
assertThat(portAnswers(port)).isFalse();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.ankurm.runners;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Runs an application's {@code main} in a second JVM with the test classpath, so the test can see what a real
|
||||
* {@code java -cp ... Main} does: the exit code and everything written to the console.
|
||||
*/
|
||||
public final class Forked {
|
||||
|
||||
private Forked() {
|
||||
}
|
||||
|
||||
public record Result(int exitCode, List<String> output) {
|
||||
|
||||
/** The lines the scenario wrote with {@code Trace.add}, without the {@code TRACE } prefix. */
|
||||
public List<String> trace() {
|
||||
return output.stream().filter(l -> l.startsWith("TRACE ")).map(l -> l.substring(6)).toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* The log lines worth quoting: the ERROR line and the exception headers ("java.lang.X: message" and
|
||||
* "Caused by: ..."), without stack frames. The pattern is fixed in {@link #LOG_PATTERN}, so there is no
|
||||
* timestamp or pid in them.
|
||||
*/
|
||||
public List<String> errorLog() {
|
||||
return output.stream()
|
||||
.filter(l -> l.startsWith("ERROR ") || l.matches("(Caused by: )?[\\w.$]+(Exception|Error)(: .*)?"))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
/** A log line without timestamp, pid or thread id, so a transcript does not change from run to run. */
|
||||
public static final String LOG_PATTERN = "%-5level %logger{24} : %msg%n%wEx";
|
||||
|
||||
public static Result run(Class<?> main, List<String> args) {
|
||||
return run(main, List.of(), args);
|
||||
}
|
||||
|
||||
/** {@code jvmOptions} go before the main class (for example {@code -Dspring.main.web-application-type=none}). */
|
||||
public static Result run(Class<?> main, List<String> jvmOptions, List<String> args) {
|
||||
String java = Path.of(System.getProperty("java.home"), "bin", "java").toString();
|
||||
var cmd = new ArrayList<String>();
|
||||
cmd.add(java);
|
||||
cmd.add("-Dtrace.echo=true");
|
||||
cmd.add("-Dspring.main.banner-mode=off");
|
||||
cmd.add("-Dlogging.pattern.console=" + LOG_PATTERN);
|
||||
cmd.addAll(jvmOptions);
|
||||
cmd.add("-cp");
|
||||
cmd.add(System.getProperty("java.class.path"));
|
||||
cmd.add(main.getName());
|
||||
cmd.addAll(args);
|
||||
try {
|
||||
Process p = new ProcessBuilder(cmd).redirectErrorStream(true).start();
|
||||
var lines = new ArrayList<String>();
|
||||
try (var r = new BufferedReader(new InputStreamReader(p.getInputStream(), StandardCharsets.UTF_8))) {
|
||||
String l;
|
||||
while ((l = r.readLine()) != null) {
|
||||
lines.add(l);
|
||||
}
|
||||
}
|
||||
if (!p.waitFor(60, TimeUnit.SECONDS)) {
|
||||
p.destroyForcibly();
|
||||
throw new IllegalStateException("forked JVM did not exit in 60 s: " + lines);
|
||||
}
|
||||
return new Result(p.exitValue(), lines);
|
||||
} catch (IOException | InterruptedException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.ankurm.runners;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import com.ankurm.runners.hello.HelloApp;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/** Section 1: the smallest working example, run the way a user would run it: a real JVM and a real command line. */
|
||||
class HelloTests {
|
||||
|
||||
@Test
|
||||
void allThreeHooksPrintOneLineEach() {
|
||||
try (var t = new Transcript("00-hello.txt", "HelloApp started with: --greeting=hi --verbose notes.txt")) {
|
||||
var r = Forked.run(HelloApp.class,
|
||||
List.of("-Dspring.main.web-application-type=none"), List.of("--greeting=hi", "--verbose", "notes.txt"));
|
||||
var lines = r.output().stream().filter(l -> l.startsWith("hello:")).toList();
|
||||
lines.forEach(t::line);
|
||||
t.line("process exit code: %d", r.exitCode());
|
||||
assertThat(r.exitCode()).isZero();
|
||||
assertThat(lines).hasSize(3);
|
||||
assertThat(lines.get(2)).isEqualTo("hello: ApplicationReadyEvent listener");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package com.ankurm.runners;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import com.ankurm.runners.lifecycle.LifecycleApp;
|
||||
import com.ankurm.runners.lifecycle.Svc;
|
||||
import com.ankurm.runners.lifeforgetful.ForgetfulApp;
|
||||
import com.ankurm.runners.lifestartfails.StartFailsApp;
|
||||
import com.ankurm.runners.support.Trace;
|
||||
import java.util.Comparator;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.WebApplicationType;
|
||||
import org.springframework.boot.SpringBootVersion;
|
||||
import org.springframework.context.SmartLifecycle;
|
||||
import org.springframework.core.SpringVersion;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
/** Sections 9 and 10: SmartLifecycle phases, start, stop, and the two ways it goes wrong. */
|
||||
class LifecycleTests {
|
||||
|
||||
@Test
|
||||
void phasesDecideTheOrderAndStopRunsBackwards() {
|
||||
try (var t = new Transcript("09-smartlifecycle-phases.txt",
|
||||
"SmartLifecycle: start in ascending phase, stop in descending phase, runners after all of it")) {
|
||||
Trace.drain();
|
||||
var run = BootRun.run(LifecycleApp.class, WebApplicationType.NONE);
|
||||
t.section("startup");
|
||||
Trace.drain().forEach(t::line);
|
||||
var manual = run.context().getBean("manual", Svc.class);
|
||||
t.line("manual.isAutoStartup()=%s isRunning()=%s (never started by the context)", manual.isAutoStartup(), manual.isRunning());
|
||||
manual.start();
|
||||
t.section("context.close()");
|
||||
run.close();
|
||||
var stop = Trace.drain();
|
||||
stop.forEach(t::line);
|
||||
assertThat(stop.stream().filter(l -> l.startsWith("stop")).map(l -> l.split("\\s+")[1]).distinct().toList())
|
||||
.containsExactly("slow", "high", "manual", "mid", "low");
|
||||
assertThat(stop).contains("stop slow finished, calling the callback");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void theWebServerIsJustTwoMoreLifecycleBeans() {
|
||||
try (var t = new Transcript("10-lifecycle-beans-in-a-web-app.txt",
|
||||
"Every SmartLifecycle bean in a servlet web application, by phase (the ones this project adds are marked)")) {
|
||||
var run = BootRun.run(LifecycleApp.class, WebApplicationType.SERVLET, "server.port=0");
|
||||
var ctx = run.context();
|
||||
ctx.getBeansOfType(SmartLifecycle.class).entrySet().stream()
|
||||
.sorted(Comparator.comparingInt(e -> e.getValue().getPhase()))
|
||||
.forEach(e -> t.line("phase %11d %-30s %s", e.getValue().getPhase(), e.getKey(),
|
||||
e.getValue() instanceof Svc ? "(this project)" : e.getValue().getClass().getSimpleName()));
|
||||
t.line("SmartLifecycle.DEFAULT_PHASE = %d", SmartLifecycle.DEFAULT_PHASE);
|
||||
t.line("Integer.MAX_VALUE = %d", Integer.MAX_VALUE);
|
||||
t.line("timeout per shutdown phase = %s ms (spring.lifecycle.timeout-per-shutdown-phase, set by Boot)",
|
||||
ReflectionTestUtils.getField(ctx.getBean("lifecycleProcessor"), "timeoutPerShutdownPhase"));
|
||||
t.line("Spring Boot %s, Spring Framework %s", SpringBootVersion.getVersion(), SpringVersion.getVersion());
|
||||
assertThat(ctx.getBeansOfType(SmartLifecycle.class)).containsKeys("webServerStartStop", "webServerGracefulShutdown");
|
||||
run.close();
|
||||
Trace.drain();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void aSmartLifecycleThatThrowsOnStartFailsTheApplicationBeforeAnyRunner() {
|
||||
try (var t = new Transcript("11-smartlifecycle-start-fails.txt",
|
||||
"SmartLifecycle.start() throws: no ApplicationStartedEvent, no runner")) {
|
||||
Trace.drain();
|
||||
var run = BootRun.run(StartFailsApp.class, WebApplicationType.NONE);
|
||||
var lines = Trace.drain();
|
||||
lines.forEach(t::line);
|
||||
t.blank();
|
||||
t.line("application started? %s", run.started());
|
||||
BootRun.chain(run.failure()).forEach(l -> t.line(" %s", l));
|
||||
assertThat(run.started()).isFalse();
|
||||
assertThat(lines).doesNotContain("CommandLineRunner runs", "ApplicationStartedEvent");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void aSmartLifecycleThatNeverReportsRunningIsNeverStopped() {
|
||||
try (var t = new Transcript("12-smartlifecycle-forgot-isrunning.txt",
|
||||
"isRunning() always false: start() is called, stop() never is")) {
|
||||
Trace.drain();
|
||||
var run = BootRun.run(ForgetfulApp.class, WebApplicationType.NONE);
|
||||
t.line("--- startup ---");
|
||||
Trace.drain().forEach(t::line);
|
||||
run.close();
|
||||
t.line("--- after context.close() ---");
|
||||
var afterClose = Trace.drain();
|
||||
afterClose.forEach(t::line);
|
||||
if (afterClose.isEmpty()) {
|
||||
t.line("(nothing)");
|
||||
}
|
||||
assertThat(afterClose).isEmpty();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.ankurm.runners;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import com.ankurm.runners.order.OrderApp;
|
||||
import com.ankurm.runners.support.Trace;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.WebApplicationType;
|
||||
|
||||
/** Section 2: where each startup hook runs, printed from a real run. */
|
||||
class OrderTests {
|
||||
|
||||
@Test
|
||||
void theHooksRunInOneFixedSequence() {
|
||||
try (var t = new Transcript("01-startup-hooks-in-order.txt",
|
||||
"Every startup hook in the order Spring Boot 4.1.2 really calls it")) {
|
||||
t.section("non-web application");
|
||||
Trace.drain();
|
||||
var plain = BootRun.run(OrderApp.class, WebApplicationType.NONE);
|
||||
List<String> noWeb = Trace.drain();
|
||||
noWeb.forEach(t::line);
|
||||
plain.close();
|
||||
|
||||
t.section("servlet web application (Tomcat on a random port)");
|
||||
var web = BootRun.run(OrderApp.class, WebApplicationType.SERVLET, "server.port=0");
|
||||
List<String> withWeb = Trace.drain();
|
||||
withWeb.forEach(t::line);
|
||||
web.close();
|
||||
|
||||
assertThat(noWeb).containsExactly(
|
||||
"@PostConstruct",
|
||||
"InitializingBean.afterPropertiesSet",
|
||||
"SmartInitializingSingleton.afterSingletonsInstantiated",
|
||||
"SmartLifecycle.start (default phase)",
|
||||
"ContextRefreshedEvent",
|
||||
"ApplicationStartedEvent",
|
||||
"AvailabilityChangeEvent -> CORRECT",
|
||||
"CommandLineRunner (readiness seen from inside: REFUSING_TRAFFIC, liveness: CORRECT)",
|
||||
"ApplicationRunner (readiness seen from inside: REFUSING_TRAFFIC)",
|
||||
"ApplicationReadyEvent (readiness seen from inside: REFUSING_TRAFFIC)",
|
||||
"AvailabilityChangeEvent -> ACCEPTING_TRAFFIC");
|
||||
assertThat(withWeb).contains("WebServerInitializedEvent (port is open)");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.ankurm.runners;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import com.ankurm.runners.args.ArgsApp;
|
||||
import com.ankurm.runners.ordering.OrderingApp;
|
||||
import com.ankurm.runners.support.Trace;
|
||||
import com.ankurm.runners.ties.TiesApp;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.WebApplicationType;
|
||||
|
||||
/** Sections 3 and 4: ordering between runners, and the arguments each flavour receives. */
|
||||
class RunnerTests {
|
||||
|
||||
@Test
|
||||
void orderAnnotationsSortBothFlavoursIntoOneList() {
|
||||
try (var t = new Transcript("02-order-annotation-and-ordered.txt",
|
||||
"CommandLineRunner and ApplicationRunner beans sorted together by @Order / Ordered")) {
|
||||
Trace.drain();
|
||||
var run = BootRun.run(OrderingApp.class, WebApplicationType.NONE);
|
||||
var lines = Trace.drain();
|
||||
run.close();
|
||||
t.line("(declaration order in OrderingApp.java is NOT the order below)");
|
||||
t.blank();
|
||||
lines.forEach(t::line);
|
||||
assertThat(lines).containsExactly(
|
||||
"CommandLineRunner @Order(HIGHEST_PRECEDENCE)",
|
||||
"ApplicationRunner @Order(1) on the class",
|
||||
"CommandLineRunner @Order(2) on the class",
|
||||
"ApplicationRunner implements Ordered, getOrder() = 3",
|
||||
"Both @Order(4) -> ApplicationRunner.run",
|
||||
"Both @Order(4) -> CommandLineRunner.run",
|
||||
"CommandLineRunner @Order(5) on a @Bean method",
|
||||
"ApplicationRunner no order at all (lowest precedence)");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void runnersWithTheSameOrderHaveNoPromisedSequence() {
|
||||
// Every run is a fresh JVM, because the sequence comes from identity hash codes, which change per JVM.
|
||||
try (var t = new Transcript("03-ties-have-no-promised-order.txt",
|
||||
"Four runners without @Order: the order chosen by each of 8 fresh JVMs")) {
|
||||
Map<String, Integer> seen = new LinkedHashMap<>();
|
||||
for (int i = 1; i <= 8; i++) {
|
||||
var result = Forked.run(TiesApp.class, List.of("--spring.main.web-application-type=none"));
|
||||
assertThat(result.exitCode()).isZero();
|
||||
String order = String.join(" ", result.trace());
|
||||
t.line("JVM %d: %s", i, order);
|
||||
seen.merge(order, 1, Integer::sum);
|
||||
}
|
||||
t.blank();
|
||||
t.line("distinct sequences seen: %d", seen.size());
|
||||
seen.forEach((order, n) -> t.line(" %-22s x%d", order, n));
|
||||
// The contract is only "all four ran". How many distinct sequences appear is exactly what is not promised.
|
||||
seen.keySet().forEach(order -> assertThat(order.split(" ")).containsExactlyInAnyOrder("cliA", "arB", "cliC", "arD"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void theTwoFlavoursSeeTheSameCommandLineDifferently() {
|
||||
try (var t = new Transcript("04-arguments.txt", "The same command line, seen by CommandLineRunner and ApplicationRunner")) {
|
||||
String[] args = {"--name=Ankur", "--verbose", "--name=Second", "data.csv", "-x", "--", "--after-the-dashes"};
|
||||
t.line("args passed to SpringApplication.run: %s", java.util.Arrays.toString(args));
|
||||
t.blank();
|
||||
Trace.drain();
|
||||
var run = BootRun.runWithArgs(ArgsApp.class, WebApplicationType.NONE, args);
|
||||
var lines = Trace.drain();
|
||||
var name = run.context().getEnvironment().getProperty("name");
|
||||
run.close();
|
||||
lines.forEach(t::line);
|
||||
t.blank();
|
||||
t.line("Environment property 'name' (both values, joined by a comma): %s", name);
|
||||
assertThat(lines).hasSize(8);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.ankurm.runners;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import com.ankurm.runners.slices.SlicesApp;
|
||||
import com.ankurm.runners.support.Trace;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
|
||||
/** Section 11: {@code @SpringBootTest} starts the application through SpringApplication, so every startup hook runs. */
|
||||
@SpringBootTest(classes = SlicesApp.class, args = "--from-the-test=1")
|
||||
class SpringBootTestHooksTests {
|
||||
|
||||
@BeforeAll
|
||||
static void clear() {
|
||||
Trace.drain();
|
||||
}
|
||||
|
||||
@Test
|
||||
void springBootTestRunsRunnersAndTheReadyEvent() {
|
||||
List<String> lines = Trace.drain();
|
||||
try (var t = new Transcript("13-spring-boot-test-runs-the-hooks.txt",
|
||||
"@SpringBootTest(classes = SlicesApp.class, args = \"--from-the-test=1\"): what ran before the first test method")) {
|
||||
lines.forEach(t::line);
|
||||
}
|
||||
assertThat(lines).hasSize(4);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.ankurm.runners;
|
||||
|
||||
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();
|
||||
// Absolute paths of whoever ran the build are environment noise, not a finding.
|
||||
String text = buffer.toString().replace(System.getProperty("user.dir"), "<startup-runners>");
|
||||
try {
|
||||
Files.createDirectories(path.getParent());
|
||||
Files.writeString(path, text);
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("could not write " + path, e);
|
||||
}
|
||||
System.out.print(text);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.ankurm.runners;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import com.ankurm.runners.slices.Hello;
|
||||
import com.ankurm.runners.slices.SlicesApp;
|
||||
import com.ankurm.runners.support.Trace;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
||||
import org.springframework.test.context.ContextConfiguration;
|
||||
|
||||
/** Section 11: {@code @WebMvcTest} keeps the {@code @Bean} method (it is on the configuration class) and drops the rest. */
|
||||
@WebMvcTest(controllers = Hello.class)
|
||||
@ContextConfiguration(classes = SlicesApp.class)
|
||||
class WebMvcSliceTests {
|
||||
|
||||
@BeforeAll
|
||||
static void clear() {
|
||||
Trace.drain();
|
||||
}
|
||||
|
||||
@Test
|
||||
void aWebMvcSliceDropsScannedComponentsButKeepsBeanMethodsAndNestedClasses() {
|
||||
List<String> lines = Trace.drain();
|
||||
try (var t = new Transcript("14-webmvctest-slice-hooks.txt",
|
||||
"@WebMvcTest(controllers = Hello.class): what ran before the first test method")) {
|
||||
lines.forEach(t::line);
|
||||
if (lines.isEmpty()) {
|
||||
t.line("(nothing)");
|
||||
}
|
||||
}
|
||||
assertThat(lines).hasSize(2);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user