Support "classpath*:" prefix for ResourceLoader#getResource

Introduces a general-purpose consumeContent method on Resource and EncodedResource with special behavior for multi-content resources. Regular getInputStream/getReader calls will expose the merged content of all same-named resources in the classpath.

Closes gh-36415
This commit is contained in:
Juergen Hoeller
2026-03-16 12:23:16 +01:00
parent 51fccf226f
commit 1345760087
10 changed files with 328 additions and 52 deletions
@@ -26,6 +26,7 @@ import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;
import org.apache.commons.logging.Log;
@@ -194,30 +195,31 @@ public abstract class YamlProcessor {
}
private boolean process(MatchCallback callback, Yaml yaml, Resource resource) {
int count = 0;
AtomicInteger count = new AtomicInteger();
try {
if (logger.isDebugEnabled()) {
logger.debug("Loading from YAML: " + resource);
}
try (Reader reader = new UnicodeReader(resource.getInputStream())) {
resource.consumeContent(inputStream -> {
Reader reader = new UnicodeReader(inputStream);
for (Object object : yaml.loadAll(reader)) {
if (object != null && process(asMap(object), callback)) {
count++;
count.incrementAndGet();
if (this.resolutionMethod == ResolutionMethod.FIRST_FOUND) {
break;
}
}
}
if (logger.isDebugEnabled()) {
logger.debug("Loaded " + count + " document" + (count > 1 ? "s" : "") +
logger.debug("Loaded " + count + " document" + (count.get() > 1 ? "s" : "") +
" from YAML resource: " + resource);
}
}
});
}
catch (IOException ex) {
handleProcessError(resource, ex);
}
return (count > 0);
return (count.get() > 0);
}
private void handleProcessError(Resource resource, IOException ex) {
@@ -17,7 +17,6 @@
package org.springframework.beans.factory.support;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.util.Enumeration;
import java.util.HashMap;
@@ -256,14 +255,14 @@ public class PropertiesBeanDefinitionReader extends AbstractBeanDefinitionReader
Properties props = new Properties();
try {
try (InputStream is = encodedResource.getResource().getInputStream()) {
encodedResource.getResource().consumeContent(is -> {
if (encodedResource.getEncoding() != null) {
getPropertiesPersister().load(props, new InputStreamReader(is, encodedResource.getEncoding()));
}
else {
getPropertiesPersister().load(props, is);
}
}
});
int count = registerBeanDefinitions(props, prefix, encodedResource.getResource().getDescription());
if (logger.isDebugEnabled()) {
@@ -21,6 +21,7 @@ import java.io.InputStream;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import javax.xml.parsers.ParserConfigurationException;
@@ -337,12 +338,16 @@ public class XmlBeanDefinitionReader extends AbstractBeanDefinitionReader {
"Detected cyclic loading of " + encodedResource + " - check your import definitions!");
}
try (InputStream inputStream = encodedResource.getResource().getInputStream()) {
InputSource inputSource = new InputSource(inputStream);
if (encodedResource.getEncoding() != null) {
inputSource.setEncoding(encodedResource.getEncoding());
}
return doLoadBeanDefinitions(inputSource, encodedResource.getResource());
try {
AtomicInteger count = new AtomicInteger();
encodedResource.getResource().consumeContent(inputStream -> {
InputSource inputSource = new InputSource(inputStream);
if (encodedResource.getEncoding() != null) {
inputSource.setEncoding(encodedResource.getEncoding());
}
count.addAndGet(doLoadBeanDefinitions(inputSource, encodedResource.getResource()));
});
return count.get();
}
catch (IOException ex) {
throw new BeanDefinitionStoreException(
@@ -16,10 +16,19 @@
package org.springframework.core.io;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.io.SequenceInputStream;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLConnection;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Enumeration;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
@@ -30,6 +39,7 @@ import org.springframework.util.Assert;
import org.springframework.util.ClassUtils;
import org.springframework.util.ResourceUtils;
import org.springframework.util.StringUtils;
import org.springframework.util.function.IOConsumer;
/**
* Default implementation of the {@link ResourceLoader} interface.
@@ -158,6 +168,9 @@ public class DefaultResourceLoader implements ResourceLoader {
else if (location.startsWith(CLASSPATH_URL_PREFIX)) {
return new ClassPathResource(location.substring(CLASSPATH_URL_PREFIX.length()), getClassLoader());
}
else if (location.startsWith(CLASSPATH_ALL_URL_PREFIX)) {
return new ClassPathAllResource(location.substring(CLASSPATH_ALL_URL_PREFIX.length()), getClassLoader());
}
else {
try {
// Try to parse the location as a URL...
@@ -187,6 +200,96 @@ public class DefaultResourceLoader implements ResourceLoader {
}
/**
* A multi-content ClassPathResource handle that can expose the content
* from all matching resources in the classpath.
* @since 7.1
*/
protected static class ClassPathAllResource extends ClassPathResource {
public ClassPathAllResource(String path, @Nullable ClassLoader classLoader) {
super(path, classLoader);
}
@Override
public boolean isFile() {
return false;
}
@Override
public URL getURL() throws IOException {
throw new FileNotFoundException(
getDescription() + " cannot be resolved to single URL or File - use 'classpath:' instead");
}
@Override
public long contentLength() throws IOException {
long combinedLength = 0;
ClassLoader cl = getClassLoader();
Enumeration<URL> urls = (cl != null ? cl.getResources(getPath()) : ClassLoader.getSystemResources(getPath()));
while (urls.hasMoreElements()) {
URLConnection con = urls.nextElement().openConnection();
long length = con.getContentLengthLong();
if (length < 0) {
return -1;
}
combinedLength += length;
}
return combinedLength;
}
@Override
public InputStream getInputStream() throws IOException {
List<InputStream> streams = new ArrayList<>();
ClassLoader cl = getClassLoader();
Enumeration<URL> urls = (cl != null ? cl.getResources(getPath()) : ClassLoader.getSystemResources(getPath()));
while (urls.hasMoreElements()) {
try {
streams.add(urls.nextElement().openStream());
}
catch (IOException ex) {
streams.forEach(stream -> {
try {
stream.close();
}
catch (IOException ex2) {
ex.addSuppressed(ex2);
}
});
throw ex;
}
}
return switch (streams.size()) {
case 0 -> InputStream.nullInputStream();
case 1 -> streams.get(0);
default -> new SequenceInputStream(Collections.enumeration(streams));
};
}
@Override
public void consumeContent(IOConsumer<InputStream> consumer) throws IOException {
ClassLoader cl = getClassLoader();
Enumeration<URL> urls = (cl != null ? cl.getResources(getPath()) : ClassLoader.getSystemResources(getPath()));
while (urls.hasMoreElements()) {
try (InputStream inputStream = urls.nextElement().openStream()) {
consumer.accept(inputStream);
}
}
}
@Override
public Resource createRelative(String relativePath) {
String pathToUse = StringUtils.applyRelativePath(getPath(), relativePath);
return new ClassPathAllResource(pathToUse, getClassLoader());
}
@Override
public String getDescription() {
return "'classpath*:' resource [" + getPath() + "]";
}
}
/**
* ClassPathResource that explicitly expresses a context-relative path
* through implementing the ContextResource interface.
@@ -30,6 +30,7 @@ import java.nio.file.Path;
import org.jspecify.annotations.Nullable;
import org.springframework.util.FileCopyUtils;
import org.springframework.util.function.IOConsumer;
/**
* Interface for a resource descriptor that abstracts from the actual
@@ -156,6 +157,26 @@ public interface Resource extends InputStreamSource {
return Channels.newChannel(getInputStream());
}
/**
* Process the contents of this resource through the given consumer callback.
* <p>The given consumer will be invoked a single time by default - but may
* also be invoked multiple times in case of a multi-content resource handle,
* for example returned from a
* {@link ResourceLoader#getResource getResource("classpath*:..."} call.
* While {@link #getInputStream()} returns a merged sequence of content
* in such a case, this method performs one callback per file content.
* @param consumer a consumer for each InputStream
* @throws IOException in case of general resolution/reading failures
* @since 7.1
* @see #getInputStream()
* @see ResourceLoader#CLASSPATH_ALL_URL_PREFIX
*/
default void consumeContent(IOConsumer<InputStream> consumer) throws IOException {
try (InputStream inputStream = getInputStream()) {
consumer.accept(inputStream);
}
}
/**
* Return the contents of this resource as a byte array.
* @return the contents of this resource as byte array
@@ -42,18 +42,47 @@ import org.springframework.util.ResourceUtils;
*/
public interface ResourceLoader {
/** Pseudo URL prefix for loading from the class path: "classpath:". */
/**
* Pseudo URL prefix for loading from the class path: "classpath:".
* This retrieves the "nearest" matching resource in the classpath.
* @see ClassLoader#getResource
*/
String CLASSPATH_URL_PREFIX = ResourceUtils.CLASSPATH_URL_PREFIX;
/**
* Pseudo URL prefix for all matching resources from the class path: {@code "classpath*:"}.
* <p>This differs from the common {@link #CLASSPATH_URL_PREFIX "classpath:"} prefix
* in that it retrieves all matching resources for a given path. For example, to
* locate all "messages.properties" files in the root of all deployed JAR files
* you can use the location pattern {@code "classpath*:/messages.properties"}.
* <p>As of Spring Framework 6.0, the semantics for the {@code "classpath*:"}
* prefix have been expanded to include the module path as well as the class path.
* <p>As of Spring Framework 7.1, this prefix is supported for {@link #getResource}
* calls as well (exposing a multi-content resource handle), rather than just for
* {@link org.springframework.core.io.support.ResourcePatternResolver#getResources}.
* @since 7.1 (previously only declared on the
* {@link org.springframework.core.io.support.ResourcePatternResolver} sub-interface)
* @see ClassLoader#getResources
* @see Resource#consumeContent
*/
String CLASSPATH_ALL_URL_PREFIX = "classpath*:";
/**
* Return a {@code Resource} handle for the specified resource location.
* <p>The handle should always be a reusable resource descriptor,
* allowing for multiple {@link Resource#getInputStream()} calls.
* <p><ul>
* <li>Must support fully qualified URLs, for example, "file:C:/test.dat".
* <li>Must support classpath pseudo-URLs, for example, "classpath:test.dat".
* <li>Should support relative file paths, for example, "WEB-INF/test.dat".
* <li>Must support fully qualified URLs, for example, "file:C:/test.properties".
* <li>Must support classpath pseudo-URLs, for example, "classpath:test.properties".
* (Exposing the "nearest" resource in the classpath; see {@link ClassLoader#getResource}.)
* <li>Should support classpath-all URLs, for example, "classpath*:test.properties".
* (If supported, the returned {@code Resource} needs to expose the entire content of
* all same-named resources in the classpath through {@link Resource#consumeContent};
* see {@link ClassLoader#getResources}.
* For individual access to each such matching resource in the classpath, use
* {@link org.springframework.core.io.support.ResourcePatternResolver#getResources}.)
* <li>Should support relative file paths, for example, "WEB-INF/test.properties".
* (This will be implementation-specific, typically provided by an
* ApplicationContext implementation.)
* </ul>
@@ -63,7 +92,7 @@ public interface ResourceLoader {
* @return a corresponding {@code Resource} handle (never {@code null})
* @see #CLASSPATH_URL_PREFIX
* @see Resource#exists()
* @see Resource#getInputStream()
* @see Resource#consumeContent
*/
Resource getResource(String location);
@@ -26,8 +26,10 @@ import org.jspecify.annotations.Nullable;
import org.springframework.core.io.InputStreamSource;
import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceLoader;
import org.springframework.util.Assert;
import org.springframework.util.ObjectUtils;
import org.springframework.util.function.IOConsumer;
/**
* Holder that combines a {@link Resource} descriptor with a specific encoding
@@ -125,26 +127,6 @@ public class EncodedResource implements InputStreamSource {
return (this.encoding != null || this.charset != null);
}
/**
* Open a {@code java.io.Reader} for the specified resource, using the specified
* {@link #getCharset() Charset} or {@linkplain #getEncoding() encoding}
* (if any).
* @throws IOException if opening the Reader failed
* @see #requiresReader()
* @see #getInputStream()
*/
public Reader getReader() throws IOException {
if (this.charset != null) {
return new InputStreamReader(this.resource.getInputStream(), this.charset);
}
else if (this.encoding != null) {
return new InputStreamReader(this.resource.getInputStream(), this.encoding);
}
else {
return new InputStreamReader(this.resource.getInputStream());
}
}
/**
* Open an {@code InputStream} for the specified resource, ignoring any specified
* {@link #getCharset() Charset} or {@linkplain #getEncoding() encoding}.
@@ -157,6 +139,47 @@ public class EncodedResource implements InputStreamSource {
return this.resource.getInputStream();
}
/**
* Open a {@code java.io.Reader} for the specified resource, using the specified
* {@link #getCharset() Charset} or {@linkplain #getEncoding() encoding}
* (if any).
* @throws IOException if opening the Reader failed
* @see #requiresReader()
* @see #getInputStream()
*/
public Reader getReader() throws IOException {
return getReader(this.resource.getInputStream());
}
private Reader getReader(InputStream inputStream) throws IOException {
if (this.charset != null) {
return new InputStreamReader(inputStream, this.charset);
}
else if (this.encoding != null) {
return new InputStreamReader(inputStream, this.encoding);
}
else {
return new InputStreamReader(inputStream);
}
}
/**
* Process the contents of this resource through the given consumer callback.
* <p>The given consumer will be invoked a single time by default - but may
* also be invoked multiple times in case of a multi-content resource handle,
* for example returned from a
* {@link ResourceLoader#getResource getResource("classpath*:..."} call.
* While {@link #getReader()} returns a merged sequence of content
* in such a case, this method performs one callback per file content.
* @param consumer a consumer for each Reader
* @throws IOException in case of general resolution/reading failures
* @since 7.1
* @see Resource#consumeContent
*/
public void consumeContent(IOConsumer<Reader> consumer) throws IOException {
this.resource.consumeContent(inputStream -> consumer.accept(getReader(inputStream)));
}
/**
* Returns the contents of the specified resource as a string, using the specified
* {@link #getCharset() Charset} or {@linkplain #getEncoding() encoding} (if any).
@@ -57,18 +57,6 @@ import org.springframework.core.io.ResourceLoader;
*/
public interface ResourcePatternResolver extends ResourceLoader {
/**
* Pseudo URL prefix for all matching resources from the class path: {@code "classpath*:"}.
* <p>This differs from ResourceLoader's {@code "classpath:"} URL prefix in
* that it retrieves all matching resources for a given path &mdash; for
* example, to locate all "beans.xml" files in the root of all deployed JAR
* files you can use the location pattern {@code "classpath*:/beans.xml"}.
* <p>As of Spring Framework 6.0, the semantics for the {@code "classpath*:"}
* prefix have been expanded to include the module path as well as the class path.
* @see org.springframework.core.io.ResourceLoader#CLASSPATH_URL_PREFIX
*/
String CLASSPATH_ALL_URL_PREFIX = "classpath*:";
/**
* Resolve the given location pattern into {@code Resource} objects.
* <p>Overlapping resource entries that point to the same physical
@@ -0,0 +1,61 @@
/*
* Copyright 2002-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.util.function;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.util.function.Consumer;
import org.springframework.core.io.Resource;
/**
* Common functional interface for I/O content consumption, for example
* consuming an {@link java.io.InputStream} or a {@link java.io.Reader}.
*
* @author Juergen Hoeller
* @since 7.1
* @param <C> the type of stream/reader
* @see Resource#consumeContent
* @see org.springframework.core.io.support.EncodedResource#consumeContent
* @see ThrowingConsumer
*/
@FunctionalInterface
public interface IOConsumer<C> extends Consumer<C> {
/**
* Performs this operation on the given argument, possibly throwing
* an {@link IOException}.
* @param content the stream/reader
* @throws IOException on error
*/
void acceptWithException(C content) throws IOException;
/**
* Default {@link Consumer#accept(Object)} that wraps any thrown
* {@link IOException} in an {@link UncheckedIOException}.
* @see java.util.function.Consumer#accept(Object)
*/
default void accept(C input) {
try {
acceptWithException(input);
}
catch (IOException ex) {
throw new UncheckedIOException(ex);
}
}
}
@@ -21,6 +21,8 @@ import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.StringWriter;
import java.io.UncheckedIOException;
import java.net.JarURLConnection;
import java.net.URISyntaxException;
@@ -55,6 +57,7 @@ import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.util.ClassUtils;
import org.springframework.util.FileCopyUtils;
import org.springframework.util.FileSystemUtils;
import org.springframework.util.ResourceUtils;
import org.springframework.util.StreamUtils;
@@ -100,6 +103,48 @@ class PathMatchingResourcePatternResolverTests {
}
@Nested
class ClassPathAll {
@Test
void getResourcesVsGetResource() throws IOException {
StringBuilder content1 = new StringBuilder(200000);
long length1 = 0;
Resource[] resources1 = resolver.getResources("classpath*:META-INF/MANIFEST.MF");
for (Resource resource : resources1) {
assertThat(resource.exists()).isTrue();
assertThat(resource.isReadable()).isTrue();
assertThat(resource.isFile()).isFalse();
length1 += resource.contentLength();
resource.consumeContent(inputStream ->
content1.append(FileCopyUtils.copyToString(new InputStreamReader(inputStream))));
}
String content = content1.toString();
StringBuilder content2 = new StringBuilder(200000);
Resource resource2 = resolver.getResource("classpath*:META-INF/MANIFEST.MF");
assertThat(resource2.exists()).isTrue();
assertThat(resource2.isReadable()).isTrue();
assertThat(resource2.isFile()).isFalse();
resource2.consumeContent(inputStream ->
content2.append(FileCopyUtils.copyToString(new InputStreamReader(inputStream))));
assertThat(content.contentEquals(content2)).isTrue();
assertThat(resource2.contentLength()).isEqualTo(length1);
String content3 = FileCopyUtils.copyToString(new InputStreamReader(resource2.getInputStream()));
assertThat(content.contentEquals(content3)).isTrue();
String content4 = new EncodedResource(resource2).getContentAsString();
assertThat(content.contentEquals(content4)).isTrue();
StringWriter content5 = new StringWriter(200000);
EncodedResource resource5 = new EncodedResource(resource2);
resource5.consumeContent(reader -> FileCopyUtils.copy(reader, content5));
assertThat(content.contentEquals(content5.getBuffer())).isTrue();
}
}
@Nested
class FileSystemResources {