Skip to content

Commit

Permalink
Support module path scanning for "classpath*:" resource prefix
Browse files Browse the repository at this point in the history
Prior to this commit, searching for classpath resources using the
"classpath*:" resource prefix did not find all applicable resources for
applications deployed as modules -- for example, when test classes and
resources are patched into the application module automatically by
Maven Surefire.

This affected component scanning -- for example, via [@]ComponentScan --
and PathMatchingResourcePatternResolver.getResources(String) in
general.

This commit addresses this by introducing first-class support for
scanning the module path when PathMatchingResourcePatternResolver's
getResources(String) method is invoked with a location pattern using
the "classpath*:" resource prefix. Specifically, getResources(String)
first searches all modules in the boot layer, excluding system modules.
It then searches the classpath using the existing Classloader-based
algorithm and returns the combined results.

Closes spring-projectsgh-28506
  • Loading branch information
sbrannen committed May 23, 2022
1 parent f07e7ab commit 957faac
Showing 1 changed file with 114 additions and 2 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.lang.module.ModuleFinder;
import java.lang.module.ModuleReader;
import java.lang.module.ResolvedModule;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.net.JarURLConnection;
Expand All @@ -32,9 +36,13 @@
import java.util.Comparator;
import java.util.Enumeration;
import java.util.LinkedHashSet;
import java.util.Objects;
import java.util.Set;
import java.util.function.Predicate;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import java.util.zip.ZipException;

