GH-2655: AOT support user declared binders

- Adds support for AOT child context generation
  for user-declared binder configurations.

Fixes #2655
This commit is contained in:
Chris Bono
2023-04-03 13:40:57 -05:00
committed by Soby Chacko
parent 43d3d9c415
commit a01bf30d38
7 changed files with 356 additions and 28 deletions

View File

@@ -64,6 +64,11 @@
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-core-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-autoconfigure-processor</artifactId>

View File

@@ -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<String, BinderConfiguration> 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<String, BinderConfiguration> binderConfigurations = BindingServiceConfiguration.getBinderConfigurations(
this.binderFactory.getBinderTypeRegistry(), declaredBinders);
this.binderFactory.updateBinderConfigurations(binderConfigurations);
Map<String, ConfigurableApplicationContext> 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<String, ApplicationContextInitializer<? extends ConfigurableApplicationContext>> 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<String, ApplicationContextInitializer<ConfigurableApplicationContext>> downcastedInitializers =
childContextInitializers.entrySet().stream()
.map(e -> Map.entry(e.getKey(), (ApplicationContextInitializer<ConfigurableApplicationContext>) e.getValue()))

View File

@@ -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<String, BinderConfiguration> 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 <T> Binder<T, ?, ?> getBinder(String name,
Class<? extends T> bindingTargetType) {
public synchronized <T> Binder<T, ?, ?> getBinder(String name, Class<? extends T> bindingTargetType) {
String binderName = StringUtils.hasText(name) ? name : this.defaultBinder;
Map<String, Binder> binders = this.context == null ? Collections.emptyMap()
: this.context.getBeansOfType(Binder.class);
Map<String, Binder> binders = this.context == null ? Collections.emptyMap() : this.context.getBeansOfType(Binder.class);
Binder<T, ConsumerProperties, ProducerProperties> binder;
if (StringUtils.hasText(binderName) && binders.containsKey(binderName)) {
binder = (Binder<T, ConsumerProperties, ProducerProperties>) this.context
.getBean(binderName);
binder = (Binder<T, ConsumerProperties, ProducerProperties>) 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 <T> Binder<T, ConsumerProperties, ProducerProperties> doGetBinder(String name, Class<? extends T> 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 <T> Binder<T, ConsumerProperties, ProducerProperties> doGetBinderAOT(String name, Class<? extends T> 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 <T> Binder<T, ConsumerProperties, ProducerProperties> 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);
}

View File

@@ -90,7 +90,7 @@ public class BindingServiceConfiguration {
@Autowired(required = false)
private Collection<DefaultBinderFactory.Listener> binderFactoryListeners;
private static Map<String, BinderConfiguration> getBinderConfigurations(
public static Map<String, BinderConfiguration> getBinderConfigurations(
BinderTypeRegistry binderTypeRegistry,
BindingServiceProperties bindingServiceProperties) {

View File

@@ -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<GenericApplicationContext> 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<MessageChannel, ?, ?> 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<GenericApplicationContext> 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<String> fooSource() {
return () -> "foo-" + System.currentTimeMillis();
}
@Bean
Consumer<String> fooSink() {
return (foo) -> LOG.info("*** FOO: " + foo);
}
}
}

View File

@@ -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<String, BinderConfiguration> binderConfigs = new HashMap<>();
binderConfigs.put("foo", mock(BinderConfiguration.class));
DefaultBinderFactory binderFactory = new DefaultBinderFactory(binderConfigs, null, null);
Map<String, BinderConfiguration> newBinderConfigs = new HashMap<>();
newBinderConfigs.put("bar", mock(BinderConfiguration.class));
binderFactory.updateBinderConfigurations(newBinderConfigs);
assertThat(binderFactory.getBinderConfigurations()).containsExactlyInAnyOrderEntriesOf(newBinderConfigs);
}
}

View File

@@ -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