From 19fec0633fa9928d671e0bdda74ed7d26681f374 Mon Sep 17 00:00:00 2001 From: Juergen Hoeller Date: Fri, 8 Mar 2024 19:12:14 +0100 Subject: [PATCH] Local root directory and jar caching in PathMatchingResourcePatternResolver Closes gh-21190 --- .../support/AbstractApplicationContext.java | 8 + .../PathMatchingResourcePatternResolver.java | 151 +++++++++++++++--- 2 files changed, 137 insertions(+), 22 deletions(-) diff --git a/spring-context/src/main/java/org/springframework/context/support/AbstractApplicationContext.java b/spring-context/src/main/java/org/springframework/context/support/AbstractApplicationContext.java index dc8ffe3d939..35a41b61443 100644 --- a/spring-context/src/main/java/org/springframework/context/support/AbstractApplicationContext.java +++ b/spring-context/src/main/java/org/springframework/context/support/AbstractApplicationContext.java @@ -1021,6 +1021,14 @@ public abstract class AbstractApplicationContext extends DefaultResourceLoader CachedIntrospectionResults.clearClassLoader(getClassLoader()); } + @Override + public void clearResourceCaches() { + super.clearResourceCaches(); + if (this.resourcePatternResolver instanceof PathMatchingResourcePatternResolver pmrpr) { + pmrpr.clearCache(); + } + } + /** * Register a shutdown hook {@linkplain Thread#getName() named} diff --git a/spring-core/src/main/java/org/springframework/core/io/support/PathMatchingResourcePatternResolver.java b/spring-core/src/main/java/org/springframework/core/io/support/PathMatchingResourcePatternResolver.java index 318855cadc0..21fcea3e4d7 100644 --- a/spring-core/src/main/java/org/springframework/core/io/support/PathMatchingResourcePatternResolver.java +++ b/spring-core/src/main/java/org/springframework/core/io/support/PathMatchingResourcePatternResolver.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-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,8 +40,11 @@ import java.util.Collections; import java.util.Enumeration; import java.util.LinkedHashSet; import java.util.Map; +import java.util.NavigableSet; import java.util.Objects; import java.util.Set; +import java.util.TreeSet; +import java.util.concurrent.ConcurrentHashMap; import java.util.function.Predicate; import java.util.jar.JarEntry; import java.util.jar.JarFile; @@ -247,6 +250,10 @@ public class PathMatchingResourcePatternResolver implements ResourcePatternResol private PathMatcher pathMatcher = new AntPathMatcher(); + private final Map rootDirCache = new ConcurrentHashMap<>(); + + private final Map> jarEntryCache = new ConcurrentHashMap<>(); + /** * Create a {@code PathMatchingResourcePatternResolver} with a @@ -355,6 +362,16 @@ public class PathMatchingResourcePatternResolver implements ResourcePatternResol } } + /** + * Clear the local resource cache, removing all cached classpath/jar structures. + * @since 6.2 + */ + public void clearCache() { + this.rootDirCache.clear(); + this.jarEntryCache.clear(); + } + + /** * Find all class location resources with the given location via the ClassLoader. *

Delegates to {@link #doFindAllClassPathResources(String)}. @@ -567,9 +584,73 @@ public class PathMatchingResourcePatternResolver implements ResourcePatternResol protected Resource[] findPathMatchingResources(String locationPattern) throws IOException { String rootDirPath = determineRootDir(locationPattern); String subPattern = locationPattern.substring(rootDirPath.length()); - Resource[] rootDirResources = getResources(rootDirPath); - Set result = new LinkedHashSet<>(16); + + // Look for pre-cached root dir resources, either a direct match + // or for a parent directory in the same classpath locations. + Resource[] rootDirResources = this.rootDirCache.get(rootDirPath); + String actualRootPath = null; + if (rootDirResources == null) { + // No direct match -> search for parent directory match. + String commonPrefix = null; + String existingPath = null; + boolean commonUnique = true; + for (String path : this.rootDirCache.keySet()) { + String currentPrefix = null; + for (int i = 0; i < path.length(); i++) { + if (i == rootDirPath.length() || path.charAt(i) != rootDirPath.charAt(i)) { + currentPrefix = path.substring(0, path.lastIndexOf('/', i - 1) + 1); + break; + } + } + if (currentPrefix != null) { + // A prefix match found, potentially to be turned into a common parent cache entry. + if (commonPrefix == null || !commonUnique || currentPrefix.length() > commonPrefix.length()) { + commonPrefix = currentPrefix; + existingPath = path; + } + else if (currentPrefix.equals(commonPrefix)) { + commonUnique = false; + } + } + else if (actualRootPath == null || path.length() > actualRootPath.length()) { + // A direct match found for a parent directory -> use it. + rootDirResources = this.rootDirCache.get(path); + actualRootPath = path; + } + } + if (rootDirResources == null & StringUtils.hasLength(commonPrefix)) { + // Try common parent directory as long as it points to the same classpath locations. + rootDirResources = getResources(commonPrefix); + Resource[] existingResources = this.rootDirCache.get(existingPath); + if (existingResources != null && rootDirResources.length == existingResources.length) { + // Replace existing subdirectory cache entry with common parent directory. + this.rootDirCache.remove(existingPath); + this.rootDirCache.put(commonPrefix, rootDirResources); + actualRootPath = commonPrefix; + } + else if (commonPrefix.equals(rootDirPath)) { + // The identified common directory is equal to the currently requested path -> + // worth caching specifically, even if it cannot replace the existing sub-entry. + this.rootDirCache.put(rootDirPath, rootDirResources); + } + else { + // Mismatch: parent directory points to more classpath locations. + rootDirResources = null; + } + } + if (rootDirResources == null) { + // Lookup for specific directory, creating a cache entry for it. + rootDirResources = getResources(rootDirPath); + this.rootDirCache.put(rootDirPath, rootDirResources); + } + } + + Set result = new LinkedHashSet<>(64); for (Resource rootDirResource : rootDirResources) { + if (actualRootPath != null && actualRootPath.length() < rootDirPath.length()) { + // Create sub-resource for requested sub-location from cached common root directory. + rootDirResource = rootDirResource.createRelative(rootDirPath.substring(actualRootPath.length())); + } rootDirResource = resolveRootDirResource(rootDirResource); URL rootDirUrl = rootDirResource.getURL(); if (equinoxResolveMethod != null && rootDirUrl.getProtocol().startsWith("bundle")) { @@ -672,10 +753,37 @@ public class PathMatchingResourcePatternResolver implements ResourcePatternResol protected Set doFindPathMatchingJarResources(Resource rootDirResource, URL rootDirUrl, String subPattern) throws IOException { + String jarFileUrl = null; + String rootEntryPath = null; + + String urlFile = rootDirUrl.getFile(); + int separatorIndex = urlFile.indexOf(ResourceUtils.WAR_URL_SEPARATOR); + if (separatorIndex == -1) { + separatorIndex = urlFile.indexOf(ResourceUtils.JAR_URL_SEPARATOR); + } + if (separatorIndex != -1) { + jarFileUrl = urlFile.substring(0, separatorIndex); + rootEntryPath = urlFile.substring(separatorIndex + 2); // both separators are 2 chars + NavigableSet entryCache = this.jarEntryCache.get(jarFileUrl); + if (entryCache != null) { + Set result = new LinkedHashSet<>(64); + // Search sorted entries from first entry with rootEntryPath prefix + for (String entryPath : entryCache.tailSet(rootEntryPath, false)) { + if (!entryPath.startsWith(rootEntryPath)) { + // We are beyond the potential matches in the current TreeSet. + break; + } + String relativePath = entryPath.substring(rootEntryPath.length()); + if (getPathMatcher().match(subPattern, relativePath)) { + result.add(rootDirResource.createRelative(relativePath)); + } + } + return result; + } + } + URLConnection con = rootDirUrl.openConnection(); JarFile jarFile; - String jarFileUrl; - String rootEntryPath; boolean closeJarFile; if (con instanceof JarURLConnection jarCon) { @@ -691,15 +799,8 @@ public class PathMatchingResourcePatternResolver implements ResourcePatternResol // We'll assume URLs of the format "jar:path!/entry", with the protocol // being arbitrary as long as following the entry format. // We'll also handle paths with and without leading "file:" prefix. - String urlFile = rootDirUrl.getFile(); try { - int separatorIndex = urlFile.indexOf(ResourceUtils.WAR_URL_SEPARATOR); - if (separatorIndex == -1) { - separatorIndex = urlFile.indexOf(ResourceUtils.JAR_URL_SEPARATOR); - } - if (separatorIndex != -1) { - jarFileUrl = urlFile.substring(0, separatorIndex); - rootEntryPath = urlFile.substring(separatorIndex + 2); // both separators are 2 chars + if (jarFileUrl != null) { jarFile = getJarFile(jarFileUrl); } else { @@ -726,10 +827,12 @@ public class PathMatchingResourcePatternResolver implements ResourcePatternResol // The Sun JRE does not return a slash here, but BEA JRockit does. rootEntryPath = rootEntryPath + "/"; } - Set result = new LinkedHashSet<>(8); - for (Enumeration entries = jarFile.entries(); entries.hasMoreElements();) { + Set result = new LinkedHashSet<>(64); + NavigableSet entryCache = new TreeSet<>(); + for (Enumeration entries = jarFile.entries(); entries.hasMoreElements(); ) { JarEntry entry = entries.nextElement(); String entryPath = entry.getName(); + entryCache.add(entryPath); if (entryPath.startsWith(rootEntryPath)) { String relativePath = entryPath.substring(rootEntryPath.length()); if (getPathMatcher().match(subPattern, relativePath)) { @@ -737,6 +840,8 @@ public class PathMatchingResourcePatternResolver implements ResourcePatternResol } } } + // Cache jar entries in TreeSet for efficient searching on re-encounter. + this.jarEntryCache.put(jarFileUrl, entryCache); return result; } finally { @@ -777,7 +882,7 @@ public class PathMatchingResourcePatternResolver implements ResourcePatternResol protected Set doFindPathMatchingFileResources(Resource rootDirResource, String subPattern) throws IOException { - Set result = new LinkedHashSet<>(); + Set result = new LinkedHashSet<>(64); URI rootDirUri; try { rootDirUri = rootDirResource.getURI(); @@ -886,7 +991,7 @@ public class PathMatchingResourcePatternResolver implements ResourcePatternResol * @see PathMatcher#match(String, String) */ protected Set findAllModulePathResources(String locationPattern) throws IOException { - Set result = new LinkedHashSet<>(16); + Set result = new LinkedHashSet<>(64); // Skip scanning the module path when running in a native image. if (NativeDetector.inNativeImage()) { @@ -987,7 +1092,7 @@ public class PathMatchingResourcePatternResolver implements ResourcePatternResol private final String rootPath; - private final Set resources = new LinkedHashSet<>(); + private final Set resources = new LinkedHashSet<>(64); public PatternVirtualFileVisitor(String rootPath, String subPattern, PathMatcher pathMatcher) { this.subPattern = subPattern; @@ -1000,15 +1105,17 @@ public class PathMatchingResourcePatternResolver implements ResourcePatternResol public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { String methodName = method.getName(); if (Object.class == method.getDeclaringClass()) { - switch(methodName) { - case "equals": + switch (methodName) { + case "equals" -> { // Only consider equal when proxies are identical. return (proxy == args[0]); - case "hashCode": + } + case "hashCode" -> { return System.identityHashCode(proxy); + } } } - return switch(methodName) { + return switch (methodName) { case "getAttributes" -> getAttributes(); case "visit" -> { visit(args[0]);