From cb9f9b52d514ad222f2d094ca695e93b3d04f12f Mon Sep 17 00:00:00 2001 From: Dmytro Nosan Date: Fri, 25 Oct 2024 15:00:10 +0300 Subject: [PATCH] TestcontainersBeanRegistrationAotProcessor that replaces InstanceSupplier of Container by either direct field usage or a reflection equivalent. If the field is private, the reflection will be used; otherwise, direct access to the field will be used --- .../ImportTestcontainersTests.java | 112 ++++++++++++++++++ .../DynamicPropertySourceMethodsImporter.java | 109 +++++++++++++++++ .../TestcontainerFieldBeanDefinition.java | 89 +++++++++++++- .../TestcontainersPropertySource.java | 17 ++- .../resources/META-INF/spring/aot.factories | 11 +- 5 files changed, 334 insertions(+), 4 deletions(-) diff --git a/spring-boot-project/spring-boot-testcontainers/src/dockerTest/java/org/springframework/boot/testcontainers/ImportTestcontainersTests.java b/spring-boot-project/spring-boot-testcontainers/src/dockerTest/java/org/springframework/boot/testcontainers/ImportTestcontainersTests.java index c3d0bd43703b..fc9499089b54 100644 --- a/spring-boot-project/spring-boot-testcontainers/src/dockerTest/java/org/springframework/boot/testcontainers/ImportTestcontainersTests.java +++ b/spring-boot-project/spring-boot-testcontainers/src/dockerTest/java/org/springframework/boot/testcontainers/ImportTestcontainersTests.java @@ -18,17 +18,27 @@ import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; +import java.util.function.BiConsumer; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.testcontainers.containers.Container; import org.testcontainers.containers.PostgreSQLContainer; +import org.springframework.aot.test.generate.TestGenerationContext; import org.springframework.boot.testcontainers.beans.TestcontainerBeanDefinition; import org.springframework.boot.testcontainers.context.ImportTestcontainers; +import org.springframework.boot.testcontainers.lifecycle.TestcontainersLifecycleApplicationContextInitializer; import org.springframework.boot.testsupport.container.DisabledIfDockerUnavailable; import org.springframework.boot.testsupport.container.TestImage; +import org.springframework.context.ApplicationContextInitializer; import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.aot.ApplicationContextAotGenerator; +import org.springframework.context.support.GenericApplicationContext; +import org.springframework.core.test.tools.CompileWithForkedClassLoader; +import org.springframework.core.test.tools.Compiled; +import org.springframework.core.test.tools.TestCompiler; +import org.springframework.javapoet.ClassName; import org.springframework.test.context.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertySource; @@ -43,6 +53,8 @@ @DisabledIfDockerUnavailable class ImportTestcontainersTests { + private final TestGenerationContext generationContext = new TestGenerationContext(); + private AnnotationConfigApplicationContext applicationContext; @AfterEach @@ -122,6 +134,84 @@ void importWhenHasBadArgsDynamicPropertySourceMethod() { .withMessage("@DynamicPropertySource method 'containerProperties' must be static"); } + @Test + @CompileWithForkedClassLoader + void importTestcontainersImportWithoutValueAotContribution() { + this.applicationContext = new AnnotationConfigApplicationContext(); + this.applicationContext.register(ImportWithoutValue.class); + compile((freshContext, compiled) -> { + PostgreSQLContainer container = freshContext.getBean(PostgreSQLContainer.class); + assertThat(container).isSameAs(ImportWithoutValue.container); + }); + } + + @Test + @CompileWithForkedClassLoader + void importTestcontainersImportWithValueAotContribution() { + this.applicationContext = new AnnotationConfigApplicationContext(); + this.applicationContext.register(ImportWithValue.class); + compile((freshContext, compiled) -> { + PostgreSQLContainer container = freshContext.getBean(PostgreSQLContainer.class); + assertThat(container).isSameAs(ContainerDefinitions.container); + }); + } + + @Test + @CompileWithForkedClassLoader + void importTestcontainersWithDynamicPropertySourceAotContribution() { + this.applicationContext = new AnnotationConfigApplicationContext(); + this.applicationContext.register(ContainerDefinitionsWithDynamicPropertySource.class); + new TestcontainersLifecycleApplicationContextInitializer().initialize(this.applicationContext); + compile((freshContext, compiled) -> { + PostgreSQLContainer container = freshContext.getBean(PostgreSQLContainer.class); + assertThat(container).isSameAs(ContainerDefinitionsWithDynamicPropertySource.container); + assertThat(freshContext.getEnvironment().getProperty("container.port", Integer.class)) + .isEqualTo(ContainerDefinitionsWithDynamicPropertySource.container.getFirstMappedPort()); + }); + } + + @Test + @CompileWithForkedClassLoader + void importTestcontainersWithCustomPostgreSQLContainerAotContribution() { + this.applicationContext = new AnnotationConfigApplicationContext(); + this.applicationContext.register(CustomPostgreSQLContainerDefinitions.class); + compile((freshContext, compiled) -> { + CustomPostgreSQLContainer container = freshContext.getBean(CustomPostgreSQLContainer.class); + assertThat(container).isSameAs(CustomPostgreSQLContainerDefinitions.container); + }); + } + + @Test + @CompileWithForkedClassLoader + void importTestcontainersWithNotAccessibleContainerAotContribution() { + this.applicationContext = new AnnotationConfigApplicationContext(); + this.applicationContext.register(ImportNotAccessibleContainer.class); + compile((freshContext, compiled) -> { + PostgreSQLContainer container = freshContext.getBean(PostgreSQLContainer.class); + assertThat(container).isSameAs(ImportNotAccessibleContainer.container); + }); + } + + @SuppressWarnings("unchecked") + private void compile(BiConsumer result) { + ClassName className = processAheadOfTime(); + TestCompiler.forSystem().with(this.generationContext).compile((compiled) -> { + GenericApplicationContext freshApplicationContext = new GenericApplicationContext(); + ApplicationContextInitializer initializer = compiled + .getInstance(ApplicationContextInitializer.class, className.toString()); + initializer.initialize(freshApplicationContext); + freshApplicationContext.refresh(); + result.accept(freshApplicationContext, compiled); + }); + } + + private ClassName processAheadOfTime() { + ClassName className = new ApplicationContextAotGenerator().processAheadOfTime(this.applicationContext, + this.generationContext); + this.generationContext.writeGeneratedContent(); + return className; + } + @ImportTestcontainers static class ImportWithoutValue { @@ -196,4 +286,26 @@ void containerProperties() { } + @ImportTestcontainers + static class CustomPostgreSQLContainerDefinitions { + + static CustomPostgreSQLContainer container = new CustomPostgreSQLContainer(); + + } + + static class CustomPostgreSQLContainer extends PostgreSQLContainer { + + CustomPostgreSQLContainer() { + super("postgres:14"); + } + + } + + @ImportTestcontainers + static class ImportNotAccessibleContainer { + + private static final PostgreSQLContainer container = TestImage.container(PostgreSQLContainer.class); + + } + } diff --git a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/context/DynamicPropertySourceMethodsImporter.java b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/context/DynamicPropertySourceMethodsImporter.java index d680f7504c81..33264e8a4df0 100644 --- a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/context/DynamicPropertySourceMethodsImporter.java +++ b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/context/DynamicPropertySourceMethodsImporter.java @@ -18,16 +18,32 @@ import java.lang.reflect.Method; import java.lang.reflect.Modifier; +import java.util.LinkedHashSet; +import java.util.Map; import java.util.Set; +import org.springframework.aot.generate.GeneratedMethod; +import org.springframework.aot.generate.GenerationContext; +import org.springframework.aot.hint.ExecutableMode; +import org.springframework.beans.factory.aot.BeanFactoryInitializationAotContribution; +import org.springframework.beans.factory.aot.BeanFactoryInitializationAotProcessor; +import org.springframework.beans.factory.aot.BeanFactoryInitializationCode; +import org.springframework.beans.factory.aot.BeanRegistrationExcludeFilter; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; import org.springframework.beans.factory.support.BeanDefinitionRegistry; +import org.springframework.beans.factory.support.DefaultListableBeanFactory; +import org.springframework.beans.factory.support.RegisteredBean; +import org.springframework.beans.factory.support.RootBeanDefinition; import org.springframework.boot.testcontainers.properties.TestcontainersPropertySource; import org.springframework.core.MethodIntrospector; import org.springframework.core.annotation.MergedAnnotations; +import org.springframework.core.env.ConfigurableEnvironment; import org.springframework.core.env.Environment; import org.springframework.test.context.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertySource; import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; import org.springframework.util.ReflectionUtils; /** @@ -51,11 +67,22 @@ void registerDynamicPropertySources(BeanDefinitionRegistry beanDefinitionRegistr } DynamicPropertyRegistry dynamicPropertyRegistry = TestcontainersPropertySource.attach(this.environment, beanDefinitionRegistry); + DynamicPropertySourceMethodsImporterMetadata metadata = new DynamicPropertySourceMethodsImporterMetadata(); methods.forEach((method) -> { assertValid(method); ReflectionUtils.makeAccessible(method); ReflectionUtils.invokeMethod(method, null, dynamicPropertyRegistry); + metadata.methods.add(method); }); + String beanName = "importTestContainer.%s.%s".formatted(DynamicPropertySource.class.getName(), definitionClass); + if (!beanDefinitionRegistry.containsBeanDefinition(beanName)) { + RootBeanDefinition bd = new RootBeanDefinition(DynamicPropertySourceMethodsImporterMetadata.class); + bd.setInstanceSupplier(() -> metadata); + bd.setRole(BeanDefinition.ROLE_INFRASTRUCTURE); + bd.setAutowireCandidate(false); + bd.setAttribute(DynamicPropertySourceMethodsImporterMetadata.class.getName(), true); + beanDefinitionRegistry.registerBeanDefinition(beanName, bd); + } } private boolean isAnnotated(Method method) { @@ -71,4 +98,86 @@ private void assertValid(Method method) { + "' must accept a single DynamicPropertyRegistry argument"); } + private static final class DynamicPropertySourceMethodsImporterMetadata { + + private final Set methods = new LinkedHashSet<>(); + + } + + static class DynamicPropertySourceMethodsImporterMetadataBeanRegistrationExcludeFilter + implements BeanRegistrationExcludeFilter { + + @Override + public boolean isExcludedFromAotProcessing(RegisteredBean registeredBean) { + return registeredBean.getMergedBeanDefinition() + .hasAttribute(DynamicPropertySourceMethodsImporterMetadata.class.getName()); + } + + } + + /** + * {@link BeanFactoryInitializationAotProcessor} that generates all + * {@link DynamicPropertySource} methods if any. + * + */ + static class DynamicPropertySourceBeanFactoryInitializationAotProcessor + implements BeanFactoryInitializationAotProcessor { + + @Override + public BeanFactoryInitializationAotContribution processAheadOfTime( + ConfigurableListableBeanFactory beanFactory) { + Map metadata = beanFactory + .getBeansOfType(DynamicPropertySourceMethodsImporterMetadata.class); + if (metadata.isEmpty()) { + return null; + } + return new AotContibution(metadata); + } + + private static final class AotContibution implements BeanFactoryInitializationAotContribution { + + private final Map metadata; + + private AotContibution(Map metadata) { + this.metadata = metadata; + } + + @Override + public void applyTo(GenerationContext generationContext, + BeanFactoryInitializationCode beanFactoryInitializationCode) { + this.metadata.forEach((name, metadata) -> metadata.methods.forEach((method) -> { + generationContext.getRuntimeHints().reflection().registerMethod(method, ExecutableMode.INVOKE); + GeneratedMethod generatedMethod = beanFactoryInitializationCode.getMethods() + .add(method.getName(), (code) -> { + code.addJavadoc("DynamicPropertySource for method $L.$L", + method.getDeclaringClass().getName(), method.getName()); + code.addModifiers(javax.lang.model.element.Modifier.PRIVATE, + javax.lang.model.element.Modifier.STATIC); + code.addParameter(ConfigurableEnvironment.class, "environment"); + code.addParameter(DefaultListableBeanFactory.class, "beanFactory"); + code.addStatement("$T dynamicPropertyRegistry = $T.attach(environment, beanFactory)", + DynamicPropertyRegistry.class, TestcontainersPropertySource.class); + code.beginControlFlow("try"); + code.addStatement("$T clazz = $T.forName($S, beanFactory.getBeanClassLoader())", + Class.class, ClassUtils.class, method.getDeclaringClass().getName()); + code.addStatement("$T method = $T.findMethod(clazz, $S, $T.class)", Method.class, + ReflectionUtils.class, method.getName(), DynamicPropertyRegistry.class); + code.addStatement("$T.notNull(method, $S)", Assert.class, + "Method '" + method.getName() + "' is not found"); + code.addStatement("$T.makeAccessible(method)", ReflectionUtils.class); + code.addStatement("$T.invokeMethod(method, null, dynamicPropertyRegistry)", + ReflectionUtils.class); + code.nextControlFlow("catch ($T ex)", ClassNotFoundException.class); + code.addStatement("throw new $T(ex)", RuntimeException.class); + code.endControlFlow(); + }); + beanFactoryInitializationCode.addInitializer(generatedMethod.toMethodReference()); + })); + + } + + } + + } + } diff --git a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/context/TestcontainerFieldBeanDefinition.java b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/context/TestcontainerFieldBeanDefinition.java index c5cf32d4b1aa..526fc844ab9b 100644 --- a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/context/TestcontainerFieldBeanDefinition.java +++ b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/context/TestcontainerFieldBeanDefinition.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,11 +18,26 @@ import java.lang.reflect.Field; +import javax.lang.model.element.Modifier; + import org.testcontainers.containers.Container; +import org.springframework.aot.generate.AccessControl; +import org.springframework.aot.generate.GeneratedMethod; +import org.springframework.aot.generate.GenerationContext; +import org.springframework.beans.factory.aot.BeanRegistrationAotContribution; +import org.springframework.beans.factory.aot.BeanRegistrationAotProcessor; +import org.springframework.beans.factory.aot.BeanRegistrationCode; +import org.springframework.beans.factory.aot.BeanRegistrationCodeFragments; +import org.springframework.beans.factory.aot.BeanRegistrationCodeFragmentsDecorator; +import org.springframework.beans.factory.support.RegisteredBean; import org.springframework.beans.factory.support.RootBeanDefinition; import org.springframework.boot.testcontainers.beans.TestcontainerBeanDefinition; import org.springframework.core.annotation.MergedAnnotations; +import org.springframework.javapoet.ClassName; +import org.springframework.javapoet.CodeBlock; +import org.springframework.util.Assert; +import org.springframework.util.ReflectionUtils; /** * {@link RootBeanDefinition} used for testcontainer bean definitions. @@ -38,9 +53,10 @@ class TestcontainerFieldBeanDefinition extends RootBeanDefinition implements Tes TestcontainerFieldBeanDefinition(Field field, Container container) { this.container = container; this.annotations = MergedAnnotations.from(field); - this.setBeanClass(container.getClass()); + setBeanClass(container.getClass()); setInstanceSupplier(() -> container); setRole(ROLE_INFRASTRUCTURE); + setAttribute(TestcontainerFieldBeanDefinition.class.getName(), field); } @Override @@ -53,4 +69,73 @@ public MergedAnnotations getAnnotations() { return this.annotations; } + /** + * {@link BeanRegistrationAotProcessor} that replaces InstanceSupplier of + * {@link Container} by either direct field usage or a reflection equivalent. + *

+ * If the field is private, the reflection will be used; otherwise, direct access to + * the field will be used. + * + */ + static class TestcontainersBeanRegistrationAotProcessor implements BeanRegistrationAotProcessor { + + @Override + public BeanRegistrationAotContribution processAheadOfTime(RegisteredBean registeredBean) { + RootBeanDefinition bd = registeredBean.getMergedBeanDefinition(); + String attributeName = TestcontainerFieldBeanDefinition.class.getName(); + Object field = bd.getAttribute(attributeName); + if (field != null) { + Assert.isInstanceOf(Field.class, field, "BeanDefinition attribute '" + attributeName + + "' value must be a type of '" + Field.class + "'"); + return BeanRegistrationAotContribution.withCustomCodeFragments( + (codeFragments) -> new AotContribution(codeFragments, registeredBean, ((Field) field))); + } + return null; + } + + private static final class AotContribution extends BeanRegistrationCodeFragmentsDecorator { + + private final RegisteredBean registeredBean; + + private final Field field; + + private AotContribution(BeanRegistrationCodeFragments delegate, RegisteredBean registeredBean, + Field field) { + super(delegate); + this.registeredBean = registeredBean; + this.field = field; + } + + @Override + public ClassName getTarget(RegisteredBean registeredBean) { + return ClassName.get(this.field.getDeclaringClass()); + } + + @Override + public CodeBlock generateInstanceSupplierCode(GenerationContext generationContext, + BeanRegistrationCode beanRegistrationCode, boolean allowDirectSupplierShortcut) { + if (AccessControl.forMember(this.field).isAccessibleFrom(beanRegistrationCode.getClassName())) { + return CodeBlock.of("() -> $T.$L", this.field.getDeclaringClass(), this.field.getName()); + } + generationContext.getRuntimeHints().reflection().registerField(this.field); + GeneratedMethod generatedMethod = beanRegistrationCode.getMethods() + .add("getInstance", (method) -> method.addModifiers(Modifier.PRIVATE, Modifier.STATIC) + .addJavadoc("Get the bean instance for '$L'.", this.registeredBean.getBeanName()) + .returns(this.registeredBean.getBeanClass()) + .addStatement("$T field = $T.findField($T.class, $S)", Field.class, ReflectionUtils.class, + this.field.getDeclaringClass(), this.field.getName()) + .addStatement("$T.notNull(field, $S)", Assert.class, + "Field '" + this.field.getName() + "' is not found") + .addStatement("$T.makeAccessible(field)", ReflectionUtils.class) + .addStatement("$T container = $T.getField(field, null)", Object.class, ReflectionUtils.class) + .addStatement("$T.notNull(container, $S)", Assert.class, + "Container field '" + this.field.getName() + "' must not have a null value") + .addStatement("return ($T) container", this.registeredBean.getBeanClass())); + return generatedMethod.toMethodReference().toCodeBlock(); + } + + } + + } + } diff --git a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/properties/TestcontainersPropertySource.java b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/properties/TestcontainersPropertySource.java index f1ecfe878c80..dba0a408116b 100644 --- a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/properties/TestcontainersPropertySource.java +++ b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/properties/TestcontainersPropertySource.java @@ -26,9 +26,11 @@ import org.testcontainers.containers.Container; import org.springframework.beans.BeansException; +import org.springframework.beans.factory.aot.BeanRegistrationExcludeFilter; import org.springframework.beans.factory.config.BeanFactoryPostProcessor; import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; import org.springframework.beans.factory.support.BeanDefinitionRegistry; +import org.springframework.beans.factory.support.RegisteredBean; import org.springframework.beans.factory.support.RootBeanDefinition; import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.ApplicationEventPublisherAware; @@ -50,7 +52,11 @@ */ public class TestcontainersPropertySource extends EnumerablePropertySource>> { - static final String NAME = "testcontainersPropertySource"; + /** + * A name of {@link TestcontainersPropertySource}. + * @since 3.2.12 + */ + public static final String NAME = "testcontainersPropertySource"; private final DynamicPropertyRegistry registry; @@ -166,4 +172,13 @@ public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) } + static class EventPublisherRegistrarBeanRegistrationExcludeFilter implements BeanRegistrationExcludeFilter { + + @Override + public boolean isExcludedFromAotProcessing(RegisteredBean registeredBean) { + return EventPublisherRegistrar.NAME.equals(registeredBean.getBeanName()); + } + + } + } diff --git a/spring-boot-project/spring-boot-testcontainers/src/main/resources/META-INF/spring/aot.factories b/spring-boot-project/spring-boot-testcontainers/src/main/resources/META-INF/spring/aot.factories index 5b3d49bd5020..4cca5e7f3431 100644 --- a/spring-boot-project/spring-boot-testcontainers/src/main/resources/META-INF/spring/aot.factories +++ b/spring-boot-project/spring-boot-testcontainers/src/main/resources/META-INF/spring/aot.factories @@ -1,5 +1,14 @@ org.springframework.beans.factory.aot.BeanRegistrationExcludeFilter=\ -org.springframework.boot.testcontainers.service.connection.ConnectionDetailsRegistrar.ServiceConnectionBeanRegistrationExcludeFilter +org.springframework.boot.testcontainers.service.connection.ConnectionDetailsRegistrar.ServiceConnectionBeanRegistrationExcludeFilter,\ +org.springframework.boot.testcontainers.properties.TestcontainersPropertySource.EventPublisherRegistrarBeanRegistrationExcludeFilter,\ +org.springframework.boot.testcontainers.context.DynamicPropertySourceMethodsImporter.DynamicPropertySourceMethodsImporterMetadataBeanRegistrationExcludeFilter org.springframework.aot.hint.RuntimeHintsRegistrar=\ org.springframework.boot.testcontainers.service.connection.ContainerConnectionDetailsFactory.ContainerConnectionDetailsFactoriesRuntimeHints + +org.springframework.beans.factory.aot.BeanRegistrationAotProcessor=\ +org.springframework.boot.testcontainers.context.TestcontainerFieldBeanDefinition.TestcontainersBeanRegistrationAotProcessor + + +org.springframework.beans.factory.aot.BeanFactoryInitializationAotProcessor=\ +org.springframework.boot.testcontainers.context.DynamicPropertySourceMethodsImporter.DynamicPropertySourceBeanFactoryInitializationAotProcessor