diff --git a/src/main/java/org/springframework/data/repository/config/DefaultAotRepositoryContext.java b/src/main/java/org/springframework/data/repository/config/DefaultAotRepositoryContext.java index 359dbdb0a7..6284f0a66a 100644 --- a/src/main/java/org/springframework/data/repository/config/DefaultAotRepositoryContext.java +++ b/src/main/java/org/springframework/data/repository/config/DefaultAotRepositoryContext.java @@ -19,7 +19,9 @@ import java.util.Collection; import java.util.Collections; import java.util.LinkedHashSet; +import java.util.Map; import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Collectors; import org.springframework.beans.factory.support.RegisteredBean; @@ -36,6 +38,7 @@ * @author Christoph Strobl * @author John Blum * @author Mark Paluch + * @author Blaz Snuderl * @see AotRepositoryContext * @since 3.0 */ @@ -50,6 +53,7 @@ class DefaultAotRepositoryContext extends AotRepositoryContextSupport { private Collection> identifyingAnnotations = Collections.emptySet(); private String beanName; + private Map>> identifyingTypesCache = new ConcurrentHashMap<>(); public DefaultAotRepositoryContext(RegisteredBean bean, RepositoryInformation repositoryInformation, String moduleName, AotContext aotContext, RepositoryConfigurationSource configurationSource) { @@ -91,6 +95,15 @@ public void setIdentifyingAnnotations(Collection> id this.identifyingAnnotations = identifyingAnnotations; } + /** + * Set the cache to reuse types discovered through scanning base packages for + * {@link #getIdentifyingAnnotations() identifying annotations}. Repository contexts sharing a cache scan and inspect a + * set of base packages once instead of once per repository. + */ + public void setIdentifyingTypesCache(Map>> identifyingTypesCache) { + this.identifyingTypesCache = identifyingTypesCache; + } + @Override public RepositoryInformation getRepositoryInformation() { return repositoryInformation; @@ -132,13 +145,27 @@ protected Set> discoverTypes() { if (!getIdentifyingAnnotations().isEmpty()) { - Set> classes = aotContext.getTypeScanner() - .scanPackages(getConfigurationSource().getBasePackages().toSet()) - .forTypesAnnotatedWith(getIdentifyingAnnotations()).collectAsSet(); - types.addAll(TypeCollector.inspect(classes).list()); + Set basePackages = getConfigurationSource().getBasePackages().toSet(); + IdentifyingTypesKey cacheKey = new IdentifyingTypesKey(basePackages, Set.copyOf(getIdentifyingAnnotations())); + + types.addAll(identifyingTypesCache.computeIfAbsent(cacheKey, this::discoverIdentifyingTypes)); } return types; } + private Set> discoverIdentifyingTypes(IdentifyingTypesKey key) { + + Set> classes = aotContext.getTypeScanner().scanPackages(key.basePackages()) + .forTypesAnnotatedWith(key.identifyingAnnotations()).collectAsSet(); + + return new LinkedHashSet<>(TypeCollector.inspect(classes).list()); + } + + /** + * Key to cache types discovered by scanning base packages for identifying annotations. + */ + record IdentifyingTypesKey(Set basePackages, Set> identifyingAnnotations) { + } + } diff --git a/src/main/java/org/springframework/data/repository/config/RepositoryRegistrationAotProcessor.java b/src/main/java/org/springframework/data/repository/config/RepositoryRegistrationAotProcessor.java index 547a38e912..26d05c1a41 100644 --- a/src/main/java/org/springframework/data/repository/config/RepositoryRegistrationAotProcessor.java +++ b/src/main/java/org/springframework/data/repository/config/RepositoryRegistrationAotProcessor.java @@ -21,6 +21,7 @@ import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import java.util.function.Consumer; import java.util.stream.Stream; @@ -78,6 +79,7 @@ * @author Christoph Strobl * @author John Blum * @author Mark Paluch + * @author Blaz Snuderl * @since 3.0 */ public class RepositoryRegistrationAotProcessor @@ -103,6 +105,8 @@ public class RepositoryRegistrationAotProcessor private Map> configMap = Collections.emptyMap(); + private final Map>> identifyingTypesCache = new ConcurrentHashMap<>(); + @Override public void setBeanFactory(BeanFactory beanFactory) throws BeansException { @@ -261,8 +265,9 @@ private void configureDomainTypeContributions(AotRepositoryContext repositoryCon .filter(it -> TypeContributor.isPartOf(it, Set.of(information.getDomainType().getPackageName()))) .forEach(it -> configureTypeContribution(it, repositoryContext)); - repositoryContext.getResolvedTypes().stream().filter(it -> !isJavaOrPrimitiveType(it)) - .forEach(it -> contributeType(it, generationContext)); + TypeContributor.contribute( + repositoryContext.getResolvedTypes().stream().filter(it -> !isJavaOrPrimitiveType(it)).toList(), it -> true, + generationContext); } /** @@ -365,6 +370,7 @@ private static void registerReflectiveHints(Class typeToRegister, GenerationC extension.getModuleName(), aotContext, configuration.getConfigurationSource()); repositoryContext.setIdentifyingAnnotations(extension.getIdentifyingAnnotations()); + repositoryContext.setIdentifyingTypesCache(identifyingTypesCache); return repositoryContext; } diff --git a/src/main/java/org/springframework/data/util/TypeCollector.java b/src/main/java/org/springframework/data/util/TypeCollector.java index 5b8be8ba5c..540337cc2b 100644 --- a/src/main/java/org/springframework/data/util/TypeCollector.java +++ b/src/main/java/org/springframework/data/util/TypeCollector.java @@ -60,6 +60,7 @@ * @author Sebastien Deleuze * @author John Blum * @author Mark Paluch + * @author Blaz Snuderl * @since 3.0 */ public class TypeCollector { @@ -197,8 +198,8 @@ public Predicate> getTypeFilter() { return this.typeFilter; } - private void process(Class root, Consumer consumer) { - processType(ResolvableType.forType(root), new InspectionCache(), consumer); + private void process(Class root, InspectionCache cache, Consumer consumer) { + processType(ResolvableType.forType(root), cache, consumer); } private void processType(ResolvableType type, InspectionCache cache, Consumer callback) { @@ -318,7 +319,10 @@ public static class ReachableTypes { * @param action The action to be performed for each element */ public void forEach(Consumer action) { - roots.forEach(it -> typeCollector.process(it, action)); + + InspectionCache cache = new InspectionCache(); + + roots.forEach(it -> typeCollector.process(it, cache, action)); } /** diff --git a/src/main/java/org/springframework/data/util/TypeContributor.java b/src/main/java/org/springframework/data/util/TypeContributor.java index 802ec9429a..a35e0febc6 100644 --- a/src/main/java/org/springframework/data/util/TypeContributor.java +++ b/src/main/java/org/springframework/data/util/TypeContributor.java @@ -16,17 +16,26 @@ package org.springframework.data.util; import java.lang.annotation.Annotation; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.Collection; import java.util.Collections; +import java.util.List; +import java.util.Map; import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import java.util.function.Predicate; import org.springframework.aot.generate.GenerationContext; import org.springframework.aot.hint.BindingReflectionHintsRegistrar; +import org.springframework.aot.hint.RuntimeHints; import org.springframework.aot.hint.annotation.ReflectiveRuntimeHintsRegistrar; import org.springframework.core.annotation.MergedAnnotation; +import org.springframework.util.ConcurrentReferenceHashMap; /** * @author Christoph Strobl + * @author Blaz Snuderl * @since 3.0 */ public class TypeContributor { @@ -35,6 +44,14 @@ public class TypeContributor { public static final BindingReflectionHintsRegistrar DATA_BINDING_REGISTRAR = new BindingReflectionHintsRegistrar(); public static final ReflectiveRuntimeHintsRegistrar REFLECTIVE_REGISTRAR = new ReflectiveRuntimeHintsRegistrar(); + /** + * Types already contributed to a {@link RuntimeHints} instance. Contributing a (non-annotation) type twice registers + * the very same hints again while re-walking its entire type graph, which is why contributions are tracked per + * {@link RuntimeHints}. Keys are held weakly to not retain hints beyond AOT processing. + */ + private static final Map>> contributedTypes = new ConcurrentReferenceHashMap<>(8, + ConcurrentReferenceHashMap.ReferenceType.WEAK); + /** * Contribute the type with default reflection configuration, skip annotations. * @@ -68,10 +85,71 @@ public static void contribute(Class type, Predicate + * Data binding hints are registered for all given types in a single pass so that types reachable from more than one of + * the given types are visited once instead of once per given type. + * + * @param types the types to contribute. + * @param filter filter to include annotation types. + * @param contribution the generation context to contribute to. + * @since 4.2 + */ + @SuppressWarnings("unchecked") + public static void contribute(Collection> types, Predicate> filter, + GenerationContext contribution) { + + List> dataBindingTypes = new ArrayList<>(types.size()); + + for (Class type : types) { + + if (type.isPrimitive()) { + continue; + } + + if (type.isAnnotation() && filter.test((Class) type)) { + + contribution.getRuntimeHints().reflection().registerType(type, hint -> {}); + continue; + } + + if (!isNewContribution(type, contribution)) { + continue; + } + + dataBindingTypes.add(type); + } + + if (dataBindingTypes.isEmpty()) { + return; + } + + DATA_BINDING_REGISTRAR.registerReflectionHints(contribution.getRuntimeHints().reflection(), + dataBindingTypes.toArray(Type[]::new)); + + for (Class type : dataBindingTypes) { + REFLECTIVE_REGISTRAR.registerRuntimeHints(contribution.getRuntimeHints(), type); + } + } + + /** + * Register the type as contributed to the given {@link GenerationContext} returning whether the type was contributed + * for the first time. + */ + private static boolean isNewContribution(Class type, GenerationContext contribution) { + return contributedTypes.computeIfAbsent(contribution.getRuntimeHints(), it -> ConcurrentHashMap.newKeySet()) + .add(type); + } + /** * Contribute the type with default reflection configuration and only include annotations from a certain namespace and * those meta annotated with one of them. diff --git a/src/test/java/org/springframework/data/aot/TypeCollectorUnitTests.java b/src/test/java/org/springframework/data/aot/TypeCollectorUnitTests.java index 315116363a..14b8ebfe4a 100644 --- a/src/test/java/org/springframework/data/aot/TypeCollectorUnitTests.java +++ b/src/test/java/org/springframework/data/aot/TypeCollectorUnitTests.java @@ -29,6 +29,7 @@ * * @author Christoph Strobl * @author Mark Paluch + * @author Blaz Snuderl */ public class TypeCollectorUnitTests { @@ -67,6 +68,13 @@ void includesDeclaredClassesInInspection() { WithDeclaredClass.SomeEnum.class); } + @Test + void inspectsTypesReachableFromMultipleRootsOnce() { + + assertThat(TypeCollector.inspect(CyclicPropertiesA.class, CyclicPropertiesB.class).list()) + .containsExactlyInAnyOrder(CyclicPropertiesA.class, CyclicPropertiesB.class); + } + @Test // GH-2744 void skipsCoreFrameworkType() { assertThat(TypeCollector.inspect(org.springframework.core.AliasRegistry.class).list()).isEmpty(); diff --git a/src/test/java/org/springframework/data/util/TypeContributorUnitTests.java b/src/test/java/org/springframework/data/util/TypeContributorUnitTests.java new file mode 100644 index 0000000000..6ab4a234e5 --- /dev/null +++ b/src/test/java/org/springframework/data/util/TypeContributorUnitTests.java @@ -0,0 +1,69 @@ +/* + * Copyright 2025-present 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.data.util; + +import static org.assertj.core.api.Assertions.*; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import org.springframework.aot.generate.GenerationContext; +import org.springframework.aot.hint.TypeReference; +import org.springframework.aot.test.generate.TestGenerationContext; +import org.springframework.data.aot.types.CyclicPropertiesA; +import org.springframework.data.aot.types.CyclicPropertiesB; + +/** + * Unit tests for {@link TypeContributor}. + * + * @author Blaz Snuderl + */ +class TypeContributorUnitTests { + + @Test + void contributesTypesOnlyOncePerGenerationContext() { + + GenerationContext generationContext = new TestGenerationContext(); + + TypeContributor.contribute(CyclicPropertiesA.class, it -> true, generationContext); + + List afterFirstContribution = registeredTypes(generationContext); + + TypeContributor.contribute(CyclicPropertiesA.class, it -> true, generationContext); + + assertThat(registeredTypes(generationContext)).containsExactlyElementsOf(afterFirstContribution); + } + + @Test + void contributingCollectionRegistersSameHintsAsIndividualContributions() { + + GenerationContext individually = new TestGenerationContext(); + + TypeContributor.contribute(CyclicPropertiesA.class, it -> true, individually); + TypeContributor.contribute(CyclicPropertiesB.class, it -> true, individually); + + GenerationContext batched = new TestGenerationContext(); + + TypeContributor.contribute(List.of(CyclicPropertiesA.class, CyclicPropertiesB.class), it -> true, batched); + + assertThat(registeredTypes(batched)).containsExactlyInAnyOrderElementsOf(registeredTypes(individually)); + } + + private static List registeredTypes(GenerationContext generationContext) { + return generationContext.getRuntimeHints().reflection().typeHints().map(it -> it.getType()).sorted().toList(); + } +}