Add core-di: constructor vs setter vs field injection and @Autowired candidate resolution on Boot 4.1
Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01JoVmf2fWvcpoXndcDSwRf7
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
package com.ankurm.coredi;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
/**
|
||||
* Only used by scripts/capture-failure-analysis.sh: it starts a real Boot application so the
|
||||
* "APPLICATION FAILED TO START" report for a circular dependency can be captured verbatim.
|
||||
* Everything else in this module runs from the tests.
|
||||
*/
|
||||
@SpringBootApplication(scanBasePackages = "com.ankurm.coredi.boot")
|
||||
public class DiApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
var ctx = SpringApplication.run(DiApplication.class, args);
|
||||
var cycleBeans = java.util.Arrays.stream(ctx.getBeanDefinitionNames())
|
||||
.filter(n -> n.startsWith("bootCycles.")).sorted().toList();
|
||||
System.out.println("STARTED. Beans from the cycle: " + cycleBeans);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.ankurm.coredi.boot;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/** Three ways to start the same application with a cycle in it. Pick with --spring.profiles.active. */
|
||||
public final class BootCycles {
|
||||
|
||||
private BootCycles() {
|
||||
}
|
||||
|
||||
@Component
|
||||
@Profile("ctor-cycle")
|
||||
public static class CtorOne {
|
||||
public CtorOne(CtorTwo two) {
|
||||
}
|
||||
}
|
||||
|
||||
@Component
|
||||
@Profile("ctor-cycle")
|
||||
public static class CtorTwo {
|
||||
public CtorTwo(CtorOne one) {
|
||||
}
|
||||
}
|
||||
|
||||
@Component
|
||||
@Profile("field-cycle")
|
||||
public static class FieldOne {
|
||||
@Autowired
|
||||
FieldTwo two;
|
||||
}
|
||||
|
||||
@Component
|
||||
@Profile("field-cycle")
|
||||
public static class FieldTwo {
|
||||
@Autowired
|
||||
FieldOne one;
|
||||
}
|
||||
|
||||
@Component
|
||||
@Profile("lazy-cycle")
|
||||
public static class LazyOne {
|
||||
public LazyOne(@Lazy LazyTwo two) {
|
||||
}
|
||||
}
|
||||
|
||||
@Component
|
||||
@Profile("lazy-cycle")
|
||||
public static class LazyTwo {
|
||||
public LazyTwo(LazyOne one) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.ankurm.coredi.injection;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
|
||||
/** One bean using all three styles at once, only to make Spring's order of operations visible. */
|
||||
public class AllThreeStyles {
|
||||
|
||||
@Autowired
|
||||
private Notifier fieldDependency;
|
||||
|
||||
private PaymentGateway setterDependency;
|
||||
|
||||
private final Notifier constructorDependency;
|
||||
|
||||
public AllThreeStyles(Notifier constructorDependency) {
|
||||
this.constructorDependency = constructorDependency;
|
||||
Trace.log("constructor : constructor arg=" + describe(constructorDependency)
|
||||
+ ", field=" + describe(fieldDependency) + ", setter=" + describe(setterDependency));
|
||||
}
|
||||
|
||||
@Autowired
|
||||
public void setSetterDependency(PaymentGateway gateway) {
|
||||
this.setterDependency = gateway;
|
||||
Trace.log("setter : setter arg=" + describe(gateway) + ", field=" + describe(fieldDependency));
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
void afterInjection() {
|
||||
Trace.log("@PostConstruct: constructor=" + describe(constructorDependency)
|
||||
+ ", field=" + describe(fieldDependency) + ", setter=" + describe(setterDependency));
|
||||
}
|
||||
|
||||
private static String describe(Object o) {
|
||||
return o == null ? "null" : "set";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.ankurm.coredi.injection;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/** The two collaborators, registered once so every service variant can be pointed at them. */
|
||||
@Configuration
|
||||
public class BaseBeans {
|
||||
|
||||
@Bean
|
||||
PaymentGateway paymentGateway() {
|
||||
return new LoggingPaymentGateway();
|
||||
}
|
||||
|
||||
@Bean
|
||||
Notifier notifier() {
|
||||
return new RecordingNotifier();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.ankurm.coredi.injection;
|
||||
|
||||
/**
|
||||
* Constructor injection. One constructor, so {@code @Autowired} is not needed. Both fields are
|
||||
* {@code final}, and the class cannot be instantiated without its collaborators.
|
||||
*/
|
||||
public class ConstructorOrderService implements OrderService {
|
||||
|
||||
private final PaymentGateway gateway;
|
||||
private final Notifier notifier;
|
||||
|
||||
public ConstructorOrderService(PaymentGateway gateway, Notifier notifier) {
|
||||
this.gateway = gateway;
|
||||
this.notifier = notifier;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String place(String orderId, int cents) {
|
||||
String receipt = gateway.charge(orderId, cents);
|
||||
notifier.send(receipt);
|
||||
return receipt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.ankurm.coredi.injection;
|
||||
|
||||
public class CtorA {
|
||||
public CtorA(CtorB b) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.ankurm.coredi.injection;
|
||||
|
||||
public class CtorB {
|
||||
public CtorB(CtorA a) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.ankurm.coredi.injection;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
|
||||
public class FieldA {
|
||||
@Autowired
|
||||
public FieldB b;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.ankurm.coredi.injection;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
|
||||
public class FieldB {
|
||||
@Autowired
|
||||
public FieldA a;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.ankurm.coredi.injection;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
|
||||
/** Field injection. Shortest to write; the only way to supply the fields outside Spring is reflection. */
|
||||
public class FieldOrderService implements OrderService {
|
||||
|
||||
@Autowired
|
||||
private PaymentGateway gateway;
|
||||
|
||||
@Autowired
|
||||
private Notifier notifier;
|
||||
|
||||
@Override
|
||||
public String place(String orderId, int cents) {
|
||||
String receipt = gateway.charge(orderId, cents);
|
||||
notifier.send(receipt);
|
||||
return receipt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.ankurm.coredi.injection;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
|
||||
/** Uses a field-injected collaborator in its constructor, before Spring has had a chance to set it. */
|
||||
public class FieldTrapService {
|
||||
|
||||
@Autowired
|
||||
private PaymentGateway gateway;
|
||||
|
||||
private final String warmup;
|
||||
|
||||
public FieldTrapService() {
|
||||
this.warmup = gateway.charge("warm-up", 0);
|
||||
}
|
||||
|
||||
public String warmup() {
|
||||
return warmup;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.ankurm.coredi.injection;
|
||||
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
|
||||
/** The cycle is still there; {@code @Lazy} turns one edge into a proxy that resolves on first use. */
|
||||
public class LazyA {
|
||||
|
||||
public final LazyB b;
|
||||
|
||||
public LazyA(@Lazy LazyB b) {
|
||||
this.b = b;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.ankurm.coredi.injection;
|
||||
|
||||
public class LazyB {
|
||||
|
||||
final LazyA a;
|
||||
|
||||
public LazyB(LazyA a) {
|
||||
this.a = a;
|
||||
}
|
||||
|
||||
public String hello() {
|
||||
return "LazyB reached through LazyA's proxy";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.ankurm.coredi.injection;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class LoggingPaymentGateway implements PaymentGateway {
|
||||
|
||||
private final List<String> charges = new ArrayList<>();
|
||||
|
||||
@Override
|
||||
public String charge(String orderId, int cents) {
|
||||
String receipt = "charged " + cents + " cents for " + orderId;
|
||||
charges.add(receipt);
|
||||
return receipt;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> charges() {
|
||||
return charges;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.ankurm.coredi.injection;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface Notifier {
|
||||
|
||||
void send(String message);
|
||||
|
||||
List<String> sent();
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.ankurm.coredi.injection;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
|
||||
/** Setter injection's honest use: a dependency that is allowed to be absent. */
|
||||
public class OptionalAudit {
|
||||
|
||||
private Notifier notifier;
|
||||
|
||||
@Autowired(required = false)
|
||||
public void setNotifier(Notifier notifier) {
|
||||
this.notifier = notifier;
|
||||
}
|
||||
|
||||
public String status() {
|
||||
return notifier == null ? "no notifier configured, auditing silently" : "notifier present";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.ankurm.coredi.injection;
|
||||
|
||||
public interface OrderService {
|
||||
|
||||
/** Charges the customer, then tells them. The order matters: see the setter and field variants. */
|
||||
String place(String orderId, int cents);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.ankurm.coredi.injection;
|
||||
|
||||
/** The collaborator every order service needs. */
|
||||
public interface PaymentGateway {
|
||||
|
||||
String charge(String orderId, int cents);
|
||||
|
||||
/** Every charge that really happened, so a test can prove a half-built service still took money. */
|
||||
java.util.List<String> charges();
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.ankurm.coredi.injection;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class RecordingNotifier implements Notifier {
|
||||
|
||||
private final List<String> sent = new ArrayList<>();
|
||||
|
||||
@Override
|
||||
public void send(String message) {
|
||||
sent.add(message);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> sent() {
|
||||
return sent;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.ankurm.coredi.injection;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
|
||||
/** Setter injection. The object exists, half-built, before Spring calls the setters. */
|
||||
public class SetterOrderService implements OrderService {
|
||||
|
||||
private PaymentGateway gateway;
|
||||
private Notifier notifier;
|
||||
|
||||
@Autowired
|
||||
public void setGateway(PaymentGateway gateway) {
|
||||
this.gateway = gateway;
|
||||
}
|
||||
|
||||
@Autowired
|
||||
public void setNotifier(Notifier notifier) {
|
||||
this.notifier = notifier;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String place(String orderId, int cents) {
|
||||
String receipt = gateway.charge(orderId, cents);
|
||||
notifier.send(receipt);
|
||||
return receipt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.ankurm.coredi.injection;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/** A shared event log so a test can print the order in which Spring touches a bean. */
|
||||
public final class Trace {
|
||||
|
||||
private static final List<String> EVENTS = Collections.synchronizedList(new ArrayList<>());
|
||||
|
||||
private Trace() {
|
||||
}
|
||||
|
||||
public static void log(String event) {
|
||||
EVENTS.add(event);
|
||||
}
|
||||
|
||||
public static List<String> drain() {
|
||||
List<String> copy;
|
||||
synchronized (EVENTS) {
|
||||
copy = new ArrayList<>(EVENTS);
|
||||
EVENTS.clear();
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.ankurm.coredi.injection;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
|
||||
/** The fix: say which constructor Spring should use. */
|
||||
public class TwoConstructorsAnnotated {
|
||||
|
||||
private final PaymentGateway gateway;
|
||||
|
||||
@Autowired
|
||||
public TwoConstructorsAnnotated(PaymentGateway gateway) {
|
||||
this.gateway = gateway;
|
||||
Trace.log("annotated constructor used");
|
||||
}
|
||||
|
||||
public TwoConstructorsAnnotated(PaymentGateway gateway, Notifier notifier) {
|
||||
this.gateway = gateway;
|
||||
Trace.log("two-arg constructor used");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.ankurm.coredi.injection;
|
||||
|
||||
/** Two constructors, no no-argument one, none marked {@code @Autowired}: Spring cannot choose. */
|
||||
public class TwoConstructorsNoDefault {
|
||||
|
||||
public TwoConstructorsNoDefault(PaymentGateway gateway) {
|
||||
}
|
||||
|
||||
public TwoConstructorsNoDefault(PaymentGateway gateway, Notifier notifier) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.ankurm.coredi.injection;
|
||||
|
||||
/** Two public constructors, none marked {@code @Autowired}: Spring falls back to the no-argument one. */
|
||||
public class TwoConstructorsService {
|
||||
|
||||
private PaymentGateway gateway;
|
||||
|
||||
public TwoConstructorsService() {
|
||||
Trace.log("no-arg constructor used");
|
||||
}
|
||||
|
||||
public TwoConstructorsService(PaymentGateway gateway) {
|
||||
this.gateway = gateway;
|
||||
Trace.log("one-arg constructor used");
|
||||
}
|
||||
|
||||
public boolean hasGateway() {
|
||||
return gateway != null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.ankurm.coredi.resolution;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import com.ankurm.coredi.injection.Notifier;
|
||||
|
||||
/** Receives every Notifier three ways: as a List, a Map keyed by bean name, and a Set. */
|
||||
public class Broadcaster {
|
||||
|
||||
public final List<Notifier> asList;
|
||||
public final Map<String, Notifier> asMap;
|
||||
public final Set<Notifier> asSet;
|
||||
|
||||
public Broadcaster(List<Notifier> asList, Map<String, Notifier> asMap, Set<Notifier> asSet) {
|
||||
this.asList = asList;
|
||||
this.asMap = asMap;
|
||||
this.asSet = asSet;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.ankurm.coredi.resolution;
|
||||
|
||||
import com.ankurm.coredi.injection.PaymentGateway;
|
||||
|
||||
public class CheckoutFast {
|
||||
|
||||
private final PaymentGateway gateway;
|
||||
|
||||
public CheckoutFast(@Fast PaymentGateway gateway) {
|
||||
this.gateway = gateway;
|
||||
}
|
||||
|
||||
public String gatewayClass() {
|
||||
return gateway.getClass().getSimpleName();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.ankurm.coredi.resolution;
|
||||
|
||||
import com.ankurm.coredi.injection.PaymentGateway;
|
||||
|
||||
/** The parameter name is the only hint: it matches the bean name {@code paypalGateway}. */
|
||||
public class CheckoutNamed {
|
||||
|
||||
private final PaymentGateway paypalGateway;
|
||||
|
||||
public CheckoutNamed(PaymentGateway paypalGateway) {
|
||||
this.paypalGateway = paypalGateway;
|
||||
}
|
||||
|
||||
public String gatewayClass() {
|
||||
return paypalGateway.getClass().getSimpleName();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.ankurm.coredi.resolution;
|
||||
|
||||
import com.ankurm.coredi.injection.PaymentGateway;
|
||||
|
||||
/** Asks for a PaymentGateway and says nothing more. */
|
||||
public class CheckoutPlain {
|
||||
|
||||
private final PaymentGateway gateway;
|
||||
|
||||
public CheckoutPlain(PaymentGateway gateway) {
|
||||
this.gateway = gateway;
|
||||
}
|
||||
|
||||
public String gatewayClass() {
|
||||
return gateway.getClass().getSimpleName();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.ankurm.coredi.resolution;
|
||||
|
||||
import com.ankurm.coredi.injection.PaymentGateway;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
|
||||
public class CheckoutQualified {
|
||||
|
||||
private final PaymentGateway gateway;
|
||||
|
||||
public CheckoutQualified(@Qualifier("paypalGateway") PaymentGateway gateway) {
|
||||
this.gateway = gateway;
|
||||
}
|
||||
|
||||
public String gatewayClass() {
|
||||
return gateway.getClass().getSimpleName();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.ankurm.coredi.resolution;
|
||||
|
||||
import com.ankurm.coredi.injection.RecordingNotifier;
|
||||
import org.springframework.core.annotation.Order;
|
||||
|
||||
@Order(2)
|
||||
public class EmailNotifier extends RecordingNotifier {
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.ankurm.coredi.resolution;
|
||||
|
||||
import org.springframework.context.annotation.Fallback;
|
||||
|
||||
/** Wins only when nothing else qualifies. */
|
||||
@Fallback
|
||||
public class FallbackGateway extends PaypalGateway {
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.ankurm.coredi.resolution;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
|
||||
/** A custom qualifier: a meta-annotation over {@code @Qualifier}, so call sites do not repeat a string. */
|
||||
@Target({ElementType.TYPE, ElementType.PARAMETER, ElementType.FIELD})
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Qualifier
|
||||
public @interface Fast {
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.ankurm.coredi.resolution;
|
||||
|
||||
@Fast
|
||||
public class FastGateway extends StripeGateway {
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.ankurm.coredi.resolution;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** The generic type argument is part of the injection point: only Handler<String> beans arrive. */
|
||||
public class GenericConsumer {
|
||||
|
||||
public final List<Handler<String>> stringHandlers;
|
||||
public final Handler<Integer> integerHandler;
|
||||
|
||||
public GenericConsumer(List<Handler<String>> stringHandlers, Handler<Integer> integerHandler) {
|
||||
this.stringHandlers = stringHandlers;
|
||||
this.integerHandler = integerHandler;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.ankurm.coredi.resolution;
|
||||
|
||||
public interface Handler<T> {
|
||||
|
||||
String handle(T input);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.ankurm.coredi.resolution;
|
||||
|
||||
public class IntegerHandler implements Handler<Integer> {
|
||||
|
||||
@Override
|
||||
public String handle(Integer input) {
|
||||
return "integer:" + input;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.ankurm.coredi.resolution;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import com.ankurm.coredi.injection.Notifier;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
/** Every way to say "this dependency may not exist", one nested class each. */
|
||||
public final class OptionalConsumers {
|
||||
|
||||
private OptionalConsumers() {
|
||||
}
|
||||
|
||||
public static class Required {
|
||||
public Required(Notifier notifier) {
|
||||
}
|
||||
}
|
||||
|
||||
public static class WithOptional {
|
||||
public final Optional<Notifier> notifier;
|
||||
|
||||
public WithOptional(Optional<Notifier> notifier) {
|
||||
this.notifier = notifier;
|
||||
}
|
||||
}
|
||||
|
||||
public static class WithNullable {
|
||||
public final Notifier notifier;
|
||||
|
||||
public WithNullable(@Nullable Notifier notifier) {
|
||||
this.notifier = notifier;
|
||||
}
|
||||
}
|
||||
|
||||
public static class WithProvider {
|
||||
public final ObjectProvider<Notifier> provider;
|
||||
|
||||
public WithProvider(ObjectProvider<Notifier> provider) {
|
||||
this.provider = provider;
|
||||
}
|
||||
}
|
||||
|
||||
public static class WithRequiredFalse {
|
||||
public Notifier notifier;
|
||||
|
||||
@Autowired(required = false)
|
||||
public void setNotifier(Notifier notifier) {
|
||||
this.notifier = notifier;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.ankurm.coredi.resolution;
|
||||
|
||||
import com.ankurm.coredi.injection.LoggingPaymentGateway;
|
||||
|
||||
public class PaypalGateway extends LoggingPaymentGateway {
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.ankurm.coredi.resolution;
|
||||
|
||||
public interface Plugin {
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package com.ankurm.coredi.resolution;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
/** What a collection injection point does when zero beans of the element type exist. */
|
||||
public final class PluginHosts {
|
||||
|
||||
private PluginHosts() {
|
||||
}
|
||||
|
||||
public static class Plain {
|
||||
public Plain(List<Plugin> plugins) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Same collection, but injected into a field: no constructor "empty collection" fallback applies. */
|
||||
public static class FieldInjected {
|
||||
@Autowired
|
||||
public List<Plugin> plugins;
|
||||
}
|
||||
|
||||
/** Same collection, injected through a required setter. */
|
||||
public static class SetterInjected {
|
||||
public List<Plugin> plugins;
|
||||
|
||||
@Autowired
|
||||
public void setPlugins(List<Plugin> plugins) {
|
||||
this.plugins = plugins;
|
||||
}
|
||||
}
|
||||
|
||||
/** Two constructors, one marked: the "single constructor" fallback for empty collections does not apply. */
|
||||
public static class TwoConstructors {
|
||||
public List<Plugin> plugins;
|
||||
|
||||
@Autowired
|
||||
public TwoConstructors(List<Plugin> plugins) {
|
||||
this.plugins = plugins;
|
||||
}
|
||||
|
||||
public TwoConstructors() {
|
||||
}
|
||||
}
|
||||
|
||||
public static class WithOptional {
|
||||
public final Optional<List<Plugin>> plugins;
|
||||
|
||||
public WithOptional(Optional<List<Plugin>> plugins) {
|
||||
this.plugins = plugins;
|
||||
}
|
||||
}
|
||||
|
||||
public static class WithNullable {
|
||||
public final List<Plugin> plugins;
|
||||
|
||||
public WithNullable(@Nullable List<Plugin> plugins) {
|
||||
this.plugins = plugins;
|
||||
}
|
||||
}
|
||||
|
||||
public static class WithProvider {
|
||||
public final List<Plugin> plugins;
|
||||
|
||||
public WithProvider(ObjectProvider<Plugin> provider) {
|
||||
this.plugins = provider.orderedStream().toList();
|
||||
}
|
||||
}
|
||||
|
||||
public static class WithRequiredFalse {
|
||||
public List<Plugin> plugins = List.of();
|
||||
|
||||
@Autowired(required = false)
|
||||
public void setPlugins(List<Plugin> plugins) {
|
||||
this.plugins = plugins;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.ankurm.coredi.resolution;
|
||||
|
||||
import org.springframework.context.annotation.Primary;
|
||||
|
||||
@Primary
|
||||
public class PrimaryStripeGateway extends StripeGateway {
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.ankurm.coredi.resolution;
|
||||
|
||||
import jakarta.annotation.Priority;
|
||||
|
||||
@Priority(1)
|
||||
public class PriorityOneGateway extends StripeGateway {
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.ankurm.coredi.resolution;
|
||||
|
||||
import jakarta.annotation.Priority;
|
||||
|
||||
@Priority(2)
|
||||
public class PriorityTwoGateway extends PaypalGateway {
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.ankurm.coredi.resolution;
|
||||
|
||||
import com.ankurm.coredi.injection.RecordingNotifier;
|
||||
|
||||
/** No {@code @Order}: sorts after every ordered bean. */
|
||||
public class PushNotifier extends RecordingNotifier {
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.ankurm.coredi.resolution;
|
||||
|
||||
import com.ankurm.coredi.injection.PaymentGateway;
|
||||
import jakarta.annotation.Resource;
|
||||
|
||||
/** {@code @Resource} matches by name first; the field name is the default name. */
|
||||
public class ResourceConsumer {
|
||||
|
||||
@Resource
|
||||
PaymentGateway paypalGateway;
|
||||
|
||||
@Resource(name = "stripeGateway")
|
||||
PaymentGateway whateverIWantToCallIt;
|
||||
|
||||
public String describe() {
|
||||
return "paypalGateway field -> " + paypalGateway.getClass().getSimpleName()
|
||||
+ ", whateverIWantToCallIt field -> " + whateverIWantToCallIt.getClass().getSimpleName();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.ankurm.coredi.resolution;
|
||||
|
||||
import com.ankurm.coredi.injection.RecordingNotifier;
|
||||
import org.springframework.core.annotation.Order;
|
||||
|
||||
@Order(1)
|
||||
public class SmsNotifier extends RecordingNotifier {
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.ankurm.coredi.resolution;
|
||||
|
||||
public class StringHandler implements Handler<String> {
|
||||
|
||||
@Override
|
||||
public String handle(String input) {
|
||||
return "string:" + input;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.ankurm.coredi.resolution;
|
||||
|
||||
import com.ankurm.coredi.injection.LoggingPaymentGateway;
|
||||
|
||||
public class StripeGateway extends LoggingPaymentGateway {
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
spring:
|
||||
application:
|
||||
name: core-di
|
||||
main:
|
||||
banner-mode: off
|
||||
@@ -0,0 +1,247 @@
|
||||
package com.ankurm.coredi;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import com.ankurm.coredi.injection.*;
|
||||
import com.ankurm.coredi.resolution.*;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.context.support.GenericApplicationContext;
|
||||
|
||||
/** Post 17: how @Autowired chooses between candidates. */
|
||||
class CandidateResolutionTest {
|
||||
|
||||
@Test
|
||||
void twoCandidatesNoHint() {
|
||||
try (var t = new Transcript("10-no-unique-bean.txt", "Two PaymentGateway beans and a consumer that asks for one")) {
|
||||
var outcome = Ctx.tryStart(StripeGateway.class, PaypalGateway.class, CheckoutPlain.class);
|
||||
assertThat(outcome.started()).isFalse();
|
||||
t.line("started: false");
|
||||
t.line("top exception : %s", outcome.failure().getClass().getName());
|
||||
t.line("top message : %s", outcome.failure().getMessage());
|
||||
t.blank();
|
||||
t.line("root cause : %s", Ctx.root(outcome.failure()).getClass().getName());
|
||||
t.line("root message : %s", Ctx.root(outcome.failure()).getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void theResolutionLadder() {
|
||||
try (var t = new Transcript("11-resolution-ladder.txt",
|
||||
"Who wins when several rules apply at once? (one mini-context per row)")) {
|
||||
t.line("%-58s %s", "beans registered / injection point", "winner");
|
||||
t.line("%-58s %s", "-".repeat(58), "-".repeat(20));
|
||||
|
||||
row(t, "@Primary Stripe + Paypal; CheckoutPlain", PrimaryStripeGateway.class, PaypalGateway.class,
|
||||
CheckoutPlain.class);
|
||||
row(t, "@Primary Stripe + Paypal; @Qualifier(\"paypalGateway\")", PrimaryStripeGateway.class,
|
||||
PaypalGateway.class, CheckoutQualified.class);
|
||||
row(t, "@Priority(1) Stripe + @Priority(2) Paypal; CheckoutPlain", PriorityOneGateway.class,
|
||||
PriorityTwoGateway.class, CheckoutPlain.class);
|
||||
row(t, "@Priority(1) + Paypal(no priority); param named paypalGateway", PriorityOneGateway.class,
|
||||
PaypalGateway.class, CheckoutNamed.class);
|
||||
row(t, "Stripe + Paypal (plain); param named paypalGateway", StripeGateway.class, PaypalGateway.class,
|
||||
CheckoutNamed.class);
|
||||
row(t, "Stripe (plain) + @Fallback Paypal; CheckoutPlain", StripeGateway.class, FallbackGateway.class,
|
||||
CheckoutPlain.class);
|
||||
row(t, "@Fast custom qualifier, FastGateway + Paypal; @Fast param", FastGateway.class, PaypalGateway.class,
|
||||
CheckoutFast.class);
|
||||
}
|
||||
}
|
||||
|
||||
private static void row(Transcript t, String label, Class<?>... classes) {
|
||||
var outcome = Ctx.tryStart(classes);
|
||||
if (outcome.started()) {
|
||||
Object consumer = null;
|
||||
for (Class<?> c : classes) {
|
||||
if (c.getSimpleName().startsWith("Checkout")) {
|
||||
consumer = outcome.context().getBean(c);
|
||||
}
|
||||
}
|
||||
String winner;
|
||||
try {
|
||||
winner = (String) consumer.getClass().getMethod("gatewayClass").invoke(consumer);
|
||||
} catch (ReflectiveOperationException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
t.line("%-58s %s", label, winner);
|
||||
outcome.closeQuietly();
|
||||
} else {
|
||||
t.line("%-58s FAILED %s", label, Ctx.root(outcome.failure()).getClass().getSimpleName());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void parameterNameFallbackNeedsTheParametersFlag() throws Exception {
|
||||
try (var t = new Transcript("18-parameters-flag.txt",
|
||||
"The parameter-name fallback needs javac -parameters (compiled twice from the same source)")) {
|
||||
String source = """
|
||||
package demo;
|
||||
import com.ankurm.coredi.injection.PaymentGateway;
|
||||
public class NamedConsumer {
|
||||
private final PaymentGateway paypalGateway;
|
||||
public NamedConsumer(PaymentGateway paypalGateway) { this.paypalGateway = paypalGateway; }
|
||||
public String picked() { return paypalGateway.getClass().getSimpleName(); }
|
||||
}
|
||||
""";
|
||||
for (boolean flag : new boolean[] {true, false}) {
|
||||
java.nio.file.Path dir = java.nio.file.Files.createTempDirectory("named");
|
||||
java.nio.file.Path src = dir.resolve("demo/NamedConsumer.java");
|
||||
java.nio.file.Files.createDirectories(src.getParent());
|
||||
java.nio.file.Files.writeString(src, source);
|
||||
var compiler = javax.tools.ToolProvider.getSystemJavaCompiler();
|
||||
java.util.List<String> args = new java.util.ArrayList<>(java.util.List.of(
|
||||
"-cp", System.getProperty("java.class.path"), "-d", dir.toString()));
|
||||
if (flag) {
|
||||
args.add("-parameters");
|
||||
}
|
||||
args.add(src.toString());
|
||||
assertThat(compiler.run(null, null, null, args.toArray(String[]::new))).isZero();
|
||||
try (var loader = new java.net.URLClassLoader(new java.net.URL[] {dir.toUri().toURL()},
|
||||
getClass().getClassLoader())) {
|
||||
Class<?> consumer = loader.loadClass("demo.NamedConsumer");
|
||||
var outcome = Ctx.tryStart(StripeGateway.class, PaypalGateway.class, consumer);
|
||||
if (outcome.started()) {
|
||||
Object bean = outcome.context().getBean(consumer);
|
||||
t.line("javac %-12s : started, picked %s", flag ? "-parameters" : "(no flag)",
|
||||
consumer.getMethod("picked").invoke(bean));
|
||||
outcome.closeQuietly();
|
||||
} else {
|
||||
t.line("javac %-12s : FAILED %s", flag ? "-parameters" : "(no flag)",
|
||||
Ctx.root(outcome.failure()).getClass().getSimpleName());
|
||||
t.line(" %s", Ctx.root(outcome.failure()).getMessage());
|
||||
}
|
||||
assertThat(outcome.started()).isEqualTo(flag);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingBean() {
|
||||
try (var t = new Transcript("12-missing-bean.txt", "No PaymentGateway bean at all")) {
|
||||
var outcome = Ctx.tryStart(CheckoutPlain.class);
|
||||
assertThat(outcome.started()).isFalse();
|
||||
t.line("top exception : %s", outcome.failure().getClass().getName());
|
||||
t.line("top message : %s", outcome.failure().getMessage());
|
||||
t.blank();
|
||||
t.line("root cause : %s", Ctx.root(outcome.failure()).getClass().getName());
|
||||
t.line("root message : %s", Ctx.root(outcome.failure()).getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void optionalWaysToAbsent() {
|
||||
try (var t = new Transcript("13-optional-and-objectprovider.txt",
|
||||
"Five ways to say 'this may not exist', with zero and with two Notifier beans (Email registered before Sms)")) {
|
||||
t.section("zero Notifier beans");
|
||||
var required = Ctx.tryStart(OptionalConsumers.Required.class);
|
||||
t.line("plain constructor param : %s", required.started() ? "started" : "FAILED " + Ctx.root(required.failure()).getClass().getSimpleName());
|
||||
var opt = Ctx.plain(OptionalConsumers.WithOptional.class);
|
||||
t.line("Optional<Notifier> : isPresent=%s", opt.getBean(OptionalConsumers.WithOptional.class).notifier.isPresent());
|
||||
var nul = Ctx.plain(OptionalConsumers.WithNullable.class);
|
||||
t.line("@Nullable Notifier : value=%s", nul.getBean(OptionalConsumers.WithNullable.class).notifier);
|
||||
var prov = Ctx.plain(OptionalConsumers.WithProvider.class);
|
||||
var p = prov.getBean(OptionalConsumers.WithProvider.class).provider;
|
||||
t.line("ObjectProvider.getIfAvailable() : %s", p.getIfAvailable());
|
||||
t.line("ObjectProvider.getIfUnique() : %s", p.getIfUnique());
|
||||
t.line("ObjectProvider.getObject() : %s", Ctx.attempt(p::getObject));
|
||||
var rf = Ctx.plain(OptionalConsumers.WithRequiredFalse.class);
|
||||
t.line("@Autowired(required=false) setter: value=%s", rf.getBean(OptionalConsumers.WithRequiredFalse.class).notifier);
|
||||
assertThat(required.started()).isFalse();
|
||||
assertThat(opt.getBean(OptionalConsumers.WithOptional.class).notifier).isEmpty();
|
||||
opt.close(); nul.close(); prov.close(); rf.close();
|
||||
|
||||
t.section("two Notifier beans (Sms, Email), no @Primary");
|
||||
var two = Ctx.plain(EmailNotifier.class, SmsNotifier.class, OptionalConsumers.WithProvider.class);
|
||||
var p2 = two.getBean(OptionalConsumers.WithProvider.class).provider;
|
||||
t.line("ObjectProvider.getIfAvailable() : %s", Ctx.attempt(p2::getIfAvailable));
|
||||
t.line("ObjectProvider.getIfUnique() : %s", p2.getIfUnique());
|
||||
t.line("ObjectProvider.stream() classes : %s", p2.stream().map(n -> n.getClass().getSimpleName()).toList());
|
||||
t.line("ObjectProvider.orderedStream() : %s", p2.orderedStream().map(n -> n.getClass().getSimpleName()).toList());
|
||||
two.close();
|
||||
var optTwo = Ctx.tryStart(EmailNotifier.class, SmsNotifier.class, OptionalConsumers.WithOptional.class);
|
||||
assertThat(optTwo.started()).isFalse();
|
||||
t.line("Optional<Notifier> with two beans: FAILED %s", Ctx.root(optTwo.failure()).getClass().getSimpleName());
|
||||
t.line(" %s", Ctx.root(optTwo.failure()).getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void collectionInjection() {
|
||||
try (var t = new Transcript("14-list-map-set-injection.txt",
|
||||
"List, Map and Set injection; registered Push, Email, Sms in that order")) {
|
||||
try (var ctx = Ctx.plain(PushNotifier.class, EmailNotifier.class, SmsNotifier.class, Broadcaster.class)) {
|
||||
var b = ctx.getBean(Broadcaster.class);
|
||||
t.line("List<Notifier> order : %s", b.asList.stream().map(n -> n.getClass().getSimpleName()).toList());
|
||||
t.line("Map<String,Notifier> : %s", b.asMap.keySet());
|
||||
t.line("Set<Notifier> size : %d", b.asSet.size());
|
||||
assertThat(b.asList.stream().map(n -> n.getClass().getSimpleName()).toList())
|
||||
.containsExactly("SmsNotifier", "EmailNotifier", "PushNotifier");
|
||||
assertThat(b.asMap.keySet()).containsExactly("pushNotifier", "emailNotifier", "smsNotifier");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyCollections() {
|
||||
try (var t = new Transcript("15-empty-collection.txt", "A List<Plugin> injection point when zero Plugin beans exist")) {
|
||||
t.section("required List<Plugin>, zero Plugin beans");
|
||||
var plain = Ctx.tryStart(PluginHosts.Plain.class);
|
||||
t.line("single constructor param : %s", plain.started() ? "started (empty list injected)" : "FAILED");
|
||||
assertThat(plain.started()).isTrue();
|
||||
assertThat(plain.context().getBean(PluginHosts.Plain.class)).isNotNull();
|
||||
plain.closeQuietly();
|
||||
var annotated = Ctx.tryStart(PluginHosts.TwoConstructors.class);
|
||||
t.line("@Autowired ctor + no-arg ctor : %s", annotated.started() ? "started (empty list injected)" : "FAILED");
|
||||
assertThat(annotated.started()).isTrue();
|
||||
annotated.closeQuietly();
|
||||
for (Class<?> host : new Class<?>[] {PluginHosts.FieldInjected.class, PluginHosts.SetterInjected.class}) {
|
||||
var o = Ctx.tryStart(host);
|
||||
assertThat(o.started()).isFalse();
|
||||
t.line("%-26s : FAILED %s", host.getSimpleName(), Ctx.root(o.failure()).getClass().getSimpleName());
|
||||
t.line(" %s", Ctx.root(o.failure()).getMessage());
|
||||
}
|
||||
|
||||
t.section("ways to make an absent collection legal");
|
||||
var opt = Ctx.plain(PluginHosts.WithOptional.class);
|
||||
t.line("Optional<List<Plugin>> : isPresent=%s", opt.getBean(PluginHosts.WithOptional.class).plugins.isPresent());
|
||||
var nul = Ctx.plain(PluginHosts.WithNullable.class);
|
||||
t.line("@Nullable List<Plugin> : value=%s", nul.getBean(PluginHosts.WithNullable.class).plugins);
|
||||
var prov = Ctx.plain(PluginHosts.WithProvider.class);
|
||||
t.line("ObjectProvider<Plugin> -> list : %s", prov.getBean(PluginHosts.WithProvider.class).plugins);
|
||||
var rf = Ctx.plain(PluginHosts.WithRequiredFalse.class);
|
||||
t.line("@Autowired(required=false) list: %s", rf.getBean(PluginHosts.WithRequiredFalse.class).plugins);
|
||||
opt.close(); nul.close(); prov.close(); rf.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void genericsAndResource() {
|
||||
try (var t = new Transcript("16-generics-and-resource.txt", "Generic type arguments and @Resource")) {
|
||||
try (var ctx = Ctx.plain(StringHandler.class, IntegerHandler.class, GenericConsumer.class)) {
|
||||
var c = ctx.getBean(GenericConsumer.class);
|
||||
t.line("List<Handler<String>> : %s", c.stringHandlers.stream().map(h -> h.getClass().getSimpleName()).toList());
|
||||
t.line("Handler<Integer> : %s", c.integerHandler.getClass().getSimpleName());
|
||||
assertThat(c.stringHandlers).hasSize(1);
|
||||
}
|
||||
try (var ctx = Ctx.plain(StripeGateway.class, PaypalGateway.class, ResourceConsumer.class)) {
|
||||
t.line("@Resource : %s", ctx.getBean(ResourceConsumer.class).describe());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void dependencyGraph() {
|
||||
try (var t = new Transcript("17-dependency-graph.txt",
|
||||
"getDependenciesForBean: who did Spring wire into whom?")) {
|
||||
try (var ctx = Ctx.plain(BaseBeans.class, ConstructorOrderService.class, SetterOrderService.class,
|
||||
FieldOrderService.class)) {
|
||||
var bf = ctx.getBeanFactory();
|
||||
for (String name : new String[] {"constructorOrderService", "setterOrderService", "fieldOrderService"}) {
|
||||
t.line("%-24s depends on %s", name, java.util.Arrays.toString(bf.getDependenciesForBean(name)));
|
||||
}
|
||||
t.line("%-24s is used by %s", "paymentGateway", java.util.Arrays.toString(bf.getDependentBeans("paymentGateway")));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.ankurm.coredi;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import com.ankurm.coredi.injection.*;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.WebApplicationType;
|
||||
import org.springframework.boot.builder.SpringApplicationBuilder;
|
||||
import org.springframework.context.ConfigurableApplicationContext;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/** Post 16: what Spring does with A -> B -> A, in plain Spring and under Boot. */
|
||||
class CircularDependencyTest {
|
||||
|
||||
@Configuration
|
||||
static class ConstructorCycleConfig {
|
||||
}
|
||||
|
||||
@Test
|
||||
void plainSpringVersusBoot() {
|
||||
try (var t = new Transcript("08-circular-plain-vs-boot.txt",
|
||||
"Constructor cycle vs field cycle: plain Spring and Spring Boot")) {
|
||||
|
||||
t.section("plain Spring, constructor cycle (CtorA <-> CtorB)");
|
||||
var ctor = Ctx.tryStart(CtorA.class, CtorB.class);
|
||||
assertThat(ctor.started()).isFalse();
|
||||
t.line("started: false");
|
||||
t.line("root cause: %s", Ctx.root(ctor.failure()).getClass().getName());
|
||||
t.line("%s", Ctx.root(ctor.failure()).getMessage());
|
||||
|
||||
t.section("plain Spring, field cycle (FieldA <-> FieldB)");
|
||||
var field = Ctx.tryStart(FieldA.class, FieldB.class);
|
||||
t.line("started: %s", field.started());
|
||||
assertThat(field.started()).isTrue();
|
||||
t.line("FieldA.b is FieldB: %s", field.context().getBean(FieldA.class).b == field.context().getBean(FieldB.class));
|
||||
field.closeQuietly();
|
||||
|
||||
t.section("Spring Boot, field cycle, default settings");
|
||||
String defaultBoot = bootStart(false, FieldA.class, FieldB.class);
|
||||
t.line("%s", defaultBoot);
|
||||
assertThat(defaultBoot).startsWith("FAILED");
|
||||
|
||||
t.section("Spring Boot, field cycle, spring.main.allow-circular-references=true");
|
||||
String allowed = bootStart(true, FieldA.class, FieldB.class);
|
||||
t.line("%s", allowed);
|
||||
assertThat(allowed).startsWith("started");
|
||||
|
||||
t.section("Spring Boot, constructor cycle, spring.main.allow-circular-references=true");
|
||||
String ctorAllowed = bootStart(true, CtorA.class, CtorB.class);
|
||||
t.line("%s", ctorAllowed);
|
||||
assertThat(ctorAllowed).startsWith("FAILED");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void lazyBreaksAConstructorCycle() {
|
||||
try (var t = new Transcript("09-lazy-breaks-constructor-cycle.txt",
|
||||
"@Lazy on one constructor parameter breaks a constructor cycle")) {
|
||||
var outcome = Ctx.tryStart(LazyA.class, LazyB.class);
|
||||
assertThat(outcome.started()).isTrue();
|
||||
var a = outcome.context().getBean(LazyA.class);
|
||||
t.line("context started: true");
|
||||
t.line("LazyA.b runtime class : %s", a.b.getClass().getName());
|
||||
t.line("LazyA.b.hello() : %s", a.b.hello());
|
||||
t.line("LazyA.b is the real LazyB bean: %s", a.b == outcome.context().getBean(LazyB.class));
|
||||
assertThat(a.b.getClass().getName()).contains("SpringCGLIB");
|
||||
outcome.closeQuietly();
|
||||
}
|
||||
}
|
||||
|
||||
private static String bootStart(boolean allowCircular, Class<?>... beans) {
|
||||
var builder = new SpringApplicationBuilder(beans)
|
||||
.web(WebApplicationType.NONE)
|
||||
.logStartupInfo(false)
|
||||
.properties("logging.level.root=OFF", "spring.main.banner-mode=off");
|
||||
if (allowCircular) {
|
||||
builder.properties("spring.main.allow-circular-references=true");
|
||||
}
|
||||
try (ConfigurableApplicationContext ctx = builder.run()) {
|
||||
return "started";
|
||||
} catch (Exception e) {
|
||||
Throwable root = Ctx.root(e);
|
||||
return "FAILED: " + root.getClass().getSimpleName() + ": " + root.getMessage();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.ankurm.coredi;
|
||||
|
||||
import java.util.concurrent.Callable;
|
||||
|
||||
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
|
||||
|
||||
/** Small helpers so each test reads as the scenario, not the plumbing. */
|
||||
public final class Ctx {
|
||||
|
||||
private Ctx() {
|
||||
}
|
||||
|
||||
/** A plain Spring context (no Boot) with the given classes registered and refreshed. */
|
||||
public static AnnotationConfigApplicationContext plain(Class<?>... classes) {
|
||||
var ctx = new AnnotationConfigApplicationContext();
|
||||
ctx.register(classes);
|
||||
ctx.refresh();
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/** Refreshes a context and returns either "started" or the exception, so failures can be printed. */
|
||||
public static Outcome tryStart(Class<?>... classes) {
|
||||
var ctx = new AnnotationConfigApplicationContext();
|
||||
try {
|
||||
ctx.register(classes);
|
||||
ctx.refresh();
|
||||
return new Outcome(ctx, null);
|
||||
} catch (RuntimeException e) {
|
||||
ctx.close();
|
||||
return new Outcome(null, e);
|
||||
}
|
||||
}
|
||||
|
||||
public static <T> String attempt(Callable<T> call) {
|
||||
try {
|
||||
return "OK -> " + call.call();
|
||||
} catch (Exception e) {
|
||||
return e.getClass().getSimpleName() + ": " + e.getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
public static Throwable root(Throwable t) {
|
||||
while (t.getCause() != null && t.getCause() != t) {
|
||||
t = t.getCause();
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
public record Outcome(AnnotationConfigApplicationContext context, RuntimeException failure) {
|
||||
|
||||
public boolean started() {
|
||||
return failure == null;
|
||||
}
|
||||
|
||||
public void closeQuietly() {
|
||||
if (context != null) {
|
||||
context.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package com.ankurm.coredi;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Modifier;
|
||||
import java.util.List;
|
||||
|
||||
import com.ankurm.coredi.injection.*;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
/** Post 16: the same service, three injection styles. */
|
||||
class InjectionStylesTest {
|
||||
|
||||
@Test
|
||||
void threeStylesUnderPlainNew() {
|
||||
try (var t = new Transcript("01-three-styles-under-plain-new.txt",
|
||||
"The same OrderService built with plain 'new', no Spring anywhere")) {
|
||||
|
||||
t.section("constructor injection");
|
||||
var gateway = new LoggingPaymentGateway();
|
||||
var notifier = new RecordingNotifier();
|
||||
var ctor = new ConstructorOrderService(gateway, notifier);
|
||||
t.line("new ConstructorOrderService(gateway, notifier).place(\"A-1\", 500)");
|
||||
t.line(" %s", Ctx.attempt(() -> ctor.place("A-1", 500)));
|
||||
t.line(" charges=%d, notifications=%d", gateway.charges().size(), notifier.sent().size());
|
||||
|
||||
t.section("setter injection, one setter forgotten");
|
||||
var gateway2 = new LoggingPaymentGateway();
|
||||
var setter = new SetterOrderService();
|
||||
setter.setGateway(gateway2);
|
||||
String setterResult = Ctx.attempt(() -> setter.place("A-2", 500));
|
||||
t.line("new SetterOrderService(); setter.setGateway(gateway); // setNotifier never called");
|
||||
t.line(" %s", setterResult);
|
||||
t.line(" charges=%d <-- the customer was charged before the failure", gateway2.charges().size());
|
||||
|
||||
t.section("field injection");
|
||||
var field = new FieldOrderService();
|
||||
String fieldResult = Ctx.attempt(() -> field.place("A-3", 500));
|
||||
t.line("new FieldOrderService().place(\"A-3\", 500)");
|
||||
t.line(" %s", fieldResult);
|
||||
var gateway3 = new LoggingPaymentGateway();
|
||||
ReflectionTestUtils.setField(field, "gateway", gateway3);
|
||||
ReflectionTestUtils.setField(field, "notifier", new RecordingNotifier());
|
||||
t.line("after ReflectionTestUtils.setField(...) twice:");
|
||||
t.line(" %s", Ctx.attempt(() -> field.place("A-3", 500)));
|
||||
|
||||
assertThat(setterResult).startsWith("NullPointerException");
|
||||
assertThat(gateway2.charges()).hasSize(1);
|
||||
assertThat(fieldResult).startsWith("NullPointerException");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void fieldsAndFinality() {
|
||||
try (var t = new Transcript("02-fields-and-finality.txt",
|
||||
"Which injected fields can be final? (reflection over the three variants)")) {
|
||||
for (Class<?> type : List.of(ConstructorOrderService.class, SetterOrderService.class,
|
||||
FieldOrderService.class)) {
|
||||
for (Field f : type.getDeclaredFields()) {
|
||||
t.line("%-24s %-10s final=%s", type.getSimpleName(), f.getName(),
|
||||
Modifier.isFinal(f.getModifiers()));
|
||||
}
|
||||
}
|
||||
for (Field f : ConstructorOrderService.class.getDeclaredFields()) {
|
||||
assertThat(Modifier.isFinal(f.getModifiers())).isTrue();
|
||||
}
|
||||
for (Field f : FieldOrderService.class.getDeclaredFields()) {
|
||||
assertThat(Modifier.isFinal(f.getModifiers())).isFalse();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void allThreeStylesSideBySide() {
|
||||
Trace.drain();
|
||||
try (var t = new Transcript("03-injection-order.txt",
|
||||
"The order Spring touches one bean that uses all three styles")) {
|
||||
try (var ctx = Ctx.plain(BaseBeans.class, AllThreeStyles.class)) {
|
||||
Trace.drain().forEach(e -> t.line("%s", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void fieldInjectedDependencyUsedInConstructor() {
|
||||
try (var t = new Transcript("04-field-used-in-constructor.txt",
|
||||
"A field-injected collaborator used in the constructor")) {
|
||||
var outcome = Ctx.tryStart(BaseBeans.class, FieldTrapService.class);
|
||||
t.line("context started: %s", outcome.started());
|
||||
assertThat(outcome.started()).isFalse();
|
||||
t.line("top exception : %s", outcome.failure().getClass().getName());
|
||||
t.line("top message : %s", outcome.failure().getMessage());
|
||||
t.line("root cause : %s", Ctx.root(outcome.failure()));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void multipleConstructors() {
|
||||
Trace.drain();
|
||||
try (var t = new Transcript("05-multiple-constructors.txt",
|
||||
"Two constructors: which one does Spring pick?")) {
|
||||
t.section("TwoConstructorsService: no-arg + one-arg, neither annotated");
|
||||
try (var ctx = Ctx.plain(BaseBeans.class, TwoConstructorsService.class)) {
|
||||
t.line("%s", Trace.drain());
|
||||
t.line("gateway injected: %s", ctx.getBean(TwoConstructorsService.class).hasGateway());
|
||||
assertThat(ctx.getBean(TwoConstructorsService.class).hasGateway()).isFalse();
|
||||
}
|
||||
|
||||
t.section("TwoConstructorsNoDefault: one-arg + two-arg, neither annotated");
|
||||
var outcome = Ctx.tryStart(BaseBeans.class, TwoConstructorsNoDefault.class);
|
||||
assertThat(outcome.started()).isFalse();
|
||||
t.line("%s", outcome.failure().getClass().getName());
|
||||
t.line("%s", outcome.failure().getMessage());
|
||||
t.line("root cause: %s", Ctx.root(outcome.failure()));
|
||||
|
||||
t.section("TwoConstructorsAnnotated: @Autowired on one constructor");
|
||||
try (var ctx = Ctx.plain(BaseBeans.class, TwoConstructorsAnnotated.class)) {
|
||||
t.line("%s", Trace.drain());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void optionalDependencyViaSetter() {
|
||||
try (var t = new Transcript("06-optional-setter.txt", "@Autowired(required = false) on a setter")) {
|
||||
try (var with = Ctx.plain(BaseBeans.class, OptionalAudit.class)) {
|
||||
t.line("with a Notifier bean : %s", with.getBean(OptionalAudit.class).status());
|
||||
}
|
||||
try (var without = Ctx.plain(OptionalAudit.class)) {
|
||||
t.line("without a Notifier bean : %s", without.getBean(OptionalAudit.class).status());
|
||||
assertThat(without.getBean(OptionalAudit.class).status()).contains("no notifier");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void springWiresAllThreeIdentically() {
|
||||
try (var t = new Transcript("07-spring-wires-all-three.txt", "Inside a Spring context all three styles work")) {
|
||||
try (var ctx = Ctx.plain(BaseBeans.class, ConstructorOrderService.class, SetterOrderService.class,
|
||||
FieldOrderService.class)) {
|
||||
for (Class<? extends OrderService> type : List.of(ConstructorOrderService.class,
|
||||
SetterOrderService.class, FieldOrderService.class)) {
|
||||
t.line("%-24s -> %s", type.getSimpleName(), ctx.getBean(type).place("B-1", 700));
|
||||
}
|
||||
assertThat(ctx.getBean(PaymentGateway.class).charges()).hasSize(3);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.ankurm.coredi;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.PrintWriter;
|
||||
import java.io.StringWriter;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
/**
|
||||
* Writes a numbered transcript under {@code output/} and echoes it to the console.
|
||||
* Every console block quoted in the article comes out of one of these files verbatim.
|
||||
*/
|
||||
public final class Transcript implements AutoCloseable {
|
||||
|
||||
private final Path path;
|
||||
private final StringWriter buffer = new StringWriter();
|
||||
private final PrintWriter out = new PrintWriter(buffer);
|
||||
|
||||
public Transcript(String fileName, String title) {
|
||||
this.path = Path.of("output", fileName);
|
||||
out.println("# " + title);
|
||||
out.println();
|
||||
}
|
||||
|
||||
public Transcript line(String format, Object... args) {
|
||||
out.println(args.length == 0 ? format : String.format(format, args));
|
||||
return this;
|
||||
}
|
||||
|
||||
public Transcript blank() {
|
||||
out.println();
|
||||
return this;
|
||||
}
|
||||
|
||||
public Transcript section(String heading) {
|
||||
out.println();
|
||||
out.println("--- " + heading + " ---");
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
out.flush();
|
||||
try {
|
||||
Files.createDirectories(path.getParent());
|
||||
Files.writeString(path, buffer.toString());
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("could not write " + path, e);
|
||||
}
|
||||
System.out.print(buffer);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user