Prevent early initialization of Container beans

Update testcontainers auto-configuration so that `Container` bean
instances are no longer needed when registering `ConnectionDetails`
beans. Registration now occurs based on the bean type and the `name`
attribute of `@ServiceConnection`.

Fixes gh-35168
This commit is contained in:
Phillip Webb
2023-04-30 19:23:49 -07:00
parent c21cf31853
commit b4cd2572d5
12 changed files with 356 additions and 151 deletions

View File

@@ -31,6 +31,7 @@ import org.springframework.beans.factory.support.BeanDefinitionRegistry;
import org.springframework.beans.factory.support.RootBeanDefinition;
import org.springframework.boot.autoconfigure.service.connection.ConnectionDetails;
import org.springframework.boot.autoconfigure.service.connection.ConnectionDetailsFactories;
import org.springframework.boot.autoconfigure.service.connection.ConnectionDetailsFactoryNotFoundException;
import org.springframework.core.log.LogMessage;
import org.springframework.util.ClassUtils;
import org.springframework.util.ObjectUtils;
@@ -61,10 +62,21 @@ class ConnectionDetailsRegistrar {
sources.forEach((source) -> registerBeanDefinitions(registry, source));
}
private void registerBeanDefinitions(BeanDefinitionRegistry registry, ContainerConnectionSource<?> source) {
this.connectionDetailsFactories.getConnectionDetails(source, true)
.forEach((connectionDetailsType, connectionDetails) -> registerBeanDefinition(registry, source,
connectionDetailsType, connectionDetails));
void registerBeanDefinitions(BeanDefinitionRegistry registry, ContainerConnectionSource<?> source) {
try {
this.connectionDetailsFactories.getConnectionDetails(source, true)
.forEach((connectionDetailsType, connectionDetails) -> registerBeanDefinition(registry, source,
connectionDetailsType, connectionDetails));
}
catch (ConnectionDetailsFactoryNotFoundException ex) {
if (!StringUtils.hasText(source.getConnectionName())) {
StringBuilder message = new StringBuilder(ex.getMessage());
message.append((!message.toString().endsWith(".")) ? "." : "");
message.append(" You may need to add a 'name' to your @ServiceConnection annotation");
throw new ConnectionDetailsFactoryNotFoundException(message.toString(), ex.getCause());
}
throw ex;
}
}
@SuppressWarnings("unchecked")

View File

@@ -20,6 +20,7 @@ import java.util.Arrays;
import org.testcontainers.containers.Container;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.boot.autoconfigure.service.connection.ConnectionDetails;
import org.springframework.boot.autoconfigure.service.connection.ConnectionDetailsFactory;
import org.springframework.boot.origin.Origin;
@@ -108,16 +109,18 @@ public abstract class ContainerConnectionDetailsFactory<C extends Container<?>,
protected abstract D getContainerConnectionDetails(ContainerConnectionSource<C> source);
/**
* Convenient base class for {@link ConnectionDetails} results that are backed by a
* Base class for {@link ConnectionDetails} results that are backed by a
* {@link ContainerConnectionSource}.
*
* @param <C> the container type
*/
protected static class ContainerConnectionDetails<C extends Container<?>>
implements ConnectionDetails, OriginProvider {
implements ConnectionDetails, OriginProvider, InitializingBean {
private final ContainerConnectionSource<C> source;
private volatile C container;
/**
* Create a new {@link ContainerConnectionDetails} instance.
* @param source the source {@link ContainerConnectionSource}
@@ -127,8 +130,20 @@ public abstract class ContainerConnectionDetailsFactory<C extends Container<?>,
this.source = source;
}
@Override
public void afterPropertiesSet() throws Exception {
this.container = this.source.getContainerSupplier().get();
}
/**
* Return the container that back this connection details instance. This method
* can only be called once the connection details bean has been initialized.
* @return the container instance
*/
protected final C getContainer() {
return this.source.getContainer();
Assert.state(this.container != null,
"Container cannot be obtained before the connection details bean has been initialized");
return this.container;
}
@Override

View File

@@ -17,6 +17,7 @@
package org.springframework.boot.testcontainers.service.connection;
import java.util.Set;
import java.util.function.Supplier;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
@@ -49,63 +50,72 @@ public final class ContainerConnectionSource<C extends Container<?>> implements
private final Origin origin;
private final C container;
private final Class<C> containerType;
private final String acceptedConnectionName;
private final String connectionName;
private final Set<Class<?>> acceptedConnectionDetailsTypes;
private final Set<Class<?>> connectionDetailsTypes;
ContainerConnectionSource(String beanNameSuffix, Origin origin, C container,
MergedAnnotation<ServiceConnection> annotation) {
private Supplier<C> containerSupplier;
ContainerConnectionSource(String beanNameSuffix, Origin origin, Class<C> containerType, String containerImageName,
MergedAnnotation<ServiceConnection> annotation, Supplier<C> containerSupplier) {
this.beanNameSuffix = beanNameSuffix;
this.origin = origin;
this.container = container;
this.acceptedConnectionName = getConnectionName(container, annotation.getString("name"));
this.acceptedConnectionDetailsTypes = Set.of(annotation.getClassArray("type"));
this.containerType = containerType;
this.connectionName = getOrDeduceConnectionName(annotation.getString("name"), containerImageName);
this.connectionDetailsTypes = Set.of(annotation.getClassArray("type"));
this.containerSupplier = containerSupplier;
}
ContainerConnectionSource(String beanNameSuffix, Origin origin, C container, ServiceConnection annotation) {
ContainerConnectionSource(String beanNameSuffix, Origin origin, Class<C> containerType, String containerImageName,
ServiceConnection annotation, Supplier<C> containerSupplier) {
this.beanNameSuffix = beanNameSuffix;
this.origin = origin;
this.container = container;
this.acceptedConnectionName = getConnectionName(container, annotation.name());
this.acceptedConnectionDetailsTypes = Set.of(annotation.type());
this.containerType = containerType;
this.connectionName = getOrDeduceConnectionName(annotation.name(), containerImageName);
this.connectionDetailsTypes = Set.of(annotation.type());
this.containerSupplier = containerSupplier;
}
private static String getConnectionName(Container<?> container, String connectionName) {
if (StringUtils.hasLength(connectionName)) {
private static String getOrDeduceConnectionName(String connectionName, String containerImageName) {
if (StringUtils.hasText(connectionName)) {
return connectionName;
}
try {
DockerImageName imageName = DockerImageName.parse(container.getDockerImageName());
if (StringUtils.hasText(containerImageName)) {
DockerImageName imageName = DockerImageName.parse(containerImageName);
imageName.assertValid();
return imageName.getRepository();
}
catch (IllegalArgumentException ex) {
return container.getDockerImageName();
}
return null;
}
boolean accepts(String connectionName, Class<?> connectionDetailsType, Class<?> containerType) {
if (!containerType.isInstance(this.container)) {
logger.trace(LogMessage.of(() -> "%s not accepted as %s is not an instance of %s".formatted(this,
this.container.getClass().getName(), containerType.getName())));
boolean accepts(String requiredConnectionName, Class<?> requiredContainerType,
Class<?> requiredConnectionDetailsType) {
if (StringUtils.hasText(requiredConnectionName)
&& !requiredConnectionName.equalsIgnoreCase(this.connectionName)) {
logger.trace(LogMessage
.of(() -> "%s not accepted as source connection name '%s' does not match required connection name '%s'"
.formatted(this, this.connectionName, requiredConnectionName)));
return false;
}
if (StringUtils.hasLength(connectionName) && !connectionName.equalsIgnoreCase(this.acceptedConnectionName)) {
logger.trace(LogMessage.of(() -> "%s not accepted as connection names '%s' and '%s' do not match"
.formatted(this, connectionName, this.acceptedConnectionName)));
if (!requiredContainerType.isAssignableFrom(this.containerType)) {
logger.trace(LogMessage.of(() -> "%s not accepted as source container type %s is not assignable from %s"
.formatted(this, this.containerType.getName(), requiredContainerType.getName())));
return false;
}
if (!this.acceptedConnectionDetailsTypes.isEmpty() && this.acceptedConnectionDetailsTypes.stream()
.noneMatch((candidate) -> candidate.isAssignableFrom(connectionDetailsType))) {
logger.trace(LogMessage.of(() -> "%s not accepted as connection details type %s not in %s".formatted(this,
connectionDetailsType, this.acceptedConnectionDetailsTypes)));
if (!this.connectionDetailsTypes.isEmpty() && this.connectionDetailsTypes.stream()
.noneMatch((candidate) -> candidate.isAssignableFrom(requiredConnectionDetailsType))) {
logger.trace(LogMessage
.of(() -> "%s not accepted as source connection details types %s has no element assignable from %s"
.formatted(this, this.connectionDetailsTypes.stream().map(Class::getName).toList(),
requiredConnectionDetailsType.getName())));
return false;
}
logger.trace(LogMessage
.of(() -> "%s accepted for connection name '%s', connection details type %s, container type %s"
.formatted(this, connectionName, connectionDetailsType.getName(), containerType.getName())));
logger.trace(
LogMessage.of(() -> "%s accepted for connection name '%s' container type %s, connection details type %s"
.formatted(this, requiredConnectionName, requiredContainerType.getName(),
requiredConnectionDetailsType.getName())));
return true;
}
@@ -118,8 +128,12 @@ public final class ContainerConnectionSource<C extends Container<?>> implements
return this.origin;
}
C getContainer() {
return this.container;
String getConnectionName() {
return this.connectionName;
}
Supplier<C> getContainerSupplier() {
return this.containerSupplier;
}
@Override

View File

@@ -22,8 +22,10 @@ import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.testcontainers.containers.Container;
import org.testcontainers.utility.DockerImageName;
import org.springframework.boot.autoconfigure.service.connection.ConnectionDetails;
import org.springframework.context.annotation.Bean;
import org.springframework.core.annotation.AliasFor;
/**
@@ -40,9 +42,18 @@ import org.springframework.core.annotation.AliasFor;
public @interface ServiceConnection {
/**
* The name of the service being connected to. If not specified, the image name will
* be used. Container names are used to determine the connection details that should
* be created when a technology-specific {@link Container} subclass is not available.
* The name of the service being connected to. Container names are used to determine
* the connection details that should be created when a technology-specific
* {@link Container} subclass is not available.
* <p>
* If not specified, and if the {@link Container} instance is available, the
* {@link DockerImageName#getRepository() repository} part of the
* {@link Container#getDockerImageName() docker image name} will be used. Note that
* {@link Container} instances are <em>not</em> available early enough when the
* container is defined as a {@link Bean @Bean} method. All
* {@link ServiceConnection @ServiceConnection} {@link Bean @Bean} methods that need
* to match on the connection name <em>must</em> declare this attribute.
* <p>
* This attribute is an alias for {@link #name()}.
* @return the name of the service
* @see #name()
@@ -52,8 +63,19 @@ public @interface ServiceConnection {
/**
* The name of the service being connected to. If not specified, the image name will
* be used. Container names are used to determine the connection details that should
* be created when a technology-specific {@link Container} subclass is not available.
* The name of the service being connected to. Container names are used to determine
* the connection details that should be created when a technology-specific
* {@link Container} subclass is not available.
* <p>
* If not specified, and if the {@link Container} instance is available, the
* {@link DockerImageName#getRepository() repository} part of the
* {@link Container#getDockerImageName() docker image name} will be used. Note that
* {@link Container} instances are <em>not</em> available early enough when the
* container is defined as a {@link Bean @Bean} method. All
* {@link ServiceConnection @ServiceConnection} {@link Bean @Bean} methods that need
* to match on the connection name <em>must</em> declare this attribute.
* <p>
* This attribute is an alias for {@link #value()}.
* @return the name of the service
* @see #value()
*/

View File

@@ -16,13 +16,12 @@
package org.springframework.boot.testcontainers.service.connection;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import org.testcontainers.containers.Container;
import org.springframework.beans.factory.BeanFactory;
import org.springframework.beans.factory.NoSuchBeanDefinitionException;
import org.springframework.beans.factory.config.BeanDefinition;
import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
import org.springframework.beans.factory.support.BeanDefinitionRegistry;
@@ -48,33 +47,43 @@ class ServiceConnectionAutoConfigurationRegistrar implements ImportBeanDefinitio
@Override
public void registerBeanDefinitions(AnnotationMetadata importingClassMetadata, BeanDefinitionRegistry registry) {
if (this.beanFactory instanceof ConfigurableListableBeanFactory listableBeanFactory) {
ConnectionDetailsFactories connectionDetailsFactories = new ConnectionDetailsFactories();
List<ContainerConnectionSource<?>> sources = getSources(listableBeanFactory);
new ConnectionDetailsRegistrar(listableBeanFactory, connectionDetailsFactories)
.registerBeanDefinitions(registry, sources);
registerBeanDefinitions(listableBeanFactory, registry);
}
}
private List<ContainerConnectionSource<?>> getSources(ConfigurableListableBeanFactory beanFactory) {
List<ContainerConnectionSource<?>> sources = new ArrayList<>();
for (String candidate : beanFactory.getBeanNamesForType(Container.class)) {
Set<ServiceConnection> annotations = beanFactory.findAllAnnotationsOnBean(candidate,
ServiceConnection.class, false);
if (!annotations.isEmpty()) {
addSources(sources, beanFactory, candidate, annotations);
private void registerBeanDefinitions(ConfigurableListableBeanFactory beanFactory, BeanDefinitionRegistry registry) {
ConnectionDetailsRegistrar registrar = new ConnectionDetailsRegistrar(beanFactory,
new ConnectionDetailsFactories());
for (String beanName : beanFactory.getBeanNamesForType(Container.class)) {
BeanDefinition beanDefinition = getBeanDefinition(beanFactory, beanName);
for (ServiceConnection annotation : getAnnotations(beanFactory, beanName)) {
ContainerConnectionSource<?> source = createSource(beanFactory, beanName, beanDefinition, annotation);
registrar.registerBeanDefinitions(registry, source);
}
}
return sources;
}
private void addSources(List<ContainerConnectionSource<?>> sources, ConfigurableListableBeanFactory beanFactory,
String beanName, Set<ServiceConnection> annotations) {
BeanDefinition beanDefinition = beanFactory.getBeanDefinition(beanName);
Origin origin = new BeanOrigin(beanName, beanDefinition);
Container<?> container = beanFactory.getBean(beanName, Container.class);
for (ServiceConnection annotation : annotations) {
sources.add(new ContainerConnectionSource<>(beanName, origin, container, annotation));
private Set<ServiceConnection> getAnnotations(ConfigurableListableBeanFactory beanFactory, String beanName) {
return beanFactory.findAllAnnotationsOnBean(beanName, ServiceConnection.class, false);
}
private BeanDefinition getBeanDefinition(ConfigurableListableBeanFactory beanFactory, String beanName) {
try {
return beanFactory.getBeanDefinition(beanName);
}
catch (NoSuchBeanDefinitionException ex) {
return null;
}
}
@SuppressWarnings("unchecked")
private <C extends Container<?>> ContainerConnectionSource<C> createSource(
ConfigurableListableBeanFactory beanFactory, String beanName, BeanDefinition beanDefinition,
ServiceConnection annotation) {
Origin origin = new BeanOrigin(beanName, beanDefinition);
Class<C> containerType = (Class<C>) beanFactory.getType(beanName, false);
return new ContainerConnectionSource<>(beanName, origin, containerType, null, annotation,
() -> beanFactory.getBean(beanName, containerType));
}
}

View File

@@ -31,9 +31,7 @@ import org.springframework.test.context.ContextCustomizer;
import org.springframework.test.context.ContextCustomizerFactory;
import org.springframework.test.context.TestContextAnnotationUtils;
import org.springframework.util.Assert;
import org.springframework.util.ClassUtils;
import org.springframework.util.ReflectionUtils;
import org.springframework.util.StringUtils;
/**
* Spring Test {@link ContextCustomizerFactory} to support
@@ -65,17 +63,19 @@ class ServiceConnectionContextCustomizerFactory implements ContextCustomizerFact
}
}
private ContainerConnectionSource<?> createSource(Field field, MergedAnnotation<ServiceConnection> annotation) {
@SuppressWarnings("unchecked")
private <C extends Container<?>> ContainerConnectionSource<?> createSource(Field field,
MergedAnnotation<ServiceConnection> annotation) {
Assert.state(Modifier.isStatic(field.getModifiers()),
() -> "@ServiceConnection field '%s' must be static".formatted(field.getName()));
String beanNameSuffix = StringUtils.capitalize(ClassUtils.getShortNameAsProperty(field.getDeclaringClass()))
+ StringUtils.capitalize(field.getName());
Origin origin = new FieldOrigin(field);
Object fieldValue = getFieldValue(field);
Assert.state(fieldValue instanceof Container, () -> "Field '%s' in %s must be a %s".formatted(field.getName(),
field.getDeclaringClass().getName(), Container.class.getName()));
Container<?> container = (Container<?>) fieldValue;
return new ContainerConnectionSource<>(beanNameSuffix, origin, container, annotation);
Class<C> containerType = (Class<C>) fieldValue.getClass();
C container = (C) fieldValue;
return new ContainerConnectionSource<>("test", origin, containerType, container.getDockerImageName(),
annotation, () -> container);
}
private Object getFieldValue(Field field) {