import org.apache.commons.logging.Log;
Expand Down Expand Up @@ -140,6 +148,14 @@
*
* <p><b>Other notes:</b>
*
* <p>As of Spring Framework 6.0, if {@link #getResources(String)} is invoked
* with a location pattern using the "classpath*:" prefix it will first search
* all modules in the {@linkplain ModuleLayer#boot() boot layer}, excluding
* {@linkplain ModuleFinder#ofSystem() system modules}. It will then search the
* classpath using {@link Classloader} APIs as described previously and return the
* combined results. Consequently, some of the limitations of classpath searches
* may not apply when applications are deployed as modules.
*
* <p><b>WARNING:</b> Note that "{@code classpath*:}" when combined with
* Ant-style patterns will only work reliably with at least one root directory
* before the pattern starts, unless the actual target files reside in the file
Expand Down Expand Up @@ -174,6 +190,7 @@
* @author Marius Bogoevici
* @author Costin Leau
* @author Phillip Webb
* @author Sam Brannen
* @since 1.0.2
* @see #CLASSPATH_ALL_URL_PREFIX
* @see org.springframework.util.AntPathMatcher
Expand All @@ -184,6 +201,23 @@ public class PathMatchingResourcePatternResolver implements ResourcePatternResol

private static final Log logger = LogFactory.getLog(PathMatchingResourcePatternResolver.class);

/**
* {@link Set} of {@linkplain ModuleFinder#ofSystem() system module} names.
* @since 6.0
* @see #isNotSystemModule
*/
private static final Set<String> systemModuleNames = ModuleFinder.ofSystem().findAll().stream()
.map(moduleReference -> moduleReference.descriptor().name()).collect(Collectors.toSet());

/**
* {@link Predicate} that tests whether the supplied {@link ResolvedModule}
* is not a {@linkplain ModuleFinder#ofSystem() system module}.
* @since 6.0
* @see #systemModuleNames
*/
private static final Predicate<ResolvedModule> isNotSystemModule =
resolvedModule -> !systemModuleNames.contains(resolvedModule.name());

@Nullable
private static Method equinoxResolveMethod;

Expand Down Expand Up @@ -280,14 +314,17 @@ public Resource[] getResources(String locationPattern) throws IOException {
if (locationPattern.startsWith(CLASSPATH_ALL_URL_PREFIX)) {
// a class path resource (multiple resources for same name possible)
String locationPatternWithoutPrefix = locationPattern.substring(CLASSPATH_ALL_URL_PREFIX.length());
// Search the module path first.
Set<Resource> resources = findAllModulePathResources(locationPatternWithoutPrefix);
if (getPathMatcher().isPattern(locationPatternWithoutPrefix)) {
// a class path resource pattern
return findPathMatchingResources(locationPattern);
Collections.addAll(resources, findPathMatchingResources(locationPattern));
}
else {
// all class path resources with the given name
return findAllClassPathResources(locationPatternWithoutPrefix);
Collections.addAll(resources, findAllClassPathResources(locationPatternWithoutPrefix));
}
return resources.toArray(new Resource[0]);
}
else {
// Generally only look for a pattern after a prefix here,
Expand Down Expand Up @@ -830,6 +867,81 @@ protected File[] listDirectory(File dir) {
return files;
}

/**
* Resolve the given location pattern into {@code Resource} objects for all
* matching resources found in the module path.
* <p>The location pattern may be an explicit resource path such as
* {@code "com/example/config.xml"} or an
* Ant-style pattern such as <code>"com/example/**&#47;config-*.xml"</code>
* to be matched using the configured {@link #getPathMatcher() PathMatcher}.
* <p>The default implementation scans all modules in the {@linkplain ModuleLayer#boot()
* boot layer}, excluding {@linkplain ModuleFinder#ofSystem() system modules}.
* @param locationPattern the location pattern to resolve
* @return a modifiable {@code Set} containing the corresponding {@code Resource}
* objects
* @throws IOException in case of I/O errors
* @since 6.0
* @see ModuleLayer#boot()
* @see ModuleFinder#ofSystem()
* @see ModuleReader
* @see PathMatcher#match(String, String)
*/
protected Set<Resource> findAllModulePathResources(String locationPattern) throws IOException {
Set<Resource> result = new LinkedHashSet<>(16);
String resourcePattern = stripLeadingSlash(locationPattern);
Predicate<String> resourcePatternMatches = (getPathMatcher().isPattern(resourcePattern) ?
path -> getPathMatcher().match(resourcePattern, path) :
resourcePattern::equals);

try {
ModuleLayer.boot().configuration().modules().stream()
.filter(isNotSystemModule)
.forEach(resolvedModule -> {
// NOTE: a ModuleReader and a Stream returned from ModuleReader.list() must be closed.
try (ModuleReader moduleReader = resolvedModule.reference().open();
Stream<String> names = moduleReader.list()) {
names.filter(resourcePatternMatches)
.map(name -> findResource(moduleReader, name))
.filter(Objects::nonNull)
.forEach(result::add);
}
catch (IOException ex) {
if (logger.isDebugEnabled()) {
logger.debug("Failed to read contents of module [%s]".formatted(resolvedModule), ex);
}
throw new UncheckedIOException(ex);
}
});
}
catch (UncheckedIOException ex) {
// Unwrap IOException to conform to this method's contract.
throw ex.getCause();
}

if (logger.isTraceEnabled()) {
logger.trace("Resolved module-path location pattern [%s] to resources %s".formatted(resourcePattern, result));
}
return result;
}

@Nullable
private static Resource findResource(ModuleReader moduleReader, String name) {
try {
return moduleReader.find(name)
// If it's a "file:" URI, use FileSystemResource to avoid duplicates
// for the same path discovered via class-path scanning.
.map(uri -> ResourceUtils.URL_PROTOCOL_FILE.equals(uri.getScheme()) ?
new FileSystemResource(uri.getPath()) :
UrlResource.from(uri))
.orElse(null);
}
catch (Exception ex) {
if (logger.isDebugEnabled()) {
logger.debug("Failed to find resource [%s] in module path".formatted(name), ex);
}
return null;
}
}

private static String stripLeadingSlash(String path) {
return (path.startsWith("/") ? path.substring(1) : path);
Expand Down

0 comments on commit 957faac

Please sign in to comment.