From 1345760087bf6e68a64923e13d4c0ebcc82b7f34 Mon Sep 17 00:00:00 2001 From: Juergen Hoeller Date: Mon, 16 Mar 2026 12:23:16 +0100 Subject: [PATCH] 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 --- .../beans/factory/config/YamlProcessor.java | 14 ++- .../PropertiesBeanDefinitionReader.java | 5 +- .../factory/xml/XmlBeanDefinitionReader.java | 17 ++- .../core/io/DefaultResourceLoader.java | 103 ++++++++++++++++++ .../org/springframework/core/io/Resource.java | 21 ++++ .../core/io/ResourceLoader.java | 39 ++++++- .../core/io/support/EncodedResource.java | 63 +++++++---- .../io/support/ResourcePatternResolver.java | 12 -- .../util/function/IOConsumer.java | 61 +++++++++++ ...hMatchingResourcePatternResolverTests.java | 45 ++++++++ 10 files changed, 328 insertions(+), 52 deletions(-) create mode 100644 spring-core/src/main/java/org/springframework/util/function/IOConsumer.java diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/config/YamlProcessor.java b/spring-beans/src/main/java/org/springframework/beans/factory/config/YamlProcessor.java index 480352cfe95..a31eab3533a 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/config/YamlProcessor.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/config/YamlProcessor.java @@ -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) { diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/support/PropertiesBeanDefinitionReader.java b/spring-beans/src/main/java/org/springframework/beans/factory/support/PropertiesBeanDefinitionReader.java index 60a49a92afa..b7a5c326dd5 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/support/PropertiesBeanDefinitionReader.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/support/PropertiesBeanDefinitionReader.java @@ -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()) { diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/xml/XmlBeanDefinitionReader.java b/spring-beans/src/main/java/org/springframework/beans/factory/xml/XmlBeanDefinitionReader.java index b7b4cf63431..631bb958d9d 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/xml/XmlBeanDefinitionReader.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/xml/XmlBeanDefinitionReader.java @@ -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( diff --git a/spring-core/src/main/java/org/springframework/core/io/DefaultResourceLoader.java b/spring-core/src/main/java/org/springframework/core/io/DefaultResourceLoader.java index 164d85d9c00..9ec371e39ba 100644 --- a/spring-core/src/main/java/org/springframework/core/io/DefaultResourceLoader.java +++ b/spring-core/src/main/java/org/springframework/core/io/DefaultResourceLoader.java @@ -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 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 streams = new ArrayList<>(); + ClassLoader cl = getClassLoader(); + Enumeration 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 consumer) throws IOException { + ClassLoader cl = getClassLoader(); + Enumeration 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. diff --git a/spring-core/src/main/java/org/springframework/core/io/Resource.java b/spring-core/src/main/java/org/springframework/core/io/Resource.java index 54b7084a43e..3b4c4e3ddb7 100644 --- a/spring-core/src/main/java/org/springframework/core/io/Resource.java +++ b/spring-core/src/main/java/org/springframework/core/io/Resource.java @@ -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. + *

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 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 diff --git a/spring-core/src/main/java/org/springframework/core/io/ResourceLoader.java b/spring-core/src/main/java/org/springframework/core/io/ResourceLoader.java index 2e281c21c0d..2b0b0d0d76b 100644 --- a/spring-core/src/main/java/org/springframework/core/io/ResourceLoader.java +++ b/spring-core/src/main/java/org/springframework/core/io/ResourceLoader.java @@ -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*:"}. + *

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"}. + *

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. + *

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. *

The handle should always be a reusable resource descriptor, * allowing for multiple {@link Resource#getInputStream()} calls. *

    - *
  • Must support fully qualified URLs, for example, "file:C:/test.dat". - *
  • Must support classpath pseudo-URLs, for example, "classpath:test.dat". - *
  • Should support relative file paths, for example, "WEB-INF/test.dat". + *
  • Must support fully qualified URLs, for example, "file:C:/test.properties". + *
  • Must support classpath pseudo-URLs, for example, "classpath:test.properties". + * (Exposing the "nearest" resource in the classpath; see {@link ClassLoader#getResource}.) + *
  • 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}.) + *
  • Should support relative file paths, for example, "WEB-INF/test.properties". * (This will be implementation-specific, typically provided by an * ApplicationContext implementation.) *
@@ -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); diff --git a/spring-core/src/main/java/org/springframework/core/io/support/EncodedResource.java b/spring-core/src/main/java/org/springframework/core/io/support/EncodedResource.java index 46f7f57cb8a..b5f4523ea23 100644 --- a/spring-core/src/main/java/org/springframework/core/io/support/EncodedResource.java +++ b/spring-core/src/main/java/org/springframework/core/io/support/EncodedResource.java @@ -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. + *

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 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). diff --git a/spring-core/src/main/java/org/springframework/core/io/support/ResourcePatternResolver.java b/spring-core/src/main/java/org/springframework/core/io/support/ResourcePatternResolver.java index f2dc7a52434..5947edafffe 100644 --- a/spring-core/src/main/java/org/springframework/core/io/support/ResourcePatternResolver.java +++ b/spring-core/src/main/java/org/springframework/core/io/support/ResourcePatternResolver.java @@ -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*:"}. - *

This differs from ResourceLoader's {@code "classpath:"} URL prefix in - * that it retrieves all matching resources for a given path — 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"}. - *

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. *

Overlapping resource entries that point to the same physical diff --git a/spring-core/src/main/java/org/springframework/util/function/IOConsumer.java b/spring-core/src/main/java/org/springframework/util/function/IOConsumer.java new file mode 100644 index 00000000000..e52b40ab0a8 --- /dev/null +++ b/spring-core/src/main/java/org/springframework/util/function/IOConsumer.java @@ -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 the type of stream/reader + * @see Resource#consumeContent + * @see org.springframework.core.io.support.EncodedResource#consumeContent + * @see ThrowingConsumer + */ +@FunctionalInterface +public interface IOConsumer extends Consumer { + + /** + * 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); + } + } + +} diff --git a/spring-core/src/test/java/org/springframework/core/io/support/PathMatchingResourcePatternResolverTests.java b/spring-core/src/test/java/org/springframework/core/io/support/PathMatchingResourcePatternResolverTests.java index e0390b6c7b0..65002af9ebf 100644 --- a/spring-core/src/test/java/org/springframework/core/io/support/PathMatchingResourcePatternResolverTests.java +++ b/spring-core/src/test/java/org/springframework/core/io/support/PathMatchingResourcePatternResolverTests.java @@ -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 {