From d62b36cd209c38f807196fe0056e6d39e9c20241 Mon Sep 17 00:00:00 2001 From: Andreas Hufler Date: Wed, 6 May 2026 08:22:11 +0200 Subject: [PATCH 1/5] use a more light-weight method for class scanning --- .../reflection/util/ClassScannerUtil.java | 58 +++++----- .../reflection/util/ClassScannerUtilTest.java | 106 ++++++++++++++++++ .../testfixtures/ScanTestAbstractImpl.java | 4 + .../testfixtures/ScanTestAnnotatedClass.java | 5 + .../util/testfixtures/ScanTestAnnotation.java | 11 ++ .../testfixtures/ScanTestConcreteImpl.java | 4 + .../util/testfixtures/ScanTestInterface.java | 4 + 7 files changed, 167 insertions(+), 25 deletions(-) create mode 100644 src/test/java/it/aboutbits/springboot/toolbox/reflection/util/ClassScannerUtilTest.java create mode 100644 src/test/java/it/aboutbits/springboot/toolbox/reflection/util/testfixtures/ScanTestAbstractImpl.java create mode 100644 src/test/java/it/aboutbits/springboot/toolbox/reflection/util/testfixtures/ScanTestAnnotatedClass.java create mode 100644 src/test/java/it/aboutbits/springboot/toolbox/reflection/util/testfixtures/ScanTestAnnotation.java create mode 100644 src/test/java/it/aboutbits/springboot/toolbox/reflection/util/testfixtures/ScanTestConcreteImpl.java create mode 100644 src/test/java/it/aboutbits/springboot/toolbox/reflection/util/testfixtures/ScanTestInterface.java diff --git a/src/main/java/it/aboutbits/springboot/toolbox/reflection/util/ClassScannerUtil.java b/src/main/java/it/aboutbits/springboot/toolbox/reflection/util/ClassScannerUtil.java index 195feb7..ba4b22d 100644 --- a/src/main/java/it/aboutbits/springboot/toolbox/reflection/util/ClassScannerUtil.java +++ b/src/main/java/it/aboutbits/springboot/toolbox/reflection/util/ClassScannerUtil.java @@ -1,41 +1,33 @@ package it.aboutbits.springboot.toolbox.reflection.util; -import io.github.classgraph.ClassGraph; -import io.github.classgraph.ClassInfo; -import io.github.classgraph.ScanResult; +import lombok.SneakyThrows; import org.jspecify.annotations.NullMarked; +import org.springframework.beans.factory.annotation.AnnotatedBeanDefinition; +import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider; +import org.springframework.core.type.filter.AnnotationTypeFilter; +import org.springframework.core.type.filter.AssignableTypeFilter; import java.lang.annotation.Annotation; import java.util.Arrays; -import java.util.Map; +import java.util.Objects; import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Collectors; @NullMarked public final class ClassScannerUtil { - private static final Map CACHE = new ConcurrentHashMap<>(); private ClassScannerUtil() { } public static ClassScanner getScannerForPackages(String... packages) { - var cacheKey = Arrays.stream(packages) - .sorted() - .collect(Collectors.joining("|")); - return CACHE.computeIfAbsent(cacheKey, _ -> new ClassScanner(packages)); + return new ClassScanner(packages); } public static final class ClassScanner { - private final ScanResult scanResult; private final String[] packages; private ClassScanner(String... packages) { this.packages = packages; - this.scanResult = new ClassGraph() - .enableAllInfo() - .acceptPackages(packages) - .scan(); } public String[] getScannedPackages() { @@ -44,20 +36,36 @@ public String[] getScannedPackages() { @SuppressWarnings("unchecked") public Set> getSubTypesOf(Class clazz) { - var classInfoList = clazz.isInterface() - ? scanResult.getClassesImplementing(clazz) - : scanResult.getSubclasses(clazz); - return classInfoList.loadClasses() - .stream() - .map(item -> (Class) item) + var scanner = createScanner(); + scanner.addIncludeFilter(new AssignableTypeFilter(clazz)); + return Arrays.stream(packages) + .flatMap(pkg -> scanner.findCandidateComponents(pkg).stream()) + .map(bd -> (Class) loadClass(Objects.requireNonNull(bd.getBeanClassName()))) + .filter(c -> !c.equals(clazz)) .collect(Collectors.toSet()); } public Set> getClassesAnnotatedWith(Class clazz) { - var result = scanResult.getClassesWithAnnotation(clazz); - return result.stream().map( - ClassInfo::loadClass - ).collect(Collectors.toSet()); + var scanner = createScanner(); + scanner.addIncludeFilter(new AnnotationTypeFilter(clazz)); + return Arrays.stream(packages) + .flatMap(pkg -> scanner.findCandidateComponents(pkg).stream()) + .map(bd -> loadClass(Objects.requireNonNull(bd.getBeanClassName()))) + .collect(Collectors.toSet()); + } + + private static ClassPathScanningCandidateComponentProvider createScanner() { + return new ClassPathScanningCandidateComponentProvider(false) { + @Override + protected boolean isCandidateComponent(AnnotatedBeanDefinition beanDefinition) { + return true; + } + }; + } + + @SneakyThrows(ClassNotFoundException.class) + private static Class loadClass(String className) { + return Class.forName(className); } } } diff --git a/src/test/java/it/aboutbits/springboot/toolbox/reflection/util/ClassScannerUtilTest.java b/src/test/java/it/aboutbits/springboot/toolbox/reflection/util/ClassScannerUtilTest.java new file mode 100644 index 0000000..2bb7c6c --- /dev/null +++ b/src/test/java/it/aboutbits/springboot/toolbox/reflection/util/ClassScannerUtilTest.java @@ -0,0 +1,106 @@ +package it.aboutbits.springboot.toolbox.reflection.util; + +import it.aboutbits.springboot.toolbox.reflection.util.testfixtures.ScanTestAbstractImpl; +import it.aboutbits.springboot.toolbox.reflection.util.testfixtures.ScanTestAnnotatedClass; +import it.aboutbits.springboot.toolbox.reflection.util.testfixtures.ScanTestAnnotation; +import it.aboutbits.springboot.toolbox.reflection.util.testfixtures.ScanTestConcreteImpl; +import it.aboutbits.springboot.toolbox.reflection.util.testfixtures.ScanTestInterface; +import org.jspecify.annotations.NullMarked; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +@NullMarked +class ClassScannerUtilTest { + private static final String FIXTURES_PACKAGE = "it.aboutbits.springboot.toolbox.reflection.util.testfixtures"; + private static final String OTHER_PACKAGE = "it.aboutbits.springboot.toolbox.util"; + + @Nested + class GetSubTypesOf { + @Test + @DisplayName("Should find concrete and abstract subtypes of an interface") + void shouldFindConcreteAndAbstractSubtypesOfInterface() { + // given + var scanner = ClassScannerUtil.getScannerForPackages(FIXTURES_PACKAGE); + + // when + var result = scanner.getSubTypesOf(ScanTestInterface.class); + + // then + assertThat(result).containsExactlyInAnyOrder(ScanTestConcreteImpl.class, ScanTestAbstractImpl.class); + } + + @Test + @DisplayName("Should not include the target interface itself") + void shouldNotIncludeTargetInterfaceItself() { + // given + var scanner = ClassScannerUtil.getScannerForPackages(FIXTURES_PACKAGE); + + // when + var result = scanner.getSubTypesOf(ScanTestInterface.class); + + // then + assertThat(result).doesNotContain(ScanTestInterface.class); + } + + @Test + @DisplayName("Should return empty set when no subtypes exist in scanned package") + void shouldReturnEmptySetWhenNoSubtypesExistInScannedPackage() { + // given + var scanner = ClassScannerUtil.getScannerForPackages(OTHER_PACKAGE); + + // when + var result = scanner.getSubTypesOf(ScanTestInterface.class); + + // then + assertThat(result).isEmpty(); + } + } + + @Nested + class GetClassesAnnotatedWith { + @Test + @DisplayName("Should find classes annotated with the given annotation") + void shouldFindClassesAnnotatedWithGivenAnnotation() { + // given + var scanner = ClassScannerUtil.getScannerForPackages(FIXTURES_PACKAGE); + + // when + var result = scanner.getClassesAnnotatedWith(ScanTestAnnotation.class); + + // then + assertThat(result).containsExactly(ScanTestAnnotatedClass.class); + } + + @Test + @DisplayName("Should return empty set when no annotated classes exist in scanned package") + void shouldReturnEmptySetWhenNoAnnotatedClassesExistInScannedPackage() { + // given + var scanner = ClassScannerUtil.getScannerForPackages(OTHER_PACKAGE); + + // when + var result = scanner.getClassesAnnotatedWith(ScanTestAnnotation.class); + + // then + assertThat(result).isEmpty(); + } + } + + @Nested + class GetScannedPackages { + @Test + @DisplayName("Should return the packages provided at construction") + void shouldReturnPackagesProvidedAtConstruction() { + // given + var scanner = ClassScannerUtil.getScannerForPackages(FIXTURES_PACKAGE, OTHER_PACKAGE); + + // when + var result = scanner.getScannedPackages(); + + // then + assertThat(result).containsExactlyInAnyOrder(FIXTURES_PACKAGE, OTHER_PACKAGE); + } + } +} diff --git a/src/test/java/it/aboutbits/springboot/toolbox/reflection/util/testfixtures/ScanTestAbstractImpl.java b/src/test/java/it/aboutbits/springboot/toolbox/reflection/util/testfixtures/ScanTestAbstractImpl.java new file mode 100644 index 0000000..1eeae1f --- /dev/null +++ b/src/test/java/it/aboutbits/springboot/toolbox/reflection/util/testfixtures/ScanTestAbstractImpl.java @@ -0,0 +1,4 @@ +package it.aboutbits.springboot.toolbox.reflection.util.testfixtures; + +public abstract class ScanTestAbstractImpl implements ScanTestInterface { +} diff --git a/src/test/java/it/aboutbits/springboot/toolbox/reflection/util/testfixtures/ScanTestAnnotatedClass.java b/src/test/java/it/aboutbits/springboot/toolbox/reflection/util/testfixtures/ScanTestAnnotatedClass.java new file mode 100644 index 0000000..ea16b33 --- /dev/null +++ b/src/test/java/it/aboutbits/springboot/toolbox/reflection/util/testfixtures/ScanTestAnnotatedClass.java @@ -0,0 +1,5 @@ +package it.aboutbits.springboot.toolbox.reflection.util.testfixtures; + +@ScanTestAnnotation +public class ScanTestAnnotatedClass { +} diff --git a/src/test/java/it/aboutbits/springboot/toolbox/reflection/util/testfixtures/ScanTestAnnotation.java b/src/test/java/it/aboutbits/springboot/toolbox/reflection/util/testfixtures/ScanTestAnnotation.java new file mode 100644 index 0000000..765b24c --- /dev/null +++ b/src/test/java/it/aboutbits/springboot/toolbox/reflection/util/testfixtures/ScanTestAnnotation.java @@ -0,0 +1,11 @@ +package it.aboutbits.springboot.toolbox.reflection.util.testfixtures; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +public @interface ScanTestAnnotation { +} diff --git a/src/test/java/it/aboutbits/springboot/toolbox/reflection/util/testfixtures/ScanTestConcreteImpl.java b/src/test/java/it/aboutbits/springboot/toolbox/reflection/util/testfixtures/ScanTestConcreteImpl.java new file mode 100644 index 0000000..7962cda --- /dev/null +++ b/src/test/java/it/aboutbits/springboot/toolbox/reflection/util/testfixtures/ScanTestConcreteImpl.java @@ -0,0 +1,4 @@ +package it.aboutbits.springboot.toolbox.reflection.util.testfixtures; + +public class ScanTestConcreteImpl implements ScanTestInterface { +} diff --git a/src/test/java/it/aboutbits/springboot/toolbox/reflection/util/testfixtures/ScanTestInterface.java b/src/test/java/it/aboutbits/springboot/toolbox/reflection/util/testfixtures/ScanTestInterface.java new file mode 100644 index 0000000..bd5e3fb --- /dev/null +++ b/src/test/java/it/aboutbits/springboot/toolbox/reflection/util/testfixtures/ScanTestInterface.java @@ -0,0 +1,4 @@ +package it.aboutbits.springboot.toolbox.reflection.util.testfixtures; + +public interface ScanTestInterface { +} From 974b5b3aa9f71c0159b183cbc4100c0c9beb8779 Mon Sep 17 00:00:00 2001 From: Andreas Hufler Date: Wed, 6 May 2026 08:28:16 +0200 Subject: [PATCH 2/5] add missing annotations --- .../reflection/util/testfixtures/ScanTestAbstractImpl.java | 3 +++ .../reflection/util/testfixtures/ScanTestAnnotatedClass.java | 3 +++ .../reflection/util/testfixtures/ScanTestConcreteImpl.java | 3 +++ .../reflection/util/testfixtures/ScanTestInterface.java | 3 +++ 4 files changed, 12 insertions(+) diff --git a/src/test/java/it/aboutbits/springboot/toolbox/reflection/util/testfixtures/ScanTestAbstractImpl.java b/src/test/java/it/aboutbits/springboot/toolbox/reflection/util/testfixtures/ScanTestAbstractImpl.java index 1eeae1f..17d8a84 100644 --- a/src/test/java/it/aboutbits/springboot/toolbox/reflection/util/testfixtures/ScanTestAbstractImpl.java +++ b/src/test/java/it/aboutbits/springboot/toolbox/reflection/util/testfixtures/ScanTestAbstractImpl.java @@ -1,4 +1,7 @@ package it.aboutbits.springboot.toolbox.reflection.util.testfixtures; +import org.jspecify.annotations.NullMarked; + +@NullMarked public abstract class ScanTestAbstractImpl implements ScanTestInterface { } diff --git a/src/test/java/it/aboutbits/springboot/toolbox/reflection/util/testfixtures/ScanTestAnnotatedClass.java b/src/test/java/it/aboutbits/springboot/toolbox/reflection/util/testfixtures/ScanTestAnnotatedClass.java index ea16b33..a6d8fbc 100644 --- a/src/test/java/it/aboutbits/springboot/toolbox/reflection/util/testfixtures/ScanTestAnnotatedClass.java +++ b/src/test/java/it/aboutbits/springboot/toolbox/reflection/util/testfixtures/ScanTestAnnotatedClass.java @@ -1,5 +1,8 @@ package it.aboutbits.springboot.toolbox.reflection.util.testfixtures; +import org.jspecify.annotations.NullMarked; + @ScanTestAnnotation +@NullMarked public class ScanTestAnnotatedClass { } diff --git a/src/test/java/it/aboutbits/springboot/toolbox/reflection/util/testfixtures/ScanTestConcreteImpl.java b/src/test/java/it/aboutbits/springboot/toolbox/reflection/util/testfixtures/ScanTestConcreteImpl.java index 7962cda..ae29125 100644 --- a/src/test/java/it/aboutbits/springboot/toolbox/reflection/util/testfixtures/ScanTestConcreteImpl.java +++ b/src/test/java/it/aboutbits/springboot/toolbox/reflection/util/testfixtures/ScanTestConcreteImpl.java @@ -1,4 +1,7 @@ package it.aboutbits.springboot.toolbox.reflection.util.testfixtures; +import org.jspecify.annotations.NullMarked; + +@NullMarked public class ScanTestConcreteImpl implements ScanTestInterface { } diff --git a/src/test/java/it/aboutbits/springboot/toolbox/reflection/util/testfixtures/ScanTestInterface.java b/src/test/java/it/aboutbits/springboot/toolbox/reflection/util/testfixtures/ScanTestInterface.java index bd5e3fb..fe061b9 100644 --- a/src/test/java/it/aboutbits/springboot/toolbox/reflection/util/testfixtures/ScanTestInterface.java +++ b/src/test/java/it/aboutbits/springboot/toolbox/reflection/util/testfixtures/ScanTestInterface.java @@ -1,4 +1,7 @@ package it.aboutbits.springboot.toolbox.reflection.util.testfixtures; +import org.jspecify.annotations.NullMarked; + +@NullMarked public interface ScanTestInterface { } From e39fdc40b67462a4c1b7967e32ad055e151587f4 Mon Sep 17 00:00:00 2001 From: Andreas Hufler Date: Wed, 6 May 2026 08:30:46 +0200 Subject: [PATCH 3/5] fix test nesting --- .../reflection/util/ClassScannerUtilTest.java | 157 +++++++++--------- 1 file changed, 80 insertions(+), 77 deletions(-) diff --git a/src/test/java/it/aboutbits/springboot/toolbox/reflection/util/ClassScannerUtilTest.java b/src/test/java/it/aboutbits/springboot/toolbox/reflection/util/ClassScannerUtilTest.java index 2bb7c6c..43894da 100644 --- a/src/test/java/it/aboutbits/springboot/toolbox/reflection/util/ClassScannerUtilTest.java +++ b/src/test/java/it/aboutbits/springboot/toolbox/reflection/util/ClassScannerUtilTest.java @@ -18,89 +18,92 @@ class ClassScannerUtilTest { private static final String OTHER_PACKAGE = "it.aboutbits.springboot.toolbox.util"; @Nested - class GetSubTypesOf { - @Test - @DisplayName("Should find concrete and abstract subtypes of an interface") - void shouldFindConcreteAndAbstractSubtypesOfInterface() { - // given - var scanner = ClassScannerUtil.getScannerForPackages(FIXTURES_PACKAGE); - - // when - var result = scanner.getSubTypesOf(ScanTestInterface.class); - - // then - assertThat(result).containsExactlyInAnyOrder(ScanTestConcreteImpl.class, ScanTestAbstractImpl.class); + class ClassScanner { + @Nested + class GetSubTypesOf { + @Test + @DisplayName("Should find concrete and abstract subtypes of an interface") + void shouldFindConcreteAndAbstractSubtypesOfInterface() { + // given + var scanner = ClassScannerUtil.getScannerForPackages(FIXTURES_PACKAGE); + + // when + var result = scanner.getSubTypesOf(ScanTestInterface.class); + + // then + assertThat(result).containsExactlyInAnyOrder(ScanTestConcreteImpl.class, ScanTestAbstractImpl.class); + } + + @Test + @DisplayName("Should not include the target interface itself") + void shouldNotIncludeTargetInterfaceItself() { + // given + var scanner = ClassScannerUtil.getScannerForPackages(FIXTURES_PACKAGE); + + // when + var result = scanner.getSubTypesOf(ScanTestInterface.class); + + // then + assertThat(result).doesNotContain(ScanTestInterface.class); + } + + @Test + @DisplayName("Should return empty set when no subtypes exist in scanned package") + void shouldReturnEmptySetWhenNoSubtypesExistInScannedPackage() { + // given + var scanner = ClassScannerUtil.getScannerForPackages(OTHER_PACKAGE); + + // when + var result = scanner.getSubTypesOf(ScanTestInterface.class); + + // then + assertThat(result).isEmpty(); + } } - @Test - @DisplayName("Should not include the target interface itself") - void shouldNotIncludeTargetInterfaceItself() { - // given - var scanner = ClassScannerUtil.getScannerForPackages(FIXTURES_PACKAGE); - - // when - var result = scanner.getSubTypesOf(ScanTestInterface.class); - - // then - assertThat(result).doesNotContain(ScanTestInterface.class); - } - - @Test - @DisplayName("Should return empty set when no subtypes exist in scanned package") - void shouldReturnEmptySetWhenNoSubtypesExistInScannedPackage() { - // given - var scanner = ClassScannerUtil.getScannerForPackages(OTHER_PACKAGE); - - // when - var result = scanner.getSubTypesOf(ScanTestInterface.class); - - // then - assertThat(result).isEmpty(); - } - } - - @Nested - class GetClassesAnnotatedWith { - @Test - @DisplayName("Should find classes annotated with the given annotation") - void shouldFindClassesAnnotatedWithGivenAnnotation() { - // given - var scanner = ClassScannerUtil.getScannerForPackages(FIXTURES_PACKAGE); - - // when - var result = scanner.getClassesAnnotatedWith(ScanTestAnnotation.class); - - // then - assertThat(result).containsExactly(ScanTestAnnotatedClass.class); + @Nested + class GetClassesAnnotatedWith { + @Test + @DisplayName("Should find classes annotated with the given annotation") + void shouldFindClassesAnnotatedWithGivenAnnotation() { + // given + var scanner = ClassScannerUtil.getScannerForPackages(FIXTURES_PACKAGE); + + // when + var result = scanner.getClassesAnnotatedWith(ScanTestAnnotation.class); + + // then + assertThat(result).containsExactly(ScanTestAnnotatedClass.class); + } + + @Test + @DisplayName("Should return empty set when no annotated classes exist in scanned package") + void shouldReturnEmptySetWhenNoAnnotatedClassesExistInScannedPackage() { + // given + var scanner = ClassScannerUtil.getScannerForPackages(OTHER_PACKAGE); + + // when + var result = scanner.getClassesAnnotatedWith(ScanTestAnnotation.class); + + // then + assertThat(result).isEmpty(); + } } - @Test - @DisplayName("Should return empty set when no annotated classes exist in scanned package") - void shouldReturnEmptySetWhenNoAnnotatedClassesExistInScannedPackage() { - // given - var scanner = ClassScannerUtil.getScannerForPackages(OTHER_PACKAGE); + @Nested + class GetScannedPackages { + @Test + @DisplayName("Should return the packages provided at construction") + void shouldReturnPackagesProvidedAtConstruction() { + // given + var scanner = ClassScannerUtil.getScannerForPackages(FIXTURES_PACKAGE, OTHER_PACKAGE); - // when - var result = scanner.getClassesAnnotatedWith(ScanTestAnnotation.class); + // when + var result = scanner.getScannedPackages(); - // then - assertThat(result).isEmpty(); - } - } - - @Nested - class GetScannedPackages { - @Test - @DisplayName("Should return the packages provided at construction") - void shouldReturnPackagesProvidedAtConstruction() { - // given - var scanner = ClassScannerUtil.getScannerForPackages(FIXTURES_PACKAGE, OTHER_PACKAGE); - - // when - var result = scanner.getScannedPackages(); - - // then - assertThat(result).containsExactlyInAnyOrder(FIXTURES_PACKAGE, OTHER_PACKAGE); + // then + assertThat(result).containsExactlyInAnyOrder(FIXTURES_PACKAGE, OTHER_PACKAGE); + } } } } From b905dd1c63041a29124eac813d0371885c3a213a Mon Sep 17 00:00:00 2001 From: AboutBits Tech Date: Wed, 6 May 2026 06:34:14 +0000 Subject: [PATCH 4/5] 2.4.5-RC1 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 14a4b51..d44bd94 100644 --- a/pom.xml +++ b/pom.xml @@ -11,7 +11,7 @@ it.aboutbits spring-boot-toolbox - 2.4.4 + 2.4.5-RC1 Utility library for Spring Boot projects. jar From 17bea2994c00d2abeb25aed28737ee2e1f91a7a9 Mon Sep 17 00:00:00 2001 From: Andreas Hufler Date: Wed, 6 May 2026 08:39:55 +0200 Subject: [PATCH 5/5] remove now obsolete depencency for class scanner --- pom.xml | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/pom.xml b/pom.xml index d44bd94..6d6e5ed 100644 --- a/pom.xml +++ b/pom.xml @@ -1,5 +1,6 @@ - + 4.0.0 @@ -69,15 +70,6 @@ true - - - - io.github.classgraph - classgraph - 4.8.184 - - -