Skip to content

Commit 0eb6678

Browse files
committed
Introduce generic FileSystem support in PathMatchingResourcePatternResolver
This commit introduces support in PathMatchingResourcePatternResolver for resolving files in a generic fashion using the java.nio.file.FileSystem API, including preliminary support for class path scanning within a GraalVM native image. See included commits for details. Closes gh-29163
2 parents c5fc0a5 + ccff526 commit 0eb6678

File tree

2 files changed

+93
-139
lines changed

2 files changed

+93
-139
lines changed

spring-core/src/main/java/org/springframework/core/io/support/PathMatchingResourcePatternResolver.java

+92-139
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717
package org.springframework.core.io.support;
1818

1919
import java.io.File;
20-
import java.io.FileNotFoundException;
2120
import java.io.IOException;
2221
import java.io.UncheckedIOException;
2322
import java.lang.module.ModuleFinder;
@@ -27,15 +26,19 @@
2726
import java.lang.reflect.Method;
2827
import java.net.JarURLConnection;
2928
import java.net.MalformedURLException;
29+
import java.net.URI;
3030
import java.net.URISyntaxException;
3131
import java.net.URL;
3232
import java.net.URLClassLoader;
3333
import java.net.URLConnection;
34-
import java.util.Arrays;
34+
import java.nio.file.FileSystem;
35+
import java.nio.file.FileSystems;
36+
import java.nio.file.Files;
37+
import java.nio.file.Path;
3538
import java.util.Collections;
36-
import java.util.Comparator;
3739
import java.util.Enumeration;
3840
import java.util.LinkedHashSet;
41+
import java.util.Map;
3942
import java.util.Objects;
4043
import java.util.Set;
4144
import java.util.function.Predicate;
@@ -96,14 +99,13 @@
9699
* classpath:com/mycompany/**&#47;applicationContext.xml</pre>
97100
* the resolver follows a more complex but defined procedure to try to resolve
98101
* the wildcard. It produces a {@code Resource} for the path up to the last
99-
* non-wildcard segment and obtains a {@code URL} from it. If this URL is
100-
* not a "{@code jar:}" URL or container-specific variant (e.g.
101-
* "{@code zip:}" in WebLogic, "{@code wsjar}" in WebSphere", etc.),
102-
* then a {@code java.io.File} is obtained from it, and used to resolve the
103-
* wildcard by walking the filesystem. In the case of a jar URL, the resolver
104-
* either gets a {@code java.net.JarURLConnection} from it, or manually parses
105-
* the jar URL, and then traverses the contents of the jar file, to resolve the
106-
* wildcards.
102+
* non-wildcard segment and obtains a {@code URL} from it. If this URL is not a
103+
* "{@code jar:}" URL or container-specific variant (e.g. "{@code zip:}" in WebLogic,
104+
* "{@code wsjar}" in WebSphere", etc.), then the root directory of the filesystem
105+
* associated with the URL is obtained and used to resolve the wildcards by walking
106+
* the filesystem. In the case of a jar URL, the resolver either gets a
107+
* {@code java.net.JarURLConnection} from it, or manually parses the jar URL, and
108+
* then traverses the contents of the jar file, to resolve the wildcards.
107109
*
108110
* <p><b>Implications on portability:</b>
109111
*
@@ -133,7 +135,7 @@
133135
*
134136
* <p>There is special support for retrieving multiple class path resources with
135137
* the same name, via the "{@code classpath*:}" prefix. For example,
136-
* "{@code classpath*:META-INF/beans.xml}" will find all "beans.xml"
138+
* "{@code classpath*:META-INF/beans.xml}" will find all "META-INF/beans.xml"
137139
* files in the class path, be it in "classes" directories or in JAR files.
138140
* This is particularly useful for autodetecting config files of the same name
139141
* at the same location within each jar file. Internally, this happens via a
@@ -145,7 +147,7 @@
145147
* {@code ClassLoader.getResources()} call is used on the last non-wildcard
146148
* path segment to get all the matching resources in the class loader hierarchy,
147149
* and then off each resource the same PathMatcher resolution strategy described
148-
* above is used for the wildcard subpath.
150+
* above is used for the wildcard sub pattern.
149151
*
150152
* <p><b>Other notes:</b>
151153
*
@@ -193,6 +195,7 @@
193195
* @author Phillip Webb
194196
* @author Sam Brannen
195197
* @author Sebastien Deleuze
198+
* @author Dave Syer
196199
* @since 1.0.2
197200
* @see #CLASSPATH_ALL_URL_PREFIX
198201
* @see org.springframework.util.AntPathMatcher
@@ -521,8 +524,8 @@ private boolean hasDuplicate(String filePath, Set<Resource> result) {
521524

522525
/**
523526
* Find all resources that match the given location pattern via the
524-
* Ant-style PathMatcher. Supports resources in jar files and zip files
525-
* and in the file system.
527+
* Ant-style PathMatcher. Supports resources in OSGi bundles, JBoss VFS,
528+
* jar files, zip files, and file systems.
526529
* @param locationPattern the location pattern to match
527530
* @return the result as Resource array
528531
* @throws IOException in case of I/O errors
@@ -563,15 +566,13 @@ else if (ResourceUtils.isJarURL(rootDirUrl) || isJarResource(rootDirResource)) {
563566

564567
/**
565568
* Determine the root directory for the given location.
566-
* <p>Used for determining the starting point for file matching,
567-
* resolving the root directory location to a {@code java.io.File}
568-
* and passing it into {@code retrieveMatchingFiles}, with the
569-
* remainder of the location as pattern.
570-
* <p>Will return "/WEB-INF/" for the pattern "/WEB-INF/*.xml",
571-
* for example.
569+
* <p>Used for determining the starting point for file matching, resolving the
570+
* root directory location to be passed into {@link #getResources(String)},
571+
* with the remainder of the location to be used as the sub pattern.
572+
* <p>Will return "/WEB-INF/" for the location "/WEB-INF/*.xml", for example.
572573
* @param location the location to check
573574
* @return the part of the location that denotes the root directory
574-
* @see #retrieveMatchingFiles
575+
* @see #findPathMatchingResources(String)
575576
*/
576577
protected String determineRootDir(String location) {
577578
int prefixEnd = location.indexOf(':') + 1;
@@ -724,151 +725,99 @@ protected JarFile getJarFile(String jarFileUrl) throws IOException {
724725
}
725726

726727
/**
727-
* Find all resources in the file system that match the given location pattern
728-
* via the Ant-style PathMatcher.
729-
* @param rootDirResource the root directory as Resource
728+
* Find all resources in the file system of the supplied root directory that
729+
* match the given location sub pattern via the Ant-style PathMatcher.
730+
* @param rootDirResource the root directory as a Resource
730731
* @param subPattern the sub pattern to match (below the root directory)
731732
* @return a mutable Set of matching Resource instances
732733
* @throws IOException in case of I/O errors
733-
* @see #retrieveMatchingFiles
734734
* @see org.springframework.util.PathMatcher
735735
*/
736736
protected Set<Resource> doFindPathMatchingFileResources(Resource rootDirResource, String subPattern)
737737
throws IOException {
738738

739-
File rootDir;
739+
URI rootDirUri;
740+
String rootDir;
740741
try {
741-
rootDir = rootDirResource.getFile().getAbsoluteFile();
742-
}
743-
catch (FileNotFoundException ex) {
744-
if (logger.isDebugEnabled()) {
745-
logger.debug("Cannot search for matching files underneath " + rootDirResource +
746-
" in the file system: " + ex.getMessage());
742+
rootDirUri = rootDirResource.getURI();
743+
rootDir = rootDirUri.getPath();
744+
// If the URI is for a "resource" in the GraalVM native image file system, we have to
745+
// ensure that the root directory does not end in a slash while simultaneously ensuring
746+
// that the root directory is not an empty string (since fileSystem.getPath("").resolve(str)
747+
// throws an ArrayIndexOutOfBoundsException in a native image).
748+
if ("resource".equals(rootDirUri.getScheme()) && (rootDir.length() > 1) && rootDir.endsWith("/")) {
749+
rootDir = rootDir.substring(0, rootDir.length() - 1);
747750
}
748-
return Collections.emptySet();
749751
}
750752
catch (Exception ex) {
751753
if (logger.isInfoEnabled()) {
752-
logger.info("Failed to resolve " + rootDirResource + " in the file system: " + ex);
754+
logger.info("Failed to resolve %s in the file system: %s".formatted(rootDirResource, ex));
753755
}
754756
return Collections.emptySet();
755757
}
756-
return doFindMatchingFileSystemResources(rootDir, subPattern);
757-
}
758758

759-
/**
760-
* Find all resources in the file system that match the given location pattern
761-
* via the Ant-style PathMatcher.
762-
* @param rootDir the root directory in the file system
763-
* @param subPattern the sub pattern to match (below the root directory)
764-
* @return a mutable Set of matching Resource instances
765-
* @throws IOException in case of I/O errors
766-
* @see #retrieveMatchingFiles
767-
* @see org.springframework.util.PathMatcher
768-
*/
769-
protected Set<Resource> doFindMatchingFileSystemResources(File rootDir, String subPattern) throws IOException {
770-
if (logger.isTraceEnabled()) {
771-
logger.trace("Looking for matching resources in directory tree [" + rootDir.getPath() + "]");
772-
}
773-
Set<File> matchingFiles = retrieveMatchingFiles(rootDir, subPattern);
774-
Set<Resource> result = new LinkedHashSet<>(matchingFiles.size());
775-
for (File file : matchingFiles) {
776-
result.add(new FileSystemResource(file));
759+
FileSystem fileSystem = getFileSystem(rootDirUri);
760+
if (fileSystem == null) {
761+
return Collections.emptySet();
777762
}
778-
return result;
779-
}
780763

781-
/**
782-
* Retrieve files that match the given path pattern,
783-
* checking the given directory and its subdirectories.
784-
* @param rootDir the directory to start from
785-
* @param pattern the pattern to match against,
786-
* relative to the root directory
787-
* @return a mutable Set of matching Resource instances
788-
* @throws IOException if directory contents could not be retrieved
789-
*/
790-
protected Set<File> retrieveMatchingFiles(File rootDir, String pattern) throws IOException {
791-
if (!rootDir.exists()) {
792-
// Silently skip non-existing directories.
793-
if (logger.isDebugEnabled()) {
794-
logger.debug("Skipping [" + rootDir.getAbsolutePath() + "] because it does not exist");
764+
try {
765+
Path rootPath = fileSystem.getPath(rootDir);
766+
String resourcePattern = rootPath.resolve(subPattern).toString();
767+
Predicate<Path> resourcePatternMatches = path -> getPathMatcher().match(resourcePattern, path.toString());
768+
if (logger.isTraceEnabled()) {
769+
logger.trace("Searching directory [%s] for files matching pattern [%s]"
770+
.formatted(rootPath.toAbsolutePath(), subPattern));
795771
}
796-
return Collections.emptySet();
797-
}
798-
if (!rootDir.isDirectory()) {
799-
// Complain louder if it exists but is no directory.
800-
if (logger.isInfoEnabled()) {
801-
logger.info("Skipping [" + rootDir.getAbsolutePath() + "] because it does not denote a directory");
772+
Set<Resource> result = new LinkedHashSet<>();
773+
try (Stream<Path> files = Files.walk(rootPath)) {
774+
files.filter(resourcePatternMatches).sorted().forEach(file -> {
775+
try {
776+
result.add(convertToResource(file.toUri()));
777+
}
778+
catch (Exception ex) {
779+
if (logger.isDebugEnabled()) {
780+
logger.debug("Failed to convert file %s to an org.springframework.core.io.Resource: %s"
781+
.formatted(file, ex));
782+
}
783+
}
784+
});
802785
}
803-
return Collections.emptySet();
804-
}
805-
if (!rootDir.canRead()) {
806-
if (logger.isInfoEnabled()) {
807-
logger.info("Skipping search for matching files underneath directory [" + rootDir.getAbsolutePath() +
808-
"] because the application is not allowed to read the directory");
786+
catch (Exception ex) {
787+
if (logger.isDebugEnabled()) {
788+
logger.debug("Faild to complete search in directory [%s] for files matching pattern [%s]: %s"
789+
.formatted(rootPath.toAbsolutePath(), subPattern, ex));
790+
}
809791
}
810-
return Collections.emptySet();
792+
return result;
811793
}
812-
String fullPattern = StringUtils.replace(rootDir.getAbsolutePath(), File.separator, "/");
813-
if (!pattern.startsWith("/")) {
814-
fullPattern += "/";
794+
finally {
795+
try {
796+
fileSystem.close();
797+
}
798+
catch (UnsupportedOperationException ex) {
799+
// ignore
800+
}
815801
}
816-
fullPattern = fullPattern + StringUtils.replace(pattern, File.separator, "/");
817-
Set<File> result = new LinkedHashSet<>(8);
818-
doRetrieveMatchingFiles(fullPattern, rootDir, result);
819-
return result;
820802
}
821803

822-
/**
823-
* Recursively retrieve files that match the given pattern,
824-
* adding them to the given result list.
825-
* @param fullPattern the pattern to match against,
826-
* with prepended root directory path
827-
* @param dir the current directory
828-
* @param result the Set of matching File instances to add to
829-
* @throws IOException if directory contents could not be retrieved
830-
*/
831-
protected void doRetrieveMatchingFiles(String fullPattern, File dir, Set<File> result) throws IOException {
832-
if (logger.isTraceEnabled()) {
833-
logger.trace("Searching directory [" + dir.getAbsolutePath() +
834-
"] for files matching pattern [" + fullPattern + "]");
835-
}
836-
for (File content : listDirectory(dir)) {
837-
String currPath = StringUtils.replace(content.getAbsolutePath(), File.separator, "/");
838-
if (content.isDirectory() && getPathMatcher().matchStart(fullPattern, currPath + "/")) {
839-
if (!content.canRead()) {
840-
if (logger.isDebugEnabled()) {
841-
logger.debug("Skipping subdirectory [" + dir.getAbsolutePath() +
842-
"] because the application is not allowed to read the directory");
843-
}
844-
}
845-
else {
846-
doRetrieveMatchingFiles(fullPattern, content, result);
847-
}
804+
@Nullable
805+
private FileSystem getFileSystem(URI uri) {
806+
try {
807+
URI root = uri.resolve("/");
808+
try {
809+
return FileSystems.getFileSystem(root);
848810
}
849-
if (getPathMatcher().match(fullPattern, currPath)) {
850-
result.add(content);
811+
catch (Exception ex) {
812+
return FileSystems.newFileSystem(root, Map.of(), ClassUtils.getDefaultClassLoader());
851813
}
852814
}
853-
}
854-
855-
/**
856-
* Determine a sorted list of files in the given directory.
857-
* @param dir the directory to introspect
858-
* @return the sorted list of files (by default in alphabetical order)
859-
* @since 5.1
860-
* @see File#listFiles()
861-
*/
862-
protected File[] listDirectory(File dir) {
863-
File[] files = dir.listFiles();
864-
if (files == null) {
815+
catch (Exception ex) {
865816
if (logger.isInfoEnabled()) {
866-
logger.info("Could not retrieve contents of directory [" + dir.getAbsolutePath() + "]");
817+
logger.info("Failed to resolve java.nio.file.FileSystem for %s: %s".formatted(uri, ex));
867818
}
868-
return new File[0];
819+
return null;
869820
}
870-
Arrays.sort(files, Comparator.comparing(File::getName));
871-
return files;
872821
}
873822

874823
/**
@@ -935,14 +884,12 @@ protected Set<Resource> findAllModulePathResources(String locationPattern) throw
935884
}
936885

937886
@Nullable
938-
private static Resource findResource(ModuleReader moduleReader, String name) {
887+
private Resource findResource(ModuleReader moduleReader, String name) {
939888
try {
940889
return moduleReader.find(name)
941890
// If it's a "file:" URI, use FileSystemResource to avoid duplicates
942891
// for the same path discovered via class-path scanning.
943-
.map(uri -> ResourceUtils.URL_PROTOCOL_FILE.equals(uri.getScheme()) ?
944-
new FileSystemResource(uri.getPath()) :
945-
UrlResource.from(uri))
892+
.map(this::convertToResource)
946893
.orElse(null);
947894
}
948895
catch (Exception ex) {
@@ -953,6 +900,12 @@ private static Resource findResource(ModuleReader moduleReader, String name) {
953900
}
954901
}
955902

903+
private Resource convertToResource(URI uri) {
904+
return ResourceUtils.URL_PROTOCOL_FILE.equals(uri.getScheme()) ?
905+
new FileSystemResource(uri.getPath()) :
906+
UrlResource.from(uri);
907+
}
908+
956909
private static String stripLeadingSlash(String path) {
957910
return (path.startsWith("/") ? path.substring(1) : path);
958911
}

spring-core/src/test/java/org/springframework/core/io/support/PathMatchingResourcePatternResolverTests.java

+1
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ private void assertFilenames(String pattern, String... filenames) throws IOExcep
137137
Resource[] resources = resolver.getResources(pattern);
138138
List<String> actualNames = Arrays.stream(resources)
139139
.map(Resource::getFilename)
140+
// Need to decode within GraalVM native image to get %23 converted to #.
140141
.map(filename -> URLDecoder.decode(filename, UTF_8))
141142
.sorted()
142143
.toList();

0 commit comments

Comments
 (0)