diff --git a/spring-batch-core/src/main/java/org/springframework/batch/core/configuration/support/JobRegistryBeanPostProcessor.java b/spring-batch-core/src/main/java/org/springframework/batch/core/configuration/support/JobRegistryBeanPostProcessor.java index 0670560c94..69ffe907e9 100644 --- a/spring-batch-core/src/main/java/org/springframework/batch/core/configuration/support/JobRegistryBeanPostProcessor.java +++ b/spring-batch-core/src/main/java/org/springframework/batch/core/configuration/support/JobRegistryBeanPostProcessor.java @@ -1,5 +1,5 @@ /* - * Copyright 2006-2023 the original author or authors. + * Copyright 2006-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. @@ -40,6 +40,10 @@ * {@link JobRegistry}. Include a bean of this type along with your job configuration and * use the same {@link JobRegistry} as a {@link JobLocator} when you need to locate a * {@link Job} to launch. + *

+ * An alternative to this class is {@link JobRegistrySmartInitializingSingleton}, which is + * recommended in cases where this class may cause early bean initializations. You must + * include at most one of either of them as a bean. * * @author Dave Syer * @author Mahmoud Ben Hassine diff --git a/spring-batch-core/src/main/java/org/springframework/batch/core/configuration/support/JobRegistrySmartInitializingSingleton.java b/spring-batch-core/src/main/java/org/springframework/batch/core/configuration/support/JobRegistrySmartInitializingSingleton.java new file mode 100644 index 0000000000..9e4bbb3a4f --- /dev/null +++ b/spring-batch-core/src/main/java/org/springframework/batch/core/configuration/support/JobRegistrySmartInitializingSingleton.java @@ -0,0 +1,174 @@ +/* + * Copyright 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. + * 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.batch.core.configuration.support; + +import java.util.Collection; +import java.util.HashSet; +import java.util.Map; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.configuration.DuplicateJobException; +import org.springframework.batch.core.configuration.JobLocator; +import org.springframework.batch.core.configuration.JobRegistry; +import org.springframework.beans.BeansException; +import org.springframework.beans.FatalBeanException; +import org.springframework.beans.factory.BeanFactory; +import org.springframework.beans.factory.BeanFactoryAware; +import org.springframework.beans.factory.DisposableBean; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.beans.factory.ListableBeanFactory; +import org.springframework.beans.factory.SmartInitializingSingleton; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.beans.factory.support.DefaultListableBeanFactory; +import org.springframework.util.Assert; + +/** + * A {@link SmartInitializingSingleton} that registers {@link Job} beans with a + * {@link JobRegistry}. Include a bean of this type along with your job configuration and + * use the same {@link JobRegistry} as a {@link JobLocator} when you need to locate a + * {@link Job} to launch. + *

+ * This class is an alternative to {@link JobRegistryBeanPostProcessor} and prevents early + * bean initializations. You must include at most one of either of them as a bean. + * + * @author Henning Pöttker + * @since 5.1.1 + */ +public class JobRegistrySmartInitializingSingleton + implements SmartInitializingSingleton, BeanFactoryAware, InitializingBean, DisposableBean { + + private static final Log logger = LogFactory.getLog(JobRegistrySmartInitializingSingleton.class); + + // It doesn't make sense for this to have a default value... + private JobRegistry jobRegistry = null; + + private final Collection jobNames = new HashSet<>(); + + private String groupName = null; + + private ListableBeanFactory beanFactory; + + /** + * Default constructor. + */ + public JobRegistrySmartInitializingSingleton() { + } + + /** + * Convenience constructor for setting the {@link JobRegistry}. + * @param jobRegistry the {@link JobRegistry} to register the {@link Job}s with + */ + public JobRegistrySmartInitializingSingleton(JobRegistry jobRegistry) { + this.jobRegistry = jobRegistry; + } + + /** + * The group name for jobs registered by this component. Optional (defaults to null, + * which means that jobs are registered with their bean names). Useful where there is + * a hierarchy of application contexts all contributing to the same + * {@link JobRegistry}: child contexts can then define an instance with a unique group + * name to avoid clashes between job names. + * @param groupName the groupName to set + */ + public void setGroupName(String groupName) { + this.groupName = groupName; + } + + /** + * Injection setter for {@link JobRegistry}. + * @param jobRegistry the {@link JobRegistry} to register the {@link Job}s with + */ + public void setJobRegistry(JobRegistry jobRegistry) { + this.jobRegistry = jobRegistry; + } + + @Override + public void setBeanFactory(BeanFactory beanFactory) throws BeansException { + if (beanFactory instanceof ListableBeanFactory listableBeanFactory) { + this.beanFactory = listableBeanFactory; + } + } + + /** + * Make sure the registry is set before use. + */ + @Override + public void afterPropertiesSet() throws Exception { + Assert.state(jobRegistry != null, "JobRegistry must not be null"); + } + + /** + * Unregister all the {@link Job} instances that were registered by this post + * processor. + */ + @Override + public void destroy() throws Exception { + for (String name : jobNames) { + if (logger.isDebugEnabled()) { + logger.debug("Unregistering job: " + name); + } + jobRegistry.unregister(name); + } + jobNames.clear(); + } + + @Override + public void afterSingletonsInstantiated() { + if (beanFactory == null) { + return; + } + Map jobs = beanFactory.getBeansOfType(Job.class, false, false); + for (var entry : jobs.entrySet()) { + postProcessAfterInitialization(entry.getValue(), entry.getKey()); + } + } + + private void postProcessAfterInitialization(Job job, String beanName) { + try { + String groupName = this.groupName; + if (beanFactory instanceof DefaultListableBeanFactory defaultListableBeanFactory + && beanFactory.containsBean(beanName)) { + groupName = getGroupName(defaultListableBeanFactory.getBeanDefinition(beanName), job); + } + job = groupName == null ? job : new GroupAwareJob(groupName, job); + ReferenceJobFactory jobFactory = new ReferenceJobFactory(job); + String name = jobFactory.getJobName(); + if (logger.isDebugEnabled()) { + logger.debug("Registering job: " + name); + } + jobRegistry.register(jobFactory); + jobNames.add(name); + } + catch (DuplicateJobException e) { + throw new FatalBeanException("Cannot register job configuration", e); + } + } + + /** + * Determine a group name for the job to be registered. The default implementation + * returns the {@link #setGroupName(String) groupName} configured. Provides an + * extension point for specialised subclasses. + * @param beanDefinition the bean definition for the job + * @param job the job + * @return a group name for the job (or null if not needed) + */ + protected String getGroupName(BeanDefinition beanDefinition, Job job) { + return groupName; + } + +} diff --git a/spring-batch-core/src/test/java/org/springframework/batch/core/configuration/support/JobRegistrySmartInitializingSingletonTests.java b/spring-batch-core/src/test/java/org/springframework/batch/core/configuration/support/JobRegistrySmartInitializingSingletonTests.java new file mode 100644 index 0000000000..7738ee2d4f --- /dev/null +++ b/spring-batch-core/src/test/java/org/springframework/batch/core/configuration/support/JobRegistrySmartInitializingSingletonTests.java @@ -0,0 +1,116 @@ +/* + * Copyright 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. + * 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.batch.core.configuration.support; + +import java.util.Collection; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.configuration.DuplicateJobException; +import org.springframework.batch.core.configuration.JobRegistry; +import org.springframework.batch.core.job.JobSupport; +import org.springframework.beans.FatalBeanException; +import org.springframework.beans.factory.ListableBeanFactory; +import org.springframework.context.support.ClassPathXmlApplicationContext; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; + +/** + * @author Henning Pöttker + */ +class JobRegistrySmartInitializingSingletonTests { + + private final JobRegistry jobRegistry = new MapJobRegistry(); + + private final JobRegistrySmartInitializingSingleton singleton = new JobRegistrySmartInitializingSingleton( + jobRegistry); + + private final ListableBeanFactory beanFactory = mock(ListableBeanFactory.class); + + @BeforeEach + void setUp() { + var job = new JobSupport(); + job.setName("foo"); + lenient().when(beanFactory.getBeansOfType(Job.class, false, false)).thenReturn(Map.of("bar", job)); + singleton.setBeanFactory(beanFactory); + } + + @Test + void testInitializationFails() { + singleton.setJobRegistry(null); + var exception = assertThrows(IllegalStateException.class, singleton::afterPropertiesSet); + assertTrue(exception.getMessage().contains("JobRegistry")); + } + + @Test + void testAfterSingletonsInstantiated() { + singleton.afterSingletonsInstantiated(); + assertEquals("[foo]", jobRegistry.getJobNames().toString()); + } + + @Test + void testAfterSingletonsInstantiatedWithGroupName() { + singleton.setGroupName("jobs"); + singleton.afterSingletonsInstantiated(); + assertEquals("[jobs.foo]", jobRegistry.getJobNames().toString()); + } + + @Test + void testAfterSingletonsInstantiatedWithDuplicate() { + singleton.afterSingletonsInstantiated(); + var exception = assertThrows(FatalBeanException.class, singleton::afterSingletonsInstantiated); + assertTrue(exception.getCause() instanceof DuplicateJobException); + } + + @Test + void testUnregisterOnDestroy() throws Exception { + singleton.afterSingletonsInstantiated(); + singleton.destroy(); + assertEquals("[]", jobRegistry.getJobNames().toString()); + } + + @Test + void testExecutionWithApplicationContext() throws Exception { + var context = new ClassPathXmlApplicationContext("test-context-with-smart-initializing-singleton.xml", + getClass()); + var registry = context.getBean("registry", JobRegistry.class); + Collection jobNames = registry.getJobNames(); + String[] names = context.getBeanNamesForType(JobSupport.class); + int count = names.length; + // Each concrete bean of type JobConfiguration is registered... + assertEquals(count, jobNames.size()); + // N.B. there is a failure / wonky mode where a parent bean is given an + // explicit name or beanName (using property setter): in this case then + // child beans will have the same name and will be re-registered (and + // override, if the registry supports that). + assertNotNull(registry.getJob("test-job")); + assertEquals(context.getBean("test-job-with-name"), registry.getJob("foo")); + assertEquals(context.getBean("test-job-with-bean-name"), registry.getJob("bar")); + assertEquals(context.getBean("test-job-with-parent-and-name"), registry.getJob("spam")); + assertEquals(context.getBean("test-job-with-parent-and-bean-name"), registry.getJob("bucket")); + assertEquals(context.getBean("test-job-with-concrete-parent"), registry.getJob("maps")); + assertEquals(context.getBean("test-job-with-concrete-parent-and-name"), registry.getJob("oof")); + assertEquals(context.getBean("test-job-with-concrete-parent-and-bean-name"), registry.getJob("rab")); + } + +} diff --git a/spring-batch-core/src/test/resources/org/springframework/batch/core/configuration/support/test-context-with-smart-initializing-singleton.xml b/spring-batch-core/src/test/resources/org/springframework/batch/core/configuration/support/test-context-with-smart-initializing-singleton.xml new file mode 100644 index 0000000000..64ae6eed68 --- /dev/null +++ b/spring-batch-core/src/test/resources/org/springframework/batch/core/configuration/support/test-context-with-smart-initializing-singleton.xml @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +