diff --git a/core/spring-cloud-stream/pom.xml b/core/spring-cloud-stream/pom.xml index 2adf5ca9d..3fc8a3942 100644 --- a/core/spring-cloud-stream/pom.xml +++ b/core/spring-cloud-stream/pom.xml @@ -64,6 +64,11 @@ spring-boot-starter-test test + + org.springframework + spring-core-test + test + org.springframework.boot spring-boot-autoconfigure-processor diff --git a/core/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/BinderChildContextInitializer.java b/core/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/BinderChildContextInitializer.java index 11dc24dd8..8c429fe2f 100644 --- a/core/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/BinderChildContextInitializer.java +++ b/core/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/BinderChildContextInitializer.java @@ -30,6 +30,10 @@ 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.support.RegisteredBean; +import org.springframework.boot.context.properties.bind.Bindable; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.cloud.stream.config.BindingServiceConfiguration; +import org.springframework.cloud.stream.config.BindingServiceProperties; import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContextAware; import org.springframework.context.ApplicationContextInitializer; @@ -85,7 +89,13 @@ public class BinderChildContextInitializer implements ApplicationContextAware, B if (registeredBean.getBeanClass().equals(getClass())) { //&& registeredBean.getBeanFactory().equals(this.context)) { this.logger.debug(() -> "Beginning AOT processing for binder child contexts"); ensureBinderFactoryIsSet(); - Map binderConfigurations = this.binderFactory.getBinderConfigurations(); + // Load the binding service properties from the environment and update the binder factory with them + // in order to pick up any user-declared binders. Without this step only the default binder defined + // in 'META-INF/spring.binders' will be processed. + BindingServiceProperties declaredBinders = this.createBindingServiceProperties(); + Map binderConfigurations = BindingServiceConfiguration.getBinderConfigurations( + this.binderFactory.getBinderTypeRegistry(), declaredBinders); + this.binderFactory.updateBinderConfigurations(binderConfigurations); Map binderChildContexts = binderConfigurations.entrySet().stream() .map(e -> Map.entry(e.getKey(), binderFactory.createBinderContextForAOT(e.getKey()))) .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); @@ -94,6 +104,13 @@ public class BinderChildContextInitializer implements ApplicationContextAware, B return null; } + private BindingServiceProperties createBindingServiceProperties() { + BindingServiceProperties bindingServiceProperties = new BindingServiceProperties(); + Binder.get(this.context.getEnvironment()) + .bind("spring.cloud.stream", Bindable.ofInstance(bindingServiceProperties)); + return bindingServiceProperties; + } + private void ensureBinderFactoryIsSet() { if (this.binderFactory == null) { Assert.notNull(this.context, () -> "Unable to lookup binder factory from context as this.context is null"); @@ -111,7 +128,7 @@ public class BinderChildContextInitializer implements ApplicationContextAware, B @SuppressWarnings({"unused", "raw"}) public BinderChildContextInitializer withChildContextInitializers( Map> childContextInitializers) { - this.logger.debug(() -> "Replacing instance w/ one that uses; child context initializers"); + this.logger.debug(() -> "Replacing instance w/ one that uses child context initializers"); Map> downcastedInitializers = childContextInitializers.entrySet().stream() .map(e -> Map.entry(e.getKey(), (ApplicationContextInitializer) e.getValue())) diff --git a/core/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/DefaultBinderFactory.java b/core/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/DefaultBinderFactory.java index f0b0ffabb..21f9c7fdf 100644 --- a/core/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/DefaultBinderFactory.java +++ b/core/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/binder/DefaultBinderFactory.java @@ -104,6 +104,21 @@ public class DefaultBinderFactory implements BinderFactory, DisposableBean, Appl this.binderCustomizer = binderCustomizer; } + /** + * Replaces the existing binder configurations - useful in AOT processing where the binding service properties + * have to be manually loaded after the binder factory is constructed. + * + * @param binderConfigurations the updated configurations + */ + void updateBinderConfigurations(Map binderConfigurations) { + this.binderConfigurations.clear(); + this.binderConfigurations.putAll(binderConfigurations); + } + + BinderTypeRegistry getBinderTypeRegistry() { + return this.binderTypeRegistry; + } + @Override public void setApplicationContext(ApplicationContext applicationContext) { Assert.isInstanceOf(ConfigurableApplicationContext.class, applicationContext); @@ -126,17 +141,14 @@ public class DefaultBinderFactory implements BinderFactory, DisposableBean, Appl @SuppressWarnings({ "unchecked", "rawtypes" }) @Override - public synchronized Binder getBinder(String name, - Class bindingTargetType) { + public synchronized Binder getBinder(String name, Class bindingTargetType) { String binderName = StringUtils.hasText(name) ? name : this.defaultBinder; - Map binders = this.context == null ? Collections.emptyMap() - : this.context.getBeansOfType(Binder.class); + Map binders = this.context == null ? Collections.emptyMap() : this.context.getBeansOfType(Binder.class); Binder binder; if (StringUtils.hasText(binderName) && binders.containsKey(binderName)) { - binder = (Binder) this.context - .getBean(binderName); + binder = (Binder) this.context.getBean(binderName); } else if (binders.size() == 1) { binder = binders.values().iterator().next(); @@ -149,7 +161,7 @@ public class DefaultBinderFactory implements BinderFactory, DisposableBean, Appl } else { /* - * This is the fall back to the old bootstrap that relies on spring.binders. + * This is the fallback to the old bootstrap that relies on spring.binders. */ binder = this.doGetBinder(binderName, bindingTargetType); } @@ -160,27 +172,37 @@ public class DefaultBinderFactory implements BinderFactory, DisposableBean, Appl } private Binder doGetBinder(String name, Class bindingTargetType) { - if (CollectionUtils.isEmpty(this.binderChildContextInitializers)) { - return this.doGetBinderConventional(name, bindingTargetType); + // If child initializers - use AOT lookup + if (!CollectionUtils.isEmpty(this.binderChildContextInitializers)) { + return this.doGetBinderAOT(name, bindingTargetType); } - else { - if ((!StringUtils.hasText(name) || this.defaultBinder != null) && this.binderChildContextInitializers.size() == 1) { + return this.doGetBinderConventional(name, bindingTargetType); + } + + private Binder doGetBinderAOT(String name, Class bindingTargetType) { + // If neither name nor default given - return single or fail when > 1 + if (!StringUtils.hasText(name) && !StringUtils.hasText(this.defaultBinder)) { + if (this.binderChildContextInitializers.size() == 1) { String configurationName = this.binderChildContextInitializers.keySet().iterator().next(); + this.logger.debug("No specific name or default given - using single available child initializer '" + configurationName + "'"); return this.getBinderInstance(configurationName); } - else if (this.defaultBinder != null && this.binderChildContextInitializers.size() > 1) { - // Handling default binder when different binders are present on the classpath. - for (String binderName : this.binderChildContextInitializers.keySet()) { - if (binderName.equals(this.defaultBinder)) { - return this.getBinderInstance(binderName); - } - } - throw new IllegalStateException("Default binder provided, but can't determine which binder to initialize"); - } - else { - throw new IllegalStateException("Can't determine which binder to use: " + name + "/" + this.binderChildContextInitializers.size()); - } + throw new IllegalStateException("No specific name or default given - can't determine which binder to use"); } + + // Prefer specific name over default + String configurationName = name; + if (!StringUtils.hasText(configurationName)) { + configurationName = this.defaultBinder; + } + + // Check for matching child initializer + if (this.binderChildContextInitializers.containsKey(configurationName)) { + return this.getBinderInstance(configurationName); + } + + throw new IllegalStateException("Requested binder '" + name + "' did not match available binders: " + + this.binderChildContextInitializers.keySet()); } private Binder doGetBinderConventional(String name, @@ -296,16 +318,19 @@ public class DefaultBinderFactory implements BinderFactory, DisposableBean, Appl ConfigurableApplicationContext binderProducingContext; if (this.binderChildContextInitializers.containsKey(configurationName)) { this.logger.info("Using AOT pre-prepared initializer to construct binder child context for " + configurationName); + if (binderConfiguration != null) { + this.flatten(null, binderConfiguration.getProperties(), binderProperties); + } binderProducingContext = this.createUnitializedContextForAOT(configurationName, binderProperties, binderConfiguration); this.binderChildContextInitializers.get(configurationName).initialize(binderProducingContext); binderProducingContext.refresh(); } else { + this.logger.info("Constructing binder child context for " + configurationName); Assert.state(binderConfiguration != null, "Unknown binder configuration: " + configurationName); + this.flatten(null, binderConfiguration.getProperties(), binderProperties); BinderType binderType = this.binderTypeRegistry.get(binderConfiguration.getBinderType()); Assert.notNull(binderType, "Binder type " + binderConfiguration.getBinderType() + " is not defined"); - this.flatten(null, binderConfiguration.getProperties(), binderProperties); - this.logger.info("Constructing binder child context for " + configurationName); binderProducingContext = this.initializeBinderContextSimple(configurationName, binderProperties, binderType, binderConfiguration, true); } diff --git a/core/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/config/BindingServiceConfiguration.java b/core/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/config/BindingServiceConfiguration.java index 00e37a3dd..56a6f9f0c 100644 --- a/core/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/config/BindingServiceConfiguration.java +++ b/core/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/config/BindingServiceConfiguration.java @@ -90,7 +90,7 @@ public class BindingServiceConfiguration { @Autowired(required = false) private Collection binderFactoryListeners; - private static Map getBinderConfigurations( + public static Map getBinderConfigurations( BinderTypeRegistry binderTypeRegistry, BindingServiceProperties bindingServiceProperties) { diff --git a/core/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/BinderChildContextInitializerTests.java b/core/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/BinderChildContextInitializerTests.java new file mode 100644 index 000000000..76d42a988 --- /dev/null +++ b/core/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/BinderChildContextInitializerTests.java @@ -0,0 +1,213 @@ +/* + * Copyright 2022-2023 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. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.stream.binder; + +import java.util.function.Consumer; +import java.util.function.Supplier; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.aot.AotDetector; +import org.springframework.aot.test.generate.TestGenerationContext; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.context.annotation.UserConfigurations; +import org.springframework.boot.test.context.ConfigDataApplicationContextInitializer; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; +import org.springframework.boot.test.util.TestPropertyValues; +import org.springframework.cloud.stream.config.BinderFactoryAutoConfiguration; +import org.springframework.cloud.stream.config.BindingServiceConfiguration; +import org.springframework.cloud.stream.function.FunctionConfiguration; +import org.springframework.context.ApplicationContextInitializer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.aot.ApplicationContextAotGenerator; +import org.springframework.context.support.GenericApplicationContext; +import org.springframework.core.convert.support.GenericConversionService; +import org.springframework.core.log.LogAccessor; +import org.springframework.core.test.tools.CompileWithForkedClassLoader; +import org.springframework.core.test.tools.TestCompiler; +import org.springframework.javapoet.ClassName; +import org.springframework.messaging.MessageChannel; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; +import static org.mockito.Mockito.mock; + +/** + * Tests for the {@link BinderChildContextInitializer}. + * + * @author Chris Bono + */ +@ExtendWith(OutputCaptureExtension.class) +class BinderChildContextInitializerTests { + + private static final LogAccessor LOG = new LogAccessor(BinderChildContextInitializerTests.class); + + @Test + @CompileWithForkedClassLoader + void shouldStartDefaultBinderChildContextFromAotContributions(CapturedOutput output) { + + // Test description: + // ----------------------- + // Use context runner to create a boostrap context that we can then pass into AOT processor. + // The AOT processor will then generate the ACI for the default binder (no user declared binders). + // We then initialize a fresh app context using the generated ACI and verify the expected output. + + ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(BinderFactoryAutoConfiguration.class, + BindingServiceConfiguration.class, FunctionConfiguration.class)) + .withInitializer(new ConfigDataApplicationContextInitializer()) + .withConfiguration(UserConfigurations.of(TestFooBinderAppConfiguration.class)); + contextRunner.prepare(context -> { + TestGenerationContext generationContext = new TestGenerationContext(TestTarget.class); + ClassName className = new ApplicationContextAotGenerator().processAheadOfTime( + (GenericApplicationContext) context.getSourceApplicationContext(), generationContext); + generationContext.writeGeneratedContent(); + TestCompiler compiler = TestCompiler.forSystem(); + compiler.with(generationContext).compile(compiled -> { + // Initialize the context w/ the generated ACI + GenericApplicationContext freshApplicationContext = new GenericApplicationContext(); + ApplicationContextInitializer initializer = compiled + .getInstance(ApplicationContextInitializer.class, className.toString()); + initializer.initialize(freshApplicationContext); + assertThat(output).contains("Beginning AOT processing for binder child contexts"); + assertThat(output).contains("Pre-creating binder child context (AOT) for mock"); + assertThat(output).contains("Generating AOT child context initializer for mock"); + assertThat(output).contains("Refreshing mock_context"); + + // Refresh the initialized context and verify the binder child contexts are used + TestPropertyValues.of(AotDetector.AOT_ENABLED + "=true") + .applyToSystemProperties(freshApplicationContext::refresh); + assertThat(output).contains("Replacing instance w/ one that uses child context initializers"); + assertThat(output).contains("Setting binder child context initializers on binder factory"); + + // Make sure we can get the binders + DefaultBinderFactory binderFactory = freshApplicationContext.getBean(DefaultBinderFactory.class); + Binder mockBinder = binderFactory.getBinder("mock", MessageChannel.class); + assertThat(mockBinder).isNotNull(); + assertThat(output).contains("Caching the binder: mock"); + + // no default or name given - uses single available binder + assertThat(binderFactory.getBinder(null, MessageChannel.class)).isSameAs(mockBinder); + assertThat(output).contains("No specific name or default given - using single available child initializer 'mock'"); + + assertThatIllegalStateException().isThrownBy( + () -> binderFactory.getBinder("mockBinder1", MessageChannel.class)) + .withMessageContaining("Requested binder 'mockBinder1' did not match available binders"); + + binderFactory.setDefaultBinder("mock"); + assertThat(binderFactory.getBinder(null, MessageChannel.class)).isSameAs(mockBinder); + }); + }); + } + + @Test + @CompileWithForkedClassLoader + @SuppressWarnings("unchecked") + void shouldStartDeclardBinderChildContextsFromAotContributions(CapturedOutput output) { + + // Test description: + // ----------------------- + // Use context runner to create a boostrap context that we can then pass into AOT processor. + // The AOT processor will then generate the ACI for each binder child context defined in the application.yml. + // We then initialize a fresh app context using the generated ACIs and verify the expected output. + ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(BinderFactoryAutoConfiguration.class, + BindingServiceConfiguration.class, FunctionConfiguration.class)) + .withInitializer(new ConfigDataApplicationContextInitializer()) + .withPropertyValues("spring.config.location=classpath:binder-aot-test/") + .withConfiguration(UserConfigurations.of(TestFooBinderAppConfiguration.class)); + + contextRunner.prepare(context -> { + TestGenerationContext generationContext = new TestGenerationContext(TestTarget.class); + ClassName className = new ApplicationContextAotGenerator().processAheadOfTime( + (GenericApplicationContext) context.getSourceApplicationContext(), generationContext); + generationContext.writeGeneratedContent(); + TestCompiler compiler = TestCompiler.forSystem(); + compiler.with(generationContext).compile(compiled -> { + // Initialize the context w/ the generated ACI + GenericApplicationContext freshApplicationContext = new GenericApplicationContext(); + ApplicationContextInitializer initializer = compiled + .getInstance(ApplicationContextInitializer.class, className.toString()); + initializer.initialize(freshApplicationContext); + + assertThat(output).contains("Beginning AOT processing for binder child contexts"); + assertThat(output).contains("Pre-creating binder child context (AOT) for mockBinder2"); + assertThat(output).contains("Pre-creating binder child context (AOT) for mockBinder1"); + assertThat(output).contains("Generating AOT child context initializer for mockBinder2"); + assertThat(output).contains("Refreshing mockBinder2_context"); + assertThat(output).contains("Generating AOT child context initializer for mockBinder1"); + assertThat(output).contains("Refreshing mockBinder1_context"); + + // Refresh the initialized context and verify the binder child contexts are used + TestPropertyValues.of(AotDetector.AOT_ENABLED + "=true") + .applyToSystemProperties(freshApplicationContext::refresh); + + assertThat(output).contains("Replacing instance w/ one that uses child context initializers"); + assertThat(output).contains("Setting binder child context initializers on binder factory"); + + // Make sure we can get the binders + DefaultBinderFactory binderFactory = freshApplicationContext.getBean(DefaultBinderFactory.class); + assertThat(binderFactory.getBinder("mockBinder1", MessageChannel.class)).isNotNull(); + assertThat(output).contains("Caching the binder: mockBinder1"); + + Binder mockBinder2 = binderFactory.getBinder("mockBinder2", MessageChannel.class); + assertThat(mockBinder2).isNotNull(); + assertThat(output).contains("Caching the binder: mockBinder2"); + + assertThatIllegalStateException().isThrownBy( + () -> binderFactory.getBinder("mockBinder3", MessageChannel.class)) + .withMessageContaining("Requested binder 'mockBinder3' did not match available binders"); + + assertThatIllegalStateException().isThrownBy( + () -> binderFactory.getBinder(null, MessageChannel.class)) + .withMessageContaining("No specific name or default given - can't determine which binder to use"); + + binderFactory.setDefaultBinder("mockBinder2"); + assertThat(binderFactory.getBinder(null, MessageChannel.class)).isSameAs(mockBinder2); + }); + }); + } + + static class TestTarget { + } + + @EnableAutoConfiguration + @Configuration(proxyBeanMethods = false) + static class TestFooBinderAppConfiguration { + + @Bean + GenericConversionService integrationConversionService() { + return mock(GenericConversionService.class); + } + + @Bean + Supplier fooSource() { + return () -> "foo-" + System.currentTimeMillis(); + } + + @Bean + Consumer fooSink() { + return (foo) -> LOG.info("*** FOO: " + foo); + } + } + +} diff --git a/core/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/DefaultBinderFactoryTests.java b/core/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/DefaultBinderFactoryTests.java new file mode 100644 index 000000000..d6edc2328 --- /dev/null +++ b/core/spring-cloud-stream/src/test/java/org/springframework/cloud/stream/binder/DefaultBinderFactoryTests.java @@ -0,0 +1,47 @@ +/* + * Copyright 2023-2023 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. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.stream.binder; + +import java.util.HashMap; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link DefaultBinderFactory} + * + * @author Chris Bono + */ +class DefaultBinderFactoryTests { + + @Test + void updateBinderConfigurations() { + Map binderConfigs = new HashMap<>(); + binderConfigs.put("foo", mock(BinderConfiguration.class)); + DefaultBinderFactory binderFactory = new DefaultBinderFactory(binderConfigs, null, null); + + Map newBinderConfigs = new HashMap<>(); + newBinderConfigs.put("bar", mock(BinderConfiguration.class)); + binderFactory.updateBinderConfigurations(newBinderConfigs); + + assertThat(binderFactory.getBinderConfigurations()).containsExactlyInAnyOrderEntriesOf(newBinderConfigs); + } + +} diff --git a/core/spring-cloud-stream/src/test/resources/binder-aot-test/application.yml b/core/spring-cloud-stream/src/test/resources/binder-aot-test/application.yml new file mode 100644 index 000000000..c85248c56 --- /dev/null +++ b/core/spring-cloud-stream/src/test/resources/binder-aot-test/application.yml @@ -0,0 +1,21 @@ +spring.cloud: + function: + definition: fooSource;fooSink + stream: + default-binder: mockBinder1 + binders: + mockBinder1: + type: mock + environment: + foo: bar1 + mockBinder2: + type: mock + environment: + foo: bar2 + bindings: + fooSource-out-0: + destination: fooSink-in-0 + binder: mockBinder2 + fooSink-in-0: + destination: fooSource-out-0 + binder: mockBinder2